@estiva-app/interop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1833 @@
1
+ /**
2
+ * Rendering another app's objects from its published manifest — the pure part.
3
+ *
4
+ * Split from `foreign.ts` so it can be exercised against a live relay without a
5
+ * Convex deployment: everything here takes a `query` function rather than
6
+ * reaching for one. `foreign.ts` supplies the authenticated bridge; a test
7
+ * supplies its own.
8
+ *
9
+ * **Nothing in this file knows what Linear-lite is.** No kind number, no field
10
+ * name, no status vocabulary is hardcoded — every one comes off the NIP-89
11
+ * manifest at runtime. If that stops being true the demo stops proving
12
+ * anything: it becomes an integration written against one app, which is the
13
+ * thing the whole exercise argues against.
14
+ */
15
+ import { encodeNaddr, pointerToAddress, referenceToPointer, type AddressPointer } from '@estiva-app/protocol'
16
+ import { parseProfile, type Profile, type SignedEvent } from '@estiva-app/protocol'
17
+
18
+ /** Query the relay. Returns matching events; shape mirrors the HTTP bridge. */
19
+ export type QueryFn = (filters: Record<string, unknown>[]) => Promise<SignedEvent[]>
20
+
21
+ /** An unsigned Nostr event, ready for `sign.ts`. */
22
+ export interface UnsignedActionEvent {
23
+ pubkey: string
24
+ created_at: number
25
+ kind: number
26
+ tags: string[][]
27
+ content: string
28
+ }
29
+
30
+ /**
31
+ * The people named on another app's objects (FEE-1).
32
+ *
33
+ * A manifest says a slot holds a pubkey; it never says whose. Peek showed the
34
+ * first eight characters of the key, which is not a person — it is the thing a
35
+ * person is behind. The name and face come from kind:0, the same place the
36
+ * message list already gets them, so the same person reads the same way in both.
37
+ *
38
+ * Keyed by pubkey. A key that resolves to nothing is simply absent, and the
39
+ * renderer decides what an unknown person looks like.
40
+ */
41
+ export type People = Record<string, Profile>
42
+
43
+ /**
44
+ * Looking people up, as a seam rather than a direct call.
45
+ *
46
+ * The browser puts a session cache in front of this: a topic with ten reference
47
+ * widgets resolves ten times, and it is the same handful of people every time.
48
+ * Tests and Storybook pass a fixture instead and never touch a relay.
49
+ */
50
+ export type PeopleFn = (pubkeys: string[]) => Promise<People>
51
+
52
+ /** The default: one kind:0 query, straight to the relay. */
53
+ export function peopleViaRelay(query: QueryFn): PeopleFn {
54
+ return async (pubkeys) => {
55
+ if (pubkeys.length === 0) return {}
56
+ const events = await query([
57
+ { kinds: [KIND_PROFILE], authors: pubkeys, limit: pubkeys.length },
58
+ ])
59
+ const people: People = {}
60
+ // Oldest first, so a newer profile overwrites an older one. kind:0 is
61
+ // replaceable and the relay should hold one per author, but ordering the
62
+ // fold is cheaper than trusting that.
63
+ for (const event of [...events].sort(byOrder)) {
64
+ people[event.pubkey] = parseProfile(event)
65
+ }
66
+ return people
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Every pubkey an object would put on screen.
72
+ *
73
+ * Slots and meta carry them as values; a `pubkey` action carries the current
74
+ * holder of the field it sets, which in the sidebar is the *only* place the
75
+ * project's lead appears. Missing that one is what left a face-shaped button
76
+ * showing eight hex characters.
77
+ */
78
+ function pubkeysIn(object: ForeignObject): string[] {
79
+ const keys: string[] = []
80
+ for (const slot of [...Object.values(object.slots), ...object.meta]) {
81
+ if (slot.isPubkey && slot.value) keys.push(slot.value)
82
+ }
83
+ for (const action of object.actions) {
84
+ if (action.control === 'pubkey' && action.current) keys.push(action.current)
85
+ }
86
+ return keys
87
+ }
88
+
89
+ /** kind:0, the profile every other app publishes too. */
90
+ const KIND_PROFILE = 0
91
+
92
+ /** NIP-89 kinds. These two are protocol, not app-specific. */
93
+ const KIND_HANDLER_RECOMMENDATION = 31989
94
+ const KIND_HANDLER_INFORMATION = 31990
95
+ /**
96
+ * Default kind for comments on a foreign object.
97
+ *
98
+ * NIP-22, and no app owns it — but the owning app gets to say otherwise. An app
99
+ * whose objects live in a Folder may well treat a comment as a message in that
100
+ * container instead, which is a better answer when the container is also a Peek
101
+ * topic: the comment and the topic conversation become the same event rather
102
+ * than two records nobody reconciles.
103
+ *
104
+ * So this is the fallback, and `commentKindsOf` reads the real one off the
105
+ * manifest's own `comment` action. Hardcoding it here would quietly stop
106
+ * finding comments the moment an app said something different.
107
+ */
108
+ const KIND_COMMENT = 1111
109
+
110
+ /**
111
+ * Every kind an app's comments might be under — what it publishes **now**, plus
112
+ * any it has published before.
113
+ *
114
+ * `emits.kind` is a single number, and for a while that was enough. It stops
115
+ * being enough the moment an app *changes* the kind it emits, because the old
116
+ * events do not move: a `kind:9` message is not replaceable at all, so a comment
117
+ * written under the old kind stays under it permanently. Reading only the
118
+ * declared kind would show an object's newest comments and silently drop every
119
+ * one written before the change — no error, nothing empty, just a thread that
120
+ * begins in the middle.
121
+ *
122
+ * So the owning app may also declare `emits.alsoRead`, which is the kinds it
123
+ * used to publish. That is the only place the knowledge actually lives; the
124
+ * alternative is Peek hardcoding one app's history, which is exactly what the
125
+ * note on `KIND_COMMENT` above says not to do.
126
+ *
127
+ * Absent `alsoRead` this returns a single kind, so a manifest written before
128
+ * the field existed behaves exactly as it did.
129
+ */
130
+ export function commentKindsOf(manifest: Manifest): number[] {
131
+ const declared = manifest.actions?.find((action) => action.id === 'comment')
132
+ const current = declared?.emits?.kind ?? KIND_COMMENT
133
+ const superseded = declared?.emits?.alsoRead ?? []
134
+ return [...new Set([current, ...superseded])]
135
+ }
136
+
137
+ /** How the owning app says its records should be read. */
138
+ interface RecordsRule {
139
+ changeKind: number
140
+ targetTag: string
141
+ fieldTag: string
142
+ valueTag: string
143
+ order: string[]
144
+ rule: string
145
+ /**
146
+ * A folded field whose value means "do not show this object".
147
+ *
148
+ * Archiving is a field, not a deletion, so an archived record still comes
149
+ * back from every query and renders perfectly well. Without this a consumer
150
+ * cannot tell it apart from live work — which is how a topic whose Folder
151
+ * held three archived projects ended up showing one of them.
152
+ *
153
+ * Peek honours the rule without knowing what the word means: the owner says
154
+ * which field and which value, and this stays out of it.
155
+ */
156
+ hiddenWhen?: { field: string; equals: string }
157
+ }
158
+
159
+ interface SlotSpec {
160
+ /**
161
+ * A tag on the root event, or a list of them meaning **first that resolves**.
162
+ *
163
+ * A list exists for the same reason `emits.alsoRead` does: an app that
164
+ * renames a tag cannot rename it on the events it already published, so its
165
+ * history spans both spellings permanently. Ship moving a project's title
166
+ * from `title` to `name` is the case in hand — without a fallback, either
167
+ * every record written before the rename renders blank or every one written
168
+ * after does.
169
+ *
170
+ * Not to be confused with a `SlotSpec[]`, which the caller treats as "render
171
+ * all of these as meta". This is one slot with several places to look.
172
+ */
173
+ tag?: string | string[]
174
+ field?: string
175
+ fold?: string
176
+ map?: string
177
+ as?: string
178
+ label?: string
179
+ truncate?: number
180
+ default?: string
181
+ /**
182
+ * **Child objects, found by the tag on the child that names this one.**
183
+ *
184
+ * The `list` slot (RFC 0.4 §13.3, PRO-2). The only slot source that does not
185
+ * read the root event: every other field here answers "what does this event
186
+ * say?", and this one answers "what points at it?".
187
+ *
188
+ * `limit` is the producer saying how many are worth fetching. **Recursion
189
+ * depth is deliberately not here** — a child rendered through its own
190
+ * projection may declare a `list` too, and the app at risk of the render loop
191
+ * is the one drawing it, so the budget is the consumer's (`MAX_LIST_DEPTH`).
192
+ * §13.4's honour-system rule cuts that way: a producer that could set the
193
+ * consumer's recursion budget could hang it.
194
+ */
195
+ children?: {
196
+ kind: number
197
+ via: string
198
+ limit?: number
199
+ /**
200
+ * What the child's `via` tag holds — added by PRO-6.
201
+ *
202
+ * `address` (the default, and Ship's case) means the tag carries the
203
+ * parent's full `kind:pubkey:d`. `identifier` means it carries only the
204
+ * parent's `d`.
205
+ *
206
+ * Found by declaring a projection for a Peek Topic. A message names its
207
+ * channel with `h`, and `h` holds the **bare channel uuid** — which is the
208
+ * topic's `d`, not its address. Nothing in NIP-29 is going to change that,
209
+ * so a `list` slot that could only compare addresses could not express the
210
+ * one relationship Peek has. Defaulting to `address` keeps every manifest
211
+ * written before this reading exactly as it did.
212
+ */
213
+ match?: 'address' | 'identifier'
214
+ }
215
+ }
216
+
217
+ /** An action the owning app says other apps may perform. */
218
+ export interface ManifestAction {
219
+ id: string
220
+ label: string
221
+ /** Kind(s) this applies to, as strings. */
222
+ appliesTo: string | string[]
223
+ emits: {
224
+ kind: number
225
+ field?: string
226
+ scope?: string
227
+ /**
228
+ * Set by an action that creates a whole new object under this one: the tag
229
+ * the child carries, and what it points at. `toAddressOf: "self"` means the
230
+ * child names the object the action was invoked on.
231
+ *
232
+ * Peek reads these two to learn a **containment relation** — "kind X holds
233
+ * kind Y" — without being told which app or which kinds are involved. It is
234
+ * the only thing in the manifest that says so, which is what lets the
235
+ * sidebar start from a Folder and find a project with issues in it.
236
+ */
237
+ setTag?: string
238
+ toAddressOf?: string
239
+ /**
240
+ * Kinds this action *used* to emit, which consumers must still read.
241
+ *
242
+ * Only ever additive to a read, never to a write: `kind` is what gets
243
+ * published, this is what also gets fetched. See `commentKindsOf`.
244
+ */
245
+ alsoRead?: number[]
246
+ }
247
+ input?: { type: string; enum?: string }
248
+ }
249
+
250
+ /**
251
+ * An action resolved into something a renderer can draw without re-reading the
252
+ * manifest.
253
+ *
254
+ * The interpretation happens here rather than in the component on purpose: the
255
+ * widget should not have to know that `input.enum` names a vocabulary, or that
256
+ * `as: "pubkey"` means "a person". Those are manifest semantics, and keeping
257
+ * them on this side is what lets the React component stay a dumb renderer that
258
+ * would work for any app.
259
+ */
260
+ export interface ResolvedAction {
261
+ id: string
262
+ label: string
263
+ control: 'select' | 'pubkey' | 'text'
264
+ /** For `select`: the declared vocabulary, already looked up. */
265
+ options?: { value: string; label: string; colour?: string }[]
266
+ /** The value this field holds right now, so a control can show it. */
267
+ current?: string
268
+ field?: string
269
+ }
270
+
271
+ interface Manifest {
272
+ name?: string
273
+ about?: string
274
+ actions?: ManifestAction[]
275
+ records?: RecordsRule
276
+ projections?: Record<string, { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }>
277
+ vocabularies?: Record<string, { value: string; label: string; colour: string; stage?: string }[]>
278
+ }
279
+
280
+ /** Actions declared for this kind, resolved against the vocabularies. */
281
+ function resolveActions(
282
+ manifest: Manifest,
283
+ kind: number,
284
+ folded: Record<string, { value: string }>,
285
+ ): ResolvedAction[] {
286
+ const applies = (action: ManifestAction) =>
287
+ (Array.isArray(action.appliesTo) ? action.appliesTo : [action.appliesTo]).includes(String(kind))
288
+
289
+ const out: ResolvedAction[] = []
290
+ for (const action of manifest.actions ?? []) {
291
+ if (!applies(action)) continue
292
+ // Only field-setting changes and comments are renderable today. An action
293
+ // that creates a whole new object (`add-issue`) needs a form and a parent,
294
+ // so it is skipped rather than drawn as a control that cannot work.
295
+ const isChange = !!action.emits.field
296
+ const isComment = action.emits.scope === 'address'
297
+ if (!isChange && !isComment) continue
298
+
299
+ const vocab = action.input?.enum ? manifest.vocabularies?.[action.input.enum] : undefined
300
+ out.push({
301
+ id: action.id,
302
+ label: action.label,
303
+ control: vocab ? 'select' : action.input?.type === 'pubkey' ? 'pubkey' : 'text',
304
+ options: vocab?.map((v) => ({ value: v.value, label: v.label, colour: v.colour })),
305
+ current: action.emits.field ? folded[action.emits.field]?.value : undefined,
306
+ field: action.emits.field,
307
+ })
308
+ }
309
+ return out
310
+ }
311
+
312
+ const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1]
313
+
314
+ /** A manifest event's `content`, or null when it is not parseable JSON. */
315
+ function parseManifest(event: SignedEvent): Manifest | null {
316
+ try {
317
+ return JSON.parse(event.content) as Manifest
318
+ } catch {
319
+ return null
320
+ }
321
+ }
322
+
323
+ const asArray = (value: string | string[]) => (Array.isArray(value) ? value : [value])
324
+
325
+ /**
326
+ * Which manifest wins when several claim the same kind.
327
+ *
328
+ * A `#k` query is NIP-89's discovery mechanism and it returns every handler for
329
+ * a kind, ranked by nothing. So the object's **author's** kind:31989
330
+ * recommendation is consulted first: the person who created the object is the
331
+ * one entitled to say which app renders it, and that is a trust anchor Peek
332
+ * already has rather than a value someone has to paste into a config file.
333
+ *
334
+ * The `#k` fallback exists so an object whose author never published a
335
+ * recommendation still renders, rather than failing closed on a missing
336
+ * preference. When it fires, `viaRecommendation` is false and the caller can
337
+ * say so in the UI — "we guessed" and "we were told" should not look identical.
338
+ */
339
+ /**
340
+ * NIP-89's `web` tag: how to open one of these objects in the app that owns it.
341
+ *
342
+ * `["web", "https://host/#/o/<bech32>", "naddr"]` — the consumer substitutes the
343
+ * entity it is holding. Read off the *event* rather than the manifest content,
344
+ * because that is where NIP-89 puts it.
345
+ *
346
+ * Returns undefined when no usable template was published, which is an ordinary
347
+ * state rather than an error: an app may render objects it has nowhere to open.
348
+ */
349
+ function webTemplate(event: SignedEvent, entity: string): string | undefined {
350
+ for (const tag of event.tags) {
351
+ if (tag[0] !== 'web' || !tag[1]) continue
352
+ // The third element names the entity type; NIP-89 permits omitting it, in
353
+ // which case the template applies to whatever we are holding.
354
+ if (tag[2] && tag[2] !== entity) continue
355
+ if (!tag[1].includes('<bech32>')) continue
356
+ return tag[1]
357
+ }
358
+ return undefined
359
+ }
360
+
361
+ export async function resolveManifest(
362
+ pointer: AddressPointer,
363
+ query: QueryFn,
364
+ ): Promise<{
365
+ manifest: Manifest
366
+ address: string
367
+ viaRecommendation: boolean
368
+ /** NIP-89 `web` template, `<bech32>` not yet substituted. */
369
+ webTemplate?: string
370
+ } | null> {
371
+ const parse = parseManifest
372
+ const addressOf = (event: SignedEvent) =>
373
+ `${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`
374
+
375
+ const recommended = await query(
376
+ [
377
+ {
378
+ kinds: [KIND_HANDLER_RECOMMENDATION],
379
+ authors: [pointer.pubkey],
380
+ '#d': [String(pointer.kind)],
381
+ limit: 1,
382
+ },
383
+ ],
384
+ )
385
+ const manifestAddr = recommended[0]?.tags.find((t) => t[0] === 'a')?.[1]
386
+ if (manifestAddr) {
387
+ const [kind, pubkey, ...rest] = manifestAddr.split(':')
388
+ const found = await query([
389
+ { kinds: [Number(kind)], authors: [pubkey], '#d': [rest.join(':')], limit: 1 },
390
+ ])
391
+ const manifest = found[0] ? parse(found[0]) : null
392
+ if (manifest) {
393
+ return {
394
+ manifest,
395
+ address: manifestAddr,
396
+ viaRecommendation: true,
397
+ webTemplate: webTemplate(found[0], 'naddr'),
398
+ }
399
+ }
400
+ }
401
+
402
+ const claimed = await query([
403
+ { kinds: [KIND_HANDLER_INFORMATION], '#k': [String(pointer.kind)], limit: 20 },
404
+ ])
405
+ // Newest wins among unrecommended candidates. Arbitrary, and honest about it:
406
+ // there is no principled ranking without a recommendation, which is exactly
407
+ // why the recommendation exists.
408
+ const newest = [...claimed].sort((a, b) => b.created_at - a.created_at)
409
+ for (const candidate of newest) {
410
+ const manifest = parse(candidate)
411
+ if (manifest?.projections?.[String(pointer.kind)]) {
412
+ return {
413
+ manifest,
414
+ address: addressOf(candidate),
415
+ viaRecommendation: false,
416
+ webTemplate: webTemplate(candidate, 'naddr'),
417
+ }
418
+ }
419
+ }
420
+ return null
421
+ }
422
+
423
+ /**
424
+ * Ordering key for an append-only event, per the manifest's `records.order`.
425
+ *
426
+ * The manifest declares `["ts", "created_at", "id"]` and, crucially, the
427
+ * condition under which `ts` may be believed: only when it agrees with
428
+ * `created_at` to the second. `created_at` has one-second resolution and the
429
+ * relay validates it, so a `ts` pinned inside that second inherits that
430
+ * validation and can only refine ordering *within* it.
431
+ *
432
+ * Peek enforces that bound itself rather than trusting the writer. Under the
433
+ * honour-system model nothing validates these events (RFC_UPDATES.md §3), so a
434
+ * buggy or pushy client claiming a far-future `ts` would otherwise win every
435
+ * fold forever — in Peek's rendering as much as in the owning app's.
436
+ */
437
+ function orderingMs(event: SignedEvent): number {
438
+ const raw = Number.parseInt(tagValue(event, 'ts') ?? '', 10)
439
+ if (Number.isFinite(raw) && Math.abs(Math.floor(raw / 1000) - event.created_at) <= 1) return raw
440
+ return event.created_at * 1000
441
+ }
442
+
443
+ /** Total order, oldest first. Ties break on the lower event id (NIP-01's rule). */
444
+ function byOrder(a: SignedEvent, b: SignedEvent): number {
445
+ const [at, bt] = [orderingMs(a), orderingMs(b)]
446
+ if (at !== bt) return at - bt
447
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
448
+ }
449
+
450
+ /** Replay change events into current field values. Last write wins per field. */
451
+ /**
452
+ * The fold rule a manifest declares, or one that folds nothing — PRO-6.
453
+ *
454
+ * `records` is optional. An app with no change events needs no rule for
455
+ * turning them into current truth, and Peek is that app: a topic's name is a
456
+ * tag the relay wrote and a message is immutable.
457
+ *
458
+ * Both resolvers used to require it and return null for the whole projection,
459
+ * so an app that declared none rendered as *nothing* — which §13.3 spends
460
+ * several paragraphs establishing is the worst available outcome, because a
461
+ * blank card reads as "that app is broken". It was never caught because Ship
462
+ * is the only app that had ever published a manifest, and Ship folds. Nothing
463
+ * in RFC 0.4 §13.1 makes `records` mandatory.
464
+ *
465
+ * The substitute rule below is only ever used to *fold*, never to query, and
466
+ * that distinction is load-bearing. The first attempt used `changeKind: -1` as
467
+ * a sentinel and let the queries run: the fake relay in the tests accepted it,
468
+ * and production refused the entire filter with `invalid type: integer -1,
469
+ * expected a 16-bit unsigned number`. A kind is `u16` on the wire, so there is
470
+ * no out-of-band value to reach for — the query has to be skipped instead of
471
+ * being made unmatchable. Callers therefore branch on `manifest.records` for
472
+ * the filters and use this only for the fold, which runs over an empty array.
473
+ */
474
+ function foldRuleOf(manifest: Manifest): RecordsRule {
475
+ return (
476
+ manifest.records ?? {
477
+ // Never sent to a relay. See above.
478
+ changeKind: 0,
479
+ targetTag: 'a',
480
+ fieldTag: 'field',
481
+ valueTag: 'value',
482
+ order: ['created_at', 'id'],
483
+ rule: 'last-write-wins-per-field',
484
+ }
485
+ )
486
+ }
487
+
488
+ function foldChanges(changes: SignedEvent[], rule: RecordsRule) {
489
+ const fields: Record<string, { value: string; by: string; at: number }> = {}
490
+ for (const change of [...changes].sort(byOrder)) {
491
+ const field = tagValue(change, rule.fieldTag)
492
+ const value = tagValue(change, rule.valueTag)
493
+ if (!field || value === undefined) continue // a partial change sets nothing
494
+ fields[field] = { value, by: change.pubkey, at: change.created_at }
495
+ }
496
+ return fields
497
+ }
498
+
499
+ function truncate(text: string, limit?: number) {
500
+ if (!limit || text.length <= limit) return text
501
+ return `${text.slice(0, limit).trimEnd()}…`
502
+ }
503
+
504
+ /**
505
+ * Which layout to draw, from a declared type or an ordered chain of them.
506
+ *
507
+ * RFC 0.4 §13.3. A widget is a layout *hint* rather than a semantic, so an
508
+ * unknown one can degrade honestly: `["message", "card"]` means *render me as a
509
+ * message if you know it, else as a card*, and a chain must terminate in a type
510
+ * the spec closes.
511
+ *
512
+ * **It lives here rather than in each consumer** because two implementations
513
+ * would disagree the first time a chain had three entries, and the whole point
514
+ * of the chain is that producers and consumers upgrade at different times. The
515
+ * consumer supplies what it implements; the runtime does the walking.
516
+ *
517
+ * `fallback` is what to draw when the chain runs out — never "nothing". §13.3's
518
+ * argument is that a blank object is indistinguishable from one the reader may
519
+ * not be allowed to see, and reports "that app is broken" about an app doing
520
+ * exactly what it was told.
521
+ */
522
+ /**
523
+ * The widget types RFC 0.4 §13.3 closes. A chain MUST end in one of these.
524
+ *
525
+ * Exported because both halves need the same list and they must not drift: a
526
+ * producer checks its chain terminates here, a consumer's fallback is drawn
527
+ * from here. Two copies would disagree the first time the set grew, and the
528
+ * disagreement would show up as an object rendering blank in one app only.
529
+ */
530
+ export const CLOSED_WIDGETS = ['card', 'row', 'table', 'stat'] as const
531
+
532
+ /**
533
+ * Why a widget declaration is not publishable, or null when it is — PRO-3.
534
+ *
535
+ * **The producer half of the fallback chain.** `pickWidget` below makes a
536
+ * consumer safe against a chain it does not fully understand; this stops the
537
+ * unrenderable chain being published in the first place. Both are needed and
538
+ * they fail differently: without the consumer half an unknown widget renders
539
+ * blank, and without this one a *conformant* consumer renders blank through no
540
+ * fault of its own, having done exactly what it was told.
541
+ *
542
+ * A chain that does not terminate in a closed type is the PEE-10 failure with
543
+ * a longer fuse — every consumer that has not heard of `profile` walks
544
+ * `["profile"]` to the end and has nothing left to draw.
545
+ *
546
+ * Returns a sentence rather than a boolean because this is read by a person
547
+ * publishing a manifest, and "invalid widget" tells them nothing about which
548
+ * one or what to do.
549
+ */
550
+ export function widgetChainProblem(declared: unknown): string | null {
551
+ if (typeof declared === 'string') {
552
+ return (CLOSED_WIDGETS as readonly string[]).includes(declared)
553
+ ? null
554
+ : `"${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"].`
555
+ }
556
+ if (!Array.isArray(declared) || declared.length === 0) {
557
+ return 'a widget must be a type or a non-empty ordered chain of them.'
558
+ }
559
+ if (declared.some((entry) => typeof entry !== 'string' || entry === '')) {
560
+ return 'every entry in a widget chain must be a non-empty string.'
561
+ }
562
+ const last = declared[declared.length - 1]
563
+ if (!(CLOSED_WIDGETS as readonly string[]).includes(last)) {
564
+ 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.`
565
+ }
566
+ return null
567
+ }
568
+
569
+ export function pickWidget<T extends string>(
570
+ declared: string | string[],
571
+ implemented: readonly T[],
572
+ fallback: T,
573
+ ): T {
574
+ for (const candidate of Array.isArray(declared) ? declared : [declared]) {
575
+ if ((implemented as readonly string[]).includes(candidate)) return candidate as T
576
+ }
577
+ return fallback
578
+ }
579
+
580
+ /** A slot, resolved to something a renderer can put on screen. */
581
+ export interface ResolvedSlot {
582
+ label?: string
583
+ value: string
584
+ /** Set when the value came from a vocabulary — the consumer picks the colour. */
585
+ colour?: string
586
+ /** True when the value is a pubkey and should be shown as a person. */
587
+ isPubkey?: boolean
588
+ /**
589
+ * The underlying field this slot reads, when it has a name.
590
+ *
591
+ * Carried so a renderer can tell that a slot and an action are two views of
592
+ * *one* value — a status label beside a status picker is the same fact twice
593
+ * (PEEK-18). Matching on the field is what makes that check work for any app:
594
+ * the slot key (`status`) and the action's label ("Change status") are both
595
+ * free-form, but a `ResolvedAction.field` and this always name the same thing
596
+ * because the manifest wrote them both.
597
+ *
598
+ * Undefined for a slot with no named source — `{field: "content"}` reads the
599
+ * event body, which no action can set.
600
+ */
601
+ field?: string
602
+ }
603
+
604
+ /**
605
+ * A slot's value before any vocabulary mapping or truncation.
606
+ *
607
+ * Split out because "what does this object's status *say*" and "what should a
608
+ * reader see" are different questions: the display value is a vocabulary label
609
+ * ("In Progress"), and anything deciding on the value — the active-issue filter
610
+ * below — has to compare the underlying one (`in_progress`).
611
+ */
612
+ /** The first of these tags the event actually carries. */
613
+ function firstTag(root: SignedEvent, tag: string | string[] | undefined): string | undefined {
614
+ if (!tag) return undefined
615
+ for (const name of Array.isArray(tag) ? tag : [tag]) {
616
+ const value = tagValue(root, name)
617
+ if (value !== undefined && value !== '') return value
618
+ }
619
+ return undefined
620
+ }
621
+
622
+ function rawSlotValue(
623
+ spec: SlotSpec,
624
+ root: SignedEvent,
625
+ folded: Record<string, { value: string }>,
626
+ ): string | undefined {
627
+ // `fold` first, and a spec may carry both: a field that starts as a tag on
628
+ // the root event and is then overridden by changes (a project's lead is the
629
+ // case in hand). Reading the tag first would render the value the object was
630
+ // created with forever — which is exactly what someone sees right after
631
+ // reassigning it from here.
632
+ if (spec.fold) {
633
+ return folded[spec.fold]?.value ?? firstTag(root, spec.tag) ?? spec.default
634
+ }
635
+ if (spec.tag) return firstTag(root, spec.tag)
636
+ if (spec.field === 'content') return root.content
637
+ /*
638
+ `pubkey` — the event's author — added by PRO-6.
639
+
640
+ Found by trying to declare a projection for a Peek Message. §13.3 makes
641
+ `title` the one required slot, and a `kind:9` has no title: it has an
642
+ author, a body and a time. The body cannot be the title, because message
643
+ content is structured for 41% of production events and truncating structure
644
+ is exactly what PRO-8 removed from Ship's manifest. Which leaves the author,
645
+ and until now no slot source could name it.
646
+
647
+ It is a genuine top-level event field, so it belongs in `field` rather than
648
+ in a new source. Paired with `as: "pubkey"` it renders as a person, which is
649
+ what a message wants as its title everywhere it appears.
650
+ */
651
+ if (spec.field === 'pubkey') return root.pubkey
652
+ return undefined
653
+ }
654
+
655
+ function resolveSlot(
656
+ spec: SlotSpec,
657
+ root: SignedEvent,
658
+ folded: Record<string, { value: string }>,
659
+ manifest: Manifest,
660
+ ): ResolvedSlot | null {
661
+ const raw = rawSlotValue(spec, root, folded)
662
+
663
+ if (raw === undefined || raw === '') return null
664
+
665
+ let value = truncate(raw, spec.truncate)
666
+ let colour: string | undefined
667
+ if (spec.map) {
668
+ const entry = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)
669
+ // A value outside the declared vocabulary is shown as-is rather than
670
+ // dropped. Another app may have written it, and silently rendering nothing
671
+ // would hide exactly the corruption the honour system permits.
672
+ value = entry?.label ?? raw
673
+ colour = entry?.colour ?? 'muted'
674
+ }
675
+ return {
676
+ label: spec.label,
677
+ value,
678
+ colour,
679
+ isPubkey: spec.as === 'pubkey',
680
+ field: spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag),
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Every slot a projection declares, resolved.
686
+ *
687
+ * Single slots are named (`title`, `subtitle`, `status`); array specs collect
688
+ * into `meta`. Shared so the sidebar and the inline widget resolve a projection
689
+ * identically — two loops would drift the first time a slot type is added.
690
+ */
691
+ function resolveSlots(
692
+ projection: { slots: Record<string, SlotSpec | SlotSpec[]> },
693
+ root: SignedEvent,
694
+ folded: Record<string, { value: string }>,
695
+ manifest: Manifest,
696
+ ): { slots: Record<string, ResolvedSlot>; meta: ResolvedSlot[] } {
697
+ const slots: Record<string, ResolvedSlot> = {}
698
+ const meta: ResolvedSlot[] = []
699
+ for (const [name, spec] of Object.entries(projection.slots)) {
700
+ if (Array.isArray(spec)) {
701
+ for (const one of spec) {
702
+ const value = resolveSlot(one, root, folded, manifest)
703
+ if (value) meta.push(value)
704
+ }
705
+ } else {
706
+ const value = resolveSlot(spec, root, folded, manifest)
707
+ if (value) slots[name] = value
708
+ }
709
+ }
710
+ return { slots, meta }
711
+ }
712
+
713
+ /** Everything the frontend needs to draw the widget. */
714
+ export interface ForeignObject {
715
+ /**
716
+ * A stable unique handle for this object, whatever kind of thing it is.
717
+ *
718
+ * **Added by PRO-7, and it exists because not every object has an address.**
719
+ * A replaceable record is identified by `kind:pubkey:d`; a regular event —
720
+ * Peek's `kind:9` message is the case in hand — has no `d` at all and is
721
+ * identified only by its event id. Before this, `address` was required and
722
+ * doubled as the React key, the error-map key and the `data-` attribute, so
723
+ * a non-addressable object could not be represented at all.
724
+ *
725
+ * Use this for identity. Use `naddr` only where an *address* is genuinely
726
+ * required, which in practice means acting on an object.
727
+ */
728
+ ref: string
729
+ /**
730
+ * The address, `kind:pubkey:d` — **only for an addressable object.**
731
+ *
732
+ * Undefined for a regular event. See `ref`.
733
+ */
734
+ address?: string
735
+ /**
736
+ * The address as `naddr1…`, **only for an addressable object.**
737
+ *
738
+ * Carried alongside `address` because acting on an object is addressed by
739
+ * naddr (`act.ts`), and the sidebar builds its objects rather than being
740
+ * handed a reference somebody pasted. Without it every control in a
741
+ * sidebar card would have to re-encode what the server already knows.
742
+ *
743
+ * Its absence is meaningful rather than a gap: an action emits a change
744
+ * carrying an `a` tag, and there is nothing for that tag to point at on a
745
+ * non-addressable object. A consumer that has no `naddr` correctly offers no
746
+ * actions.
747
+ */
748
+ naddr?: string
749
+ /** The event id — set for every object, and the only handle a regular event has. */
750
+ eventId: string
751
+ /**
752
+ * Child objects from a `list` slot, each resolved through its own projection.
753
+ *
754
+ * Empty rather than absent when the slot is declared and nothing matched, so
755
+ * a renderer can tell "this holds nothing" from "this holds no list".
756
+ */
757
+ children?: ForeignObject[]
758
+ kind: number
759
+ /**
760
+ * The layout hint the owner declared — **a type or an ordered chain of them.**
761
+ *
762
+ * `["message", "card"]` means *render me as a message if you know it, else as
763
+ * a card*, and a chain MUST terminate in a type RFC 0.4 §13.3 closes
764
+ * (`card`/`row`/`table`/`stat`). A bare string is the older form and is a
765
+ * chain of one.
766
+ *
767
+ * **This was typed `string` until PRO-7**, while Peek's own published manifest
768
+ * declares `["message","card"]` — so the type said one thing and the wire said
769
+ * another, and a consumer writing `widget === 'card'` compared a string to an
770
+ * array and silently drew nothing. Found by Ship, the second consumer, the
771
+ * first time anything typechecked a renderer against a real chain. That is
772
+ * the answer to PRO-1's "what did the second consumer force to change".
773
+ */
774
+ widget: string | string[]
775
+ appName?: string
776
+ /** Named single slots: title, subtitle, status. */
777
+ slots: Record<string, ResolvedSlot>
778
+ /** Repeating slots, e.g. `meta`. */
779
+ meta: ResolvedSlot[]
780
+ comments: { id: string; author: string; body: string; createdAt: number }[]
781
+ /**
782
+ * The Folder this object lives in, from the root event's `h` tag.
783
+ *
784
+ * Needed to *write*: a change event carries the same `h`, and without it the
785
+ * relay rejects the write outright. Reading it off the object rather than
786
+ * asking the user is the difference between an action that works and a form
787
+ * with a "Folder id" box in it.
788
+ */
789
+ folder?: string
790
+ /**
791
+ * Where to open this object in the app that owns it, from NIP-89's `web` tag.
792
+ *
793
+ * The return leg of the roundtrip. Without it a reference is a read-only
794
+ * snapshot: Peek can render a Linear-lite issue and change its status, and
795
+ * getting back to the issue itself means alt-tabbing and hunting for a row.
796
+ *
797
+ * Absent when the owning app published no template — the widget simply does
798
+ * not offer the link, rather than guessing a URL.
799
+ */
800
+ openUrl?: string
801
+ /** What the owning app says we may do to this object. */
802
+ actions: ResolvedAction[]
803
+ /**
804
+ * Names and faces for every pubkey this object shows (FEE-1).
805
+ *
806
+ * Carried on the object rather than looked up by the renderer so a widget is
807
+ * still a pure render of what it was handed — the same property that lets
808
+ * Storybook draw a real person from a fixture. Empty when nothing published a
809
+ * profile, which the renderer treats as "someone we cannot name" rather than
810
+ * falling back to the key.
811
+ */
812
+ people?: People
813
+ /** False when no author recommendation existed and a handler was guessed. */
814
+ viaRecommendation: boolean
815
+ /**
816
+ * Set when the manifest resolved but the object itself did not.
817
+ *
818
+ * Almost always a permission problem rather than a missing object, and worth
819
+ * distinguishing because the relay makes them look identical. Objects and
820
+ * change events are channel-scoped, so a reader who is not a member of the
821
+ * Folder gets zero rows — no error, just silence. The manifest and the
822
+ * recommendation are global kinds and resolve fine without membership, so the
823
+ * failure lands late and presents as a broken reference. Telling the user
824
+ * "you may not have access to this Folder" is a far better guess than
825
+ * rendering nothing.
826
+ */
827
+ unreachable?: boolean
828
+ }
829
+
830
+
831
+ /**
832
+ * One resolved object, from its root event and the changes that have landed on
833
+ * it. Shared by the inline widget and the sidebar so both draw the same shape
834
+ * from the same rules — the difference between them is what they *fetch*, not
835
+ * how they render.
836
+ */
837
+ function buildObject(args: {
838
+ root: SignedEvent
839
+ pointer: AddressPointer
840
+ manifest: Manifest
841
+ projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
842
+ folded: Record<string, { value: string }>
843
+ viaRecommendation: boolean
844
+ webTemplate?: string
845
+ comments?: ForeignObject['comments']
846
+ }): ForeignObject {
847
+ const { root, pointer, manifest, projection, folded } = args
848
+ const naddr = encodeNaddr(pointer)
849
+ const { slots, meta } = resolveSlots(projection, root, folded, manifest)
850
+
851
+ /**
852
+ * What each field holds now, however it got there.
853
+ *
854
+ * `resolveActions` reads the fold alone, which is right for a field that only
855
+ * ever exists as a change — but a project's lead is seeded by a tag on the
856
+ * root event. Without this, the assign control on a project nobody has
857
+ * reassigned reads "Assign to me" while a lead is plainly set, and the one
858
+ * control that is supposed to both report and set the field (PEEK-18) reports
859
+ * nothing.
860
+ */
861
+ const held: Record<string, string> = {}
862
+ for (const spec of Object.values(projection.slots).flat()) {
863
+ // `tag` may name several spellings (see `SlotSpec`); the field this slot
864
+ // *is* keyed on is the first, which is the one the app writes today. The
865
+ // rest are only there to keep older records rendering.
866
+ const field = spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag)
867
+ const raw = field && rawSlotValue(spec, root, folded)
868
+ if (field && raw !== undefined) held[field] = raw
869
+ }
870
+ const objectAddress = pointerToAddress(pointer)
871
+ return {
872
+ // An addressable object's `ref` is its address: stable across the author
873
+ // replacing the event, which the event id is not.
874
+ ref: objectAddress,
875
+ address: objectAddress,
876
+ naddr,
877
+ eventId: root.id,
878
+ kind: pointer.kind,
879
+ widget: projection.widget,
880
+ appName: manifest.name,
881
+ slots,
882
+ meta,
883
+ comments: args.comments ?? [],
884
+ folder: tagValue(root, 'h'),
885
+ // Substituted here rather than in the component: `<bech32>` is a NIP-89
886
+ // detail, and the widget's job is to draw a link, not to know the spec.
887
+ openUrl: args.webTemplate?.replace('<bech32>', naddr),
888
+ actions: resolveActions(manifest, pointer.kind, folded).map((action) =>
889
+ action.current === undefined && action.field
890
+ ? { ...action, current: held[action.field] }
891
+ : action,
892
+ ),
893
+ viaRecommendation: args.viaRecommendation,
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Resolve a `nostr:naddr…` into a renderable widget.
899
+ *
900
+ * Returns `null` rather than throwing when the object cannot be rendered — an
901
+ * unresolvable reference in a chat message should degrade to plain text, not
902
+ * break the message around it.
903
+ */
904
+ export async function resolveForeignObject(
905
+ naddr: string,
906
+ query: QueryFn,
907
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
908
+ lookupPeople?: PeopleFn,
909
+ /**
910
+ * How many `list` levels have already been followed. Callers outside this
911
+ * module leave it at 0; it is the budget that stops a child's own `list`
912
+ * recursing forever. See `MAX_LIST_DEPTH`.
913
+ */
914
+ depth = 0,
915
+ ): Promise<ForeignObject | null> {
916
+ let pointer: AddressPointer
917
+ try {
918
+ // Either form: a body carries `naddr1…`, an `a` tag carries the plain
919
+ // address, and both name the same object (FEE-2).
920
+ pointer = referenceToPointer(naddr)
921
+ } catch {
922
+ return null
923
+ }
924
+ const address = pointerToAddress(pointer)
925
+
926
+ const resolved = await resolveManifest(pointer, query)
927
+ if (!resolved) return null
928
+ const { manifest, viaRecommendation } = resolved
929
+ // Substituted here rather than in the component: `<bech32>` is a NIP-89
930
+ // detail, and the widget's job is to draw a link, not to know the spec.
931
+ const openUrl = resolved.webTemplate?.replace('<bech32>', naddr.replace(/^nostr:/, ''))
932
+ const commentKinds = commentKindsOf(manifest)
933
+
934
+ const projection = manifest.projections?.[String(pointer.kind)]
935
+ if (!projection) return null
936
+ const records = foldRuleOf(manifest)
937
+
938
+ // One round trip for the root, its changes and its comments. `#a` on both the
939
+ // change and the comment kind, because both point at the object by *address*
940
+ // rather than by event id — which is what makes them survive the author
941
+ // replacing the root event.
942
+ const events = await query([
943
+ { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 },
944
+ // Only when the app actually declares a change kind — see `foldRuleOf`.
945
+ ...(manifest.records ? [{ kinds: [manifest.records.changeKind], '#a': [address], limit: 500 }] : []),
946
+ { kinds: commentKinds, '#a': [address], limit: 200 },
947
+ ])
948
+
949
+ const root = events.find(
950
+ (e) => e.kind === pointer.kind && tagValue(e, 'd') === pointer.identifier,
951
+ )
952
+ if (!root) {
953
+ // We know which app owns this and how it would be drawn; we just cannot see
954
+ // the object. Say so rather than returning null and looking like a typo.
955
+ return {
956
+ ref: address,
957
+ address,
958
+ naddr: naddr.replace(/^nostr:/, ''),
959
+ // Nothing was read, so there is no event to name. The reference is the
960
+ // address; that is all this case ever knows.
961
+ eventId: '',
962
+ kind: pointer.kind,
963
+ widget: projection.widget,
964
+ appName: manifest.name,
965
+ slots: {},
966
+ meta: [],
967
+ comments: [],
968
+ actions: [],
969
+ viaRecommendation,
970
+ // Offered even here. "You cannot see this object" is exactly when
971
+ // somebody wants to open it in the app that can.
972
+ openUrl,
973
+ unreachable: true,
974
+ }
975
+ }
976
+
977
+ const folded = foldChanges(
978
+ events.filter(
979
+ (e) => e.kind === records.changeKind && tagValue(e, records.targetTag) === address,
980
+ ),
981
+ records,
982
+ )
983
+
984
+ const comments = events
985
+ .filter((e) => commentKinds.includes(e.kind))
986
+ .sort(byOrder)
987
+ .map((e) => ({ id: e.id, author: e.pubkey, body: e.content, createdAt: e.created_at }))
988
+
989
+ const object = buildObject({
990
+ root,
991
+ pointer,
992
+ manifest,
993
+ projection,
994
+ folded,
995
+ viaRecommendation,
996
+ webTemplate: resolved.webTemplate,
997
+ comments,
998
+ })
999
+ /*
1000
+ The `list` slot — PRO-7.
1001
+
1002
+ PRO-2 built this down the *folder* path only, where containment is
1003
+ discovered from a Folder's contents. An object reached by address never
1004
+ resolved it: `rawSlotValue` returns undefined for a `children` spec, so the
1005
+ slot was silently dropped. Measured on production before this: 5 of 5 real
1006
+ Peek Topics rendered a title, a subtitle and no messages — which is exactly
1007
+ what a quiet topic looks like, so nothing reported a problem.
1008
+
1009
+ A child is resolved through *its own* projection, which is what makes "a
1010
+ card with its children underneath" compose rather than being a special case.
1011
+ A child may be a regular event with no address of its own (Peek's messages
1012
+ are), so this builds by event rather than by pointer.
1013
+ */
1014
+ const children = await resolveChildren({
1015
+ projection,
1016
+ root,
1017
+ manifest,
1018
+ query,
1019
+ depth,
1020
+ webTemplate: resolved.webTemplate,
1021
+ viaRecommendation,
1022
+ })
1023
+
1024
+ // After the object, because who to ask about is not known until it is built.
1025
+ // Folded into the same resolve rather than left to the renderer so the
1026
+ // widget's skeleton covers the wait and a key is never briefly on screen.
1027
+ // Children are included so one lookup covers the whole tree — a message list
1028
+ // is mostly other people, and a second trip per child would show a column of
1029
+ // keys while it ran.
1030
+ const forPeople = [object, ...(children ?? [])]
1031
+ const people = await (lookupPeople ?? peopleViaRelay(query))([
1032
+ ...new Set(forPeople.flatMap(pubkeysIn)),
1033
+ ])
1034
+ return { ...object, people, ...(children ? { children: children.map((c) => ({ ...c, people })) } : {}) }
1035
+ }
1036
+
1037
+ /**
1038
+ * Resolve a projection's `list` slot into child objects.
1039
+ *
1040
+ * Returns undefined when no `list` is declared — distinct from `[]`, which
1041
+ * means "declared, and nothing matched". A renderer needs to tell "this holds
1042
+ * nothing" from "this holds no list".
1043
+ */
1044
+ async function resolveChildren(args: {
1045
+ projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
1046
+ root: SignedEvent
1047
+ manifest: Manifest
1048
+ query: QueryFn
1049
+ depth: number
1050
+ webTemplate?: string
1051
+ viaRecommendation: boolean
1052
+ }): Promise<ForeignObject[] | undefined> {
1053
+ const { projection, root, manifest, query, depth, webTemplate, viaRecommendation } = args
1054
+ const spec = projection.slots.list
1055
+ const children = !Array.isArray(spec) ? spec?.children : undefined
1056
+ if (!children) return undefined
1057
+ // The consumer's budget, not the manifest's — see MAX_LIST_DEPTH.
1058
+ if (depth >= MAX_LIST_DEPTH) return []
1059
+
1060
+ const childProjection = manifest.projections?.[String(children.kind)]
1061
+ // A declared list whose child kind has no projection is not renderable, and
1062
+ // an empty list is the honest answer: the objects exist, this app has not
1063
+ // said how to draw them.
1064
+ if (!childProjection) return []
1065
+
1066
+ const identifier = tagValue(root, 'd') ?? ''
1067
+ const parent =
1068
+ children.match === 'identifier'
1069
+ ? identifier
1070
+ : pointerToAddress({ kind: root.kind, pubkey: root.pubkey, identifier, relays: [] })
1071
+
1072
+ const found = await query([
1073
+ { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
1074
+ ])
1075
+
1076
+ const records = foldRuleOf(manifest)
1077
+ return found.sort(byOrder).map((event) =>
1078
+ buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation }),
1079
+ )
1080
+ }
1081
+
1082
+ /**
1083
+ * Build a `ForeignObject` from an event that may have no address of its own.
1084
+ *
1085
+ * `buildObject` takes an `AddressPointer` and assumes one exists. A `kind:9`
1086
+ * has no `d` tag, so there is nothing to point at — its only handle is its
1087
+ * event id. That is the whole of PRO-6's finding (6), and it is why `ref` and
1088
+ * `eventId` exist alongside `address`.
1089
+ *
1090
+ * An object built this way carries **no actions**, and that is correct rather
1091
+ * than a limitation: an action emits a change carrying an `a` tag naming what
1092
+ * it changes, and a regular event cannot be named that way.
1093
+ */
1094
+ function buildChildObject(args: {
1095
+ root: SignedEvent
1096
+ manifest: Manifest
1097
+ projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
1098
+ records: RecordsRule
1099
+ webTemplate?: string
1100
+ viaRecommendation: boolean
1101
+ }): ForeignObject {
1102
+ const { root, manifest, projection, webTemplate, viaRecommendation } = args
1103
+ const identifier = tagValue(root, 'd')
1104
+ const addressable = identifier !== undefined
1105
+ const pointer: AddressPointer = {
1106
+ kind: root.kind,
1107
+ pubkey: root.pubkey,
1108
+ identifier: identifier ?? '',
1109
+ relays: [],
1110
+ }
1111
+ const address = addressable ? pointerToAddress(pointer) : undefined
1112
+ const naddr = addressable ? encodeNaddr(pointer) : undefined
1113
+ const { slots, meta } = resolveSlots(projection, root, {}, manifest)
1114
+
1115
+ return {
1116
+ // A regular event's identity is its id; a replaceable one's is its address,
1117
+ // which survives the author replacing the event.
1118
+ ref: address ?? root.id,
1119
+ address,
1120
+ naddr,
1121
+ eventId: root.id,
1122
+ kind: root.kind,
1123
+ widget: projection.widget,
1124
+ appName: manifest.name,
1125
+ slots,
1126
+ meta,
1127
+ comments: [],
1128
+ folder: tagValue(root, 'h'),
1129
+ openUrl: naddr ? webTemplate?.replace('<bech32>', naddr) : undefined,
1130
+ // See the note above: nothing can be declared to act on a regular event.
1131
+ actions: [],
1132
+ viaRecommendation,
1133
+ }
1134
+ }
1135
+
1136
+ // ── A Folder's project, for the topic sidebar (PEEK-24) ─────────────────────
1137
+
1138
+ /**
1139
+ * A Folder's project and the tickets in motion in it.
1140
+ *
1141
+ * Both are ordinary `ForeignObject`s — the same shape an `naddr` in a message
1142
+ * resolves to, carrying the same slots, the same declared actions and the same
1143
+ * link back into the owning app. The sidebar therefore renders the owning app's
1144
+ * objects with the owning app's affordances rather than a reduced copy of them,
1145
+ * and a ticket is as actionable there as it is inline in a conversation.
1146
+ */
1147
+ export interface FolderProject {
1148
+ project: ForeignObject
1149
+ /**
1150
+ * Every ticket in the project, finished or not: started work first, then the
1151
+ * queue, then what is done. Archived ones are left out — an app hiding a
1152
+ * record from its own lists is saying it is no longer part of the project.
1153
+ */
1154
+ tickets: ForeignObject[]
1155
+ /** Tickets still to do. With `doneCount`, the total the panel counts against. */
1156
+ openCount: number
1157
+ /** Finished tickets — the numerator in the panel's "1/3". */
1158
+ doneCount: number
1159
+ }
1160
+
1161
+ /**
1162
+ * What a status *means*. **The owning app's declaration, with a fallback.**
1163
+ *
1164
+ * *Rewritten by PRO-2.* This used to open "Peek's editorial rule, not the
1165
+ * owning app's", on the grounds that a vocabulary entry was `{value, label,
1166
+ * colour}` and said nothing about whether work was open or finished. That was
1167
+ * true, and treating it as an editorial position was the mistake: Peek is not
1168
+ * the expert on what a Ship status means. Ship is.
1169
+ *
1170
+ * A vocabulary entry now carries `stage` — `open | started | done | dropped` —
1171
+ * and that is read first. The word lists below survive only as the
1172
+ * compatibility path for a manifest published before the field existed, which
1173
+ * cannot be given one retroactively (the `emits.alsoRead` reason, one level
1174
+ * up).
1175
+ *
1176
+ * The old comment named the cost of guessing and called it acceptable: *"an app
1177
+ * whose statuses are spelled differently shows an empty section until its
1178
+ * values are added here. That is the failure worth having."* For a layer whose
1179
+ * purpose is making the third app cheap to build, it is not — the third app
1180
+ * ships, its words are not in the set, and its panel renders blank, which is
1181
+ * PEE-10's failure exactly. A declared stage is what removes the guess.
1182
+ *
1183
+ * Two named sets rather than one allowlist and an "everything else": the panel
1184
+ * reads "1/3", done over the two sets added together, and a status that is
1185
+ * neither should land in neither. A parked "Backlog" or "Triage" is real work
1186
+ * nobody is doing and it would inflate the total into meaninglessness; an
1187
+ * unrecognised status some other app invented is not evidence of anything.
1188
+ *
1189
+ * Leaving both out is also what keeps "3/3" reachable. A cancelled ticket is
1190
+ * not outstanding and never becomes done, so counting it in the total would
1191
+ * leave a finished project stuck at 3/4 for good.
1192
+ *
1193
+ * The visible cost: with something cancelled, the total is smaller than the
1194
+ * number of rows listed below it. The count is about progress through the work,
1195
+ * not about how long the list is, and a total nothing can ever complete is the
1196
+ * worse of the two.
1197
+ *
1198
+ * The other cost is honest: an app whose statuses are spelled differently shows
1199
+ * an empty section until its values are added here. That is the failure worth
1200
+ * having — the alternative is a count that quietly includes work nobody is on.
1201
+ */
1202
+ const OPEN_STATUSES = new Set([
1203
+ 'todo',
1204
+ 'to do',
1205
+ 'in progress',
1206
+ 'started',
1207
+ 'doing',
1208
+ 'in review',
1209
+ 'review',
1210
+ ])
1211
+ const DONE_STATUSES = new Set([
1212
+ 'done',
1213
+ 'completed',
1214
+ 'complete',
1215
+ 'closed',
1216
+ 'cancelled',
1217
+ 'canceled',
1218
+ 'duplicate',
1219
+ ])
1220
+
1221
+ /** `in_progress`, `In-Progress` and `In Progress` are the same status. */
1222
+ const normalizeStatus = (value: string) => value.trim().toLowerCase().replace(/[-_\s]+/g, ' ')
1223
+
1224
+ /**
1225
+ * Match on either what the object *says* or what a reader *sees* — the raw
1226
+ * value (`in_progress`) or the vocabulary label ("In Progress"). Apps disagree
1227
+ * about which of the two is the human-readable one, and checking both costs
1228
+ * nothing.
1229
+ */
1230
+ const inSet = (set: Set<string>) => (raw: string | undefined, label: string | undefined) =>
1231
+ [raw, label].some((v) => v !== undefined && set.has(normalizeStatus(v)))
1232
+
1233
+ const isOpenStatusByLabel = inSet(OPEN_STATUSES)
1234
+ const isDoneStatusByLabel = inSet(DONE_STATUSES)
1235
+
1236
+ /** The four stages a manifest may declare. Anything else is not a stage. */
1237
+ type Stage = 'open' | 'started' | 'done' | 'dropped'
1238
+ const STAGES = new Set<Stage>(['open', 'started', 'done', 'dropped'])
1239
+
1240
+ /**
1241
+ * The stage the owning app declares for a status value, or undefined.
1242
+ *
1243
+ * `undefined` means "this manifest does not say", which is a different fact
1244
+ * from "this status is not progress" and must not be collapsed into one — the
1245
+ * caller falls back to the word lists only in the first case.
1246
+ *
1247
+ * A `stage` outside the four is ignored rather than trusted. Validation is an
1248
+ * honour system (RFC 0.4 §13.4) and this is a consumer reading another app's
1249
+ * self-description; an unrecognised value is treated as undeclared, which
1250
+ * degrades to the fallback instead of inventing a fifth stage.
1251
+ */
1252
+ function declaredStage(
1253
+ manifest: Manifest,
1254
+ spec: SlotSpec | undefined,
1255
+ raw: string | undefined,
1256
+ ): Stage | undefined {
1257
+ if (!spec?.map || raw === undefined) return undefined
1258
+ const stage = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)?.stage
1259
+ return stage && STAGES.has(stage as Stage) ? (stage as Stage) : undefined
1260
+ }
1261
+
1262
+ /**
1263
+ * Is this status finished work?
1264
+ *
1265
+ * `dropped` counts as done rather than open, which is what keeps "3/3"
1266
+ * reachable: a cancelled ticket is never going to become done, so leaving it
1267
+ * outstanding pins a finished project below its total for ever. It is not
1268
+ * *progress* either, and an app that wanted to draw that distinction now can —
1269
+ * the stage is on the wire and this fold is the consumer's, not the protocol's.
1270
+ */
1271
+ function isDone(stage: Stage | undefined, raw: string | undefined, label: string | undefined) {
1272
+ if (stage) return stage === 'done' || stage === 'dropped'
1273
+ return isDoneStatusByLabel(raw, label)
1274
+ }
1275
+
1276
+ function isOpen(stage: Stage | undefined, raw: string | undefined, label: string | undefined) {
1277
+ if (stage) return stage === 'open' || stage === 'started'
1278
+ return isOpenStatusByLabel(raw, label)
1279
+ }
1280
+
1281
+ /**
1282
+ * Started work sits above the queue.
1283
+ *
1284
+ * Both halves are open, but "in progress" is what somebody opened the panel to
1285
+ * find; ordering by activity alone would bury it under whatever was filed most
1286
+ * recently.
1287
+ */
1288
+ const STARTED = new Set(['in progress', 'started', 'doing', 'in review', 'review'])
1289
+ const isStartedByLabel = inSet(STARTED)
1290
+
1291
+ function isStarted(stage: Stage | undefined, raw: string | undefined, label: string | undefined) {
1292
+ if (stage) return stage === 'started'
1293
+ return isStartedByLabel(raw, label)
1294
+ }
1295
+
1296
+ /**
1297
+ * The containment relation a manifest declares: "this kind holds that kind".
1298
+ *
1299
+ * Read from the `list` slot, which says it outright, falling back to the
1300
+ * inference this used to depend on. Returns null when the app says neither — in
1301
+ * which case Peek does not guess, and the sidebar shows nothing.
1302
+ */
1303
+ function containmentFor(
1304
+ manifest: Manifest,
1305
+ containerKind: number,
1306
+ ): { childKind: number; linkTag: string; limit?: number; match?: 'address' | 'identifier' } | null {
1307
+ /*
1308
+ The declared answer first — PRO-2.
1309
+
1310
+ A `list` slot says outright which kind this one holds and by which tag. What
1311
+ follows below is the older path, and it is worth naming what it does: it
1312
+ reads an action that *creates* a child (`toAddressOf: "self"`) and infers a
1313
+ *read* relationship from it. That worked, and it was a deduction from a
1314
+ write declaration — an app offering no create-action, or offering one for a
1315
+ kind it does not actually contain, was invisible or wrong respectively.
1316
+
1317
+ Kept as a compatibility path rather than deleted, because a manifest
1318
+ published before PRO-2 cannot be given a `list` slot retroactively and
1319
+ consumers upgrade before producers do. It goes when nothing in use relies
1320
+ on it.
1321
+ */
1322
+ const projection = manifest.projections?.[String(containerKind)]
1323
+ const listSlot = projection && !Array.isArray(projection.slots.list) ? projection.slots.list : undefined
1324
+ if (listSlot?.children) {
1325
+ const { kind, via, limit, match } = listSlot.children
1326
+ return { childKind: kind, linkTag: via, limit, match }
1327
+ }
1328
+
1329
+ for (const action of manifest.actions ?? []) {
1330
+ if (action.emits.toAddressOf !== 'self') continue
1331
+ if (!asArray(action.appliesTo).includes(String(containerKind))) continue
1332
+ return { childKind: action.emits.kind, linkTag: action.emits.setTag ?? 'a' }
1333
+ }
1334
+ return null
1335
+ }
1336
+
1337
+ /**
1338
+ * How deep a consumer will follow `list` slots. **The consumer's budget.**
1339
+ *
1340
+ * A child rendered through its own projection may declare a `list` of its own,
1341
+ * so resolution is recursive and something has to stop it. That something is
1342
+ * here rather than in the manifest: the app at risk of the render loop is the
1343
+ * one drawing it, and a producer able to set this number could hang any
1344
+ * consumer that trusted it (RFC 0.4 §13.4 — validation is an honour system).
1345
+ *
1346
+ * One level is what the panel needs: a project and its issues. Raising it is a
1347
+ * decision about this app's boot cost, not about anyone else's data.
1348
+ */
1349
+ const MAX_LIST_DEPTH = 1
1350
+
1351
+ /**
1352
+ * The project living in a Folder, plus the work currently in motion in it.
1353
+ *
1354
+ * Starts from a **Folder** rather than an naddr, which is the difference
1355
+ * between this and `resolveForeignObject`: a Peek topic and a Linear-lite
1356
+ * project are one container (RFC_UPDATES.md §1.1), so a topic already knows
1357
+ * enough to ask "what project is this?" without anyone pasting a reference.
1358
+ *
1359
+ * Still knows nothing about Linear-lite. Which kind is a project, which kind is
1360
+ * an issue, how they link, how status folds and what it is called all come off
1361
+ * published manifests at runtime — the same discipline as the rest of this file.
1362
+ *
1363
+ * Returns null for "nothing to show": no manifest declares a container, no
1364
+ * project in this Folder and none named by the tickets in it, or the object
1365
+ * cannot be read. All of those are ordinary states the caller renders as an
1366
+ * empty sidebar, not errors.
1367
+ */
1368
+ export async function resolveFolderProject(
1369
+ folder: string,
1370
+ query: QueryFn,
1371
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
1372
+ lookupPeople?: PeopleFn,
1373
+ /**
1374
+ * How many `list` levels have already been followed to get here. Callers
1375
+ * outside this module leave it at 0; it exists so that following a child's
1376
+ * own `list` is a budget check rather than a thing nobody remembered.
1377
+ */
1378
+ depth = 0,
1379
+ ): Promise<FolderProject | null> {
1380
+ // The render loop this forbids is not hypothetical: two folders naming each
1381
+ // other's projects resolve forever, and the manifest declaring them is
1382
+ // another app's. See MAX_LIST_DEPTH.
1383
+ if (depth > MAX_LIST_DEPTH) return null
1384
+ // 1. Which kinds are containers? A Folder query needs kind numbers up front,
1385
+ // and the only non-app-specific source for them is the published handlers.
1386
+ // This pass is discovery only — the manifest that actually *renders* the
1387
+ // project is resolved below, recommendation-first, once its author is known.
1388
+ const handlerFilter = [{ kinds: [KIND_HANDLER_INFORMATION], limit: 20 }]
1389
+ const handlers = await query(handlerFilter)
1390
+ const containers = new Map<number, { childKind: number; linkTag: string; limit?: number; match?: 'address' | 'identifier' }>()
1391
+ let discovered: Manifest | undefined
1392
+ for (const event of handlers) {
1393
+ const manifest = parseManifest(event)
1394
+ for (const kind of Object.keys(manifest?.projections ?? {})) {
1395
+ const relation = manifest && containmentFor(manifest, Number(kind))
1396
+ if (relation) {
1397
+ containers.set(Number(kind), relation)
1398
+ discovered ??= manifest
1399
+ }
1400
+ }
1401
+ }
1402
+ if (containers.size === 0) {
1403
+ return null
1404
+ }
1405
+
1406
+ // 2. One round trip for everything in the Folder: containers, their children,
1407
+ // and the changes that have landed on either — `h` is what they share.
1408
+ // Changes are needed *before* a project is chosen, because whether one is
1409
+ // archived is itself a folded field.
1410
+ const childKinds = [...new Set([...containers.values()].map((c) => c.childKind))]
1411
+ const discoveredRule = discovered?.records
1412
+ const inFolderRaw = await query([
1413
+ { '#h': [folder], kinds: [...containers.keys(), ...childKinds], limit: 200 },
1414
+ ...(discoveredRule ? [{ '#h': [folder], kinds: [discoveredRule.changeKind], limit: 500 }] : []),
1415
+ ])
1416
+
1417
+ /*
1418
+ **A Folder is not a file inside itself.**
1419
+
1420
+ A channel's own `kind:39000` comes back from an `#h` query for that channel.
1421
+ It carries no `h` tag — the relay scopes a discovery event to the channel it
1422
+ describes, so it arrives with the contents. What marks it out is that its
1423
+ `d` *is* the folder uuid; a file sitting in a Folder has its own uuid,
1424
+ different from the Folder's.
1425
+
1426
+ Dropped here, once, rather than at each use. Two places downstream ask "does
1427
+ this Folder hold a container", and they have to agree: `holdsContainer`
1428
+ decides whether to go looking for a project the Folder does not hold, and
1429
+ `roots` decides which container is the subject. Filtering only the second
1430
+ makes them disagree and the panel resolves to nothing at all.
1431
+
1432
+ Until Peek published a manifest this could not bite, because only Ship's
1433
+ kinds were containers and a `39000` was never a candidate. PRO-6 declared
1434
+ Topic a container — it holds messages — so the Topic became a candidate,
1435
+ won the contest against the actual project, and the sidebar listed the
1436
+ *messages* as tickets. Each was titled with its author's pubkey, because a
1437
+ message resolved through Peek's own projection has `title: {field: "pubkey"}`.
1438
+
1439
+ App-neutral on purpose: this is a fact about folders and discovery events,
1440
+ not about Peek.
1441
+ */
1442
+ const inFolder = inFolderRaw.filter((e) => tagValue(e, 'd') !== folder)
1443
+
1444
+ const addressOfEvent = (event: SignedEvent) =>
1445
+ pointerToAddress({
1446
+ kind: event.kind,
1447
+ pubkey: event.pubkey,
1448
+ identifier: tagValue(event, 'd') ?? '',
1449
+ relays: [],
1450
+ })
1451
+
1452
+ /**
1453
+ * 2b. A Folder can hold a project's *work* without holding the project.
1454
+ *
1455
+ * An event cannot change its own `h`. So a project created in one Folder and
1456
+ * later paired with a Peek topic keeps the `h` it was born with, and the
1457
+ * pairing is expressed the only way an append-only record can express it — a
1458
+ * change event. Ship published that change only in the Folder the *record*
1459
+ * lives in, so nothing carrying the topic's `h` named the project at all: the
1460
+ * query above saw a Folder full of tickets and no project, and the panel said
1461
+ * "no project linked" about a Folder holding eleven of its issues (PEEK-24).
1462
+ *
1463
+ * A consumer cannot require the producer to have got that right, and this one
1464
+ * must keep working against the events already on the relay, so it reads
1465
+ * whatever the Folder does hold.
1466
+ *
1467
+ * Two things in the Folder can name it, and both use a tag the manifest has
1468
+ * already declared rather than any new vocabulary.
1469
+ *
1470
+ * A **ticket** names its project in the containment relation's link tag,
1471
+ * which is the same tag step 5 reads to decide which tickets belong here. A
1472
+ * Folder whose tickets all point at one project *is* that project's Folder,
1473
+ * whichever Folder the project record happens to sit in.
1474
+ *
1475
+ * A **change published into this Folder** names its target in the records
1476
+ * rule's target tag. That is an app saying out loud "the object at this
1477
+ * address belongs here" — the only way to say it when the object's own `h`
1478
+ * cannot be moved, and the one that does not need a ticket to exist yet.
1479
+ * Estiva Ship writes it when a project is paired with a topic.
1480
+ *
1481
+ * Reading both matters, because they fail in opposite directions: the tickets
1482
+ * cover a Folder paired before anybody wrote the statement, and the statement
1483
+ * covers a Folder paired before anybody filed a ticket. Where they disagree
1484
+ * the sort below decides, and it already prefers whichever candidate the
1485
+ * Folder's work actually belongs to.
1486
+ *
1487
+ * Its changes come by address rather than by `h`, because they are wherever
1488
+ * the record is, not here. Skipped entirely when the Folder does hold a
1489
+ * container, which is the ordinary case and already correct.
1490
+ */
1491
+ const holdsContainer = inFolder.some((e) => containers.has(e.kind))
1492
+ const namedInFolder = holdsContainer
1493
+ ? []
1494
+ : [
1495
+ ...new Set(
1496
+ inFolder.flatMap((event) => {
1497
+ const relation = [...containers.values()].find((c) => c.childKind === event.kind)
1498
+ if (relation) {
1499
+ const link = tagValue(event, relation.linkTag)
1500
+ return link ? [link] : []
1501
+ }
1502
+ if (discoveredRule && event.kind === discoveredRule.changeKind) {
1503
+ const target = tagValue(event, discoveredRule.targetTag)
1504
+ return target ? [target] : []
1505
+ }
1506
+ return []
1507
+ }),
1508
+ ),
1509
+ ]
1510
+ // Only a container may be adopted this way. A child's link tag can name
1511
+ // anything, and a ticket that points at another ticket must not become the
1512
+ // subject of the panel.
1513
+ const linkedOutward = namedInFolder.flatMap((link) => {
1514
+ try {
1515
+ const pointer = referenceToPointer(link)
1516
+ return containers.has(pointer.kind) ? [{ link, pointer }] : []
1517
+ } catch {
1518
+ return []
1519
+ }
1520
+ })
1521
+
1522
+ const adopted = linkedOutward.length
1523
+ ? await query([
1524
+ ...linkedOutward.map(({ pointer }) => ({
1525
+ kinds: [pointer.kind],
1526
+ authors: [pointer.pubkey],
1527
+ '#d': [pointer.identifier],
1528
+ limit: 1,
1529
+ })),
1530
+ ...(discoveredRule
1531
+ ? [
1532
+ {
1533
+ kinds: [discoveredRule.changeKind],
1534
+ '#a': linkedOutward.map(({ link }) => link),
1535
+ limit: 500,
1536
+ },
1537
+ ]
1538
+ : []),
1539
+ ])
1540
+ : []
1541
+
1542
+ /** Everything the Folder holds, plus whatever it was adopted from. */
1543
+ const known = adopted.length ? [...inFolder, ...adopted] : inFolder
1544
+
1545
+ /** Is this object one its owning app would keep out of its own lists? */
1546
+ const isHidden = (event: SignedEvent, rule: RecordsRule | undefined, source = known) => {
1547
+ if (!rule?.hiddenWhen) return false
1548
+ const applied = source.filter(
1549
+ (e) => e.kind === rule.changeKind && tagValue(e, rule.targetTag) === addressOfEvent(event),
1550
+ )
1551
+ return foldChanges(applied, rule)[rule.hiddenWhen.field]?.value === rule.hiddenWhen.equals
1552
+ }
1553
+
1554
+ const roots = known
1555
+ .filter((e) => containers.has(e.kind))
1556
+ .filter((e) => !isHidden(e, discoveredRule))
1557
+ if (roots.length === 0) {
1558
+ return null
1559
+ }
1560
+
1561
+ /**
1562
+ * Which project, when a Folder holds several.
1563
+ *
1564
+ * Nothing forbids more than one, and pairing makes it common rather than
1565
+ * exotic: a topic accumulates a project per attempt. Ranking by age alone
1566
+ * picked whichever was created last — in practice a stray — so the work
1567
+ * decides instead. The project the Folder's tickets point at is the project
1568
+ * the Folder is about; age only breaks the tie.
1569
+ */
1570
+ const ticketsFor = (candidate: SignedEvent) => {
1571
+ const relation = containers.get(candidate.kind)!
1572
+ const candidateAddress = addressOfEvent(candidate)
1573
+ return inFolder.filter(
1574
+ (e) => e.kind === relation.childKind && tagValue(e, relation.linkTag) === candidateAddress,
1575
+ ).length
1576
+ }
1577
+ const root = [...roots].sort(
1578
+ (a, b) => ticketsFor(b) - ticketsFor(a) || b.created_at - a.created_at,
1579
+ )[0]
1580
+
1581
+ const pointer: AddressPointer = {
1582
+ kind: root.kind,
1583
+ pubkey: root.pubkey,
1584
+ identifier: tagValue(root, 'd') ?? '',
1585
+ relays: [],
1586
+ }
1587
+ const address = pointerToAddress(pointer)
1588
+
1589
+ // 3. Now the authoritative manifest — the object's author gets to say which
1590
+ // app renders their project (kind:31989), same as for an inline reference.
1591
+ const resolved = await resolveManifest(pointer, query)
1592
+ if (!resolved) {
1593
+ return null
1594
+ }
1595
+ const { manifest, viaRecommendation } = resolved
1596
+ const projection = manifest.projections?.[String(root.kind)]
1597
+ if (!projection) {
1598
+ return null
1599
+ }
1600
+ const records = foldRuleOf(manifest)
1601
+ const relation = containmentFor(manifest, root.kind) ?? containers.get(root.kind)!
1602
+
1603
+ // 4. Every change in the Folder, folded per object. Already fetched in step 2
1604
+ // unless the authoritative manifest orders records differently from the one
1605
+ // discovery happened to find, which is the only case worth a second trip.
1606
+ // That trip asks by address as well as by Folder: an adopted project's own
1607
+ // changes are not in this Folder, and a `#h` query alone would fold it
1608
+ // without them and report the defaults as its state.
1609
+ const changes = !manifest.records
1610
+ ? // The app declares no change kind, so there is nothing to ask for. Not a
1611
+ // filter that matches nothing — a kind is `u16` and the relay refuses an
1612
+ // out-of-range one outright. See `foldRuleOf`.
1613
+ []
1614
+ : discoveredRule?.changeKind === records.changeKind
1615
+ ? known.filter((e) => e.kind === records.changeKind)
1616
+ : await query([
1617
+ { kinds: [records.changeKind], '#h': [folder], limit: 500 },
1618
+ ...(adopted.length ? [{ kinds: [records.changeKind], '#a': [address], limit: 500 }] : []),
1619
+ ])
1620
+ const changesFor = (target: string) =>
1621
+ changes.filter((e) => tagValue(e, records.targetTag) === target)
1622
+
1623
+ const project = buildObject({
1624
+ root,
1625
+ pointer,
1626
+ manifest,
1627
+ projection,
1628
+ folded: foldChanges(changesFor(address), records),
1629
+ viaRecommendation,
1630
+ webTemplate: resolved.webTemplate,
1631
+ })
1632
+
1633
+ // 5. The tickets. A child either names this project or names nothing at all —
1634
+ // an unlinked ticket still lives in the Folder, and the Folder *is* the
1635
+ // project, so dropping it would under-report live work.
1636
+ const ticketProjection = manifest.projections?.[String(relation.childKind)]
1637
+ const statusSpec =
1638
+ ticketProjection && !Array.isArray(ticketProjection.slots.status)
1639
+ ? ticketProjection.slots.status
1640
+ : undefined
1641
+ // A project whose tickets have no declared projection is still worth showing;
1642
+ // it just has nothing to expand into.
1643
+ /**
1644
+ * One lookup for everyone the panel could name, shared by every object it
1645
+ * returns. The project's lead is the only one drawn today; the tickets carry
1646
+ * the same map so a renderer that starts showing assignees needs no second
1647
+ * trip, and so `ForeignObject` means the same thing whichever resolver built
1648
+ * it.
1649
+ */
1650
+ const withPeople = async (objects: ForeignObject[]) => {
1651
+ const people = await (lookupPeople ?? peopleViaRelay(query))([
1652
+ ...new Set(objects.flatMap(pubkeysIn)),
1653
+ ])
1654
+ return objects.map((object) => ({ ...object, people }))
1655
+ }
1656
+
1657
+ if (!ticketProjection) {
1658
+ const [only] = await withPeople([project])
1659
+ return { project: only, tickets: [], openCount: 0, doneCount: 0 }
1660
+ }
1661
+
1662
+ const rows: { ticket: ForeignObject; rank: number; activityMs: number }[] = []
1663
+ let openCount = 0
1664
+ let doneCount = 0
1665
+ for (const event of inFolder) {
1666
+ if (event.kind !== relation.childKind) continue
1667
+ /*
1668
+ What the link is compared against depends on what the child carries.
1669
+
1670
+ Ship's issues name their project by full address; Peek's messages name
1671
+ their channel with `h`, which holds the bare uuid — the parent's `d`, not
1672
+ its address. The manifest says which (`match`), because guessing is what
1673
+ PRO-2 removed and comparing against both would silently accept a child
1674
+ that named something else entirely whose `d` happened to collide.
1675
+ */
1676
+ const expected = relation.match === 'identifier' ? pointer.identifier : address
1677
+ const link = tagValue(event, relation.linkTag)
1678
+ if (link !== undefined && link !== expected) continue
1679
+ // Archived is the one exclusion left. Everything else the project holds is
1680
+ // listed; a record its own app hides is not part of the project any more.
1681
+ if (isHidden(event, records, changes)) continue
1682
+
1683
+ const ticketPointer: AddressPointer = {
1684
+ kind: event.kind,
1685
+ pubkey: event.pubkey,
1686
+ identifier: tagValue(event, 'd') ?? '',
1687
+ relays: [],
1688
+ }
1689
+ const ticketChanges = changesFor(pointerToAddress(ticketPointer))
1690
+ const folded = foldChanges(ticketChanges, records)
1691
+ const status = statusSpec ? resolveSlot(statusSpec, event, folded, manifest) : null
1692
+ const raw = statusSpec ? rawSlotValue(statusSpec, event, folded) : undefined
1693
+
1694
+ // The owning app's declaration first, the label guess only if it has none.
1695
+ const stage = declaredStage(manifest, statusSpec, raw)
1696
+ const done = isDone(stage, raw, status?.value)
1697
+ if (done) doneCount += 1
1698
+ else if (isOpen(stage, raw, status?.value)) openCount += 1
1699
+ // A status in neither set — parked, or one this consumer has never seen —
1700
+ // is still a ticket and still listed. It just cannot claim to be progress
1701
+ // in either direction, so it stays out of both counts.
1702
+
1703
+ rows.push({
1704
+ ticket: buildObject({
1705
+ root: event,
1706
+ pointer: ticketPointer,
1707
+ manifest,
1708
+ projection: ticketProjection,
1709
+ folded,
1710
+ viaRecommendation,
1711
+ webTemplate: resolved.webTemplate,
1712
+ }),
1713
+ rank: isStarted(stage, raw, status?.value) ? 0 : done ? 2 : 1,
1714
+ activityMs: Math.max(event.created_at * 1000, ...ticketChanges.map(orderingMs)),
1715
+ })
1716
+ }
1717
+ // Started work, then the queue, then what is finished; within each, whatever
1718
+ // moved most recently. The whole project is here — the order is what makes it
1719
+ // scannable rather than a dump.
1720
+ rows.sort((a, b) => a.rank - b.rank || b.activityMs - a.activityMs)
1721
+
1722
+ /*
1723
+ The producer's declared `limit`, applied after sorting rather than before.
1724
+
1725
+ It says how many children are worth rendering, so cutting before the sort
1726
+ would drop whichever happened to be read first and could hide every started
1727
+ ticket behind a hundred finished ones. The counts above are deliberately
1728
+ computed over everything: "4 of 117" stays true when only 200 rows are
1729
+ drawn, and a total that changed with the render budget would be a different
1730
+ and worse number.
1731
+ */
1732
+ const capped = relation.limit ? rows.slice(0, relation.limit) : rows
1733
+
1734
+ const [resolvedProject, ...tickets] = await withPeople([project, ...capped.map((r) => r.ticket)])
1735
+ return { project: resolvedProject, tickets, openCount, doneCount }
1736
+ }
1737
+
1738
+ /**
1739
+ * Build the event that performs a manifest-declared action.
1740
+ *
1741
+ * Pure, and separate from the Convex action for the same reason `projection.ts`
1742
+ * is separate from `foreign.ts`: this is the part worth testing against a live
1743
+ * relay, and it must not need a deployment or a signed-in session to run.
1744
+ *
1745
+ * Returns a string on refusal rather than throwing — every failure here is
1746
+ * something a user should read.
1747
+ */
1748
+ export function buildActionEvent(args: {
1749
+ manifest: { records?: RecordsRule; actions?: ManifestAction[]; vocabularies?: Manifest['vocabularies'] }
1750
+ kind: number
1751
+ /** Address of the object being acted on. */
1752
+ address: string
1753
+ /** Author of the object — needed for NIP-22's `P`/`p` tags. */
1754
+ objectAuthor: string
1755
+ folder: string
1756
+ actionId: string
1757
+ value: string
1758
+ pubkey: string
1759
+ createdAtMs: number
1760
+ }): UnsignedActionEvent | string {
1761
+ const { manifest, kind, address, folder, actionId, value } = args
1762
+ const records = manifest.records
1763
+ const declared = manifest.actions?.find((a) => a.id === actionId)
1764
+ if (!records) return 'That app does not say how its records are written.'
1765
+ if (!declared) return `This app does not offer "${actionId}".`
1766
+
1767
+ const appliesTo = Array.isArray(declared.appliesTo) ? declared.appliesTo : [declared.appliesTo]
1768
+ if (!appliesTo.includes(String(kind))) {
1769
+ return `"${declared.label}" does not apply to a kind ${kind}.`
1770
+ }
1771
+
1772
+ // Validate against the manifest's own vocabulary. The owning app cannot
1773
+ // enforce this — anyone can publish anything (RFC_UPDATES.md §3) — so a
1774
+ // consumer that skips the check is the one putting junk in the shared record.
1775
+ // Checking here is Peek keeping its side of the honour system.
1776
+ if (declared.input?.enum) {
1777
+ const vocab = manifest.vocabularies?.[declared.input.enum] ?? []
1778
+ if (!vocab.some((entry) => entry.value === value)) {
1779
+ return `"${value}" is not one of ${vocab.map((e) => e.value).join(', ')}.`
1780
+ }
1781
+ }
1782
+
1783
+ const tags: string[][] = declared.emits.field
1784
+ ? [
1785
+ [records.targetTag, address],
1786
+ [records.fieldTag, declared.emits.field],
1787
+ [records.valueTag, value],
1788
+ ['h', folder],
1789
+ ]
1790
+ : // NIP-22 comment. Built from the ratified NIP rather than from the
1791
+ // manifest, which is legitimate precisely because no app owns kind:1111 —
1792
+ // the same reason the owning app uses it. Uppercase tags name the thread
1793
+ // root, lowercase the immediate parent.
1794
+ [
1795
+ ['A', address],
1796
+ ['K', String(kind)],
1797
+ ['P', args.objectAuthor],
1798
+ ['a', address],
1799
+ ['k', String(kind)],
1800
+ ['p', args.objectAuthor],
1801
+ ['h', folder],
1802
+ ]
1803
+
1804
+ // The manifest told us how it orders events; write events that can be ordered.
1805
+ // Without `ts`, a change from here and one from the owning app in the same
1806
+ // second would be separated by event id — arbitrarily, and differently
1807
+ // depending on which app you asked (FRICTION.md A6).
1808
+ if (records.order?.includes('ts')) tags.push(['ts', String(args.createdAtMs)])
1809
+
1810
+ return {
1811
+ pubkey: args.pubkey,
1812
+ created_at: Math.floor(args.createdAtMs / 1000),
1813
+ kind: declared.emits.kind,
1814
+ tags,
1815
+ content: declared.emits.field ? '' : value,
1816
+ }
1817
+ }
1818
+
1819
+ /**
1820
+ * Test seam: resolve one projection's `title` slot against one event.
1821
+ *
1822
+ * Exported so the tag-fallback rules can be pinned without standing up a fake
1823
+ * relay. `resolveSlots` and `resolveSlot` are the real path; this only picks
1824
+ * the one slot out of them.
1825
+ */
1826
+ export function resolveFolderProjectSlotsForTest(
1827
+ manifest: Manifest,
1828
+ root: SignedEvent,
1829
+ ): string | undefined {
1830
+ const projection = manifest.projections?.[String(root.kind)]
1831
+ if (!projection) return undefined
1832
+ return resolveSlots(projection, root, {}, manifest).slots.title?.value
1833
+ }