@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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/projection.d.ts +524 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +1334 -0
- package/dist/projection.js.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +46 -0
- package/src/projection.ts +1833 -0
|
@@ -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
|
+
}
|