feat: file upload progress / imeta
This commit is contained in:
@ -1,14 +1,6 @@
|
||||
import { ProxyImg } from "Element/ProxyImg";
|
||||
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 {
|
||||
mime: string;
|
||||
url: string;
|
||||
|
11
packages/app/src/Element/Event/FileUpload.tsx
Normal file
11
packages/app/src/Element/Event/FileUpload.tsx
Normal 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>
|
||||
}
|
@ -20,6 +20,7 @@ import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { ZapTarget } from "Zapper";
|
||||
import { useNoteCreator } from "State/NoteCreator";
|
||||
import { NoteBroadcaster } from "./NoteBroadcaster";
|
||||
import FileUploadProgress from "./FileUpload";
|
||||
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -114,6 +115,7 @@ export function NoteCreator() {
|
||||
}
|
||||
const hk = (eb: EventBuilder) => {
|
||||
extraTags?.forEach(t => eb.tag(t));
|
||||
note.extraTags?.forEach(t => eb.tag(t));
|
||||
eb.kind(kind);
|
||||
return eb;
|
||||
};
|
||||
@ -170,6 +172,17 @@ export function NoteCreator() {
|
||||
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
|
||||
} else if (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) {
|
||||
v.error = rx.error;
|
||||
}
|
||||
@ -290,18 +303,18 @@ export function NoteCreator() {
|
||||
onChange={e => {
|
||||
note.update(
|
||||
v =>
|
||||
(v.selectedCustomRelays =
|
||||
// set false if all relays selected
|
||||
e.target.checked &&
|
||||
(v.selectedCustomRelays =
|
||||
// set false if all relays selected
|
||||
e.target.checked &&
|
||||
note.selectedCustomRelays &&
|
||||
note.selectedCustomRelays.length == a.length - 1
|
||||
? undefined
|
||||
: // otherwise return selectedCustomRelays with target relay added / removed
|
||||
a.filter(el =>
|
||||
el === r
|
||||
? e.target.checked
|
||||
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
|
||||
)),
|
||||
? undefined
|
||||
: // otherwise return selectedCustomRelays with target relay added / removed
|
||||
a.filter(el =>
|
||||
el === r
|
||||
? e.target.checked
|
||||
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
|
||||
)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -373,9 +386,9 @@ export function NoteCreator() {
|
||||
onChange={e =>
|
||||
note.update(
|
||||
v =>
|
||||
(v.zapSplits = arr.map((vv, ii) =>
|
||||
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
|
||||
)),
|
||||
(v.zapSplits = arr.map((vv, ii) =>
|
||||
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
|
||||
)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
@ -536,6 +549,7 @@ export function NoteCreator() {
|
||||
{renderPollOptions()}
|
||||
</div>
|
||||
)}
|
||||
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
|
||||
{noteCreatorFooter()}
|
||||
{note.error && <span className="error">{note.error}</span>}
|
||||
{note.advanced && noteCreatorAdvanced()}
|
||||
|
@ -1,21 +1,3 @@
|
||||
.zap-goal {
|
||||
}
|
||||
|
||||
.zap-goal h1 {
|
||||
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%;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import "./ZapGoal.css";
|
||||
import { CSSProperties, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import useZapsFeed from "Feed/ZapsFeed";
|
||||
import { formatShort } from "Number";
|
||||
@ -7,13 +7,15 @@ import { findTag } from "SnortUtils";
|
||||
import Icon from "Icons/Icon";
|
||||
import SendSats from "../SendSats";
|
||||
import { Zapper } from "Zapper";
|
||||
import Progress from "Element/Progress";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
|
||||
export function ZapGoal({ ev }: { ev: NostrEvent }) {
|
||||
const [zap, setZap] = useState(false);
|
||||
const zaps = useZapsFeed(NostrLink.fromEvent(ev));
|
||||
const target = Number(findTag(ev, "amount"));
|
||||
const amount = zaps.reduce((acc, v) => (acc += v.amount * 1000), 0);
|
||||
const progress = 100 * (amount / target);
|
||||
const progress = amount / target;
|
||||
|
||||
return (
|
||||
<div className="zap-goal card">
|
||||
@ -26,19 +28,14 @@ export function ZapGoal({ ev }: { ev: NostrEvent }) {
|
||||
</div>
|
||||
|
||||
<div className="flex f-space">
|
||||
<div>{progress.toFixed(1)}%</div>
|
||||
<div>
|
||||
<FormattedNumber value={progress} style="percent" />
|
||||
</div>
|
||||
<div>
|
||||
{formatShort(amount / 1000)}/{formatShort(target / 1000)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress">
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--progress": `${Math.min(100, progress)}%`,
|
||||
} as CSSProperties
|
||||
}></div>
|
||||
</div>
|
||||
<Progress value={progress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
23
packages/app/src/Element/Progress.css
Normal file
23
packages/app/src/Element/Progress.css
Normal 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;
|
||||
}
|
18
packages/app/src/Element/Progress.tsx
Normal file
18
packages/app/src/Element/Progress.tsx
Normal 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>
|
||||
}
|
@ -18,6 +18,7 @@ interface NoteCreatorDataSnapshot {
|
||||
sensitive?: string;
|
||||
pollOptions?: Array<string>;
|
||||
otherEvents?: Array<NostrEvent>;
|
||||
extraTags?: Array<Array<string>>;
|
||||
sending?: Array<NostrEvent>;
|
||||
sendStarted: boolean;
|
||||
reset: () => void;
|
||||
@ -63,6 +64,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
|
||||
d.pollOptions = undefined;
|
||||
d.otherEvents = undefined;
|
||||
d.sending = undefined;
|
||||
d.extraTags = undefined;
|
||||
}
|
||||
|
||||
takeSnapshot(): NoteCreatorDataSnapshot {
|
||||
|
@ -30,17 +30,38 @@ export default async function NostrBuild(file: File | Blob, publisher?: EventPub
|
||||
headers,
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const data = (await rsp.json()) as {
|
||||
success: boolean;
|
||||
data: Array<{
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
const data = (await rsp.json()) as NostrBuildUploadResponse;
|
||||
const res = data.data[0];
|
||||
return {
|
||||
url: data.data[0].url,
|
||||
url: res.url,
|
||||
metadata: {
|
||||
blurhash: res.blurhash,
|
||||
width: res.dimensions.width,
|
||||
height: res.dimensions.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export default async function VoidCatUpload(
|
||||
file: File | Blob,
|
||||
filename: string,
|
||||
publisher?: EventPublisher,
|
||||
progress?: (n: number) => void,
|
||||
): Promise<UploadResult> {
|
||||
const auth = publisher
|
||||
? async (url: string, method: string) => {
|
||||
@ -23,7 +24,9 @@ export default async function VoidCatUpload(
|
||||
}
|
||||
: undefined;
|
||||
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({
|
||||
"V-Strip-Metadata": "true",
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import NostrBuild from "Upload/NostrBuild";
|
||||
import VoidCat from "Upload/VoidCat";
|
||||
@ -16,6 +18,15 @@ export interface UploadResult {
|
||||
* NIP-94 File Header
|
||||
*/
|
||||
header?: NostrEvent;
|
||||
|
||||
/**
|
||||
* Media metadata
|
||||
*/
|
||||
metadata?: {
|
||||
blurhash?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,27 +49,102 @@ export const UploaderServices = [
|
||||
|
||||
export interface Uploader {
|
||||
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 {
|
||||
const fileUploader = useLogin().preferences.fileUploader;
|
||||
const { publisher } = useEventPublisher();
|
||||
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
|
||||
|
||||
switch (fileUploader) {
|
||||
case "nostr.build": {
|
||||
return {
|
||||
upload: f => NostrBuild(f, publisher),
|
||||
progress: [],
|
||||
} as Uploader;
|
||||
}
|
||||
case "nostrimg.com": {
|
||||
return {
|
||||
upload: NostrImg,
|
||||
progress: [],
|
||||
} as Uploader;
|
||||
}
|
||||
default: {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
Reference in New Issue
Block a user