@estiva-app/interop 0.1.2 → 0.3.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 +84 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/projection.d.ts +91 -4
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +235 -34
- package/dist/projection.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +5 -0
- package/src/projection.ts +304 -44
package/src/projection.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* anything: it becomes an integration written against one app, which is the
|
|
13
13
|
* thing the whole exercise argues against.
|
|
14
14
|
*/
|
|
15
|
-
import { encodeNaddr, pointerToAddress, referenceToPointer, type AddressPointer } from '@estiva-app/protocol'
|
|
15
|
+
import { decodeNevent, encodeNaddr, encodeNevent, pointerToAddress, referenceToPointer, type AddressPointer, type EventPointer } from '@estiva-app/protocol'
|
|
16
16
|
import { parseProfile, type Profile, type SignedEvent } from '@estiva-app/protocol'
|
|
17
17
|
|
|
18
18
|
/** Query the relay. Returns matching events; shape mirrors the HTTP bridge. */
|
|
@@ -311,6 +311,19 @@ function resolveActions(
|
|
|
311
311
|
|
|
312
312
|
const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1]
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Does the event carry this tag with this value — on **any** of them?
|
|
316
|
+
*
|
|
317
|
+
* `tagValue` reads the first tag with a given name, which is the wrong test for
|
|
318
|
+
* deciding whether an event would have matched a filter: a relay's `#a` matches
|
|
319
|
+
* if *any* `a` tag equals the wanted value, and an event may legitimately carry
|
|
320
|
+
* several. Used where a filter's own predicate is re-applied to a merged
|
|
321
|
+
* response (SHI-13), so that "did this come back because of that filter?" is
|
|
322
|
+
* answered the way the relay answered it.
|
|
323
|
+
*/
|
|
324
|
+
const hasTagValue = (e: SignedEvent, name: string, value: string) =>
|
|
325
|
+
e.tags.some((t) => t[0] === name && t[1] === value)
|
|
326
|
+
|
|
314
327
|
/** A manifest event's `content`, or null when it is not parseable JSON. */
|
|
315
328
|
function parseManifest(event: SignedEvent): Manifest | null {
|
|
316
329
|
try {
|
|
@@ -358,16 +371,100 @@ function webTemplate(event: SignedEvent, entity: string): string | undefined {
|
|
|
358
371
|
return undefined
|
|
359
372
|
}
|
|
360
373
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
query: QueryFn,
|
|
364
|
-
): Promise<{
|
|
374
|
+
/** What {@link resolveManifest} answers with. */
|
|
375
|
+
export interface ResolvedManifest {
|
|
365
376
|
manifest: Manifest
|
|
366
377
|
address: string
|
|
367
378
|
viaRecommendation: boolean
|
|
368
379
|
/** NIP-89 `web` template, `<bech32>` not yet substituted. */
|
|
369
380
|
webTemplate?: string
|
|
370
|
-
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A memo for the half of a resolve that does not change between refreshes.
|
|
385
|
+
*
|
|
386
|
+
* Resolving one reference costs four round trips, and **two of them are NIP-89
|
|
387
|
+
* discovery** — the author's `kind:31989` recommendation, then the `kind:31990`
|
|
388
|
+
* manifest itself. A consumer that re-resolves on a timer pays for both every
|
|
389
|
+
* time, and they answer the same thing until an app republishes its manifest.
|
|
390
|
+
* Measured on Ship, where a reference widget re-resolves on the poll: 4
|
|
391
|
+
* requests per reference per tick, identical on the second resolve, against a
|
|
392
|
+
* relay that meters reads at 300 a minute.
|
|
393
|
+
*
|
|
394
|
+
* **Owned by the caller, not this module.** A module-level cache would be
|
|
395
|
+
* invisible global state shared by every consumer in the process, impossible to
|
|
396
|
+
* scope to a screen and awkward to reset in a test. A caller that wants no
|
|
397
|
+
* caching passes nothing and gets exactly the old behaviour.
|
|
398
|
+
*
|
|
399
|
+
* Deliberately only the manifest. The object, its changes, its comments and its
|
|
400
|
+
* children are the parts a refresh exists to notice, and caching those is how
|
|
401
|
+
* a live widget becomes a screenshot.
|
|
402
|
+
*/
|
|
403
|
+
export interface ProjectionCache {
|
|
404
|
+
/** Forget everything. Worth calling after publishing a manifest. */
|
|
405
|
+
clear(): void
|
|
406
|
+
/** @internal */
|
|
407
|
+
lookup(key: string, now: number): { value: ResolvedManifest | null } | undefined
|
|
408
|
+
/** @internal */
|
|
409
|
+
remember(key: string, value: ResolvedManifest | null, now: number): void
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* How long a manifest may be believed without asking again.
|
|
414
|
+
*
|
|
415
|
+
* Five minutes is a compromise with one real cost: republish a manifest and
|
|
416
|
+
* consumers keep drawing the old projection for up to that long. That is
|
|
417
|
+
* recoverable and self-correcting, where the alternative — asking twice per
|
|
418
|
+
* reference per tick, for ever — is neither.
|
|
419
|
+
*/
|
|
420
|
+
export const MANIFEST_TTL_MS = 5 * 60_000
|
|
421
|
+
|
|
422
|
+
export function createProjectionCache(ttlMs: number = MANIFEST_TTL_MS): ProjectionCache {
|
|
423
|
+
const entries = new Map<string, { at: number; value: ResolvedManifest | null }>()
|
|
424
|
+
return {
|
|
425
|
+
clear: () => entries.clear(),
|
|
426
|
+
lookup(key, now) {
|
|
427
|
+
const found = entries.get(key)
|
|
428
|
+
if (!found) return undefined
|
|
429
|
+
// `>=`, not `>`: a TTL of 0 must mean "never believe it", and an entry
|
|
430
|
+
// exactly at the boundary has expired rather than being on its last tick.
|
|
431
|
+
if (now - found.at >= ttlMs) {
|
|
432
|
+
entries.delete(key)
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
return { value: found.value }
|
|
436
|
+
},
|
|
437
|
+
remember(key, value, now) {
|
|
438
|
+
entries.set(key, { at: now, value })
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A negative answer is cached too.
|
|
445
|
+
*
|
|
446
|
+
* "No app claims this kind" costs the same two round trips as a hit and is just
|
|
447
|
+
* as stable. Caching only successes would leave the expensive case — a
|
|
448
|
+
* reference nothing can draw — paying full price on every tick for ever.
|
|
449
|
+
*/
|
|
450
|
+
export async function resolveManifest(
|
|
451
|
+
pointer: AddressPointer,
|
|
452
|
+
query: QueryFn,
|
|
453
|
+
cache?: ProjectionCache,
|
|
454
|
+
): Promise<ResolvedManifest | null> {
|
|
455
|
+
const key = `${pointer.kind}:${pointer.pubkey}`
|
|
456
|
+
const now = Date.now()
|
|
457
|
+
const memo = cache?.lookup(key, now)
|
|
458
|
+
if (memo) return memo.value
|
|
459
|
+
const answer = await resolveManifestUncached(pointer, query)
|
|
460
|
+
cache?.remember(key, answer, now)
|
|
461
|
+
return answer
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function resolveManifestUncached(
|
|
465
|
+
pointer: AddressPointer,
|
|
466
|
+
query: QueryFn,
|
|
467
|
+
): Promise<ResolvedManifest | null> {
|
|
371
468
|
const parse = parseManifest
|
|
372
469
|
const addressOf = (event: SignedEvent) =>
|
|
373
470
|
`${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`
|
|
@@ -901,6 +998,87 @@ function buildObject(args: {
|
|
|
901
998
|
* unresolvable reference in a chat message should degrade to plain text, not
|
|
902
999
|
* break the message around it.
|
|
903
1000
|
*/
|
|
1001
|
+
/**
|
|
1002
|
+
* Resolve one event by id — `nevent1…`, or a bare 64-hex id.
|
|
1003
|
+
*
|
|
1004
|
+
* The counterpart to `resolveForeignObject`, and the reason it has to exist:
|
|
1005
|
+
* **not every object has an address.** A `kind:9` message carries no `d`, so
|
|
1006
|
+
* `(kind, pubkey, d)` cannot be built for it and every resolver keyed on an
|
|
1007
|
+
* address is blind to it. Measured on production during PRO-6; PRO-11 is this.
|
|
1008
|
+
*
|
|
1009
|
+
* ## What it does *not* do, and why the function is short
|
|
1010
|
+
*
|
|
1011
|
+
* A regular event is immutable and has no folded state, so there is no `records`
|
|
1012
|
+
* rule to apply, no change events to fetch, and no "current value" that differs
|
|
1013
|
+
* from what is on the event. It also cannot be the target of an `a` tag, so it
|
|
1014
|
+
* has no comments addressed to it and **no actions** — a change names its target
|
|
1015
|
+
* by address, and there is nothing here to name. That absence is the model being
|
|
1016
|
+
* honest rather than a gap to fill later.
|
|
1017
|
+
*
|
|
1018
|
+
* ## Two round trips, and the order depends on the reference
|
|
1019
|
+
*
|
|
1020
|
+
* A manifest is found by kind. An `nevent` *may* carry its kind, and when it
|
|
1021
|
+
* does the manifest and the event can be fetched together. When it does not —
|
|
1022
|
+
* a bare id, which is what a pasted `e` tag gives you — the event has to be
|
|
1023
|
+
* read first to learn what kind it is. Both paths are supported because both
|
|
1024
|
+
* arrive in practice, and a resolver that required the richer form would refuse
|
|
1025
|
+
* references other clients legitimately produce.
|
|
1026
|
+
*/
|
|
1027
|
+
export async function resolveForeignEvent(
|
|
1028
|
+
reference: string,
|
|
1029
|
+
query: QueryFn,
|
|
1030
|
+
/** Defaults to asking the relay. The browser passes a cached lookup. */
|
|
1031
|
+
lookupPeople?: PeopleFn,
|
|
1032
|
+
/** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
|
|
1033
|
+
cache?: ProjectionCache,
|
|
1034
|
+
): Promise<ForeignObject | null> {
|
|
1035
|
+
let pointer: EventPointer
|
|
1036
|
+
try {
|
|
1037
|
+
pointer = /^[0-9a-f]{64}$/i.test(reference.replace(/^nostr:/i, ''))
|
|
1038
|
+
? { id: reference.replace(/^nostr:/i, '').toLowerCase(), relays: [] }
|
|
1039
|
+
: decodeNevent(reference)
|
|
1040
|
+
} catch {
|
|
1041
|
+
return null
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const [root] = await query([{ ids: [pointer.id], limit: 1 }])
|
|
1045
|
+
if (!root) {
|
|
1046
|
+
// Nothing to draw and nothing to say about it: unlike an addressable
|
|
1047
|
+
// object, there is no manifest resolved yet that could name the app or
|
|
1048
|
+
// offer a way in. `unreachable` needs a projection to be a useful state.
|
|
1049
|
+
return null
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Whoever signed it is the app's own author, which is what a `#k` lookup
|
|
1053
|
+
// needs when no recommendation exists. The pointer's `author` is a hint and
|
|
1054
|
+
// may disagree with the event; the event wins, because it is the thing.
|
|
1055
|
+
const resolved = await resolveManifest(
|
|
1056
|
+
{ kind: root.kind, pubkey: root.pubkey, identifier: '', relays: pointer.relays },
|
|
1057
|
+
query,
|
|
1058
|
+
cache,
|
|
1059
|
+
)
|
|
1060
|
+
if (!resolved) return null
|
|
1061
|
+
|
|
1062
|
+
const projection = resolved.manifest.projections?.[String(root.kind)]
|
|
1063
|
+
if (!projection) return null
|
|
1064
|
+
|
|
1065
|
+
const object = buildChildObject({
|
|
1066
|
+
root,
|
|
1067
|
+
manifest: resolved.manifest,
|
|
1068
|
+
projection,
|
|
1069
|
+
records: foldRuleOf(resolved.manifest),
|
|
1070
|
+
webTemplate: resolved.webTemplate,
|
|
1071
|
+
viaRecommendation: resolved.viaRecommendation,
|
|
1072
|
+
// The reference as given, so "open this in the app that owns it" points at
|
|
1073
|
+
// the event rather than at nothing. A bare id is upgraded to an `nevent`
|
|
1074
|
+
// carrying what we now know, which is more than the caller had.
|
|
1075
|
+
nevent: encodeNevent({ id: root.id, relays: pointer.relays, pubkey: root.pubkey, kind: root.kind }),
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
const people = await (lookupPeople ?? peopleViaRelay(query))(pubkeysIn(object))
|
|
1079
|
+
return { ...object, people }
|
|
1080
|
+
}
|
|
1081
|
+
|
|
904
1082
|
export async function resolveForeignObject(
|
|
905
1083
|
naddr: string,
|
|
906
1084
|
query: QueryFn,
|
|
@@ -912,6 +1090,11 @@ export async function resolveForeignObject(
|
|
|
912
1090
|
* recursing forever. See `MAX_LIST_DEPTH`.
|
|
913
1091
|
*/
|
|
914
1092
|
depth = 0,
|
|
1093
|
+
/**
|
|
1094
|
+
* Optional memo for the NIP-89 discovery half. See {@link ProjectionCache} —
|
|
1095
|
+
* omitting it is exactly the old behaviour.
|
|
1096
|
+
*/
|
|
1097
|
+
cache?: ProjectionCache,
|
|
915
1098
|
): Promise<ForeignObject | null> {
|
|
916
1099
|
let pointer: AddressPointer
|
|
917
1100
|
try {
|
|
@@ -923,7 +1106,7 @@ export async function resolveForeignObject(
|
|
|
923
1106
|
}
|
|
924
1107
|
const address = pointerToAddress(pointer)
|
|
925
1108
|
|
|
926
|
-
const resolved = await resolveManifest(pointer, query)
|
|
1109
|
+
const resolved = await resolveManifest(pointer, query, cache)
|
|
927
1110
|
if (!resolved) return null
|
|
928
1111
|
const { manifest, viaRecommendation } = resolved
|
|
929
1112
|
// Substituted here rather than in the component: `<bech32>` is a NIP-89
|
|
@@ -935,15 +1118,29 @@ export async function resolveForeignObject(
|
|
|
935
1118
|
if (!projection) return null
|
|
936
1119
|
const records = foldRuleOf(manifest)
|
|
937
1120
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1121
|
+
/*
|
|
1122
|
+
One round trip for the root, its changes, its comments **and its children**.
|
|
1123
|
+
|
|
1124
|
+
`#a` on both the change and the comment kind, because both point at the
|
|
1125
|
+
object by *address* rather than by event id — which is what makes them
|
|
1126
|
+
survive the author replacing the root event.
|
|
1127
|
+
|
|
1128
|
+
The children used to be a second round trip, issued after the root came
|
|
1129
|
+
back. They never needed to be: the child filter is built from the manifest
|
|
1130
|
+
and the *pointer*, and an addressable event's `d` is `pointer.identifier` by
|
|
1131
|
+
definition — it is what we just queried by. So once the manifest is known,
|
|
1132
|
+
nothing about the child filter depends on the root's contents (SHI-13).
|
|
1133
|
+
|
|
1134
|
+
That is the difference between two requests per reference per refresh and
|
|
1135
|
+
one, and with the manifest memoised it is the whole cost of a tick.
|
|
1136
|
+
*/
|
|
1137
|
+
const childFilter = childFilterFor({ projection, manifest, pointer, depth })
|
|
942
1138
|
const events = await query([
|
|
943
1139
|
{ kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 },
|
|
944
1140
|
// Only when the app actually declares a change kind — see `foldRuleOf`.
|
|
945
1141
|
...(manifest.records ? [{ kinds: [manifest.records.changeKind], '#a': [address], limit: 500 }] : []),
|
|
946
1142
|
{ kinds: commentKinds, '#a': [address], limit: 200 },
|
|
1143
|
+
...(childFilter ? [childFilter.filter] : []),
|
|
947
1144
|
])
|
|
948
1145
|
|
|
949
1146
|
const root = events.find(
|
|
@@ -981,8 +1178,19 @@ export async function resolveForeignObject(
|
|
|
981
1178
|
records,
|
|
982
1179
|
)
|
|
983
1180
|
|
|
1181
|
+
/*
|
|
1182
|
+
Matched on the comment filter's own criteria — the kind **and** the `a` tag.
|
|
1183
|
+
|
|
1184
|
+
Kind alone was safe while this was its own query: the relay only returned
|
|
1185
|
+
what the comment filter asked for. Now that the children ride in the same
|
|
1186
|
+
request (SHI-13), a child sharing the comment kind would arrive here too and
|
|
1187
|
+
be counted as a comment on its own parent. Peek is exactly that shape: its
|
|
1188
|
+
Topic declares `kind:9` messages as children and `kind:9` as its comment
|
|
1189
|
+
kind, so every message in the Folder would have become a comment on the
|
|
1190
|
+
Topic — a widget silently showing a conversation twice.
|
|
1191
|
+
*/
|
|
984
1192
|
const comments = events
|
|
985
|
-
.filter((e) => commentKinds.includes(e.kind))
|
|
1193
|
+
.filter((e) => commentKinds.includes(e.kind) && hasTagValue(e, 'a', address))
|
|
986
1194
|
.sort(byOrder)
|
|
987
1195
|
.map((e) => ({ id: e.id, author: e.pubkey, body: e.content, createdAt: e.created_at }))
|
|
988
1196
|
|
|
@@ -1011,12 +1219,10 @@ export async function resolveForeignObject(
|
|
|
1011
1219
|
A child may be a regular event with no address of its own (Peek's messages
|
|
1012
1220
|
are), so this builds by event rather than by pointer.
|
|
1013
1221
|
*/
|
|
1014
|
-
const children =
|
|
1015
|
-
|
|
1016
|
-
|
|
1222
|
+
const children = childrenFrom({
|
|
1223
|
+
events,
|
|
1224
|
+
childFilter,
|
|
1017
1225
|
manifest,
|
|
1018
|
-
query,
|
|
1019
|
-
depth,
|
|
1020
1226
|
webTemplate: resolved.webTemplate,
|
|
1021
1227
|
viaRecommendation,
|
|
1022
1228
|
})
|
|
@@ -1035,48 +1241,82 @@ export async function resolveForeignObject(
|
|
|
1035
1241
|
}
|
|
1036
1242
|
|
|
1037
1243
|
/**
|
|
1038
|
-
*
|
|
1244
|
+
* The filter for a projection's `list` slot, or nothing.
|
|
1245
|
+
*
|
|
1246
|
+
* Split out from fetching so it can be built **before** the root event is in
|
|
1247
|
+
* hand and merged into the object's own round trip (SHI-13). Everything it
|
|
1248
|
+
* needs is in the manifest and the pointer: an addressable event's `d` *is*
|
|
1249
|
+
* `pointer.identifier`, since that is what the root filter matches on, so
|
|
1250
|
+
* reading it back off the root taught us nothing we did not already know.
|
|
1039
1251
|
*
|
|
1040
|
-
* Returns undefined when no `list` is declared — distinct from
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1252
|
+
* Returns undefined when no `list` is declared — distinct from a declared list
|
|
1253
|
+
* that matches nothing, which a renderer must be able to tell apart. The two
|
|
1254
|
+
* other "declared but not renderable" cases are folded in here too, and both
|
|
1255
|
+
* come back as `[]` from {@link childrenFrom}: a depth budget already spent,
|
|
1256
|
+
* and a child kind the manifest never says how to draw.
|
|
1043
1257
|
*/
|
|
1044
|
-
|
|
1258
|
+
function childFilterFor(args: {
|
|
1045
1259
|
projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
|
|
1046
|
-
root: SignedEvent
|
|
1047
1260
|
manifest: Manifest
|
|
1048
|
-
|
|
1261
|
+
pointer: AddressPointer
|
|
1049
1262
|
depth: number
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
}): Promise<ForeignObject[] | undefined> {
|
|
1053
|
-
const { projection, root, manifest, query, depth, webTemplate, viaRecommendation } = args
|
|
1263
|
+
}): { filter: Record<string, unknown>; kind: number; via: string; parent: string } | null | undefined {
|
|
1264
|
+
const { projection, manifest, pointer, depth } = args
|
|
1054
1265
|
const spec = projection.slots.list
|
|
1055
1266
|
const children = !Array.isArray(spec) ? spec?.children : undefined
|
|
1056
1267
|
if (!children) return undefined
|
|
1057
1268
|
// 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)]
|
|
1269
|
+
if (depth >= MAX_LIST_DEPTH) return null
|
|
1061
1270
|
// A declared list whose child kind has no projection is not renderable, and
|
|
1062
1271
|
// an empty list is the honest answer: the objects exist, this app has not
|
|
1063
1272
|
// said how to draw them.
|
|
1064
|
-
if (!
|
|
1273
|
+
if (!manifest.projections?.[String(children.kind)]) return null
|
|
1065
1274
|
|
|
1066
|
-
const identifier = tagValue(root, 'd') ?? ''
|
|
1067
1275
|
const parent =
|
|
1068
1276
|
children.match === 'identifier'
|
|
1069
|
-
? identifier
|
|
1070
|
-
: pointerToAddress({
|
|
1277
|
+
? pointer.identifier
|
|
1278
|
+
: pointerToAddress({ ...pointer, relays: [] })
|
|
1071
1279
|
|
|
1072
|
-
|
|
1073
|
-
{ kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
|
|
1074
|
-
|
|
1280
|
+
return {
|
|
1281
|
+
filter: { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
|
|
1282
|
+
kind: children.kind,
|
|
1283
|
+
via: children.via,
|
|
1284
|
+
parent,
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1075
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* The child objects, picked back out of the merged result set.
|
|
1290
|
+
*
|
|
1291
|
+
* **Matched on the filter's own criteria, never on kind alone.** Peek's Topic
|
|
1292
|
+
* declares `kind:9` messages as its children and `kind:9` as its comment kind,
|
|
1293
|
+
* so a merged response carries both under one number and only the tag tells
|
|
1294
|
+
* them apart. An event can honestly be both — Ship writes a `kind:9` with an
|
|
1295
|
+
* `a` naming the object *and* an `h` naming the Folder — and it appeared in
|
|
1296
|
+
* both result sets when these were two queries. Re-applying each filter's own
|
|
1297
|
+
* predicate reproduces that, rather than making them compete.
|
|
1298
|
+
*/
|
|
1299
|
+
function childrenFrom(args: {
|
|
1300
|
+
events: SignedEvent[]
|
|
1301
|
+
childFilter: ReturnType<typeof childFilterFor>
|
|
1302
|
+
manifest: Manifest
|
|
1303
|
+
webTemplate?: string
|
|
1304
|
+
viaRecommendation: boolean
|
|
1305
|
+
}): ForeignObject[] | undefined {
|
|
1306
|
+
const { events, childFilter, manifest, webTemplate, viaRecommendation } = args
|
|
1307
|
+
if (childFilter === undefined) return undefined
|
|
1308
|
+
if (childFilter === null) return []
|
|
1309
|
+
|
|
1310
|
+
const childProjection = manifest.projections?.[String(childFilter.kind)]
|
|
1311
|
+
if (!childProjection) return []
|
|
1076
1312
|
const records = foldRuleOf(manifest)
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1313
|
+
|
|
1314
|
+
return events
|
|
1315
|
+
.filter((e) => e.kind === childFilter.kind && hasTagValue(e, childFilter.via, childFilter.parent))
|
|
1316
|
+
.sort(byOrder)
|
|
1317
|
+
.map((event) =>
|
|
1318
|
+
buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation }),
|
|
1319
|
+
)
|
|
1080
1320
|
}
|
|
1081
1321
|
|
|
1082
1322
|
/**
|
|
@@ -1098,8 +1338,17 @@ function buildChildObject(args: {
|
|
|
1098
1338
|
records: RecordsRule
|
|
1099
1339
|
webTemplate?: string
|
|
1100
1340
|
viaRecommendation: boolean
|
|
1341
|
+
/**
|
|
1342
|
+
* The `nevent1…` for a non-addressable object, when the caller has one.
|
|
1343
|
+
*
|
|
1344
|
+
* Only `resolveForeignEvent` passes it: a child reached through a `list` slot
|
|
1345
|
+
* is drawn inside its parent and has nowhere of its own to open, while an
|
|
1346
|
+
* event somebody referenced directly does. Without it a message has no
|
|
1347
|
+
* `openUrl` at all, which is PRO-11's "its web link opens that message".
|
|
1348
|
+
*/
|
|
1349
|
+
nevent?: string
|
|
1101
1350
|
}): ForeignObject {
|
|
1102
|
-
const { root, manifest, projection, webTemplate, viaRecommendation } = args
|
|
1351
|
+
const { root, manifest, projection, webTemplate, viaRecommendation, nevent } = args
|
|
1103
1352
|
const identifier = tagValue(root, 'd')
|
|
1104
1353
|
const addressable = identifier !== undefined
|
|
1105
1354
|
const pointer: AddressPointer = {
|
|
@@ -1126,7 +1375,16 @@ function buildChildObject(args: {
|
|
|
1126
1375
|
meta,
|
|
1127
1376
|
comments: [],
|
|
1128
1377
|
folder: tagValue(root, 'h'),
|
|
1129
|
-
|
|
1378
|
+
/*
|
|
1379
|
+
`<bech32>` is whichever form this object actually has.
|
|
1380
|
+
|
|
1381
|
+
NIP-89's template says nothing about which NIP-19 entity it will be handed
|
|
1382
|
+
— Ship's declares `naddr` in its own tag because every Ship object is
|
|
1383
|
+
addressable, and a template for an app with non-addressable objects is
|
|
1384
|
+
handed an `nevent`. Substituting the one the object *has* is what makes a
|
|
1385
|
+
single template serve both, and what stops a message linking to nothing.
|
|
1386
|
+
*/
|
|
1387
|
+
openUrl: (naddr ?? nevent) ? webTemplate?.replace('<bech32>', (naddr ?? nevent) as string) : undefined,
|
|
1130
1388
|
// See the note above: nothing can be declared to act on a regular event.
|
|
1131
1389
|
actions: [],
|
|
1132
1390
|
viaRecommendation,
|
|
@@ -1376,6 +1634,8 @@ export async function resolveFolderProject(
|
|
|
1376
1634
|
* own `list` is a budget check rather than a thing nobody remembered.
|
|
1377
1635
|
*/
|
|
1378
1636
|
depth = 0,
|
|
1637
|
+
/** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
|
|
1638
|
+
cache?: ProjectionCache,
|
|
1379
1639
|
): Promise<FolderProject | null> {
|
|
1380
1640
|
// The render loop this forbids is not hypothetical: two folders naming each
|
|
1381
1641
|
// other's projects resolve forever, and the manifest declaring them is
|
|
@@ -1588,7 +1848,7 @@ export async function resolveFolderProject(
|
|
|
1588
1848
|
|
|
1589
1849
|
// 3. Now the authoritative manifest — the object's author gets to say which
|
|
1590
1850
|
// app renders their project (kind:31989), same as for an inline reference.
|
|
1591
|
-
const resolved = await resolveManifest(pointer, query)
|
|
1851
|
+
const resolved = await resolveManifest(pointer, query, cache)
|
|
1592
1852
|
if (!resolved) {
|
|
1593
1853
|
return null
|
|
1594
1854
|
}
|