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..5c614340 100644
--- a/packages/app/src/Element/NostrLink.tsx
+++ b/packages/app/src/Element/NostrLink.tsx
@@ -1,8 +1,9 @@
-import { NostrPrefix } from "@snort/nostr";
+import { EventKind, NostrPrefix } from "@snort/nostr";
import { Link } from "react-router-dom";
import Mention from "Element/Mention";
-import { parseNostrLink } from "Util";
+import NostrFileHeader from "Element/NostrFileHeader";
+import { eventLink, parseNostrLink } from "Util";
export default function NostrLink({ link }: { link: string }) {
const nav = parseNostrLink(link);
@@ -10,7 +11,10 @@ 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) {
- const evLink = nav.encode();
+ if (nav.kind === EventKind.FileHeader) {
+ return ;
+ }
+ const evLink = eventLink(nav.id, nav.relays);
return (
e.stopPropagation()} state={{ from: location.pathname }}>
#{evLink.substring(0, 12)}
diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx
index 20658049..695920ef 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();
@@ -82,24 +86,29 @@ export function NoteCreator() {
);
return;
}
+
+ if (sensitive) {
+ extraTags ??= [];
+ extraTags.push(["content-warning", sensitive]);
+ }
+ const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
+ if (pollOptions) {
+ extraTags ??= [];
+ extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
+ }
+ const hk = (eb: EventBuilder) => {
+ extraTags?.forEach(t => eb.tag(t));
+ eb.kind(kind);
+ return eb;
+ };
+ 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());
}
- if (sensitive) {
- extraTags ??= [];
- extraTags.push(["content-warning", sensitive]);
- }
- const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
- if (pollOptions) {
- extraTags ??= [];
- extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
- }
- const hk = (eb: EventBuilder) => {
- extraTags?.forEach(t => eb.tag(t));
- eb.kind(kind);
- return eb;
- };
- const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
- publisher.broadcast(ev);
- 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([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/Upload/VoidCat.ts b/packages/app/src/Upload/VoidCat.ts
index df99976f..a53f9d4d 100644
--- a/packages/app/src/Upload/VoidCat.ts
+++ b/packages/app/src/Upload/VoidCat.ts
@@ -1,12 +1,18 @@
import * as secp from "@noble/secp256k1";
+import { EventKind } from "@snort/nostr";
import { FileExtensionRegex, VoidCatHost } from "Const";
+import { EventPublisher } from "Feed/EventPublisher";
import { UploadResult } from "Upload";
/**
* 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 +37,24 @@ 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 = [
+ ["u", resultUrl],
+ ["hash", rsp.file?.metadata?.digest ?? "", "sha256"],
+ ["type", rsp.file?.metadata?.mimeType ?? "application/octet-stream"],
+ ];
+ if (rsp.file?.metadata?.magnetLink) {
+ tags.push(["u", rsp.file.metadata.magnetLink]);
+ }
+ ret.header = await publisher.generic(filename, EventKind.FileHeader, tags);
+ }
+ return ret;
} else {
return {
error: rsp.errorMessage,
@@ -69,4 +90,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..c4cfc065 100644
--- a/packages/app/src/Upload/index.ts
+++ b/packages/app/src/Upload/index.ts
@@ -1,11 +1,19 @@
import useLogin from "Hooks/useLogin";
+import { RawEvent } from "@snort/nostr";
+import useEventPublisher from "Feed/EventPublisher";
+
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 +22,7 @@ export interface Uploader {
export default function useFileUpload(): Uploader {
const fileUploader = useLogin().preferences.fileUploader;
+ const publisher = useEventPublisher();
switch (fileUploader) {
case "nostr.build": {
@@ -28,7 +37,7 @@ export default function useFileUpload(): Uploader {
}
default: {
return {
- upload: VoidCat,
+ upload: (f, n) => VoidCat(f, n, publisher),
} 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);