refactor: replace nip96 with blossom

feat: blossom fallback image loader
This commit is contained in:
2025-05-06 17:24:41 +01:00
parent d4115e9073
commit 4e5feede23
16 changed files with 267 additions and 278 deletions

View File

@ -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>
);

View File

@ -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";
});
}}

View File

@ -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", {

View File

@ -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;
}, []);

View File

@ -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],

View File

@ -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>

View File

@ -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;

View File

@ -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) {

View File

@ -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>;
}

View 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),
},
});
}
}

View File

@ -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);
}
}

View File

@ -219,9 +219,6 @@
"3gOsZq": {
"defaultMessage": "Translators"
},
"3kbIhS": {
"defaultMessage": "Untitled"
},
"3qnJlS": {
"defaultMessage": "You are voting with {amount} sats"
},

View File

@ -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",

View File

@ -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

View File

@ -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) {

View File

@ -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;
}