feat: file upload progress / imeta

This commit is contained in:
Kieran 2023-10-16 11:07:13 +01:00
parent a29d82bd56
commit 6448996529
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
13 changed files with 257 additions and 60 deletions

View File

@ -0,0 +1,47 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApyUVkYJVwV7XgluUnllgCtrsdq1ctRICm5gQy8nd+aEdDQjA
CKPOWh5miLl/fAQVZGZy/JxavzXulwXo8238E6n6bmNB1Us2nuw7a0aW4iUSQ1Pt
P4ZhPpcrqeqMf+hp7iBW0nAHFy/aa2UR84d7tBmSk5J3NNrfBsZdUex/7FqF1EVv
mEzlc8kepU9lRXWFQDtZCllEZ1kY3SBJPm10h0g9saI8YIVRxUuNII5GHDYAE3hb
EmoY6fuSEoiXA8u0Yt9soBQxgxIhQVKSRPPoIPjGFOxsGHY6h8R9nx1kxhHKFRuV
nwsn0uWl/7yjhwyHanogJu73/WgelPcgP/hMDQIDAQABAoIBAAru+xU0oGVwzcoi
MXuWPxkWrwcoWfsiPXduIBMklleg+WSD4QPvqyzr9isVb0huf/O8W+M4WxtM7NmG
MnHSDP5ATThxV7obHGyS6WQgDvimEibDU66nHK9adim8RQqM6nkANo23dE9I+xGx
X9Y9U5M5ZQQwPYoAkzw/N5WHUerk+cSEYWYV8jDtO7wJhYOMu5qliPeuNOaWZ1W6
1uwr8A4ih69WwzugPuBSgBrPAW1c84zWIFN+njAugqPF5x8xp2uM3tUO9s5UlHJC
FWEuU40KcDT2utSUY+2HXSHbycF4KLKT5jAKSa4sPziLfo+YifrlN0Y3rhofUlZT
jCaeZ8ECgYEA5/xpk8aVhCEvv5iCghv0p/IHcjdXjx5+PCWh3Adx0fF91UvU5oqn
okdyYZDShZMuLDfJ0lG+OMKZd01JapnbTtiVNceVRMnraIdoWEM2/4bTXTSZGtdA
8gh/Kc/PMbPf5ppVWwqTCbUkPOSyGHyGc7+DQquq1w6yZu04A3x9vHECgYEAuHJk
uz8YKY5ZUR7CZ3y7YFuwq5Lcpl43AfiiCasjRch0P8yLrITc/6fORsXyy64XW9fC
h3YmXvEPaM03W2dxw2aQDvXEvXiEITzmILs7SE3UmZR9m7OMy7Jeqr3+JOc0ckZe
Rz5FfuMt1IvNB6lrpfHVtoVrpCOXpzHgC/k/x10CgYA6lU18GfwL/+107uiWPsUL
3FzxBPTBmau7OK2lSOP/ZoKmaJ39Eiq/GlfSN6ZSQRa55+S5jhcBcnMa45OUrgHp
6VvU1u/lDTC7luZM07yBzuR1dyDq3Ez0Uhz6zBXAsXHrZDIF6ae0HeBm2EH5WQkD
Fevp3DwqTvXSdDle+AMwoQKBgQCBSlaH1rNmNc0wCsK07f8ejUcrDZgz2mjurc1P
v7HK8bdjHUtvE/ciEguLGqiV06O2EmjesZg2Bv4JNYivPrTFBrjGc8qEEd10uw6J
NRVaGoyDV04w/UwdYRvwzZs/XP4reF4PzHvEdRSkH5cJ3t2BhiKLfby1YumkHlbx
rbbiVQKBgB02jyZUiB6pPTCP8vXZCJbZELgqNyS04ALhBBpdfGMcU1+0hRLJFBaE
tClJPGARFXl+MPkY032vmJZOuH3LrcTCm8DmMLzM/hT1EWawQ8BJkkwiIokE4lqc
Bi8CrkvuQs2cuCStK6C3Nkyr1lTkDge46trsb7KTcfHdtLsS7EPj
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIJDji8iiceMvQlMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMTCWxvY2FsaG9zdDAeFw0yMzEwMTYwOTI0MThaFw0yMzExMTUxMDI0MThaMBQx
EjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKclFZGCVcFe14JblJ5ZYAra7HatXLUSApuYEMvJ3fmhHQ0IwAijzloeZoi5
f3wEFWRmcvycWr817pcF6PNt/BOp+m5jQdVLNp7sO2tGluIlEkNT7T+GYT6XK6nq
jH/oae4gVtJwBxcv2mtlEfOHe7QZkpOSdzTa3wbGXVHsf+xahdRFb5hM5XPJHqVP
ZUV1hUA7WQpZRGdZGN0gST5tdIdIPbGiPGCFUcVLjSCORhw2ABN4WxJqGOn7khKI
lwPLtGLfbKAUMYMSIUFSkkTz6CD4xhTsbBh2OofEfZ8dZMYRyhUblZ8LJ9Llpf+8
o4cMh2p6ICbu9/1oHpT3ID/4TA0CAwEAAaOBrzCBrDAMBgNVHRMEBTADAQH/MAsG
A1UdDwQEAwIC9DAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUF
BwMDBggrBgEFBQcDCDBcBgNVHREEVTBTgglsb2NhbGhvc3SCFWxvY2FsaG9zdC5s
b2NhbGRvbWFpboIGbHZoLm1lgggqLmx2aC5tZYIFWzo6MV2HBH8AAAGHEP6AAAAA
AAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBABY0rgWuzLYvVtvoVvWKS9cg
8rVhBRIFvpYO814ocN1iaxYQ9t9uLRsJXj0K+z1BHWf0zBiw4mB3dD9VpiKpuliL
4tRT+vATA96OYCd9G5k7DFQascAau40H3jxckh9rimIWa45FUSd7FIcddo1jeciv
gdAdiNUuHBen82O8KHJb+1PCBdA8RYeO5EGKfJM2yrOovu7dAFilf1ZPkXWgXnfG
nN6YfDDo9rAVDbvNXImrkwmGqEcN3Pq909IHiM/VETlU5lP4AbTNgrDa/aaZ+I+b
1MC1p87MvnibyXs+rTlK5+j8E6noNcD7tsHNd4ufkVCqr+pvSpuA3OvnXjbbm54=
-----END CERTIFICATE-----

