diff --git a/packages/app/src/Element/NostrFileHeader.tsx b/packages/app/src/Element/NostrFileHeader.tsx new file mode 100644 index 00000000..f60793b5 --- /dev/null +++ b/packages/app/src/Element/NostrFileHeader.tsx @@ -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 ; + + // 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 ; + } else { + return ( + + + + ); + } +} diff --git a/packages/app/src/Element/NostrLink.tsx b/packages/app/src/Element/NostrLink.tsx index 284e54af..4362799c 100644 --- a/packages/app/src/Element/NostrLink.tsx +++ b/packages/app/src/Element/NostrLink.tsx @@ -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 ; } else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) { + if (nav.kind === EventKind.FileHeader) { + return ; + } const evLink = nav.encode(); return ( e.stopPropagation()} state={{ from: location.pathname }}> diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index 20658049..0c4b1753 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -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)); diff --git a/packages/app/src/Feed/EventFeed.ts b/packages/app/src/Feed/EventFeed.ts new file mode 100644 index 00000000..3fce393f --- /dev/null +++ b/packages/app/src/Feed/EventFeed.ts @@ -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, sub); +} diff --git a/packages/app/src/State/NoteCreator.ts b/packages/app/src/State/NoteCreator.ts index c547f8c9..484a1cc5 100644 --- a/packages/app/src/State/NoteCreator.ts +++ b/packages/app/src/State/NoteCreator.ts @@ -12,6 +12,7 @@ interface NoteCreatorStore { zapForward: string; sensitive: string; pollOptions?: Array; + otherEvents: Array; } 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 | undefined>) => { state.pollOptions = action.payload; }, + setOtherEvents: (state, action: PayloadAction>) => { + state.otherEvents = action.payload; + }, reset: () => InitState, }, }); @@ -73,6 +78,7 @@ export const { setZapForward, setSensitive, setPollOptions, + setOtherEvents, reset, } = NoteCreatorSlice.actions; diff --git a/packages/app/src/System/EventBuilder.test.ts b/packages/app/src/System/EventBuilder.test.ts index 7e217d5e..30878519 100644 --- a/packages/app/src/System/EventBuilder.test.ts +++ b/packages/app/src/System/EventBuilder.test.ts @@ -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); }); }); +*/ diff --git a/packages/app/src/System/EventBuilder.ts b/packages/app/src/System/EventBuilder.ts index f202685e..6c4b9039 100644 --- a/packages/app/src/System/EventBuilder.ts +++ b/packages/app/src/System/EventBuilder.ts @@ -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"); } } diff --git a/packages/app/src/Upload/VoidCat.ts b/packages/app/src/Upload/VoidCat.ts index df99976f..99c878b4 100644 --- a/packages/app/src/Upload/VoidCat.ts +++ b/packages/app/src/Upload/VoidCat.ts @@ -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 { +export default async function VoidCat( + file: File | Blob, + filename: string, + publisher?: EventPublisher +): Promise { 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; }; diff --git a/packages/app/src/Upload/index.ts b/packages/app/src/Upload/index.ts index a3bf6da0..a93b67ec 100644 --- a/packages/app/src/Upload/index.ts +++ b/packages/app/src/Upload/index.ts @@ -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; } } diff --git a/packages/nostr/README.md b/packages/nostr/README.md index f9cacbcf..bcc6293e 100644 --- a/packages/nostr/README.md +++ b/packages/nostr/README.md @@ -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 diff --git a/packages/nostr/src/legacy/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts index 370f56d7..72b57662 100644 --- a/packages/nostr/src/legacy/EventKind.ts +++ b/packages/nostr/src/legacy/EventKind.ts @@ -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 diff --git a/packages/nostr/src/legacy/Links.ts b/packages/nostr/src/legacy/Links.ts index 3b315ba0..8cc26187 100644 --- a/packages/nostr/src/legacy/Links.ts +++ b/packages/nostr/src/legacy/Links.ts @@ -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);