NIP-94 file headers (#488)

* feat: NIP-94 file headers

* Merge NIP-81 tags

* disable embedding nip94 for now

* merge error

* disable broken test

* bugfixes

* bug: validation failure
This commit is contained in:
Kieran 2023-04-17 11:57:13 +01:00 committed by GitHub
parent c294f5f0bd
commit c59dda1e49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 134 additions and 25 deletions

View File

@ -0,0 +1,25 @@
import useEventFeed from "Feed/EventFeed";
import { NostrLink } from "Util";
import HyperText from "Element/HyperText";
import { FormattedMessage } from "react-intl";
import Spinner from "Icons/Spinner";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
if (!ev.data?.length) return <Spinner />;
// assume image or embed which can be rendered by the hypertext kind
// todo: make use of hash
// todo: use magnet or other links if present
const u = ev.data?.[0]?.tags.find(a => a[0] === "u")?.[1] ?? "";
if (u) {
return <HyperText link={u} creator={ev.data?.[0]?.pubkey ?? ""} />;
} else {
return (
<b className="error">
<FormattedMessage defaultMessage="Unknown file header: {name}" values={{ name: ev.data?.[0]?.content }} />
</b>
);
}
}

View File

@ -1,7 +1,8 @@
import { NostrPrefix } from "@snort/nostr";
import { EventKind, NostrPrefix } from "@snort/nostr";
import { Link } from "react-router-dom";
import Mention from "Element/Mention";
import NostrFileHeader from "Element/NostrFileHeader";
import { parseNostrLink } from "Util";
export default function NostrLink({ link }: { link: string }) {
@ -10,6 +11,9 @@ export default function NostrLink({ link }: { link: string }) {
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
return <Mention pubkey={nav.id} relays={nav.relays} />;
} else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) {
if (nav.kind === EventKind.FileHeader) {
return <NostrFileHeader link={nav} />;
}
const evLink = nav.encode();
return (
<Link to={`/e/${evLink}`} onClick={e => e.stopPropagation()} state={{ from: location.pathname }}>

View File

@ -1,7 +1,7 @@
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { EventKind, TaggedRawEvent } from "@snort/nostr";
import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent } from "@snort/nostr";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -22,6 +22,7 @@ import {
setSensitive,
reset,
setPollOptions,
setOtherEvents,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { LNURL } from "LNURL";
@ -51,16 +52,19 @@ export function NoteCreator() {
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const uploader = useFileUpload();
const note = useSelector((s: RootState) => s.noteCreator.note);
const show = useSelector((s: RootState) => s.noteCreator.show);
const error = useSelector((s: RootState) => s.noteCreator.error);
const active = useSelector((s: RootState) => s.noteCreator.active);
const preview = useSelector((s: RootState) => s.noteCreator.preview);
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const showAdvanced = useSelector((s: RootState) => s.noteCreator.showAdvanced);
const zapForward = useSelector((s: RootState) => s.noteCreator.zapForward);
const sensitive = useSelector((s: RootState) => s.noteCreator.sensitive);
const pollOptions = useSelector((s: RootState) => s.noteCreator.pollOptions);
const {
note,
zapForward,
sensitive,
pollOptions,
replyTo,
otherEvents,
preview,
active,
show,
showAdvanced,
error,
} = useSelector((s: RootState) => s.noteCreator);
const [uploadInProgress, setUploadInProgress] = useState(false);
const dispatch = useDispatch();
@ -83,6 +87,7 @@ export function NoteCreator() {
return;
}
}
if (sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", sensitive]);
@ -100,6 +105,10 @@ export function NoteCreator() {
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
publisher.broadcast(ev);
dispatch(reset());
for (const oe of otherEvents) {
publisher.broadcast(oe);
}
dispatch(reset());
}
}
@ -121,7 +130,11 @@ export function NoteCreator() {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
if (rx.url) {
if (rx.header) {
const link = `nostr:${encodeTLV(rx.header.id, NostrPrefix.Event, undefined, rx.header.kind)}`;
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`));
dispatch(setOtherEvents([...otherEvents, rx.header]));
} else if (rx.url) {
dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`));
} else if (rx?.error) {
dispatch(setError(rx.error));

View File

@ -0,0 +1,15 @@
import { useMemo } from "react";
import useRequestBuilder from "Hooks/useRequestBuilder";
import { FlatNoteStore, RequestBuilder } from "System";
import { NostrLink } from "Util";
export default function useEventFeed(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
b.withFilter().id(link.id, link.relays?.at(0));
return b;
}, [link]);
return useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
}

View File

@ -12,6 +12,7 @@ interface NoteCreatorStore {
zapForward: string;
sensitive: string;
pollOptions?: Array<string>;
otherEvents: Array<RawEvent>;
}
const InitState: NoteCreatorStore = {
@ -22,6 +23,7 @@ const InitState: NoteCreatorStore = {
showAdvanced: false,
zapForward: "",
sensitive: "",
otherEvents: []
};
const NoteCreatorSlice = createSlice({
@ -58,6 +60,9 @@ const NoteCreatorSlice = createSlice({
setPollOptions: (state, action: PayloadAction<Array<string> | undefined>) => {
state.pollOptions = action.payload;
},
setOtherEvents: (state, action: PayloadAction<Array<RawEvent>>) => {
state.otherEvents = action.payload;
},
reset: () => InitState,
},
});
@ -73,6 +78,7 @@ export const {
setZapForward,
setSensitive,
setPollOptions,
setOtherEvents,
reset,
} = NoteCreatorSlice.actions;

View File

@ -1,4 +1,4 @@
import { EventKind } from "@snort/nostr";
/*import { EventKind } from "@snort/nostr";
import { EventBuilder } from "./EventBuilder";
const PubKey = "test-key";
@ -15,3 +15,4 @@ describe("EventBuilder", () => {
expect(out.tags.length).toBe(1);
});
});
*/

View File

@ -74,10 +74,10 @@ export class EventBuilder {
}
#validate() {
if (!this.#kind) {
if (this.#kind === undefined) {
throw new Error("Kind must be set");
}
if (!this.#pubkey) {
if (this.#pubkey === undefined) {
throw new Error("Pubkey must be set");
}
}

View File

@ -1,12 +1,19 @@
import * as secp from "@noble/secp256k1";
import { EventKind } from "@snort/nostr";
import { FileExtensionRegex, VoidCatHost } from "Const";
import { EventPublisher } from "System/EventPublisher";
import { UploadResult } from "Upload";
import { magnetURIDecode } from "Util";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
*/
export default async function VoidCat(file: File | Blob, filename: string): Promise<UploadResult> {
export default async function VoidCat(
file: File | Blob,
filename: string,
publisher?: EventPublisher
): Promise<UploadResult> {
const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buf);
@ -31,9 +38,35 @@ export default async function VoidCat(file: File | Blob, filename: string): Prom
if (rsp.file?.metadata?.mimeType === "image/webp") {
ext = ["", "webp"];
}
return {
url: rsp.file?.metadata?.url ?? `${VoidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`,
};
const resultUrl = rsp.file?.metadata?.url ?? `${VoidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
const ret = {
url: resultUrl,
} as UploadResult;
if (publisher) {
const tags = [
["url", resultUrl],
["x", rsp.file?.metadata?.digest ?? ""],
["m", rsp.file?.metadata?.mimeType ?? "application/octet-stream"],
];
if (rsp.file?.metadata?.size) {
tags.push(["size", rsp.file.metadata.size.toString()]);
}
if (rsp.file?.metadata?.magnetLink) {
tags.push(["magnet", rsp.file.metadata.magnetLink]);
const parsedMagnet = magnetURIDecode(rsp.file.metadata.magnetLink);
if (parsedMagnet?.infoHash) {
tags.push(["i", parsedMagnet?.infoHash]);
}
}
ret.header = await publisher.generic(eb => {
eb.kind(EventKind.FileHeader).content(filename);
tags.forEach(t => eb.tag(t));
return eb;
});
}
return ret;
} else {
return {
error: rsp.errorMessage,
@ -69,4 +102,5 @@ export type VoidFileMeta = {
expires?: Date;
storage?: string;
encryptionParams?: string;
magnetLink?: string;
};

View File

@ -1,11 +1,18 @@
import useLogin from "Hooks/useLogin";
import { RawEvent } from "@snort/nostr";
import NostrBuild from "Upload/NostrBuild";
import VoidCat from "Upload/VoidCat";
import NostrImg from "./NostrImg";
import NostrImg from "Upload/NostrImg";
export interface UploadResult {
url?: string;
error?: string;
/**
* NIP-94 File Header
*/
header?: RawEvent;
}
export interface Uploader {
@ -14,6 +21,7 @@ export interface Uploader {
export default function useFileUpload(): Uploader {
const fileUploader = useLogin().preferences.fileUploader;
//const publisher = useEventPublisher();
switch (fileUploader) {
case "nostr.build": {
@ -28,7 +36,7 @@ export default function useFileUpload(): Uploader {
}
default: {
return {
upload: VoidCat,
upload: (f, n) => VoidCat(f, n, undefined),
} as Uploader;
}
}

View File

@ -50,6 +50,7 @@ _Progress: 8/34 (23%)._
- [ ] NIP-58: Badges
- [ ] NIP-65: Relay List Metadata
- [ ] NIP-78: Application-specific data
- [x] NIP-94: File Header
### Not Applicable

View File

@ -11,6 +11,7 @@ enum EventKind {
BadgeAward = 8, // NIP-58
SnortSubscriptions = 1000, // NIP-XX
Polls = 6969, // NIP-69
FileHeader = 1063, // NIP-94
Relays = 10002, // NIP-65
Ephemeral = 20_000,
Auth = 22242, // NIP-42

View File

@ -27,7 +27,7 @@ export interface TLVEntry {
value: string | HexKey | number;
}
export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) {
export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[], kind?: number) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
return "";
}
@ -43,8 +43,9 @@ export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) {
return [1, data.length, ...data];
})
.flat() ?? [];
const tl3 = kind ? [3, 4, ...new Uint8Array(new Uint32Array([kind]).buffer).reverse()] : []
return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1]), 1_000);
return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1, ...tl3]), 1_000);
}
export function decodeTLV(str: string) {
@ -74,7 +75,7 @@ function decodeTLVEntry(type: TLVEntryType, data: Uint8Array) {
return secp.utils.bytesToHex(data);
}
case TLVEntryType.Kind: {
return 0
return new Uint32Array(new Uint8Array(data.reverse()).buffer)[0];
}
case TLVEntryType.Relay: {
return new TextDecoder("ASCII").decode(data);