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