@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,1334 @@
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 } from '@estiva-app/protocol';
16
+ import { parseProfile } from '@estiva-app/protocol';
17
+ /** The default: one kind:0 query, straight to the relay. */
18
+ export function peopleViaRelay(query) {
19
+ return async (pubkeys) => {
20
+ if (pubkeys.length === 0)
21
+ return {};
22
+ const events = await query([
23
+ { kinds: [KIND_PROFILE], authors: pubkeys, limit: pubkeys.length },
24
+ ]);
25
+ const people = {};
26
+ // Oldest first, so a newer profile overwrites an older one. kind:0 is
27
+ // replaceable and the relay should hold one per author, but ordering the
28
+ // fold is cheaper than trusting that.
29
+ for (const event of [...events].sort(byOrder)) {
30
+ people[event.pubkey] = parseProfile(event);
31
+ }
32
+ return people;
33
+ };
34
+ }
35
+ /**
36
+ * Every pubkey an object would put on screen.
37
+ *
38
+ * Slots and meta carry them as values; a `pubkey` action carries the current
39
+ * holder of the field it sets, which in the sidebar is the *only* place the
40
+ * project's lead appears. Missing that one is what left a face-shaped button
41
+ * showing eight hex characters.
42
+ */
43
+ function pubkeysIn(object) {
44
+ const keys = [];
45
+ for (const slot of [...Object.values(object.slots), ...object.meta]) {
46
+ if (slot.isPubkey && slot.value)
47
+ keys.push(slot.value);
48
+ }
49
+ for (const action of object.actions) {
50
+ if (action.control === 'pubkey' && action.current)
51
+ keys.push(action.current);
52
+ }
53
+ return keys;
54
+ }
55
+ /** kind:0, the profile every other app publishes too. */
56
+ const KIND_PROFILE = 0;
57
+ /** NIP-89 kinds. These two are protocol, not app-specific. */
58
+ const KIND_HANDLER_RECOMMENDATION = 31989;
59
+ const KIND_HANDLER_INFORMATION = 31990;
60
+ /**
61
+ * Default kind for comments on a foreign object.
62
+ *
63
+ * NIP-22, and no app owns it — but the owning app gets to say otherwise. An app
64
+ * whose objects live in a Folder may well treat a comment as a message in that
65
+ * container instead, which is a better answer when the container is also a Peek
66
+ * topic: the comment and the topic conversation become the same event rather
67
+ * than two records nobody reconciles.
68
+ *
69
+ * So this is the fallback, and `commentKindsOf` reads the real one off the
70
+ * manifest's own `comment` action. Hardcoding it here would quietly stop
71
+ * finding comments the moment an app said something different.
72
+ */
73
+ const KIND_COMMENT = 1111;
74
+ /**
75
+ * Every kind an app's comments might be under — what it publishes **now**, plus
76
+ * any it has published before.
77
+ *
78
+ * `emits.kind` is a single number, and for a while that was enough. It stops
79
+ * being enough the moment an app *changes* the kind it emits, because the old
80
+ * events do not move: a `kind:9` message is not replaceable at all, so a comment
81
+ * written under the old kind stays under it permanently. Reading only the
82
+ * declared kind would show an object's newest comments and silently drop every
83
+ * one written before the change — no error, nothing empty, just a thread that
84
+ * begins in the middle.
85
+ *
86
+ * So the owning app may also declare `emits.alsoRead`, which is the kinds it
87
+ * used to publish. That is the only place the knowledge actually lives; the
88
+ * alternative is Peek hardcoding one app's history, which is exactly what the
89
+ * note on `KIND_COMMENT` above says not to do.
90
+ *
91
+ * Absent `alsoRead` this returns a single kind, so a manifest written before
92
+ * the field existed behaves exactly as it did.
93
+ */
94
+ export function commentKindsOf(manifest) {
95
+ const declared = manifest.actions?.find((action) => action.id === 'comment');
96
+ const current = declared?.emits?.kind ?? KIND_COMMENT;
97
+ const superseded = declared?.emits?.alsoRead ?? [];
98
+ return [...new Set([current, ...superseded])];
99
+ }
100
+ /** Actions declared for this kind, resolved against the vocabularies. */
101
+ function resolveActions(manifest, kind, folded) {
102
+ const applies = (action) => (Array.isArray(action.appliesTo) ? action.appliesTo : [action.appliesTo]).includes(String(kind));
103
+ const out = [];
104
+ for (const action of manifest.actions ?? []) {
105
+ if (!applies(action))
106
+ continue;
107
+ // Only field-setting changes and comments are renderable today. An action
108
+ // that creates a whole new object (`add-issue`) needs a form and a parent,
109
+ // so it is skipped rather than drawn as a control that cannot work.
110
+ const isChange = !!action.emits.field;
111
+ const isComment = action.emits.scope === 'address';
112
+ if (!isChange && !isComment)
113
+ continue;
114
+ const vocab = action.input?.enum ? manifest.vocabularies?.[action.input.enum] : undefined;
115
+ out.push({
116
+ id: action.id,
117
+ label: action.label,
118
+ control: vocab ? 'select' : action.input?.type === 'pubkey' ? 'pubkey' : 'text',
119
+ options: vocab?.map((v) => ({ value: v.value, label: v.label, colour: v.colour })),
120
+ current: action.emits.field ? folded[action.emits.field]?.value : undefined,
121
+ field: action.emits.field,
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+ const tagValue = (e, name) => e.tags.find((t) => t[0] === name)?.[1];
127
+ /** A manifest event's `content`, or null when it is not parseable JSON. */
128
+ function parseManifest(event) {
129
+ try {
130
+ return JSON.parse(event.content);
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ const asArray = (value) => (Array.isArray(value) ? value : [value]);
137
+ /**
138
+ * Which manifest wins when several claim the same kind.
139
+ *
140
+ * A `#k` query is NIP-89's discovery mechanism and it returns every handler for
141
+ * a kind, ranked by nothing. So the object's **author's** kind:31989
142
+ * recommendation is consulted first: the person who created the object is the
143
+ * one entitled to say which app renders it, and that is a trust anchor Peek
144
+ * already has rather than a value someone has to paste into a config file.
145
+ *
146
+ * The `#k` fallback exists so an object whose author never published a
147
+ * recommendation still renders, rather than failing closed on a missing
148
+ * preference. When it fires, `viaRecommendation` is false and the caller can
149
+ * say so in the UI — "we guessed" and "we were told" should not look identical.
150
+ */
151
+ /**
152
+ * NIP-89's `web` tag: how to open one of these objects in the app that owns it.
153
+ *
154
+ * `["web", "https://host/#/o/<bech32>", "naddr"]` — the consumer substitutes the
155
+ * entity it is holding. Read off the *event* rather than the manifest content,
156
+ * because that is where NIP-89 puts it.
157
+ *
158
+ * Returns undefined when no usable template was published, which is an ordinary
159
+ * state rather than an error: an app may render objects it has nowhere to open.
160
+ */
161
+ function webTemplate(event, entity) {
162
+ for (const tag of event.tags) {
163
+ if (tag[0] !== 'web' || !tag[1])
164
+ continue;
165
+ // The third element names the entity type; NIP-89 permits omitting it, in
166
+ // which case the template applies to whatever we are holding.
167
+ if (tag[2] && tag[2] !== entity)
168
+ continue;
169
+ if (!tag[1].includes('<bech32>'))
170
+ continue;
171
+ return tag[1];
172
+ }
173
+ return undefined;
174
+ }
175
+ export async function resolveManifest(pointer, query) {
176
+ const parse = parseManifest;
177
+ const addressOf = (event) => `${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`;
178
+ const recommended = await query([
179
+ {
180
+ kinds: [KIND_HANDLER_RECOMMENDATION],
181
+ authors: [pointer.pubkey],
182
+ '#d': [String(pointer.kind)],
183
+ limit: 1,
184
+ },
185
+ ]);
186
+ const manifestAddr = recommended[0]?.tags.find((t) => t[0] === 'a')?.[1];
187
+ if (manifestAddr) {
188
+ const [kind, pubkey, ...rest] = manifestAddr.split(':');
189
+ const found = await query([
190
+ { kinds: [Number(kind)], authors: [pubkey], '#d': [rest.join(':')], limit: 1 },
191
+ ]);
192
+ const manifest = found[0] ? parse(found[0]) : null;
193
+ if (manifest) {
194
+ return {
195
+ manifest,
196
+ address: manifestAddr,
197
+ viaRecommendation: true,
198
+ webTemplate: webTemplate(found[0], 'naddr'),
199
+ };
200
+ }
201
+ }
202
+ const claimed = await query([
203
+ { kinds: [KIND_HANDLER_INFORMATION], '#k': [String(pointer.kind)], limit: 20 },
204
+ ]);
205
+ // Newest wins among unrecommended candidates. Arbitrary, and honest about it:
206
+ // there is no principled ranking without a recommendation, which is exactly
207
+ // why the recommendation exists.
208
+ const newest = [...claimed].sort((a, b) => b.created_at - a.created_at);
209
+ for (const candidate of newest) {
210
+ const manifest = parse(candidate);
211
+ if (manifest?.projections?.[String(pointer.kind)]) {
212
+ return {
213
+ manifest,
214
+ address: addressOf(candidate),
215
+ viaRecommendation: false,
216
+ webTemplate: webTemplate(candidate, 'naddr'),
217
+ };
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+ /**
223
+ * Ordering key for an append-only event, per the manifest's `records.order`.
224
+ *
225
+ * The manifest declares `["ts", "created_at", "id"]` and, crucially, the
226
+ * condition under which `ts` may be believed: only when it agrees with
227
+ * `created_at` to the second. `created_at` has one-second resolution and the
228
+ * relay validates it, so a `ts` pinned inside that second inherits that
229
+ * validation and can only refine ordering *within* it.
230
+ *
231
+ * Peek enforces that bound itself rather than trusting the writer. Under the
232
+ * honour-system model nothing validates these events (RFC_UPDATES.md §3), so a
233
+ * buggy or pushy client claiming a far-future `ts` would otherwise win every
234
+ * fold forever — in Peek's rendering as much as in the owning app's.
235
+ */
236
+ function orderingMs(event) {
237
+ const raw = Number.parseInt(tagValue(event, 'ts') ?? '', 10);
238
+ if (Number.isFinite(raw) && Math.abs(Math.floor(raw / 1000) - event.created_at) <= 1)
239
+ return raw;
240
+ return event.created_at * 1000;
241
+ }
242
+ /** Total order, oldest first. Ties break on the lower event id (NIP-01's rule). */
243
+ function byOrder(a, b) {
244
+ const [at, bt] = [orderingMs(a), orderingMs(b)];
245
+ if (at !== bt)
246
+ return at - bt;
247
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
248
+ }
249
+ /** Replay change events into current field values. Last write wins per field. */
250
+ /**
251
+ * The fold rule a manifest declares, or one that folds nothing — PRO-6.
252
+ *
253
+ * `records` is optional. An app with no change events needs no rule for
254
+ * turning them into current truth, and Peek is that app: a topic's name is a
255
+ * tag the relay wrote and a message is immutable.
256
+ *
257
+ * Both resolvers used to require it and return null for the whole projection,
258
+ * so an app that declared none rendered as *nothing* — which §13.3 spends
259
+ * several paragraphs establishing is the worst available outcome, because a
260
+ * blank card reads as "that app is broken". It was never caught because Ship
261
+ * is the only app that had ever published a manifest, and Ship folds. Nothing
262
+ * in RFC 0.4 §13.1 makes `records` mandatory.
263
+ *
264
+ * The substitute rule below is only ever used to *fold*, never to query, and
265
+ * that distinction is load-bearing. The first attempt used `changeKind: -1` as
266
+ * a sentinel and let the queries run: the fake relay in the tests accepted it,
267
+ * and production refused the entire filter with `invalid type: integer -1,
268
+ * expected a 16-bit unsigned number`. A kind is `u16` on the wire, so there is
269
+ * no out-of-band value to reach for — the query has to be skipped instead of
270
+ * being made unmatchable. Callers therefore branch on `manifest.records` for
271
+ * the filters and use this only for the fold, which runs over an empty array.
272
+ */
273
+ function foldRuleOf(manifest) {
274
+ return (manifest.records ?? {
275
+ // Never sent to a relay. See above.
276
+ changeKind: 0,
277
+ targetTag: 'a',
278
+ fieldTag: 'field',
279
+ valueTag: 'value',
280
+ order: ['created_at', 'id'],
281
+ rule: 'last-write-wins-per-field',
282
+ });
283
+ }
284
+ function foldChanges(changes, rule) {
285
+ const fields = {};
286
+ for (const change of [...changes].sort(byOrder)) {
287
+ const field = tagValue(change, rule.fieldTag);
288
+ const value = tagValue(change, rule.valueTag);
289
+ if (!field || value === undefined)
290
+ continue; // a partial change sets nothing
291
+ fields[field] = { value, by: change.pubkey, at: change.created_at };
292
+ }
293
+ return fields;
294
+ }
295
+ function truncate(text, limit) {
296
+ if (!limit || text.length <= limit)
297
+ return text;
298
+ return `${text.slice(0, limit).trimEnd()}…`;
299
+ }
300
+ /**
301
+ * Which layout to draw, from a declared type or an ordered chain of them.
302
+ *
303
+ * RFC 0.4 §13.3. A widget is a layout *hint* rather than a semantic, so an
304
+ * unknown one can degrade honestly: `["message", "card"]` means *render me as a
305
+ * message if you know it, else as a card*, and a chain must terminate in a type
306
+ * the spec closes.
307
+ *
308
+ * **It lives here rather than in each consumer** because two implementations
309
+ * would disagree the first time a chain had three entries, and the whole point
310
+ * of the chain is that producers and consumers upgrade at different times. The
311
+ * consumer supplies what it implements; the runtime does the walking.
312
+ *
313
+ * `fallback` is what to draw when the chain runs out — never "nothing". §13.3's
314
+ * argument is that a blank object is indistinguishable from one the reader may
315
+ * not be allowed to see, and reports "that app is broken" about an app doing
316
+ * exactly what it was told.
317
+ */
318
+ /**
319
+ * The widget types RFC 0.4 §13.3 closes. A chain MUST end in one of these.
320
+ *
321
+ * Exported because both halves need the same list and they must not drift: a
322
+ * producer checks its chain terminates here, a consumer's fallback is drawn
323
+ * from here. Two copies would disagree the first time the set grew, and the
324
+ * disagreement would show up as an object rendering blank in one app only.
325
+ */
326
+ export const CLOSED_WIDGETS = ['card', 'row', 'table', 'stat'];
327
+ /**
328
+ * Why a widget declaration is not publishable, or null when it is — PRO-3.
329
+ *
330
+ * **The producer half of the fallback chain.** `pickWidget` below makes a
331
+ * consumer safe against a chain it does not fully understand; this stops the
332
+ * unrenderable chain being published in the first place. Both are needed and
333
+ * they fail differently: without the consumer half an unknown widget renders
334
+ * blank, and without this one a *conformant* consumer renders blank through no
335
+ * fault of its own, having done exactly what it was told.
336
+ *
337
+ * A chain that does not terminate in a closed type is the PEE-10 failure with
338
+ * a longer fuse — every consumer that has not heard of `profile` walks
339
+ * `["profile"]` to the end and has nothing left to draw.
340
+ *
341
+ * Returns a sentence rather than a boolean because this is read by a person
342
+ * publishing a manifest, and "invalid widget" tells them nothing about which
343
+ * one or what to do.
344
+ */
345
+ export function widgetChainProblem(declared) {
346
+ if (typeof declared === 'string') {
347
+ return CLOSED_WIDGETS.includes(declared)
348
+ ? null
349
+ : `"${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"].`;
350
+ }
351
+ if (!Array.isArray(declared) || declared.length === 0) {
352
+ return 'a widget must be a type or a non-empty ordered chain of them.';
353
+ }
354
+ if (declared.some((entry) => typeof entry !== 'string' || entry === '')) {
355
+ return 'every entry in a widget chain must be a non-empty string.';
356
+ }
357
+ const last = declared[declared.length - 1];
358
+ if (!CLOSED_WIDGETS.includes(last)) {
359
+ 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.`;
360
+ }
361
+ return null;
362
+ }
363
+ export function pickWidget(declared, implemented, fallback) {
364
+ for (const candidate of Array.isArray(declared) ? declared : [declared]) {
365
+ if (implemented.includes(candidate))
366
+ return candidate;
367
+ }
368
+ return fallback;
369
+ }
370
+ /**
371
+ * A slot's value before any vocabulary mapping or truncation.
372
+ *
373
+ * Split out because "what does this object's status *say*" and "what should a
374
+ * reader see" are different questions: the display value is a vocabulary label
375
+ * ("In Progress"), and anything deciding on the value — the active-issue filter
376
+ * below — has to compare the underlying one (`in_progress`).
377
+ */
378
+ /** The first of these tags the event actually carries. */
379
+ function firstTag(root, tag) {
380
+ if (!tag)
381
+ return undefined;
382
+ for (const name of Array.isArray(tag) ? tag : [tag]) {
383
+ const value = tagValue(root, name);
384
+ if (value !== undefined && value !== '')
385
+ return value;
386
+ }
387
+ return undefined;
388
+ }
389
+ function rawSlotValue(spec, root, folded) {
390
+ // `fold` first, and a spec may carry both: a field that starts as a tag on
391
+ // the root event and is then overridden by changes (a project's lead is the
392
+ // case in hand). Reading the tag first would render the value the object was
393
+ // created with forever — which is exactly what someone sees right after
394
+ // reassigning it from here.
395
+ if (spec.fold) {
396
+ return folded[spec.fold]?.value ?? firstTag(root, spec.tag) ?? spec.default;
397
+ }
398
+ if (spec.tag)
399
+ return firstTag(root, spec.tag);
400
+ if (spec.field === 'content')
401
+ return root.content;
402
+ /*
403
+ `pubkey` — the event's author — added by PRO-6.
404
+
405
+ Found by trying to declare a projection for a Peek Message. §13.3 makes
406
+ `title` the one required slot, and a `kind:9` has no title: it has an
407
+ author, a body and a time. The body cannot be the title, because message
408
+ content is structured for 41% of production events and truncating structure
409
+ is exactly what PRO-8 removed from Ship's manifest. Which leaves the author,
410
+ and until now no slot source could name it.
411
+
412
+ It is a genuine top-level event field, so it belongs in `field` rather than
413
+ in a new source. Paired with `as: "pubkey"` it renders as a person, which is
414
+ what a message wants as its title everywhere it appears.
415
+ */
416
+ if (spec.field === 'pubkey')
417
+ return root.pubkey;
418
+ return undefined;
419
+ }
420
+ function resolveSlot(spec, root, folded, manifest) {
421
+ const raw = rawSlotValue(spec, root, folded);
422
+ if (raw === undefined || raw === '')
423
+ return null;
424
+ let value = truncate(raw, spec.truncate);
425
+ let colour;
426
+ if (spec.map) {
427
+ const entry = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw);
428
+ // A value outside the declared vocabulary is shown as-is rather than
429
+ // dropped. Another app may have written it, and silently rendering nothing
430
+ // would hide exactly the corruption the honour system permits.
431
+ value = entry?.label ?? raw;
432
+ colour = entry?.colour ?? 'muted';
433
+ }
434
+ return {
435
+ label: spec.label,
436
+ value,
437
+ colour,
438
+ isPubkey: spec.as === 'pubkey',
439
+ field: spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag),
440
+ };
441
+ }
442
+ /**
443
+ * Every slot a projection declares, resolved.
444
+ *
445
+ * Single slots are named (`title`, `subtitle`, `status`); array specs collect
446
+ * into `meta`. Shared so the sidebar and the inline widget resolve a projection
447
+ * identically — two loops would drift the first time a slot type is added.
448
+ */
449
+ function resolveSlots(projection, root, folded, manifest) {
450
+ const slots = {};
451
+ const meta = [];
452
+ for (const [name, spec] of Object.entries(projection.slots)) {
453
+ if (Array.isArray(spec)) {
454
+ for (const one of spec) {
455
+ const value = resolveSlot(one, root, folded, manifest);
456
+ if (value)
457
+ meta.push(value);
458
+ }
459
+ }
460
+ else {
461
+ const value = resolveSlot(spec, root, folded, manifest);
462
+ if (value)
463
+ slots[name] = value;
464
+ }
465
+ }
466
+ return { slots, meta };
467
+ }
468
+ /**
469
+ * One resolved object, from its root event and the changes that have landed on
470
+ * it. Shared by the inline widget and the sidebar so both draw the same shape
471
+ * from the same rules — the difference between them is what they *fetch*, not
472
+ * how they render.
473
+ */
474
+ function buildObject(args) {
475
+ const { root, pointer, manifest, projection, folded } = args;
476
+ const naddr = encodeNaddr(pointer);
477
+ const { slots, meta } = resolveSlots(projection, root, folded, manifest);
478
+ /**
479
+ * What each field holds now, however it got there.
480
+ *
481
+ * `resolveActions` reads the fold alone, which is right for a field that only
482
+ * ever exists as a change — but a project's lead is seeded by a tag on the
483
+ * root event. Without this, the assign control on a project nobody has
484
+ * reassigned reads "Assign to me" while a lead is plainly set, and the one
485
+ * control that is supposed to both report and set the field (PEEK-18) reports
486
+ * nothing.
487
+ */
488
+ const held = {};
489
+ for (const spec of Object.values(projection.slots).flat()) {
490
+ // `tag` may name several spellings (see `SlotSpec`); the field this slot
491
+ // *is* keyed on is the first, which is the one the app writes today. The
492
+ // rest are only there to keep older records rendering.
493
+ const field = spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag);
494
+ const raw = field && rawSlotValue(spec, root, folded);
495
+ if (field && raw !== undefined)
496
+ held[field] = raw;
497
+ }
498
+ const objectAddress = pointerToAddress(pointer);
499
+ return {
500
+ // An addressable object's `ref` is its address: stable across the author
501
+ // replacing the event, which the event id is not.
502
+ ref: objectAddress,
503
+ address: objectAddress,
504
+ naddr,
505
+ eventId: root.id,
506
+ kind: pointer.kind,
507
+ widget: projection.widget,
508
+ appName: manifest.name,
509
+ slots,
510
+ meta,
511
+ comments: args.comments ?? [],
512
+ folder: tagValue(root, 'h'),
513
+ // Substituted here rather than in the component: `<bech32>` is a NIP-89
514
+ // detail, and the widget's job is to draw a link, not to know the spec.
515
+ openUrl: args.webTemplate?.replace('<bech32>', naddr),
516
+ actions: resolveActions(manifest, pointer.kind, folded).map((action) => action.current === undefined && action.field
517
+ ? { ...action, current: held[action.field] }
518
+ : action),
519
+ viaRecommendation: args.viaRecommendation,
520
+ };
521
+ }
522
+ /**
523
+ * Resolve a `nostr:naddr…` into a renderable widget.
524
+ *
525
+ * Returns `null` rather than throwing when the object cannot be rendered — an
526
+ * unresolvable reference in a chat message should degrade to plain text, not
527
+ * break the message around it.
528
+ */
529
+ export async function resolveForeignObject(naddr, query,
530
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
531
+ lookupPeople,
532
+ /**
533
+ * How many `list` levels have already been followed. Callers outside this
534
+ * module leave it at 0; it is the budget that stops a child's own `list`
535
+ * recursing forever. See `MAX_LIST_DEPTH`.
536
+ */
537
+ depth = 0) {
538
+ let pointer;
539
+ try {
540
+ // Either form: a body carries `naddr1…`, an `a` tag carries the plain
541
+ // address, and both name the same object (FEE-2).
542
+ pointer = referenceToPointer(naddr);
543
+ }
544
+ catch {
545
+ return null;
546
+ }
547
+ const address = pointerToAddress(pointer);
548
+ const resolved = await resolveManifest(pointer, query);
549
+ if (!resolved)
550
+ return null;
551
+ const { manifest, viaRecommendation } = resolved;
552
+ // Substituted here rather than in the component: `<bech32>` is a NIP-89
553
+ // detail, and the widget's job is to draw a link, not to know the spec.
554
+ const openUrl = resolved.webTemplate?.replace('<bech32>', naddr.replace(/^nostr:/, ''));
555
+ const commentKinds = commentKindsOf(manifest);
556
+ const projection = manifest.projections?.[String(pointer.kind)];
557
+ if (!projection)
558
+ return null;
559
+ const records = foldRuleOf(manifest);
560
+ // One round trip for the root, its changes and its comments. `#a` on both the
561
+ // change and the comment kind, because both point at the object by *address*
562
+ // rather than by event id — which is what makes them survive the author
563
+ // replacing the root event.
564
+ const events = await query([
565
+ { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 },
566
+ // Only when the app actually declares a change kind — see `foldRuleOf`.
567
+ ...(manifest.records ? [{ kinds: [manifest.records.changeKind], '#a': [address], limit: 500 }] : []),
568
+ { kinds: commentKinds, '#a': [address], limit: 200 },
569
+ ]);
570
+ const root = events.find((e) => e.kind === pointer.kind && tagValue(e, 'd') === pointer.identifier);
571
+ if (!root) {
572
+ // We know which app owns this and how it would be drawn; we just cannot see
573
+ // the object. Say so rather than returning null and looking like a typo.
574
+ return {
575
+ ref: address,
576
+ address,
577
+ naddr: naddr.replace(/^nostr:/, ''),
578
+ // Nothing was read, so there is no event to name. The reference is the
579
+ // address; that is all this case ever knows.
580
+ eventId: '',
581
+ kind: pointer.kind,
582
+ widget: projection.widget,
583
+ appName: manifest.name,
584
+ slots: {},
585
+ meta: [],
586
+ comments: [],
587
+ actions: [],
588
+ viaRecommendation,
589
+ // Offered even here. "You cannot see this object" is exactly when
590
+ // somebody wants to open it in the app that can.
591
+ openUrl,
592
+ unreachable: true,
593
+ };
594
+ }
595
+ const folded = foldChanges(events.filter((e) => e.kind === records.changeKind && tagValue(e, records.targetTag) === address), records);
596
+ const comments = events
597
+ .filter((e) => commentKinds.includes(e.kind))
598
+ .sort(byOrder)
599
+ .map((e) => ({ id: e.id, author: e.pubkey, body: e.content, createdAt: e.created_at }));
600
+ const object = buildObject({
601
+ root,
602
+ pointer,
603
+ manifest,
604
+ projection,
605
+ folded,
606
+ viaRecommendation,
607
+ webTemplate: resolved.webTemplate,
608
+ comments,
609
+ });
610
+ /*
611
+ The `list` slot — PRO-7.
612
+
613
+ PRO-2 built this down the *folder* path only, where containment is
614
+ discovered from a Folder's contents. An object reached by address never
615
+ resolved it: `rawSlotValue` returns undefined for a `children` spec, so the
616
+ slot was silently dropped. Measured on production before this: 5 of 5 real
617
+ Peek Topics rendered a title, a subtitle and no messages — which is exactly
618
+ what a quiet topic looks like, so nothing reported a problem.
619
+
620
+ A child is resolved through *its own* projection, which is what makes "a
621
+ card with its children underneath" compose rather than being a special case.
622
+ A child may be a regular event with no address of its own (Peek's messages
623
+ are), so this builds by event rather than by pointer.
624
+ */
625
+ const children = await resolveChildren({
626
+ projection,
627
+ root,
628
+ manifest,
629
+ query,
630
+ depth,
631
+ webTemplate: resolved.webTemplate,
632
+ viaRecommendation,
633
+ });
634
+ // After the object, because who to ask about is not known until it is built.
635
+ // Folded into the same resolve rather than left to the renderer so the
636
+ // widget's skeleton covers the wait and a key is never briefly on screen.
637
+ // Children are included so one lookup covers the whole tree — a message list
638
+ // is mostly other people, and a second trip per child would show a column of
639
+ // keys while it ran.
640
+ const forPeople = [object, ...(children ?? [])];
641
+ const people = await (lookupPeople ?? peopleViaRelay(query))([
642
+ ...new Set(forPeople.flatMap(pubkeysIn)),
643
+ ]);
644
+ return { ...object, people, ...(children ? { children: children.map((c) => ({ ...c, people })) } : {}) };
645
+ }
646
+ /**
647
+ * Resolve a projection's `list` slot into child objects.
648
+ *
649
+ * Returns undefined when no `list` is declared — distinct from `[]`, which
650
+ * means "declared, and nothing matched". A renderer needs to tell "this holds
651
+ * nothing" from "this holds no list".
652
+ */
653
+ async function resolveChildren(args) {
654
+ const { projection, root, manifest, query, depth, webTemplate, viaRecommendation } = args;
655
+ const spec = projection.slots.list;
656
+ const children = !Array.isArray(spec) ? spec?.children : undefined;
657
+ if (!children)
658
+ return undefined;
659
+ // The consumer's budget, not the manifest's — see MAX_LIST_DEPTH.
660
+ if (depth >= MAX_LIST_DEPTH)
661
+ return [];
662
+ const childProjection = manifest.projections?.[String(children.kind)];
663
+ // A declared list whose child kind has no projection is not renderable, and
664
+ // an empty list is the honest answer: the objects exist, this app has not
665
+ // said how to draw them.
666
+ if (!childProjection)
667
+ return [];
668
+ const identifier = tagValue(root, 'd') ?? '';
669
+ const parent = children.match === 'identifier'
670
+ ? identifier
671
+ : pointerToAddress({ kind: root.kind, pubkey: root.pubkey, identifier, relays: [] });
672
+ const found = await query([
673
+ { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
674
+ ]);
675
+ const records = foldRuleOf(manifest);
676
+ return found.sort(byOrder).map((event) => buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation }));
677
+ }
678
+ /**
679
+ * Build a `ForeignObject` from an event that may have no address of its own.
680
+ *
681
+ * `buildObject` takes an `AddressPointer` and assumes one exists. A `kind:9`
682
+ * has no `d` tag, so there is nothing to point at — its only handle is its
683
+ * event id. That is the whole of PRO-6's finding (6), and it is why `ref` and
684
+ * `eventId` exist alongside `address`.
685
+ *
686
+ * An object built this way carries **no actions**, and that is correct rather
687
+ * than a limitation: an action emits a change carrying an `a` tag naming what
688
+ * it changes, and a regular event cannot be named that way.
689
+ */
690
+ function buildChildObject(args) {
691
+ const { root, manifest, projection, webTemplate, viaRecommendation } = args;
692
+ const identifier = tagValue(root, 'd');
693
+ const addressable = identifier !== undefined;
694
+ const pointer = {
695
+ kind: root.kind,
696
+ pubkey: root.pubkey,
697
+ identifier: identifier ?? '',
698
+ relays: [],
699
+ };
700
+ const address = addressable ? pointerToAddress(pointer) : undefined;
701
+ const naddr = addressable ? encodeNaddr(pointer) : undefined;
702
+ const { slots, meta } = resolveSlots(projection, root, {}, manifest);
703
+ return {
704
+ // A regular event's identity is its id; a replaceable one's is its address,
705
+ // which survives the author replacing the event.
706
+ ref: address ?? root.id,
707
+ address,
708
+ naddr,
709
+ eventId: root.id,
710
+ kind: root.kind,
711
+ widget: projection.widget,
712
+ appName: manifest.name,
713
+ slots,
714
+ meta,
715
+ comments: [],
716
+ folder: tagValue(root, 'h'),
717
+ openUrl: naddr ? webTemplate?.replace('<bech32>', naddr) : undefined,
718
+ // See the note above: nothing can be declared to act on a regular event.
719
+ actions: [],
720
+ viaRecommendation,
721
+ };
722
+ }
723
+ /**
724
+ * What a status *means*. **The owning app's declaration, with a fallback.**
725
+ *
726
+ * *Rewritten by PRO-2.* This used to open "Peek's editorial rule, not the
727
+ * owning app's", on the grounds that a vocabulary entry was `{value, label,
728
+ * colour}` and said nothing about whether work was open or finished. That was
729
+ * true, and treating it as an editorial position was the mistake: Peek is not
730
+ * the expert on what a Ship status means. Ship is.
731
+ *
732
+ * A vocabulary entry now carries `stage` — `open | started | done | dropped` —
733
+ * and that is read first. The word lists below survive only as the
734
+ * compatibility path for a manifest published before the field existed, which
735
+ * cannot be given one retroactively (the `emits.alsoRead` reason, one level
736
+ * up).
737
+ *
738
+ * The old comment named the cost of guessing and called it acceptable: *"an app
739
+ * whose statuses are spelled differently shows an empty section until its
740
+ * values are added here. That is the failure worth having."* For a layer whose
741
+ * purpose is making the third app cheap to build, it is not — the third app
742
+ * ships, its words are not in the set, and its panel renders blank, which is
743
+ * PEE-10's failure exactly. A declared stage is what removes the guess.
744
+ *
745
+ * Two named sets rather than one allowlist and an "everything else": the panel
746
+ * reads "1/3", done over the two sets added together, and a status that is
747
+ * neither should land in neither. A parked "Backlog" or "Triage" is real work
748
+ * nobody is doing and it would inflate the total into meaninglessness; an
749
+ * unrecognised status some other app invented is not evidence of anything.
750
+ *
751
+ * Leaving both out is also what keeps "3/3" reachable. A cancelled ticket is
752
+ * not outstanding and never becomes done, so counting it in the total would
753
+ * leave a finished project stuck at 3/4 for good.
754
+ *
755
+ * The visible cost: with something cancelled, the total is smaller than the
756
+ * number of rows listed below it. The count is about progress through the work,
757
+ * not about how long the list is, and a total nothing can ever complete is the
758
+ * worse of the two.
759
+ *
760
+ * The other cost is honest: an app whose statuses are spelled differently shows
761
+ * an empty section until its values are added here. That is the failure worth
762
+ * having — the alternative is a count that quietly includes work nobody is on.
763
+ */
764
+ const OPEN_STATUSES = new Set([
765
+ 'todo',
766
+ 'to do',
767
+ 'in progress',
768
+ 'started',
769
+ 'doing',
770
+ 'in review',
771
+ 'review',
772
+ ]);
773
+ const DONE_STATUSES = new Set([
774
+ 'done',
775
+ 'completed',
776
+ 'complete',
777
+ 'closed',
778
+ 'cancelled',
779
+ 'canceled',
780
+ 'duplicate',
781
+ ]);
782
+ /** `in_progress`, `In-Progress` and `In Progress` are the same status. */
783
+ const normalizeStatus = (value) => value.trim().toLowerCase().replace(/[-_\s]+/g, ' ');
784
+ /**
785
+ * Match on either what the object *says* or what a reader *sees* — the raw
786
+ * value (`in_progress`) or the vocabulary label ("In Progress"). Apps disagree
787
+ * about which of the two is the human-readable one, and checking both costs
788
+ * nothing.
789
+ */
790
+ const inSet = (set) => (raw, label) => [raw, label].some((v) => v !== undefined && set.has(normalizeStatus(v)));
791
+ const isOpenStatusByLabel = inSet(OPEN_STATUSES);
792
+ const isDoneStatusByLabel = inSet(DONE_STATUSES);
793
+ const STAGES = new Set(['open', 'started', 'done', 'dropped']);
794
+ /**
795
+ * The stage the owning app declares for a status value, or undefined.
796
+ *
797
+ * `undefined` means "this manifest does not say", which is a different fact
798
+ * from "this status is not progress" and must not be collapsed into one — the
799
+ * caller falls back to the word lists only in the first case.
800
+ *
801
+ * A `stage` outside the four is ignored rather than trusted. Validation is an
802
+ * honour system (RFC 0.4 §13.4) and this is a consumer reading another app's
803
+ * self-description; an unrecognised value is treated as undeclared, which
804
+ * degrades to the fallback instead of inventing a fifth stage.
805
+ */
806
+ function declaredStage(manifest, spec, raw) {
807
+ if (!spec?.map || raw === undefined)
808
+ return undefined;
809
+ const stage = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)?.stage;
810
+ return stage && STAGES.has(stage) ? stage : undefined;
811
+ }
812
+ /**
813
+ * Is this status finished work?
814
+ *
815
+ * `dropped` counts as done rather than open, which is what keeps "3/3"
816
+ * reachable: a cancelled ticket is never going to become done, so leaving it
817
+ * outstanding pins a finished project below its total for ever. It is not
818
+ * *progress* either, and an app that wanted to draw that distinction now can —
819
+ * the stage is on the wire and this fold is the consumer's, not the protocol's.
820
+ */
821
+ function isDone(stage, raw, label) {
822
+ if (stage)
823
+ return stage === 'done' || stage === 'dropped';
824
+ return isDoneStatusByLabel(raw, label);
825
+ }
826
+ function isOpen(stage, raw, label) {
827
+ if (stage)
828
+ return stage === 'open' || stage === 'started';
829
+ return isOpenStatusByLabel(raw, label);
830
+ }
831
+ /**
832
+ * Started work sits above the queue.
833
+ *
834
+ * Both halves are open, but "in progress" is what somebody opened the panel to
835
+ * find; ordering by activity alone would bury it under whatever was filed most
836
+ * recently.
837
+ */
838
+ const STARTED = new Set(['in progress', 'started', 'doing', 'in review', 'review']);
839
+ const isStartedByLabel = inSet(STARTED);
840
+ function isStarted(stage, raw, label) {
841
+ if (stage)
842
+ return stage === 'started';
843
+ return isStartedByLabel(raw, label);
844
+ }
845
+ /**
846
+ * The containment relation a manifest declares: "this kind holds that kind".
847
+ *
848
+ * Read from the `list` slot, which says it outright, falling back to the
849
+ * inference this used to depend on. Returns null when the app says neither — in
850
+ * which case Peek does not guess, and the sidebar shows nothing.
851
+ */
852
+ function containmentFor(manifest, containerKind) {
853
+ /*
854
+ The declared answer first — PRO-2.
855
+
856
+ A `list` slot says outright which kind this one holds and by which tag. What
857
+ follows below is the older path, and it is worth naming what it does: it
858
+ reads an action that *creates* a child (`toAddressOf: "self"`) and infers a
859
+ *read* relationship from it. That worked, and it was a deduction from a
860
+ write declaration — an app offering no create-action, or offering one for a
861
+ kind it does not actually contain, was invisible or wrong respectively.
862
+
863
+ Kept as a compatibility path rather than deleted, because a manifest
864
+ published before PRO-2 cannot be given a `list` slot retroactively and
865
+ consumers upgrade before producers do. It goes when nothing in use relies
866
+ on it.
867
+ */
868
+ const projection = manifest.projections?.[String(containerKind)];
869
+ const listSlot = projection && !Array.isArray(projection.slots.list) ? projection.slots.list : undefined;
870
+ if (listSlot?.children) {
871
+ const { kind, via, limit, match } = listSlot.children;
872
+ return { childKind: kind, linkTag: via, limit, match };
873
+ }
874
+ for (const action of manifest.actions ?? []) {
875
+ if (action.emits.toAddressOf !== 'self')
876
+ continue;
877
+ if (!asArray(action.appliesTo).includes(String(containerKind)))
878
+ continue;
879
+ return { childKind: action.emits.kind, linkTag: action.emits.setTag ?? 'a' };
880
+ }
881
+ return null;
882
+ }
883
+ /**
884
+ * How deep a consumer will follow `list` slots. **The consumer's budget.**
885
+ *
886
+ * A child rendered through its own projection may declare a `list` of its own,
887
+ * so resolution is recursive and something has to stop it. That something is
888
+ * here rather than in the manifest: the app at risk of the render loop is the
889
+ * one drawing it, and a producer able to set this number could hang any
890
+ * consumer that trusted it (RFC 0.4 §13.4 — validation is an honour system).
891
+ *
892
+ * One level is what the panel needs: a project and its issues. Raising it is a
893
+ * decision about this app's boot cost, not about anyone else's data.
894
+ */
895
+ const MAX_LIST_DEPTH = 1;
896
+ /**
897
+ * The project living in a Folder, plus the work currently in motion in it.
898
+ *
899
+ * Starts from a **Folder** rather than an naddr, which is the difference
900
+ * between this and `resolveForeignObject`: a Peek topic and a Linear-lite
901
+ * project are one container (RFC_UPDATES.md §1.1), so a topic already knows
902
+ * enough to ask "what project is this?" without anyone pasting a reference.
903
+ *
904
+ * Still knows nothing about Linear-lite. Which kind is a project, which kind is
905
+ * an issue, how they link, how status folds and what it is called all come off
906
+ * published manifests at runtime — the same discipline as the rest of this file.
907
+ *
908
+ * Returns null for "nothing to show": no manifest declares a container, no
909
+ * project in this Folder and none named by the tickets in it, or the object
910
+ * cannot be read. All of those are ordinary states the caller renders as an
911
+ * empty sidebar, not errors.
912
+ */
913
+ export async function resolveFolderProject(folder, query,
914
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
915
+ lookupPeople,
916
+ /**
917
+ * How many `list` levels have already been followed to get here. Callers
918
+ * outside this module leave it at 0; it exists so that following a child's
919
+ * own `list` is a budget check rather than a thing nobody remembered.
920
+ */
921
+ depth = 0) {
922
+ // The render loop this forbids is not hypothetical: two folders naming each
923
+ // other's projects resolve forever, and the manifest declaring them is
924
+ // another app's. See MAX_LIST_DEPTH.
925
+ if (depth > MAX_LIST_DEPTH)
926
+ return null;
927
+ // 1. Which kinds are containers? A Folder query needs kind numbers up front,
928
+ // and the only non-app-specific source for them is the published handlers.
929
+ // This pass is discovery only — the manifest that actually *renders* the
930
+ // project is resolved below, recommendation-first, once its author is known.
931
+ const handlerFilter = [{ kinds: [KIND_HANDLER_INFORMATION], limit: 20 }];
932
+ const handlers = await query(handlerFilter);
933
+ const containers = new Map();
934
+ let discovered;
935
+ for (const event of handlers) {
936
+ const manifest = parseManifest(event);
937
+ for (const kind of Object.keys(manifest?.projections ?? {})) {
938
+ const relation = manifest && containmentFor(manifest, Number(kind));
939
+ if (relation) {
940
+ containers.set(Number(kind), relation);
941
+ discovered ??= manifest;
942
+ }
943
+ }
944
+ }
945
+ if (containers.size === 0) {
946
+ return null;
947
+ }
948
+ // 2. One round trip for everything in the Folder: containers, their children,
949
+ // and the changes that have landed on either — `h` is what they share.
950
+ // Changes are needed *before* a project is chosen, because whether one is
951
+ // archived is itself a folded field.
952
+ const childKinds = [...new Set([...containers.values()].map((c) => c.childKind))];
953
+ const discoveredRule = discovered?.records;
954
+ const inFolderRaw = await query([
955
+ { '#h': [folder], kinds: [...containers.keys(), ...childKinds], limit: 200 },
956
+ ...(discoveredRule ? [{ '#h': [folder], kinds: [discoveredRule.changeKind], limit: 500 }] : []),
957
+ ]);
958
+ /*
959
+ **A Folder is not a file inside itself.**
960
+
961
+ A channel's own `kind:39000` comes back from an `#h` query for that channel.
962
+ It carries no `h` tag — the relay scopes a discovery event to the channel it
963
+ describes, so it arrives with the contents. What marks it out is that its
964
+ `d` *is* the folder uuid; a file sitting in a Folder has its own uuid,
965
+ different from the Folder's.
966
+
967
+ Dropped here, once, rather than at each use. Two places downstream ask "does
968
+ this Folder hold a container", and they have to agree: `holdsContainer`
969
+ decides whether to go looking for a project the Folder does not hold, and
970
+ `roots` decides which container is the subject. Filtering only the second
971
+ makes them disagree and the panel resolves to nothing at all.
972
+
973
+ Until Peek published a manifest this could not bite, because only Ship's
974
+ kinds were containers and a `39000` was never a candidate. PRO-6 declared
975
+ Topic a container — it holds messages — so the Topic became a candidate,
976
+ won the contest against the actual project, and the sidebar listed the
977
+ *messages* as tickets. Each was titled with its author's pubkey, because a
978
+ message resolved through Peek's own projection has `title: {field: "pubkey"}`.
979
+
980
+ App-neutral on purpose: this is a fact about folders and discovery events,
981
+ not about Peek.
982
+ */
983
+ const inFolder = inFolderRaw.filter((e) => tagValue(e, 'd') !== folder);
984
+ const addressOfEvent = (event) => pointerToAddress({
985
+ kind: event.kind,
986
+ pubkey: event.pubkey,
987
+ identifier: tagValue(event, 'd') ?? '',
988
+ relays: [],
989
+ });
990
+ /**
991
+ * 2b. A Folder can hold a project's *work* without holding the project.
992
+ *
993
+ * An event cannot change its own `h`. So a project created in one Folder and
994
+ * later paired with a Peek topic keeps the `h` it was born with, and the
995
+ * pairing is expressed the only way an append-only record can express it — a
996
+ * change event. Ship published that change only in the Folder the *record*
997
+ * lives in, so nothing carrying the topic's `h` named the project at all: the
998
+ * query above saw a Folder full of tickets and no project, and the panel said
999
+ * "no project linked" about a Folder holding eleven of its issues (PEEK-24).
1000
+ *
1001
+ * A consumer cannot require the producer to have got that right, and this one
1002
+ * must keep working against the events already on the relay, so it reads
1003
+ * whatever the Folder does hold.
1004
+ *
1005
+ * Two things in the Folder can name it, and both use a tag the manifest has
1006
+ * already declared rather than any new vocabulary.
1007
+ *
1008
+ * A **ticket** names its project in the containment relation's link tag,
1009
+ * which is the same tag step 5 reads to decide which tickets belong here. A
1010
+ * Folder whose tickets all point at one project *is* that project's Folder,
1011
+ * whichever Folder the project record happens to sit in.
1012
+ *
1013
+ * A **change published into this Folder** names its target in the records
1014
+ * rule's target tag. That is an app saying out loud "the object at this
1015
+ * address belongs here" — the only way to say it when the object's own `h`
1016
+ * cannot be moved, and the one that does not need a ticket to exist yet.
1017
+ * Estiva Ship writes it when a project is paired with a topic.
1018
+ *
1019
+ * Reading both matters, because they fail in opposite directions: the tickets
1020
+ * cover a Folder paired before anybody wrote the statement, and the statement
1021
+ * covers a Folder paired before anybody filed a ticket. Where they disagree
1022
+ * the sort below decides, and it already prefers whichever candidate the
1023
+ * Folder's work actually belongs to.
1024
+ *
1025
+ * Its changes come by address rather than by `h`, because they are wherever
1026
+ * the record is, not here. Skipped entirely when the Folder does hold a
1027
+ * container, which is the ordinary case and already correct.
1028
+ */
1029
+ const holdsContainer = inFolder.some((e) => containers.has(e.kind));
1030
+ const namedInFolder = holdsContainer
1031
+ ? []
1032
+ : [
1033
+ ...new Set(inFolder.flatMap((event) => {
1034
+ const relation = [...containers.values()].find((c) => c.childKind === event.kind);
1035
+ if (relation) {
1036
+ const link = tagValue(event, relation.linkTag);
1037
+ return link ? [link] : [];
1038
+ }
1039
+ if (discoveredRule && event.kind === discoveredRule.changeKind) {
1040
+ const target = tagValue(event, discoveredRule.targetTag);
1041
+ return target ? [target] : [];
1042
+ }
1043
+ return [];
1044
+ })),
1045
+ ];
1046
+ // Only a container may be adopted this way. A child's link tag can name
1047
+ // anything, and a ticket that points at another ticket must not become the
1048
+ // subject of the panel.
1049
+ const linkedOutward = namedInFolder.flatMap((link) => {
1050
+ try {
1051
+ const pointer = referenceToPointer(link);
1052
+ return containers.has(pointer.kind) ? [{ link, pointer }] : [];
1053
+ }
1054
+ catch {
1055
+ return [];
1056
+ }
1057
+ });
1058
+ const adopted = linkedOutward.length
1059
+ ? await query([
1060
+ ...linkedOutward.map(({ pointer }) => ({
1061
+ kinds: [pointer.kind],
1062
+ authors: [pointer.pubkey],
1063
+ '#d': [pointer.identifier],
1064
+ limit: 1,
1065
+ })),
1066
+ ...(discoveredRule
1067
+ ? [
1068
+ {
1069
+ kinds: [discoveredRule.changeKind],
1070
+ '#a': linkedOutward.map(({ link }) => link),
1071
+ limit: 500,
1072
+ },
1073
+ ]
1074
+ : []),
1075
+ ])
1076
+ : [];
1077
+ /** Everything the Folder holds, plus whatever it was adopted from. */
1078
+ const known = adopted.length ? [...inFolder, ...adopted] : inFolder;
1079
+ /** Is this object one its owning app would keep out of its own lists? */
1080
+ const isHidden = (event, rule, source = known) => {
1081
+ if (!rule?.hiddenWhen)
1082
+ return false;
1083
+ const applied = source.filter((e) => e.kind === rule.changeKind && tagValue(e, rule.targetTag) === addressOfEvent(event));
1084
+ return foldChanges(applied, rule)[rule.hiddenWhen.field]?.value === rule.hiddenWhen.equals;
1085
+ };
1086
+ const roots = known
1087
+ .filter((e) => containers.has(e.kind))
1088
+ .filter((e) => !isHidden(e, discoveredRule));
1089
+ if (roots.length === 0) {
1090
+ return null;
1091
+ }
1092
+ /**
1093
+ * Which project, when a Folder holds several.
1094
+ *
1095
+ * Nothing forbids more than one, and pairing makes it common rather than
1096
+ * exotic: a topic accumulates a project per attempt. Ranking by age alone
1097
+ * picked whichever was created last — in practice a stray — so the work
1098
+ * decides instead. The project the Folder's tickets point at is the project
1099
+ * the Folder is about; age only breaks the tie.
1100
+ */
1101
+ const ticketsFor = (candidate) => {
1102
+ const relation = containers.get(candidate.kind);
1103
+ const candidateAddress = addressOfEvent(candidate);
1104
+ return inFolder.filter((e) => e.kind === relation.childKind && tagValue(e, relation.linkTag) === candidateAddress).length;
1105
+ };
1106
+ const root = [...roots].sort((a, b) => ticketsFor(b) - ticketsFor(a) || b.created_at - a.created_at)[0];
1107
+ const pointer = {
1108
+ kind: root.kind,
1109
+ pubkey: root.pubkey,
1110
+ identifier: tagValue(root, 'd') ?? '',
1111
+ relays: [],
1112
+ };
1113
+ const address = pointerToAddress(pointer);
1114
+ // 3. Now the authoritative manifest — the object's author gets to say which
1115
+ // app renders their project (kind:31989), same as for an inline reference.
1116
+ const resolved = await resolveManifest(pointer, query);
1117
+ if (!resolved) {
1118
+ return null;
1119
+ }
1120
+ const { manifest, viaRecommendation } = resolved;
1121
+ const projection = manifest.projections?.[String(root.kind)];
1122
+ if (!projection) {
1123
+ return null;
1124
+ }
1125
+ const records = foldRuleOf(manifest);
1126
+ const relation = containmentFor(manifest, root.kind) ?? containers.get(root.kind);
1127
+ // 4. Every change in the Folder, folded per object. Already fetched in step 2
1128
+ // unless the authoritative manifest orders records differently from the one
1129
+ // discovery happened to find, which is the only case worth a second trip.
1130
+ // That trip asks by address as well as by Folder: an adopted project's own
1131
+ // changes are not in this Folder, and a `#h` query alone would fold it
1132
+ // without them and report the defaults as its state.
1133
+ const changes = !manifest.records
1134
+ ? // The app declares no change kind, so there is nothing to ask for. Not a
1135
+ // filter that matches nothing — a kind is `u16` and the relay refuses an
1136
+ // out-of-range one outright. See `foldRuleOf`.
1137
+ []
1138
+ : discoveredRule?.changeKind === records.changeKind
1139
+ ? known.filter((e) => e.kind === records.changeKind)
1140
+ : await query([
1141
+ { kinds: [records.changeKind], '#h': [folder], limit: 500 },
1142
+ ...(adopted.length ? [{ kinds: [records.changeKind], '#a': [address], limit: 500 }] : []),
1143
+ ]);
1144
+ const changesFor = (target) => changes.filter((e) => tagValue(e, records.targetTag) === target);
1145
+ const project = buildObject({
1146
+ root,
1147
+ pointer,
1148
+ manifest,
1149
+ projection,
1150
+ folded: foldChanges(changesFor(address), records),
1151
+ viaRecommendation,
1152
+ webTemplate: resolved.webTemplate,
1153
+ });
1154
+ // 5. The tickets. A child either names this project or names nothing at all —
1155
+ // an unlinked ticket still lives in the Folder, and the Folder *is* the
1156
+ // project, so dropping it would under-report live work.
1157
+ const ticketProjection = manifest.projections?.[String(relation.childKind)];
1158
+ const statusSpec = ticketProjection && !Array.isArray(ticketProjection.slots.status)
1159
+ ? ticketProjection.slots.status
1160
+ : undefined;
1161
+ // A project whose tickets have no declared projection is still worth showing;
1162
+ // it just has nothing to expand into.
1163
+ /**
1164
+ * One lookup for everyone the panel could name, shared by every object it
1165
+ * returns. The project's lead is the only one drawn today; the tickets carry
1166
+ * the same map so a renderer that starts showing assignees needs no second
1167
+ * trip, and so `ForeignObject` means the same thing whichever resolver built
1168
+ * it.
1169
+ */
1170
+ const withPeople = async (objects) => {
1171
+ const people = await (lookupPeople ?? peopleViaRelay(query))([
1172
+ ...new Set(objects.flatMap(pubkeysIn)),
1173
+ ]);
1174
+ return objects.map((object) => ({ ...object, people }));
1175
+ };
1176
+ if (!ticketProjection) {
1177
+ const [only] = await withPeople([project]);
1178
+ return { project: only, tickets: [], openCount: 0, doneCount: 0 };
1179
+ }
1180
+ const rows = [];
1181
+ let openCount = 0;
1182
+ let doneCount = 0;
1183
+ for (const event of inFolder) {
1184
+ if (event.kind !== relation.childKind)
1185
+ continue;
1186
+ /*
1187
+ What the link is compared against depends on what the child carries.
1188
+
1189
+ Ship's issues name their project by full address; Peek's messages name
1190
+ their channel with `h`, which holds the bare uuid — the parent's `d`, not
1191
+ its address. The manifest says which (`match`), because guessing is what
1192
+ PRO-2 removed and comparing against both would silently accept a child
1193
+ that named something else entirely whose `d` happened to collide.
1194
+ */
1195
+ const expected = relation.match === 'identifier' ? pointer.identifier : address;
1196
+ const link = tagValue(event, relation.linkTag);
1197
+ if (link !== undefined && link !== expected)
1198
+ continue;
1199
+ // Archived is the one exclusion left. Everything else the project holds is
1200
+ // listed; a record its own app hides is not part of the project any more.
1201
+ if (isHidden(event, records, changes))
1202
+ continue;
1203
+ const ticketPointer = {
1204
+ kind: event.kind,
1205
+ pubkey: event.pubkey,
1206
+ identifier: tagValue(event, 'd') ?? '',
1207
+ relays: [],
1208
+ };
1209
+ const ticketChanges = changesFor(pointerToAddress(ticketPointer));
1210
+ const folded = foldChanges(ticketChanges, records);
1211
+ const status = statusSpec ? resolveSlot(statusSpec, event, folded, manifest) : null;
1212
+ const raw = statusSpec ? rawSlotValue(statusSpec, event, folded) : undefined;
1213
+ // The owning app's declaration first, the label guess only if it has none.
1214
+ const stage = declaredStage(manifest, statusSpec, raw);
1215
+ const done = isDone(stage, raw, status?.value);
1216
+ if (done)
1217
+ doneCount += 1;
1218
+ else if (isOpen(stage, raw, status?.value))
1219
+ openCount += 1;
1220
+ // A status in neither set — parked, or one this consumer has never seen —
1221
+ // is still a ticket and still listed. It just cannot claim to be progress
1222
+ // in either direction, so it stays out of both counts.
1223
+ rows.push({
1224
+ ticket: buildObject({
1225
+ root: event,
1226
+ pointer: ticketPointer,
1227
+ manifest,
1228
+ projection: ticketProjection,
1229
+ folded,
1230
+ viaRecommendation,
1231
+ webTemplate: resolved.webTemplate,
1232
+ }),
1233
+ rank: isStarted(stage, raw, status?.value) ? 0 : done ? 2 : 1,
1234
+ activityMs: Math.max(event.created_at * 1000, ...ticketChanges.map(orderingMs)),
1235
+ });
1236
+ }
1237
+ // Started work, then the queue, then what is finished; within each, whatever
1238
+ // moved most recently. The whole project is here — the order is what makes it
1239
+ // scannable rather than a dump.
1240
+ rows.sort((a, b) => a.rank - b.rank || b.activityMs - a.activityMs);
1241
+ /*
1242
+ The producer's declared `limit`, applied after sorting rather than before.
1243
+
1244
+ It says how many children are worth rendering, so cutting before the sort
1245
+ would drop whichever happened to be read first and could hide every started
1246
+ ticket behind a hundred finished ones. The counts above are deliberately
1247
+ computed over everything: "4 of 117" stays true when only 200 rows are
1248
+ drawn, and a total that changed with the render budget would be a different
1249
+ and worse number.
1250
+ */
1251
+ const capped = relation.limit ? rows.slice(0, relation.limit) : rows;
1252
+ const [resolvedProject, ...tickets] = await withPeople([project, ...capped.map((r) => r.ticket)]);
1253
+ return { project: resolvedProject, tickets, openCount, doneCount };
1254
+ }
1255
+ /**
1256
+ * Build the event that performs a manifest-declared action.
1257
+ *
1258
+ * Pure, and separate from the Convex action for the same reason `projection.ts`
1259
+ * is separate from `foreign.ts`: this is the part worth testing against a live
1260
+ * relay, and it must not need a deployment or a signed-in session to run.
1261
+ *
1262
+ * Returns a string on refusal rather than throwing — every failure here is
1263
+ * something a user should read.
1264
+ */
1265
+ export function buildActionEvent(args) {
1266
+ const { manifest, kind, address, folder, actionId, value } = args;
1267
+ const records = manifest.records;
1268
+ const declared = manifest.actions?.find((a) => a.id === actionId);
1269
+ if (!records)
1270
+ return 'That app does not say how its records are written.';
1271
+ if (!declared)
1272
+ return `This app does not offer "${actionId}".`;
1273
+ const appliesTo = Array.isArray(declared.appliesTo) ? declared.appliesTo : [declared.appliesTo];
1274
+ if (!appliesTo.includes(String(kind))) {
1275
+ return `"${declared.label}" does not apply to a kind ${kind}.`;
1276
+ }
1277
+ // Validate against the manifest's own vocabulary. The owning app cannot
1278
+ // enforce this — anyone can publish anything (RFC_UPDATES.md §3) — so a
1279
+ // consumer that skips the check is the one putting junk in the shared record.
1280
+ // Checking here is Peek keeping its side of the honour system.
1281
+ if (declared.input?.enum) {
1282
+ const vocab = manifest.vocabularies?.[declared.input.enum] ?? [];
1283
+ if (!vocab.some((entry) => entry.value === value)) {
1284
+ return `"${value}" is not one of ${vocab.map((e) => e.value).join(', ')}.`;
1285
+ }
1286
+ }
1287
+ const tags = declared.emits.field
1288
+ ? [
1289
+ [records.targetTag, address],
1290
+ [records.fieldTag, declared.emits.field],
1291
+ [records.valueTag, value],
1292
+ ['h', folder],
1293
+ ]
1294
+ : // NIP-22 comment. Built from the ratified NIP rather than from the
1295
+ // manifest, which is legitimate precisely because no app owns kind:1111 —
1296
+ // the same reason the owning app uses it. Uppercase tags name the thread
1297
+ // root, lowercase the immediate parent.
1298
+ [
1299
+ ['A', address],
1300
+ ['K', String(kind)],
1301
+ ['P', args.objectAuthor],
1302
+ ['a', address],
1303
+ ['k', String(kind)],
1304
+ ['p', args.objectAuthor],
1305
+ ['h', folder],
1306
+ ];
1307
+ // The manifest told us how it orders events; write events that can be ordered.
1308
+ // Without `ts`, a change from here and one from the owning app in the same
1309
+ // second would be separated by event id — arbitrarily, and differently
1310
+ // depending on which app you asked (FRICTION.md A6).
1311
+ if (records.order?.includes('ts'))
1312
+ tags.push(['ts', String(args.createdAtMs)]);
1313
+ return {
1314
+ pubkey: args.pubkey,
1315
+ created_at: Math.floor(args.createdAtMs / 1000),
1316
+ kind: declared.emits.kind,
1317
+ tags,
1318
+ content: declared.emits.field ? '' : value,
1319
+ };
1320
+ }
1321
+ /**
1322
+ * Test seam: resolve one projection's `title` slot against one event.
1323
+ *
1324
+ * Exported so the tag-fallback rules can be pinned without standing up a fake
1325
+ * relay. `resolveSlots` and `resolveSlot` are the real path; this only picks
1326
+ * the one slot out of them.
1327
+ */
1328
+ export function resolveFolderProjectSlotsForTest(manifest, root) {
1329
+ const projection = manifest.projections?.[String(root.kind)];
1330
+ if (!projection)
1331
+ return undefined;
1332
+ return resolveSlots(projection, root, {}, manifest).slots.title?.value;
1333
+ }
1334
+ //# sourceMappingURL=projection.js.map