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:
parent
c294f5f0bd
commit
c59dda1e49
25
packages/app/src/Element/NostrFileHeader.tsx
Normal file
25
packages/app/src/Element/NostrFileHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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 }}>
|
||||
|
@ -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));
|
||||
|
15
packages/app/src/Feed/EventFeed.ts
Normal file
15
packages/app/src/Feed/EventFeed.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user