View File

@ -1,14 +1,6 @@
import { ProxyImg } from "Element/ProxyImg"; import { ProxyImg } from "Element/ProxyImg";
import React from "react"; import React from "react";
/*
[
"imeta",
"url https://nostr.build/i/148e3e8cbe29ae268b0d6aad0065a086319d3c3b1fdf8b89f1e2327d973d2d05.jpg",
"blurhash e6A0%UE2t6D*R%?u?a9G?aM|~pM|%LR*RjR-%2NG%2t7_2R*%1IVWB",
"dim 3024x4032"
],
*/
interface MediaElementProps { interface MediaElementProps {
mime: string; mime: string;
url: string; url: string;

View File

@ -0,0 +1,11 @@
import Progress from "Element/Progress";
import { UploadProgress } from "Upload";
export default function FileUploadProgress({ progress }: { progress: Array<UploadProgress> }) {
return <div className="flex-column g8">
{progress.map(p => <div className="flex-column g2" id={p.id}>
{p.file.name}
<Progress value={p.progress} />
</div>)}
</div>
}

View File

@ -20,6 +20,7 @@ import { fetchNip05Pubkey } from "@snort/shared";
import { ZapTarget } from "Zapper"; import { ZapTarget } from "Zapper";
import { useNoteCreator } from "State/NoteCreator"; import { useNoteCreator } from "State/NoteCreator";
import { NoteBroadcaster } from "./NoteBroadcaster"; import { NoteBroadcaster } from "./NoteBroadcaster";
import FileUploadProgress from "./FileUpload";
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -114,6 +115,7 @@ export function NoteCreator() {
} }
const hk = (eb: EventBuilder) => { const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t)); extraTags?.forEach(t => eb.tag(t));
note.extraTags?.forEach(t => eb.tag(t));
eb.kind(kind); eb.kind(kind);
return eb; return eb;
}; };
@ -170,6 +172,17 @@ export function NoteCreator() {
v.otherEvents = [...(v.otherEvents ?? []), rx.header]; v.otherEvents = [...(v.otherEvents ?? []), rx.header];
} else if (rx.url) { } else if (rx.url) {
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`; v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
if (rx.metadata) {
v.extraTags ??= [];
const imeta = ["imeta", `url ${rx.url}`];
if (rx.metadata.blurhash) {
imeta.push(`blurhash ${rx.metadata.blurhash}`);
}
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) { } else if (rx?.error) {
v.error = rx.error; v.error = rx.error;
} }
@ -536,6 +549,7 @@ export function NoteCreator() {
{renderPollOptions()} {renderPollOptions()}
</div> </div>
)} )}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
{noteCreatorFooter()} {noteCreatorFooter()}
{note.error && <span className="error">{note.error}</span>} {note.error && <span className="error">{note.error}</span>}
{note.advanced && noteCreatorAdvanced()} {note.advanced && noteCreatorAdvanced()}

View File

@ -1,21 +1,3 @@
.zap-goal {
}
.zap-goal h1 { .zap-goal h1 {
line-height: 1em; line-height: 1em;
} }
.zap-goal .progress {
position: relative;
height: 1em;
border-radius: 4px;
overflow: hidden;
background-color: var(--gray);
}
.zap-goal .progress > div {
position: absolute;
background-color: var(--success);
width: var(--progress);
height: 100%;
}

View File

@ -1,5 +1,5 @@
import "./ZapGoal.css"; import "./ZapGoal.css";
import { CSSProperties, useState } from "react"; import { useState } from "react";
import { NostrEvent, NostrLink } from "@snort/system"; import { NostrEvent, NostrLink } from "@snort/system";
import useZapsFeed from "Feed/ZapsFeed"; import useZapsFeed from "Feed/ZapsFeed";
import { formatShort } from "Number"; import { formatShort } from "Number";
@ -7,13 +7,15 @@ import { findTag } from "SnortUtils";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import SendSats from "../SendSats"; import SendSats from "../SendSats";
import { Zapper } from "Zapper"; import { Zapper } from "Zapper";
import Progress from "Element/Progress";
import { FormattedNumber } from "react-intl";
export function ZapGoal({ ev }: { ev: NostrEvent }) { export function ZapGoal({ ev }: { ev: NostrEvent }) {
const [zap, setZap] = useState(false); const [zap, setZap] = useState(false);
const zaps = useZapsFeed(NostrLink.fromEvent(ev)); const zaps = useZapsFeed(NostrLink.fromEvent(ev));
const target = Number(findTag(ev, "amount")); const target = Number(findTag(ev, "amount"));
const amount = zaps.reduce((acc, v) => (acc += v.amount * 1000), 0); const amount = zaps.reduce((acc, v) => (acc += v.amount * 1000), 0);
const progress = 100 * (amount / target); const progress = amount / target;
return ( return (
<div className="zap-goal card"> <div className="zap-goal card">
@ -26,19 +28,14 @@ export function ZapGoal({ ev }: { ev: NostrEvent }) {
</div> </div>
<div className="flex f-space"> <div className="flex f-space">
<div>{progress.toFixed(1)}%</div> <div>
<FormattedNumber value={progress} style="percent" />
</div>
<div> <div>
{formatShort(amount / 1000)}/{formatShort(target / 1000)} {formatShort(amount / 1000)}/{formatShort(target / 1000)}
</div> </div>
</div> </div>
<div className="progress"> <Progress value={progress} />
<div
style={
{
"--progress": `${Math.min(100, progress)}%`,
} as CSSProperties
}></div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,23 @@
.progress {
position: relative;
height: 1em;
border-radius: 4px;
overflow: hidden;
background-color: var(--gray);
}
.progress > div {
position: absolute;
background-color: var(--success);
width: var(--progress);
height: 100%;
}
.progress > span {
position: absolute;
width: 100%;
height: 100%;
text-align: center;
font-size: small;
line-height: 1em;
}

View File

@ -0,0 +1,18 @@
import { FormattedNumber } from "react-intl";
import "./Progress.css";
import { CSSProperties } from "react";
export default function Progress({ value }: { value: number }) {
const v = Math.max(0.01, Math.min(1, value));
return <div className="progress">
<div
style={
{
"--progress": `${v * 100}%`,
} as CSSProperties
}></div>
<span>
<FormattedNumber value={v} style="percent" />
</span>
</div>
}

View File

@ -18,6 +18,7 @@ interface NoteCreatorDataSnapshot {
sensitive?: string; sensitive?: string;
pollOptions?: Array<string>; pollOptions?: Array<string>;
otherEvents?: Array<NostrEvent>; otherEvents?: Array<NostrEvent>;
extraTags?: Array<Array<string>>;
sending?: Array<NostrEvent>; sending?: Array<NostrEvent>;
sendStarted: boolean; sendStarted: boolean;
reset: () => void; reset: () => void;
@ -63,6 +64,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
d.pollOptions = undefined; d.pollOptions = undefined;
d.otherEvents = undefined; d.otherEvents = undefined;
d.sending = undefined; d.sending = undefined;
d.extraTags = undefined;
} }
takeSnapshot(): NoteCreatorDataSnapshot { takeSnapshot(): NoteCreatorDataSnapshot {

View File

@ -30,17 +30,38 @@ export default async function NostrBuild(file: File | Blob, publisher?: EventPub
headers, headers,
}); });
if (rsp.ok) { if (rsp.ok) {
const data = (await rsp.json()) as { const data = (await rsp.json()) as NostrBuildUploadResponse;
success: boolean; const res = data.data[0];
data: Array<{
url: string;
}>;
};
return { return {
url: data.data[0].url, url: res.url,
metadata: {
blurhash: res.blurhash,
width: res.dimensions.width,
height: res.dimensions.height,
},
}; };
} }
return { return {
error: "Upload failed", error: "Upload failed",
}; };
} }
interface NostrBuildUploadResponse {
data: Array<NostrBuildUploadData>;
}
interface NostrBuildUploadData {
input_name: string;
name: string;
url: string;
thumbnail: string;
blurhash: string;
sha256: string;
type: string;
mime: string;
size: number;
metadata: Record<string, string>;
dimensions: {
width: number;
height: number;
};
}

View File

@ -13,6 +13,7 @@ export default async function VoidCatUpload(
file: File | Blob, file: File | Blob,
filename: string, filename: string,
publisher?: EventPublisher, publisher?: EventPublisher,
progress?: (n: number) => void,
): Promise<UploadResult> { ): Promise<UploadResult> {
const auth = publisher const auth = publisher
? async (url: string, method: string) => { ? async (url: string, method: string) => {
@ -23,7 +24,9 @@ export default async function VoidCatUpload(
} }
: undefined; : undefined;
const api = new VoidApi(VoidCatHost, auth); const api = new VoidApi(VoidCatHost, auth);
const uploader = api.getUploader(file); const uploader = api.getUploader(file, undefined, px => {
progress?.(px / file.size);
});
const rsp = await uploader.upload({ const rsp = await uploader.upload({
"V-Strip-Metadata": "true", "V-Strip-Metadata": "true",

View File

@ -1,5 +1,7 @@
import { useState } from "react";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
import { v4 as uuid } from "uuid";
import NostrBuild from "Upload/NostrBuild"; import NostrBuild from "Upload/NostrBuild";
import VoidCat from "Upload/VoidCat"; import VoidCat from "Upload/VoidCat";
@ -16,6 +18,15 @@ export interface UploadResult {
* NIP-94 File Header * NIP-94 File Header
*/ */
header?: NostrEvent; header?: NostrEvent;
/**
* Media metadata
*/
metadata?: {
blurhash?: string;
width?: number;
height?: number;
};
} }
/** /**
@ -38,27 +49,102 @@ export const UploaderServices = [
export interface Uploader { export interface Uploader {
upload: (f: File | Blob, filename: string) => Promise<UploadResult>; upload: (f: File | Blob, filename: string) => Promise<UploadResult>;
progress: Array<UploadProgress>;
}
export interface UploadProgress {
id: string;
file: File | Blob;
progress: number;
} }
export default function useFileUpload(): Uploader { export default function useFileUpload(): Uploader {
const fileUploader = useLogin().preferences.fileUploader; const fileUploader = useLogin().preferences.fileUploader;
const { publisher } = useEventPublisher(); const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
switch (fileUploader) { switch (fileUploader) {
case "nostr.build": { case "nostr.build": {
return { return {
upload: f => NostrBuild(f, publisher), upload: f => NostrBuild(f, publisher),
progress: [],
} as Uploader; } as Uploader;
} }
case "nostrimg.com": { case "nostrimg.com": {
return { return {
upload: NostrImg, upload: NostrImg,
progress: [],
} as Uploader; } as Uploader;
} }
default: { default: {
return { return {
upload: (f, n) => VoidCat(f, n, publisher), upload: async (f, n) => {
const id = uuid();
setProgress(s => [
...s,
{
id,
file: f,
progress: 0,
},
]);
const px = (n: number) => {
setProgress(s =>
s.map(v =>
v.id === id
? {
...v,
progress: n,
}
: v,
),
);
};
const ret = await VoidCat(f, n, publisher, px);
setProgress(s => s.filter(a => a.id !== id));
return ret;
},
progress,
} as Uploader; } as Uploader;
} }
} }
} }
export const ProgressStream = (file: File | Blob, progress: (n: number) => void) => {
let offset = 0;
const DefaultChunkSize = 1024 * 32;
const readChunk = async (offset: number, size: number) => {
if (offset > file.size) {
return new Uint8Array(0);
}
const end = Math.min(offset + size, file.size);
const blob = file.slice(offset, end, file.type);
const data = await blob.arrayBuffer();
return new Uint8Array(data);
};
const rsBase = new ReadableStream(
{
start: async () => {},
pull: async controller => {
const chunk = await readChunk(offset, controller.desiredSize ?? DefaultChunkSize);
if (chunk.byteLength === 0) {
controller.close();
return;
}
progress((offset + chunk.byteLength) / file.size);
offset += chunk.byteLength;
controller.enqueue(chunk);
},
cancel: reason => {
console.log(reason);
},
type: "bytes",
},
{
highWaterMark: DefaultChunkSize,
},
);
return rsBase;
};

View File

@ -52,6 +52,7 @@ const config = {
}, },
devServer: { devServer: {
open: true, open: true,
https: true,
host: "localhost", host: "localhost",
historyApiFallback: true, historyApiFallback: true,
}, },