@estiva-app/interop 0.13.0 → 0.15.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 +57 -0
- package/README.md +66 -10
- 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 +132 -29
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +294 -14
- package/dist/projection.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/projection.ts +365 -34
package/src/projection.ts
CHANGED
|
@@ -240,25 +240,6 @@ interface RecordsRule {
|
|
|
240
240
|
* which field and which value, and this stays out of it.
|
|
241
241
|
*/
|
|
242
242
|
hiddenWhen?: { field: string; equals: string }
|
|
243
|
-
/**
|
|
244
|
-
* Where this app's objects say which Folder they belong to — PRO-18.
|
|
245
|
-
*
|
|
246
|
-
* Absent, and the answer is the tags: NIP-29's `h`, or the relay's own
|
|
247
|
-
* `buzz-channel` for a record published *globally* while still naming a
|
|
248
|
-
* Folder. {@link folderOf} reads both and always has to.
|
|
249
|
-
*
|
|
250
|
-
* `"identifier"` is the third case and the reason this field exists: an
|
|
251
|
-
* object that **is** a container names no Folder because it is one, and its
|
|
252
|
-
* `d` is that Folder's id. A Peek topic is the case in hand — a `kind:39000`
|
|
253
|
-
* whose identifier is the channel — and without this an action performed on
|
|
254
|
-
* one fails with "that object has no Folder, so there is nowhere to write",
|
|
255
|
-
* which is true of the tags and false of the object.
|
|
256
|
-
*
|
|
257
|
-
* Declared rather than inferred from the kind. A consumer that special-cased
|
|
258
|
-
* 39000 would know what Peek is, which is the one thing this layer may not
|
|
259
|
-
* do; the owner says it, and the rule works for an app nobody here wrote.
|
|
260
|
-
*/
|
|
261
|
-
folder?: 'identifier'
|
|
262
243
|
}
|
|
263
244
|
|
|
264
245
|
interface SlotSpec {
|
|
@@ -578,7 +559,7 @@ function resolveActions(
|
|
|
578
559
|
const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1]
|
|
579
560
|
|
|
580
561
|
/**
|
|
581
|
-
* Which Folder an object belongs to —
|
|
562
|
+
* Which Folder an object belongs to — both spellings, in one place.
|
|
582
563
|
*
|
|
583
564
|
* **This lived in Peek and had to move** (PRO-18). Its comment there said so:
|
|
584
565
|
* reading only `h` made five of Ship's fifteen projects unactionable, because
|
|
@@ -588,22 +569,21 @@ const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === n
|
|
|
588
569
|
* are the relay's and NIP-29's, so knowing them is not knowing what any app
|
|
589
570
|
* is.
|
|
590
571
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
572
|
+
* **0.13.0 had a third case and 0.14.0 withdrew it.** A manifest could declare
|
|
573
|
+
* `records.folder: "identifier"`, meaning *my objects are containers, so the
|
|
574
|
+
* Folder is the object's own `d`*. Its only instance was a Peek topic, and
|
|
575
|
+
* RFC 0.5 §1 retires that shape: a Folder holds several files of the same kind
|
|
576
|
+
* — three topics and two projects — so a topic becomes a file inside a Folder
|
|
577
|
+
* and carries an `h` like everything else. A vocabulary field whose only
|
|
578
|
+
* instance is going away is one every future producer has to read and none can
|
|
579
|
+
* use.
|
|
580
|
+
*
|
|
581
|
+
* Returns `null` when neither tag says — which is a real answer. An object with
|
|
582
|
+
* no Folder has nowhere for a write to go, and guessing at one publishes into
|
|
597
583
|
* somebody else's channel.
|
|
598
584
|
*/
|
|
599
|
-
export function folderOf(
|
|
600
|
-
root
|
|
601
|
-
records?: { folder?: 'identifier' },
|
|
602
|
-
): string | null {
|
|
603
|
-
const tagged = tagValue(root, 'h') ?? tagValue(root, 'buzz-channel')
|
|
604
|
-
if (tagged) return tagged
|
|
605
|
-
if (records?.folder === 'identifier') return tagValue(root, 'd') ?? null
|
|
606
|
-
return null
|
|
585
|
+
export function folderOf(root: SignedEvent): string | null {
|
|
586
|
+
return tagValue(root, 'h') ?? tagValue(root, 'buzz-channel') ?? null
|
|
607
587
|
}
|
|
608
588
|
|
|
609
589
|
/**
|
|
@@ -2818,3 +2798,354 @@ export function resolveFolderProjectSlotsForTest(
|
|
|
2818
2798
|
if (!projection) return undefined
|
|
2819
2799
|
return resolveSlots(projection, root, {}, manifest).slots.title?.value
|
|
2820
2800
|
}
|
|
2801
|
+
|
|
2802
|
+
// ── The folder read model — RFC 0.4 §4 and §5 ───────────────────────────────
|
|
2803
|
+
|
|
2804
|
+
/**
|
|
2805
|
+
* The Folder as the relay maintains it — RFC 0.4 §12.1.
|
|
2806
|
+
*
|
|
2807
|
+
* Relay-signed, addressable, and **global**. §4.2 is worth reading before
|
|
2808
|
+
* changing that last word: three of the four properties a folder must have are
|
|
2809
|
+
* impossible if its state carries an `h`, because `h` files it under a channel
|
|
2810
|
+
* and gates reads by access. Exploring folders you are not in, following one,
|
|
2811
|
+
* and a file having one home while being referenced from elsewhere all need an
|
|
2812
|
+
* address that resolves without membership. So the channel keeps doing access
|
|
2813
|
+
* and conversation, and the folder becomes a layer above it.
|
|
2814
|
+
*
|
|
2815
|
+
* **Read-only here.** A client changes a folder by publishing a `kind:1852`
|
|
2816
|
+
* command and the relay emits the new state (§4.1). A folder is not a
|
|
2817
|
+
* user-signed event because NIP-01 addressable events are single-signer by
|
|
2818
|
+
* construction — the address is `(pubkey, kind, d)`, so a second person adding
|
|
2819
|
+
* a file would not replace your folder, they would create a different one.
|
|
2820
|
+
*/
|
|
2821
|
+
export const KIND_FOLDER_STATE = 30890
|
|
2822
|
+
|
|
2823
|
+
/**
|
|
2824
|
+
* NIP-29 group metadata — the Folder's channel.
|
|
2825
|
+
*
|
|
2826
|
+
* Named here for two unrelated jobs: it carries the folder's `name` before any
|
|
2827
|
+
* folder state exists, and its own address is what makes a Peek topic a file
|
|
2828
|
+
* like any other (§5.2).
|
|
2829
|
+
*/
|
|
2830
|
+
const KIND_CHANNEL = 39000
|
|
2831
|
+
|
|
2832
|
+
/** The `name` tag, when there is an event to read it off at all. */
|
|
2833
|
+
const nameOf = (event: SignedEvent | undefined) => (event ? tagValue(event, 'name') : undefined)
|
|
2834
|
+
|
|
2835
|
+
/** One folder, enough to draw a sidebar row. */
|
|
2836
|
+
export interface FolderSummary {
|
|
2837
|
+
/**
|
|
2838
|
+
* The folder's uuid. **One uuid, three roles** — the channel id, the `h` on
|
|
2839
|
+
* everything in it, and the `d` on both its `39000` and its `30890` (§5.2,
|
|
2840
|
+
* measured across all of production's channels).
|
|
2841
|
+
*/
|
|
2842
|
+
id: string
|
|
2843
|
+
name?: string
|
|
2844
|
+
/** True once the relay maintains state for it, rather than it being a bare channel. */
|
|
2845
|
+
hasState: boolean
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/** A folder and everything in it, each file drawn through its owner's manifest. */
|
|
2849
|
+
export interface FolderContents extends FolderSummary {
|
|
2850
|
+
/** The folder state's address, absent until a state event exists. */
|
|
2851
|
+
address?: string
|
|
2852
|
+
/**
|
|
2853
|
+
* The files this folder holds.
|
|
2854
|
+
*
|
|
2855
|
+
* **Peers by construction, not by special case.** A Peek topic and a Ship
|
|
2856
|
+
* project sit side by side because both are `a` tags in one list, and §5.2's
|
|
2857
|
+
* finding is exactly that one tag type is enough to name either: a channel
|
|
2858
|
+
* turned out to be addressable after all, so a topic needs no wrapper and no
|
|
2859
|
+
* second mechanism. Nothing in this function knows what either app is.
|
|
2860
|
+
*
|
|
2861
|
+
* Ordered as the folder lists them. A file that resolved to nothing is
|
|
2862
|
+
* **absent rather than marked** — see {@link resolveFolderContents}.
|
|
2863
|
+
*/
|
|
2864
|
+
files: ForeignObject[]
|
|
2865
|
+
/**
|
|
2866
|
+
* Where the list came from, because the two are not equivalent.
|
|
2867
|
+
*
|
|
2868
|
+
* `state` is the model. `channel` is the approximation available before a
|
|
2869
|
+
* folder has any state: containment inferred from `h`, which can list an
|
|
2870
|
+
* app's records and **can never list a topic**, because under `h` the topic
|
|
2871
|
+
* *is* the container rather than a thing inside it. A consumer that needs to
|
|
2872
|
+
* explain a short list is reading this field.
|
|
2873
|
+
*/
|
|
2874
|
+
source: 'state' | 'channel'
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/**
|
|
2878
|
+
* Every folder this identity can see, for a sidebar.
|
|
2879
|
+
*
|
|
2880
|
+
* Both shapes in one pass: folders the relay maintains state for, and bare
|
|
2881
|
+
* channels that have none yet. A channel with state appears once, named by its
|
|
2882
|
+
* state — the folder's name is the folder's to say.
|
|
2883
|
+
*
|
|
2884
|
+
* **A direct route, deliberately.** §4.2's post-mortem on REW-11 is that
|
|
2885
|
+
* discovering children only through their parent loses them when the parent
|
|
2886
|
+
* goes; a sidebar built by walking something else would inherit exactly that.
|
|
2887
|
+
* This asks for folders by kind.
|
|
2888
|
+
*/
|
|
2889
|
+
export async function listFolders(query: QueryFn): Promise<FolderSummary[]> {
|
|
2890
|
+
const events = await query([
|
|
2891
|
+
{ kinds: [KIND_FOLDER_STATE], limit: 500 },
|
|
2892
|
+
{ kinds: [KIND_CHANNEL], limit: 500 },
|
|
2893
|
+
])
|
|
2894
|
+
const byId = new Map<string, FolderSummary>()
|
|
2895
|
+
for (const event of events) {
|
|
2896
|
+
const id = tagValue(event, 'd')
|
|
2897
|
+
if (!id) continue
|
|
2898
|
+
const state = event.kind === KIND_FOLDER_STATE
|
|
2899
|
+
const existing = byId.get(id)
|
|
2900
|
+
// State wins over the channel for the name, whichever order they arrived.
|
|
2901
|
+
if (existing && !state) continue
|
|
2902
|
+
byId.set(id, {
|
|
2903
|
+
id,
|
|
2904
|
+
name: tagValue(event, 'name') ?? existing?.name,
|
|
2905
|
+
hasState: state || (existing?.hasState ?? false),
|
|
2906
|
+
})
|
|
2907
|
+
}
|
|
2908
|
+
return [...byId.values()].sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id))
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/**
|
|
2912
|
+
* Everything in one folder, resolved through the manifests of the apps that own it.
|
|
2913
|
+
*
|
|
2914
|
+
* The cross-app read the whole model rests on: given a folder id, list its
|
|
2915
|
+
* files whatever kind they are and whoever wrote them, so two apps drawing the
|
|
2916
|
+
* same folder show the same things. Both apps consume this rather than either
|
|
2917
|
+
* one owning it.
|
|
2918
|
+
*
|
|
2919
|
+
* ## A file the reader cannot see is absent, not "unavailable"
|
|
2920
|
+
*
|
|
2921
|
+
* The one rule here that is a product decision rather than a mechanism, and a
|
|
2922
|
+
* deliberate divergence from upstream's NIP-MP, whose fold requires the
|
|
2923
|
+
* opposite for public repositories. **The count is the disclosure**: a folder
|
|
2924
|
+
* that renders three rows and two greyed-out placeholders has told an outsider
|
|
2925
|
+
* how much they are missing, which for a folder named after a person is the
|
|
2926
|
+
* sensitive part. So an address that resolves to nothing is dropped, and
|
|
2927
|
+
* nothing in the return value counts what was dropped.
|
|
2928
|
+
*
|
|
2929
|
+
* This is why the batch below cannot use {@link resolveForeignObject}
|
|
2930
|
+
* unchanged: that returns `unreachable: true` so an inline reference can say
|
|
2931
|
+
* "you may not have access", which is right for one pasted link and wrong for
|
|
2932
|
+
* a list.
|
|
2933
|
+
*
|
|
2934
|
+
* ## Round trips do not grow with the folder
|
|
2935
|
+
*
|
|
2936
|
+
* **Flat in the number of files, linear in the number of apps.** The folder
|
|
2937
|
+
* itself, the handler sweep, the contents, one query for every root at once
|
|
2938
|
+
* and every change with it, and one for the people — then two more per
|
|
2939
|
+
* distinct `(kind, author)` for NIP-89 discovery, which {@link ProjectionCache}
|
|
2940
|
+
* memoises away.
|
|
2941
|
+
*
|
|
2942
|
+
* Measured against production, reading three folders through one cache:
|
|
2943
|
+
*
|
|
2944
|
+
* | folder | files | cold | warm |
|
|
2945
|
+
* | --- | --- | --- | --- |
|
|
2946
|
+
* | Shared foundation packages | 24 | 11 | 5 |
|
|
2947
|
+
* | Feedback on Peek | 25 | 9 | 5 |
|
|
2948
|
+
* | Folders | 9 | 6 | 4 |
|
|
2949
|
+
*
|
|
2950
|
+
* Twenty-five files and nine cost the same warm, which is the property worth
|
|
2951
|
+
* having. A resolve per file would have been four *each* against a relay that
|
|
2952
|
+
* meters reads at 300 a minute — a folder of twenty-five would not have loaded.
|
|
2953
|
+
*/
|
|
2954
|
+
export async function resolveFolderContents(
|
|
2955
|
+
folder: string,
|
|
2956
|
+
query: QueryFn,
|
|
2957
|
+
/** Defaults to asking the relay. The browser passes a cached lookup. */
|
|
2958
|
+
lookupPeople?: PeopleFn,
|
|
2959
|
+
/** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
|
|
2960
|
+
cache?: ProjectionCache,
|
|
2961
|
+
): Promise<FolderContents> {
|
|
2962
|
+
// 1. The folder itself. Both kinds in one trip: the state is the model and
|
|
2963
|
+
// the channel is what names a folder that has none yet.
|
|
2964
|
+
const identity = await query([
|
|
2965
|
+
{ kinds: [KIND_FOLDER_STATE], '#d': [folder], limit: 1 },
|
|
2966
|
+
{ kinds: [KIND_CHANNEL], '#d': [folder], limit: 1 },
|
|
2967
|
+
])
|
|
2968
|
+
const state = identity.find((e) => e.kind === KIND_FOLDER_STATE && tagValue(e, 'd') === folder)
|
|
2969
|
+
const channel = identity.find((e) => e.kind === KIND_CHANNEL && tagValue(e, 'd') === folder)
|
|
2970
|
+
|
|
2971
|
+
const summary: FolderSummary & { address?: string } = {
|
|
2972
|
+
id: folder,
|
|
2973
|
+
name: nameOf(state) ?? nameOf(channel),
|
|
2974
|
+
hasState: !!state,
|
|
2975
|
+
...(state
|
|
2976
|
+
? { address: pointerToAddress({ kind: state.kind, pubkey: state.pubkey, identifier: folder, relays: [] }) }
|
|
2977
|
+
: {}),
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const addresses = state
|
|
2981
|
+
? // §5.2: one tag type lists every file, topics included, with no special
|
|
2982
|
+
// case. The order is the folder's, so it is preserved rather than sorted.
|
|
2983
|
+
[...new Set(state.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1]))]
|
|
2984
|
+
: await addressesByContainment(folder, query)
|
|
2985
|
+
|
|
2986
|
+
if (addresses.length === 0) {
|
|
2987
|
+
return { ...summary, files: [], source: state ? 'state' : 'channel' }
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
// 2. Group by (kind, author): one manifest answers for every file an app owns
|
|
2991
|
+
// in this folder, and the cache makes the second folder free.
|
|
2992
|
+
const pointers = addresses.flatMap((address) => {
|
|
2993
|
+
try {
|
|
2994
|
+
return [{ address, pointer: referenceToPointer(address) }]
|
|
2995
|
+
} catch {
|
|
2996
|
+
// An `a` tag nothing can parse is somebody else's bug and not worth a
|
|
2997
|
+
// whole folder. Dropped like any other unresolvable file.
|
|
2998
|
+
return []
|
|
2999
|
+
}
|
|
3000
|
+
})
|
|
3001
|
+
const groups = new Map<string, { pointer: AddressPointer; addresses: string[] }>()
|
|
3002
|
+
for (const { address, pointer } of pointers) {
|
|
3003
|
+
const key = `${pointer.kind}:${pointer.pubkey}`
|
|
3004
|
+
const group = groups.get(key)
|
|
3005
|
+
if (group) group.addresses.push(address)
|
|
3006
|
+
else groups.set(key, { pointer, addresses: [address] })
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
const manifests = new Map<string, ResolvedManifest>()
|
|
3010
|
+
await Promise.all(
|
|
3011
|
+
[...groups].map(async ([key, { pointer }]) => {
|
|
3012
|
+
const resolved = await resolveManifest(pointer, query, cache)
|
|
3013
|
+
if (resolved) manifests.set(key, resolved)
|
|
3014
|
+
}),
|
|
3015
|
+
)
|
|
3016
|
+
|
|
3017
|
+
// 3. Every root, and every change against every address, in two filters.
|
|
3018
|
+
// Change kinds are a set because two apps may fold differently, and a kind
|
|
3019
|
+
// is a u16 — the relay refuses an out-of-range one outright, so an app
|
|
3020
|
+
// declaring no `records` contributes no filter rather than an empty one.
|
|
3021
|
+
const changeKinds = [
|
|
3022
|
+
...new Set(
|
|
3023
|
+
[...manifests.values()].flatMap((r) => (r.manifest.records ? [r.manifest.records.changeKind] : [])),
|
|
3024
|
+
),
|
|
3025
|
+
]
|
|
3026
|
+
const events = await query([
|
|
3027
|
+
...[...groups.values()].map(({ pointer, addresses: group }) => ({
|
|
3028
|
+
kinds: [pointer.kind],
|
|
3029
|
+
authors: [pointer.pubkey],
|
|
3030
|
+
'#d': group.map((a) => referenceToPointer(a).identifier),
|
|
3031
|
+
limit: group.length,
|
|
3032
|
+
})),
|
|
3033
|
+
...(changeKinds.length
|
|
3034
|
+
? [{ kinds: changeKinds, '#a': addresses, limit: 500 }]
|
|
3035
|
+
: []),
|
|
3036
|
+
])
|
|
3037
|
+
|
|
3038
|
+
// 4. Build each file, in the order the folder listed them.
|
|
3039
|
+
const files: ForeignObject[] = []
|
|
3040
|
+
for (const { address, pointer } of pointers) {
|
|
3041
|
+
const resolved = manifests.get(`${pointer.kind}:${pointer.pubkey}`)
|
|
3042
|
+
// No app claims this kind, so there is no projection to draw it with.
|
|
3043
|
+
if (!resolved) continue
|
|
3044
|
+
const projection = resolved.manifest.projections?.[String(pointer.kind)]
|
|
3045
|
+
if (!projection) continue
|
|
3046
|
+
const root = events.find(
|
|
3047
|
+
(e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier,
|
|
3048
|
+
)
|
|
3049
|
+
// The disclosure rule. Absent, not "unavailable".
|
|
3050
|
+
if (!root) continue
|
|
3051
|
+
|
|
3052
|
+
const records = foldRuleOf(resolved.manifest)
|
|
3053
|
+
const folded = foldChanges(
|
|
3054
|
+
events.filter((e) => e.kind === records.changeKind && hasTagValue(e, records.targetTag, address)),
|
|
3055
|
+
records,
|
|
3056
|
+
)
|
|
3057
|
+
// An app hiding a record from its own lists is saying it is not part of the
|
|
3058
|
+
// folder any more. `hiddenWhen` is the app's own declaration of that.
|
|
3059
|
+
if (records.hiddenWhen && folded[records.hiddenWhen.field]?.value === records.hiddenWhen.equals) {
|
|
3060
|
+
continue
|
|
3061
|
+
}
|
|
3062
|
+
files.push(
|
|
3063
|
+
buildObject({
|
|
3064
|
+
root,
|
|
3065
|
+
pointer,
|
|
3066
|
+
manifest: resolved.manifest,
|
|
3067
|
+
projection,
|
|
3068
|
+
folded,
|
|
3069
|
+
viaRecommendation: resolved.viaRecommendation,
|
|
3070
|
+
webTemplate: resolved.webTemplate,
|
|
3071
|
+
}),
|
|
3072
|
+
)
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
const people = await (lookupPeople ?? peopleViaRelay(query))([
|
|
3076
|
+
...new Set(files.flatMap(pubkeysIn)),
|
|
3077
|
+
])
|
|
3078
|
+
return {
|
|
3079
|
+
...summary,
|
|
3080
|
+
files: files.map((file) => ({ ...file, people })),
|
|
3081
|
+
source: state ? 'state' : 'channel',
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
/**
|
|
3086
|
+
* The contents of a folder that has no state event — containment by `h`.
|
|
3087
|
+
*
|
|
3088
|
+
* **The approximation, and it is worth being precise about what it cannot do.**
|
|
3089
|
+
* Before folder state exists, the only thing on the wire saying a file is in a
|
|
3090
|
+
* folder is the file's own `h` (or `buzz-channel`, for a record published
|
|
3091
|
+
* globally — {@link folderOf} has both spellings and why). That lists an app's
|
|
3092
|
+
* records perfectly well and **cannot list a topic**: under `h` the topic is
|
|
3093
|
+
* the container, so it would have to be inside itself.
|
|
3094
|
+
*
|
|
3095
|
+
* Kept because both wire shapes coexist permanently — no migration is
|
|
3096
|
+
* available, and a reader that only understood folder state would show every
|
|
3097
|
+
* folder on production as empty.
|
|
3098
|
+
*
|
|
3099
|
+
* Two filters rather than one: an `#h` query does not return a record placed
|
|
3100
|
+
* globally, and reading only `h` was what made five of Ship's fifteen projects
|
|
3101
|
+
* unactionable before `folderOf` existed.
|
|
3102
|
+
*/
|
|
3103
|
+
async function addressesByContainment(folder: string, query: QueryFn): Promise<string[]> {
|
|
3104
|
+
// Which kinds could be files? Every kind any app declares a projection for.
|
|
3105
|
+
// The only non-app-specific source for a kind number is a published manifest.
|
|
3106
|
+
const handlers = await query([{ kinds: [KIND_HANDLER_INFORMATION], limit: 50 }])
|
|
3107
|
+
const kinds = [
|
|
3108
|
+
...new Set(
|
|
3109
|
+
handlers.flatMap((event) =>
|
|
3110
|
+
Object.keys(parseManifest(event)?.projections ?? {}).map(Number).filter(Number.isFinite),
|
|
3111
|
+
),
|
|
3112
|
+
),
|
|
3113
|
+
]
|
|
3114
|
+
if (kinds.length === 0) return []
|
|
3115
|
+
|
|
3116
|
+
const held = await query([
|
|
3117
|
+
{ '#h': [folder], kinds, limit: 500 },
|
|
3118
|
+
{ '#buzz-channel': [folder], kinds, limit: 500 },
|
|
3119
|
+
])
|
|
3120
|
+
return [
|
|
3121
|
+
...new Set(
|
|
3122
|
+
held
|
|
3123
|
+
.filter((event) => {
|
|
3124
|
+
const d = tagValue(event, 'd')
|
|
3125
|
+
/*
|
|
3126
|
+
Two exclusions, and both are about what a file *is*.
|
|
3127
|
+
|
|
3128
|
+
**A file is addressable** — RFC 0.4 §5, "anything with an address
|
|
3129
|
+
that a folder can list". A `kind:9` message carries no `d`, so it
|
|
3130
|
+
has no address, so it is conversation rather than contents. That is
|
|
3131
|
+
the whole discriminator and it needs no kind numbers.
|
|
3132
|
+
|
|
3133
|
+
**A folder is not a file inside itself.** A channel's own `39000`
|
|
3134
|
+
comes back from an `#h` query for that channel — the relay scopes a
|
|
3135
|
+
discovery event to the channel it describes, so it arrives with the
|
|
3136
|
+
contents. What marks it out is that its `d` *is* the folder uuid.
|
|
3137
|
+
*/
|
|
3138
|
+
return d !== undefined && d !== folder
|
|
3139
|
+
})
|
|
3140
|
+
.sort((a, b) => b.created_at - a.created_at)
|
|
3141
|
+
.map((event) =>
|
|
3142
|
+
pointerToAddress({
|
|
3143
|
+
kind: event.kind,
|
|
3144
|
+
pubkey: event.pubkey,
|
|
3145
|
+
identifier: tagValue(event, 'd') ?? '',
|
|
3146
|
+
relays: [],
|
|
3147
|
+
}),
|
|
3148
|
+
),
|
|
3149
|
+
),
|
|
3150
|
+
]
|
|
3151
|
+
}
|