refactor: replace nip96 with blossom
feat: blossom fallback image loader
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import { IMeta } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
|
||||
import React, { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
@ -45,6 +45,8 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
|
||||
return style;
|
||||
}, [imageRef?.current, meta]);
|
||||
|
||||
const [alternatives, setAlternatives] = useState<Array<string>>(meta?.fallback ?? []);
|
||||
const [currentUrl, setCurrentUrl] = useState<string>(url);
|
||||
return (
|
||||
<div
|
||||
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
|
||||
@ -52,8 +54,8 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
|
||||
"cursor-pointer": onMediaClick,
|
||||
})}>
|
||||
<ProxyImg
|
||||
key={url}
|
||||
src={url}
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
size={size}
|
||||
sha256={meta?.sha256}
|
||||
onClick={onMediaClick}
|
||||
@ -62,6 +64,14 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
|
||||
})}
|
||||
style={style}
|
||||
ref={imageRef}
|
||||
onError={() => {
|
||||
const next = alternatives.at(0);
|
||||
if (next) {
|
||||
console.warn("IMG FALLBACK", "Failed to load url, trying next: ", next);
|
||||
setAlternatives(z => z.filter(y => y !== next));
|
||||
setCurrentUrl(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,9 +1,9 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
|
||||
import {
|
||||
addExtensionToNip94Url,
|
||||
EventBuilder,
|
||||
EventKind,
|
||||
Nip94Tags,
|
||||
nip94TagsToIMeta,
|
||||
NostrLink,
|
||||
NostrPrefix,
|
||||
@ -158,15 +158,32 @@ export function NoteCreator() {
|
||||
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
|
||||
}
|
||||
|
||||
for (const ex of note.otherEvents ?? []) {
|
||||
const meta = readNip94Tags(ex.tags);
|
||||
if (!meta.url) continue;
|
||||
if (!note.note.endsWith("\n")) {
|
||||
note.note += "\n";
|
||||
}
|
||||
note.note += addExtensionToNip94Url(meta);
|
||||
// attach 1 link and use other duplicates as fallback urls
|
||||
for (const [, v] of Object.entries(note.attachments ?? {})) {
|
||||
const at = v[0];
|
||||
note.note += note.note.length > 0 ? `\n${at.url}` : at.url;
|
||||
console.debug(at);
|
||||
const n94 =
|
||||
(at.nip94?.length ?? 0) > 0
|
||||
? readNip94Tags(at.nip94!)
|
||||
: ({
|
||||
url: at.url,
|
||||
hash: at.sha256,
|
||||
size: at.size,
|
||||
mimeType: at.type,
|
||||
} as Nip94Tags);
|
||||
|
||||
// attach fallbacks
|
||||
n94.fallback ??= [];
|
||||
n94.fallback.push(
|
||||
...v
|
||||
.slice(1)
|
||||
.filter(a => a.url)
|
||||
.map(a => a.url!),
|
||||
);
|
||||
|
||||
extraTags ??= [];
|
||||
extraTags.push(nip94TagsToIMeta(meta));
|
||||
extraTags.push(nip94TagsToIMeta(n94));
|
||||
}
|
||||
|
||||
// add quote repost
|
||||
@ -272,20 +289,12 @@ export function NoteCreator() {
|
||||
async function uploadFile(file: File) {
|
||||
try {
|
||||
if (file && uploader) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
const rx = await uploader.upload(file);
|
||||
note.update(v => {
|
||||
if (rx.header) {
|
||||
v.otherEvents ??= [];
|
||||
v.otherEvents.push(rx.header);
|
||||
} else if (rx.url) {
|
||||
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
|
||||
if (rx.metadata) {
|
||||
v.extraTags ??= [];
|
||||
const imeta = nip94TagsToIMeta(rx.metadata);
|
||||
v.extraTags.push(imeta);
|
||||
}
|
||||
} else if (rx?.error) {
|
||||
v.error = rx.error;
|
||||
if (rx.url) {
|
||||
v.attachments ??= {};
|
||||
v.attachments[rx.sha256] ??= [];
|
||||
v.attachments[rx.sha256].push(rx);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -677,31 +686,25 @@ export function NoteCreator() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(note.otherEvents?.length ?? 0) > 0 && !note.preview && (
|
||||
{Object.entries(note.attachments ?? {}).length > 0 && !note.preview && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{note.otherEvents
|
||||
?.map(a => ({
|
||||
event: a,
|
||||
tags: readNip94Tags(a.tags),
|
||||
}))
|
||||
.filter(a => a.tags.url)
|
||||
.map(a => (
|
||||
<div key={a.tags.url} className="relative">
|
||||
<img
|
||||
className="object-cover w-[80px] h-[80px] !mt-0 rounded-lg"
|
||||
src={addExtensionToNip94Url(a.tags)}
|
||||
/>
|
||||
<Icon
|
||||
name="x"
|
||||
className="absolute -top-[0.25rem] -right-[0.25rem] bg-gray rounded-full cursor-pointer"
|
||||
onClick={() =>
|
||||
note.update(
|
||||
n => (n.otherEvents = n.otherEvents?.filter(b => readNip94Tags(b.tags).url !== a.tags.url)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(note.attachments ?? {}).map(([k, v]) => (
|
||||
<div key={k} className="relative">
|
||||
<img className="object-cover w-[80px] h-[80px] !mt-0 rounded-lg" src={v[0].url} />
|
||||
<Icon
|
||||
name="x"
|
||||
className="absolute -top-[0.25rem] -right-[0.25rem] bg-gray rounded-full cursor-pointer"
|
||||
onClick={() =>
|
||||
note.update(n => {
|
||||
if (n.attachments?.[k]) {
|
||||
delete n.attachments[k];
|
||||
}
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{noteCreatorFooter()}
|
||||
@ -733,8 +736,11 @@ export function NoteCreator() {
|
||||
<MediaServerFileList
|
||||
onPicked={files => {
|
||||
note.update(n => {
|
||||
n.otherEvents ??= [];
|
||||
n.otherEvents?.push(...files);
|
||||
for (const x of files) {
|
||||
n.attachments ??= {};
|
||||
n.attachments[x.sha256] ??= [];
|
||||
n.attachments[x.sha256].push(x);
|
||||
}
|
||||
n.filePicker = "hidden";
|
||||
});
|
||||
}}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
@ -7,8 +6,7 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useMediaServerList } from "@/Hooks/useMediaServerList";
|
||||
import { findTag } from "@/Utils";
|
||||
import { Nip96Uploader } from "@/Utils/Upload/Nip96";
|
||||
import { BlobDescriptor, Blossom } from "@/Utils/Upload/blossom";
|
||||
|
||||
import AsyncButton from "../Button/AsyncButton";
|
||||
|
||||
@ -16,12 +14,12 @@ export function MediaServerFileList({
|
||||
onPicked,
|
||||
cols,
|
||||
}: {
|
||||
onPicked: (files: Array<NostrEvent>) => void;
|
||||
onPicked: (files: Array<BlobDescriptor>) => void;
|
||||
cols?: number;
|
||||
}) {
|
||||
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
|
||||
const { publisher } = useEventPublisher();
|
||||
const [fileList, setFilesList] = useState<Array<NostrEvent>>([]);
|
||||
const [fileList, setFilesList] = useState<Array<BlobDescriptor>>([]);
|
||||
const [pickedFiles, setPickedFiles] = useState<Array<string>>([]);
|
||||
const servers = useMediaServerList();
|
||||
|
||||
@ -30,11 +28,9 @@ export function MediaServerFileList({
|
||||
if (!publisher) return;
|
||||
for (const s of servers.servers) {
|
||||
try {
|
||||
const sx = new Nip96Uploader(s, publisher);
|
||||
const files = await sx.listFiles();
|
||||
if (files?.files) {
|
||||
res.push(...files.files);
|
||||
}
|
||||
const sx = new Blossom(s, publisher);
|
||||
const files = await sx.list(state.pubkey);
|
||||
res.push(...files);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@ -42,14 +38,12 @@ export function MediaServerFileList({
|
||||
setFilesList(res);
|
||||
}
|
||||
|
||||
function toggleFile(ev: NostrEvent) {
|
||||
const hash = findTag(ev, "x");
|
||||
if (!hash) return;
|
||||
function toggleFile(b: BlobDescriptor) {
|
||||
setPickedFiles(a => {
|
||||
if (a.includes(hash)) {
|
||||
return a.filter(a => a != hash);
|
||||
if (a.includes(b.sha256)) {
|
||||
return a.filter(a => a != b.sha256);
|
||||
} else {
|
||||
return [...a, hash];
|
||||
return [...a, b.sha256];
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -58,6 +52,17 @@ export function MediaServerFileList({
|
||||
listFiles().catch(console.error);
|
||||
}, [servers.servers.length, state?.version]);
|
||||
|
||||
const finalFileList = fileList
|
||||
.sort((a, b) => (b.uploaded ?? 0) - (a.uploaded ?? 0))
|
||||
.reduce(
|
||||
(acc, v) => {
|
||||
acc[v.sha256] ??= [];
|
||||
acc[v.sha256].push(v);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<BlobDescriptor>>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@ -65,33 +70,25 @@ export function MediaServerFileList({
|
||||
"grid-cols-2": cols === 2 || cols === undefined,
|
||||
"grid-cols-6": cols === 6,
|
||||
})}>
|
||||
{fileList.map(a => (
|
||||
<Nip96File
|
||||
key={a.id}
|
||||
file={a}
|
||||
onClick={() => toggleFile(a)}
|
||||
checked={pickedFiles.includes(findTag(a, "x") ?? "")}
|
||||
/>
|
||||
{Object.entries(finalFileList).map(([k, v]) => (
|
||||
<ServerFile key={k} file={v[0]} onClick={() => toggleFile(v[0])} checked={pickedFiles.includes(k)} />
|
||||
))}
|
||||
</div>
|
||||
<AsyncButton
|
||||
disabled={pickedFiles.length === 0}
|
||||
onClick={() => onPicked(fileList.filter(a => pickedFiles.includes(findTag(a, "x") ?? "")))}>
|
||||
onClick={() => onPicked(fileList.filter(a => pickedFiles.includes(a.sha256)))}>
|
||||
<FormattedMessage defaultMessage="Select" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Nip96File({ file, checked, onClick }: { file: NostrEvent; checked: boolean; onClick: () => void }) {
|
||||
const mime = findTag(file, "m");
|
||||
const url = findTag(file, "url");
|
||||
const size = findTag(file, "size");
|
||||
function ServerFile({ file, checked, onClick }: { file: BlobDescriptor; checked: boolean; onClick: () => void }) {
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
function backgroundImage() {
|
||||
if (url && (mime?.startsWith("image/") || mime?.startsWith("video/"))) {
|
||||
return `url(${proxy(url, 512)})`;
|
||||
if (file.url && (file.type?.startsWith("image/") || file.type?.startsWith("video/"))) {
|
||||
return `url(${proxy(file.url, 512)})`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,26 +100,25 @@ function Nip96File({ file, checked, onClick }: { file: NostrEvent; checked: bool
|
||||
backgroundImage: backgroundImage(),
|
||||
}}>
|
||||
<div className="absolute w-full h-full opacity-0 bg-black hover:opacity-80 flex flex-col items-center justify-center gap-4">
|
||||
<div>{file.content.length === 0 ? <FormattedMessage defaultMessage="Untitled" /> : file.content}</div>
|
||||
<div>
|
||||
{Number(size) > 1024 * 1024 && (
|
||||
{file.size > 1024 * 1024 && (
|
||||
<FormattedMessage
|
||||
defaultMessage="{n}MiB"
|
||||
values={{
|
||||
n: <FormattedNumber value={Number(size) / 1024 / 1024} />,
|
||||
n: <FormattedNumber value={file.size / 1024 / 1024} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Number(size) < 1024 * 1024 && (
|
||||
{file.size < 1024 * 1024 && (
|
||||
<FormattedMessage
|
||||
defaultMessage="{n}KiB"
|
||||
values={{
|
||||
n: <FormattedNumber value={Number(size) / 1024} />,
|
||||
n: <FormattedNumber value={file.size / 1024} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>{new Date(file.created_at * 1000).toLocaleString()}</div>
|
||||
<div>{file.uploaded && new Date(file.uploaded * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames("w-4 h-4 border border-2 rounded-full right-1 top-1 absolute", {
|
||||
|
@ -6,7 +6,7 @@ import { useMemo } from "react";
|
||||
export default function useDiscoverMediaServers() {
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder("media-servers-all");
|
||||
rb.withFilter().kinds([EventKind.StorageServerList]);
|
||||
rb.withFilter().kinds([EventKind.BlossomServerList]);
|
||||
return rb;
|
||||
}, []);
|
||||
|
||||
|
@ -2,23 +2,21 @@ import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
|
||||
import { EventKind, UnknownTag } from "@snort/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Nip96Uploader } from "@/Utils/Upload/Nip96";
|
||||
|
||||
import useEventPublisher from "./useEventPublisher";
|
||||
import useLogin from "./useLogin";
|
||||
|
||||
export const DefaultMediaServers = [
|
||||
//"https://media.zap.stream",
|
||||
new UnknownTag(["server", "https://nostr.build/"]),
|
||||
new UnknownTag(["server", "https://nostr.download/"]),
|
||||
new UnknownTag(["server", "https://blossom.build/"]),
|
||||
new UnknownTag(["server", "https://nostrcheck.me/"]),
|
||||
new UnknownTag(["server", "https://files.v0l.io/"]),
|
||||
new UnknownTag(["server", "https://blossom.primal.net/"]),
|
||||
];
|
||||
|
||||
export function useMediaServerList() {
|
||||
const { publisher } = useEventPublisher();
|
||||
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
|
||||
|
||||
let servers = state?.getList(EventKind.StorageServerList) ?? [];
|
||||
let servers = state?.getList(EventKind.BlossomServerList) ?? [];
|
||||
if (servers.length === 0) {
|
||||
servers = DefaultMediaServers;
|
||||
}
|
||||
@ -33,14 +31,12 @@ export function useMediaServerList() {
|
||||
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
const server = new Nip96Uploader(u, publisher);
|
||||
await server.loadInfo();
|
||||
await state?.addToList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
await state?.addToList(EventKind.BlossomServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
removeServer: async (s: string) => {
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
await state?.removeFromList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
await state?.removeFromList(EventKind.BlossomServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
}),
|
||||
[servers],
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { sanitizeRelayUrl, unwrap } from "@snort/shared";
|
||||
import { EventKind, UnknownTag } from "@snort/system";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
@ -8,35 +8,15 @@ import IconButton from "@/Components/Button/IconButton";
|
||||
import { CollapsedSection } from "@/Components/Collapsed";
|
||||
import { RelayFavicon } from "@/Components/Relay/RelaysMetadata";
|
||||
import useDiscoverMediaServers from "@/Hooks/useDiscoverMediaServers";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { Nip96Uploader } from "@/Utils/Upload/Nip96";
|
||||
import { getRelayName } from "@/Utils";
|
||||
|
||||
export default function MediaSettingsPage() {
|
||||
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
|
||||
const { publisher } = useEventPublisher();
|
||||
const list = state.getList(EventKind.StorageServerList);
|
||||
const list = state.getList(EventKind.BlossomServerList);
|
||||
const [newServer, setNewServer] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const knownServers = useDiscoverMediaServers();
|
||||
|
||||
async function validateServer(url: string) {
|
||||
if (!publisher) return;
|
||||
|
||||
setError("");
|
||||
try {
|
||||
const svc = new Nip96Uploader(url, publisher);
|
||||
await svc.loadInfo();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-xl">
|
||||
@ -57,7 +37,7 @@ export default function MediaSettingsPage() {
|
||||
size: 15,
|
||||
}}
|
||||
onClick={async () => {
|
||||
await state.removeFromList(EventKind.StorageServerList, [new UnknownTag(["server", addr])], true);
|
||||
await state.removeFromList(EventKind.BlossomServerList, [new UnknownTag(["server", addr])], true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -83,9 +63,9 @@ export default function MediaSettingsPage() {
|
||||
/>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (await validateServer(newServer)) {
|
||||
if (sanitizeRelayUrl(newServer)) {
|
||||
await state.addToList(
|
||||
EventKind.StorageServerList,
|
||||
EventKind.BlossomServerList,
|
||||
[new UnknownTag(["server", new URL(newServer).toString()])],
|
||||
true,
|
||||
);
|
||||
@ -95,7 +75,6 @@ export default function MediaSettingsPage() {
|
||||
<FormattedMessage defaultMessage="Add" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{error && <b className="text-warning">{error}</b>}
|
||||
</div>
|
||||
<CollapsedSection
|
||||
title={
|
||||
@ -127,7 +106,7 @@ export default function MediaSettingsPage() {
|
||||
<tr key={k}>
|
||||
<td className="flex gap-2 items-center">
|
||||
<RelayFavicon url={k} />
|
||||
{k}
|
||||
{getRelayName(k)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<FormattedNumber value={v} />
|
||||
@ -136,9 +115,7 @@ export default function MediaSettingsPage() {
|
||||
<AsyncButton
|
||||
className="!py-1 mb-1"
|
||||
onClick={async () => {
|
||||
if (await validateServer(k)) {
|
||||
await state.addToList(EventKind.StorageServerList, [new UnknownTag(["server", k])], true);
|
||||
}
|
||||
await state.addToList(EventKind.BlossomServerList, [new UnknownTag(["server", k])], true);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Add" />
|
||||
</AsyncButton>
|
||||
|
@ -3,6 +3,8 @@ import { NostrEvent, TaggedNostrEvent } from "@snort/system";
|
||||
import { ZapTarget } from "@snort/wallet";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
|
||||
|
||||
import { BlobDescriptor } from "@/Utils/Upload/blossom";
|
||||
|
||||
interface NoteCreatorDataSnapshot {
|
||||
show: boolean;
|
||||
note: string;
|
||||
@ -17,6 +19,7 @@ interface NoteCreatorDataSnapshot {
|
||||
sensitive?: string;
|
||||
pollOptions?: Array<string>;
|
||||
otherEvents?: Array<NostrEvent>;
|
||||
attachments?: Record<string, Array<BlobDescriptor>>;
|
||||
extraTags?: Array<Array<string>>;
|
||||
sending?: Array<NostrEvent>;
|
||||
sendStarted: boolean;
|
||||
|
@ -107,7 +107,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
},
|
||||
stateObj,
|
||||
);
|
||||
stateClass.checkIsStandardList(EventKind.StorageServerList); // track nip96 list
|
||||
stateClass.checkIsStandardList(EventKind.BlossomServerList); // track blossom list
|
||||
stateClass.on("change", () => this.#save());
|
||||
if (v.state instanceof UserState) {
|
||||
v.state.destroy();
|
||||
@ -194,7 +194,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
stalker: stalker ?? false,
|
||||
} as LoginSession;
|
||||
|
||||
newSession.state!.checkIsStandardList(EventKind.StorageServerList); // track nip96 list
|
||||
newSession.state!.checkIsStandardList(EventKind.BlossomServerList); // track blossom list
|
||||
newSession.state!.on("change", () => this.#save());
|
||||
const pub = createPublisher(newSession);
|
||||
if (pub) {
|
||||
@ -243,7 +243,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
appdataId: "snort",
|
||||
}),
|
||||
} as LoginSession;
|
||||
newSession.state!.checkIsStandardList(EventKind.StorageServerList); // track nip96 list
|
||||
newSession.state!.checkIsStandardList(EventKind.BlossomServerList); // track blossom list
|
||||
newSession.state!.on("change", () => this.#save());
|
||||
|
||||
if ("nostr_os" in window && window?.nostr_os) {
|
||||
|
@ -1,115 +0,0 @@
|
||||
import { base64 } from "@scure/base";
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
import { addExtensionToNip94Url, EventKind, EventPublisher, NostrEvent, readNip94Tags } from "@snort/system";
|
||||
|
||||
import { UploadResult } from ".";
|
||||
|
||||
export class Nip96Uploader {
|
||||
#info?: Nip96Info;
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async loadInfo() {
|
||||
const u = new URL(this.url);
|
||||
|
||||
const rsp = await fetch(`${u.protocol}//${u.host}/.well-known/nostr/nip96.json`);
|
||||
this.#info = (await rsp.json()) as Nip96Info;
|
||||
return this.#info;
|
||||
}
|
||||
|
||||
async listFiles(page = 0, count = 50) {
|
||||
const rsp = await this.#req(`?page=${page}&count=${count}`, "GET");
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as Nip96FileList;
|
||||
}
|
||||
}
|
||||
|
||||
async upload(file: File | Blob, filename: string): Promise<UploadResult> {
|
||||
const fd = new FormData();
|
||||
fd.append("size", file.size.toString());
|
||||
fd.append("caption", filename);
|
||||
fd.append("content_type", file.type);
|
||||
fd.append("file", file);
|
||||
|
||||
const rsp = await this.#req("", "POST", fd);
|
||||
if (rsp.ok) {
|
||||
const data = (await rsp.json()) as Nip96Result;
|
||||
if (data.status === "success") {
|
||||
const meta = readNip94Tags(data.nip94_event.tags);
|
||||
return {
|
||||
url: addExtensionToNip94Url(meta),
|
||||
header: data.nip94_event,
|
||||
metadata: meta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
try {
|
||||
const obj = JSON.parse(text) as Nip96Result;
|
||||
return {
|
||||
error: obj.message,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: `Upload failed: ${text}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) {
|
||||
throwIfOffline();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
|
||||
});
|
||||
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
|
||||
};
|
||||
|
||||
const info = this.#info ?? (await this.loadInfo());
|
||||
let u = info.api_url;
|
||||
if (u.startsWith("/")) {
|
||||
u = `${this.url}${u.slice(1)}`;
|
||||
}
|
||||
u += path;
|
||||
return await fetch(u, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(u, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface Nip96Info {
|
||||
api_url: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface Nip96Result {
|
||||
status: string;
|
||||
message: string;
|
||||
processing_url?: string;
|
||||
nip94_event: NostrEvent;
|
||||
}
|
||||
|
||||
export interface Nip96FileList {
|
||||
count: number;
|
||||
total: number;
|
||||
page: number;
|
||||
files: Array<NostrEvent>;
|
||||
}
|
134
packages/app/src/Utils/Upload/blossom.ts
Normal file
134
packages/app/src/Utils/Upload/blossom.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { base64, bytesToString } from "@scure/base";
|
||||
import { throwIfOffline, unixNow } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
|
||||
export interface BlobDescriptor {
|
||||
url?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
uploaded?: number;
|
||||
nip94?: Array<Array<string>>;
|
||||
}
|
||||
|
||||
export class Blossom {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
) {
|
||||
this.url = new URL(this.url).toString();
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||
|
||||
const rsp = await this.#req("upload", "PUT", "upload", file, tags);
|
||||
if (rsp.ok) {
|
||||
const ret = (await rsp.json()) as BlobDescriptor;
|
||||
this.#fixTags(ret);
|
||||
return ret;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async media(file: File) {
|
||||
const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
|
||||
|
||||
const rsp = await this.#req("media", "PUT", "media", file, tags);
|
||||
if (rsp.ok) {
|
||||
const ret = (await rsp.json()) as BlobDescriptor;
|
||||
this.#fixTags(ret);
|
||||
return ret;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async mirror(url: string) {
|
||||
const rsp = await this.#req("mirror", "PUT", "mirror", JSON.stringify({ url }), undefined, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const ret = (await rsp.json()) as BlobDescriptor;
|
||||
this.#fixTags(ret);
|
||||
return ret;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async list(pk: string) {
|
||||
const rsp = await this.#req(`list/${pk}`, "GET", "list");
|
||||
if (rsp.ok) {
|
||||
const ret = (await rsp.json()) as Array<BlobDescriptor>;
|
||||
ret.forEach(a => this.#fixTags(a));
|
||||
return ret;
|
||||
} else {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const tags = [["x", id]];
|
||||
|
||||
const rsp = await this.#req(id, "DELETE", "delete", undefined, tags);
|
||||
if (!rsp.ok) {
|
||||
const text = await rsp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
}
|
||||
|
||||
#fixTags(r: BlobDescriptor) {
|
||||
if (!r.nip94) return;
|
||||
if (Array.isArray(r.nip94)) return;
|
||||
// blossom.band invalid response
|
||||
if (r.nip94 && "tags" in r.nip94) {
|
||||
r.nip94 = r.nip94["tags"];
|
||||
return;
|
||||
}
|
||||
r.nip94 = Object.entries(r.nip94 as Record<string, string>);
|
||||
}
|
||||
|
||||
async #req(
|
||||
path: string,
|
||||
method: "GET" | "POST" | "DELETE" | "PUT",
|
||||
term: string,
|
||||
body?: BodyInit,
|
||||
tags?: Array<Array<string>>,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
throwIfOffline();
|
||||
|
||||
const url = `${this.url}${path}`;
|
||||
const now = unixNow();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic(eb => {
|
||||
eb.kind(24_242 as EventKind)
|
||||
.tag(["u", url])
|
||||
.tag(["method", method.toLowerCase()])
|
||||
.tag(["t", term])
|
||||
.tag(["expiration", (now + 10).toString()]);
|
||||
tags?.forEach(t => eb.tag(t));
|
||||
return eb;
|
||||
});
|
||||
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
|
||||
};
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
...headers,
|
||||
accept: "application/json",
|
||||
authorization: await auth(url, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import { useMediaServerList } from "@/Hooks/useMediaServerList";
|
||||
import { bech32ToHex, randomSample } from "@/Utils";
|
||||
import { KieranPubKey } from "@/Utils/Const";
|
||||
|
||||
import { Nip96Uploader } from "./Nip96";
|
||||
import { Blossom } from "./blossom";
|
||||
|
||||
export interface UploadResult {
|
||||
url?: string;
|
||||
@ -65,8 +65,8 @@ export default function useFileUpload(privKey?: string) {
|
||||
const pub = privKey ? EventPublisher.privateKey(privKey) : publisher;
|
||||
if (servers.length > 0 && pub) {
|
||||
const random = randomSample(servers, 1)[0];
|
||||
return new Nip96Uploader(random, pub);
|
||||
return new Blossom(random, pub);
|
||||
} else if (pub) {
|
||||
return new Nip96Uploader("https://nostr.build", pub);
|
||||
return new Blossom("https://blossom.build", pub);
|
||||
}
|
||||
}
|
||||
|
@ -219,9 +219,6 @@
|
||||
"3gOsZq": {
|
||||
"defaultMessage": "Translators"
|
||||
},
|
||||
"3kbIhS": {
|
||||
"defaultMessage": "Untitled"
|
||||
},
|
||||
"3qnJlS": {
|
||||
"defaultMessage": "You are voting with {amount} sats"
|
||||
},
|
||||
|
@ -72,7 +72,6 @@
|
||||
"3adEeb": "{n} viewers",
|
||||
"3cc4Ct": "Light",
|
||||
"3gOsZq": "Translators",
|
||||
"3kbIhS": "Untitled",
|
||||
"3qnJlS": "You are voting with {amount} sats",
|
||||
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
|
||||
"3tVy+Z": "{n} Followers",
|
||||
|
@ -38,6 +38,7 @@ const enum EventKind {
|
||||
SearchRelaysList = 10_007, // NIP-51
|
||||
InterestsList = 10_015, // NIP-51
|
||||
EmojisList = 10_030, // NIP-51
|
||||
BlossomServerList = 10_063,
|
||||
StorageServerList = 10_096, // NIP-96 server list
|
||||
|
||||
FollowSet = 30_000, // NIP-51
|
||||
|
@ -9,6 +9,9 @@ export function readNip94TagsFromIMeta(tag: Array<string>) {
|
||||
}
|
||||
|
||||
export function nip94TagsToIMeta(meta: Nip94Tags) {
|
||||
if (!meta.url) {
|
||||
throw new Error("URL is required!");
|
||||
}
|
||||
const ret: Array<string> = ["imeta"];
|
||||
const ifPush = (key: string, value?: string | number) => {
|
||||
if (value) {
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
import { NostrLink, validateNostrLink } from "./nostr-link";
|
||||
import { splitByUrl } from "./utils";
|
||||
import { IMeta } from "./nostr";
|
||||
import { Nip94Tags, readNip94TagsFromIMeta } from ".";
|
||||
|
||||
export interface ParsedFragment {
|
||||
type:
|
||||
@ -254,33 +255,14 @@ function extractMarkdownCode(fragments: Fragment[]): (string | ParsedFragment)[]
|
||||
}
|
||||
|
||||
export function parseIMeta(tags: Array<Array<string>>) {
|
||||
let ret: Record<string, IMeta> | undefined;
|
||||
let ret: Record<string, Nip94Tags> | undefined;
|
||||
const imetaTags = tags.filter(a => a[0] === "imeta");
|
||||
for (const imetaTag of imetaTags) {
|
||||
ret ??= {};
|
||||
let imeta: IMeta = {};
|
||||
let url = "";
|
||||
for (const t of imetaTag.slice(1)) {
|
||||
const [k, v] = t.split(" ");
|
||||
if (k === "url") {
|
||||
url = v;
|
||||
}
|
||||
if (k === "dim") {
|
||||
const [w, h] = v.split("x");
|
||||
imeta.height = Number(h);
|
||||
imeta.width = Number(w);
|
||||
}
|
||||
if (k === "blurhash") {
|
||||
imeta.blurHash = v;
|
||||
}
|
||||
if (k === "x") {
|
||||
imeta.sha256 = v;
|
||||
}
|
||||
if (k === "alt") {
|
||||
imeta.alt = v;
|
||||
}
|
||||
const meta = readNip94TagsFromIMeta(imetaTag);
|
||||
if (meta.url) {
|
||||
ret ??= {};
|
||||
ret[meta.url] = meta;
|
||||
}
|
||||
ret[url] = imeta;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
Reference in New Issue
Block a user