fix: File uploads

fix: onboarding
closes #169 #166
This commit is contained in:
kieran 2024-07-11 12:04:50 +01:00
parent c1a018820f
commit 7d3f21da84
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
14 changed files with 97 additions and 158 deletions

View File

@ -16,7 +16,6 @@
"@sqlite.org/sqlite-wasm": "^3.45.1-build1",
"@szhsin/react-menu": "^4.1.0",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
"@void-cat/api": "^1.0.12",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"buffer": "^6.0.3",
"classnames": "^2.5.1",

View File

@ -1,67 +1,57 @@
import { VoidApi } from "@void-cat/api";
import { FormattedMessage } from "react-intl";
import { Layer2Button } from "./buttons";
import { openFile } from "@/utils";
import { Icon } from "./icon";
const voidCatHost = "https://void.cat";
const fileExtensionRegex = /\.([\w]{1,7})$/i;
const voidCatApi = new VoidApi(voidCatHost);
type UploadResult = {
url?: string;
error?: string;
};
async function voidCatUpload(file: File): Promise<UploadResult> {
const uploader = voidCatApi.getUploader(file);
const rsp = await uploader.upload({
"V-Strip-Metadata": "true",
});
if (rsp.ok) {
let ext = file.name.match(fileExtensionRegex);
if (rsp.file?.metadata?.mimeType === "image/webp") {
ext = ["", "webp"];
}
const resultUrl = rsp.file?.metadata?.url ?? `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
const ret = {
url: resultUrl,
} as UploadResult;
return ret;
} else {
return {
error: rsp.errorMessage,
};
}
}
import { useMediaServerList } from "@/hooks/media-servers";
import { Nip96Server } from "@/service/upload/nip96";
import { useLogin } from "@/hooks/login";
import { ReactNode } from "react";
import { EventPublisher } from "@snort/system";
interface FileUploaderProps {
onResult(url: string | undefined): void;
onError(e: string | Error): void;
children?: ReactNode;
className?: string;
publisher?: EventPublisher;
}
export function FileUploader({ onResult }: FileUploaderProps) {
async function uploadFile() {
export function FileUploader({ onResult, onError, children, className, publisher }: FileUploaderProps) {
const servers = useMediaServerList();
const pub = publisher ?? useLogin()?.publisher?.();
async function uploadFile(e: React.MouseEvent) {
e.stopPropagation();
const file = await openFile();
if (file) {
if (file && pub) {
try {
const upload = await voidCatUpload(file);
const server = new Nip96Server(servers.servers[0], pub);
const upload = await server.upload(file, file.name);
if (upload.url) {
onResult(upload.url);
}
if (upload.error) {
console.error(upload.error);
onError(upload.error);
}
} catch (error) {
console.error(error);
if (error instanceof Error) {
onError(error);
} else {
onError(new Error("Unknown error"));
}
}
}
}
if (children) {
return (
<div onClick={uploadFile} className={className}>
{children}
</div>
);
}
return (
<Layer2Button onClick={uploadFile}>
<Layer2Button onClick={uploadFile} className={className}>
<FormattedMessage defaultMessage="Upload" />
<Icon name="upload" size={14} />
</Layer2Button>

View File

@ -11,21 +11,20 @@ import LoginWallet2x from "../login-wallet@2x.jpg";
import { useContext, useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { EventPublisher, UserMetadata } from "@snort/system";
import { EventPublisher, PrivateKeySigner, UserMetadata } from "@snort/system";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import { LNURL, bech32ToHex, getPublicKey, hexToBech32 } from "@snort/shared";
import { VoidApi } from "@void-cat/api";
import { SnortContext } from "@snort/system-react";
import { Login, LoginType } from "@/login";
import { Icon } from "./icon";
import Copy from "./copy";
import { openFile } from "@/utils";
import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { NostrStreamProvider } from "@/providers/zsz";
import { DefaultButton, Layer1Button } from "./buttons";
import { ExternalLink } from "./external-link";
import { FileUploader } from "./file-uploader";
enum Stage {
Login = 0,
@ -46,6 +45,7 @@ export function LoginSignup({ close }: { close: () => void }) {
const [key, setNewKey] = useState("");
const { formatMessage } = useIntl();
const hasNostrExtension = "nostr" in window && window.nostr;
const signer = key ? new PrivateKeySigner(key) : undefined;
function doLoginNsec() {
try {
@ -89,34 +89,6 @@ export function LoginSignup({ close }: { close: () => void }) {
close();
}
async function uploadAvatar() {
const defaultError = formatMessage({
defaultMessage: "Avatar upload fialed",
id: "uTonxS",
});
try {
const file = await openFile();
if (file) {
const VoidCatHost = "https://void.cat";
const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file);
const result = await uploader.upload({
"V-Strip-Metadata": "true",
});
console.debug(result);
if (result.ok) {
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl);
} else {
setError(result.errorMessage ?? defaultError);
}
}
} catch {
setError(defaultError);
}
}
async function setupProfile() {
const px = new NostrStreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key));
const info = await px.info();
@ -248,13 +220,17 @@ export function LoginSignup({ close }: { close: () => void }) {
<h2>
<FormattedMessage defaultMessage="Setup Profile" />
</h2>
<div className="relative mx-auto w-[100px] h-[100px] rounded-full overflow-hidden">
<div className="relative mx-auto w-[100px] h-[100px] rounded-full overflow-hidden bg-layer-3">
{avatar && <img className="absolute object-fit w-full h-full" src={avatar} />}
<div
className="absolute flex items-center justify-center w-full h-full hover:opacity-100 opacity-0 transition bg-layer-2/50 cursor-pointer"
onClick={uploadAvatar}>
<Icon name="camera-plus" />
</div>
{signer && (
<FileUploader
publisher={new EventPublisher(signer, signer.getPubKey())}
onResult={e => setAvatar(e ?? "")}
onError={e => setError(e.toString())}
className="absolute flex items-center justify-center w-full h-full hover:opacity-30 opacity-0 transition bg-black cursor-pointer">
<Icon name="camera-plus" />
</FileUploader>
)}
</div>
<input
type="text"

View File

@ -4,9 +4,9 @@ import { LNURL, fetchNip05Pubkey } from "@snort/shared";
import { mapEventToProfile } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import { debounce, openFile } from "@/utils";
import { debounce } from "@/utils";
import { PrimaryButton } from "./buttons";
import { VoidApi } from "@void-cat/api";
import { FileUploader } from "./file-uploader";
const MaxUsernameLength = 100;
const MaxAboutLength = 500;
@ -116,35 +116,6 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
});
}, [formatMessage, login?.pubkey, nip05]);
async function uploadAvatar() {
const defaultError = formatMessage({
defaultMessage: "Avatar upload fialed",
id: "uTonxS",
});
setError(undefined);
try {
const file = await openFile();
if (file) {
const VoidCatHost = "https://void.cat";
const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file);
const result = await uploader.upload({
"V-Strip-Metadata": "true",
});
console.debug(result);
if (result.ok) {
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setPicture(resultUrl);
} else {
setError(new Error(result.errorMessage ?? defaultError));
}
}
} catch {
setError(new Error(defaultError));
}
}
async function saveProfile() {
// copy user object and delete internal fields
const userCopy = {
@ -225,11 +196,12 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="flex flex-col gap-4">
<div className="mx-auto relative flex items-center justify-center w-40 h-40 aspect-square rounded-full overflow-hidden">
<img className="absolute w-full h-full object-cover" src={picture} />
<div
className="flex items-center justify-center absolute w-full h-full opacity-0 hover:opacity-80 bg-foreground cursor-pointer"
onClick={() => uploadAvatar()}>
<FileUploader
onResult={e => setPicture(e ?? "")}
onError={e => setError(e instanceof Error ? e : new Error(e))}
className="flex items-center justify-center absolute w-full h-full opacity-0 hover:opacity-80 bg-foreground cursor-pointer">
<FormattedMessage defaultMessage="Edit" />
</div>
</FileUploader>
</div>
<div className="flex flex-col w-full gap-2">
<h4>

View File

@ -47,6 +47,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [goalAmount, setGoalMount] = useState(0);
const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>();
const [error, setError] = useState("");
const login = useLogin();
const { formatMessage } = useIntl();
const system = useContext(SnortContext);
@ -180,8 +181,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{image && <img src={image} className="mb-2 aspect-video object-cover rounded-xl" />}
<div className="flex gap-2">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
<FileUploader onResult={v => setImage(v ?? "")} />
<FileUploader onResult={v => setImage(v ?? "")} onError={e => setError(e.toString())} />
</div>
{error && <b className="text-warning">{error}</b>}
</StreamInput>
)}
{(options?.canSetStream ?? true) && (

View File

@ -1,4 +1,4 @@
import { useMediaServerList } from "@/hooks/media-servers";
import { DefaultMediaServers, useMediaServerList } from "@/hooks/media-servers";
import { IconButton, PrimaryButton } from "../buttons";
import { FormattedMessage } from "react-intl";
import { useState } from "react";
@ -6,13 +6,6 @@ import { sanitizeRelayUrl } from "@snort/shared";
export function ServerList() {
const [newServer, setNewServer] = useState("");
const defaultServers = [
//"https://media.zap.stream",
"https://nostr.build/",
"https://nostrcheck.me/",
"https://files.v0l.io/",
];
const servers = useMediaServerList();
async function tryAddServer(s: string) {
@ -51,7 +44,7 @@ export function ServerList() {
<h4>
<FormattedMessage defaultMessage="Suggested Servers" />
</h4>
{defaultServers
{DefaultMediaServers.map(a => a.value[1])
.filter(a => !servers.servers.includes(a))
.map(a => (
<div className="flex items-center justify-between py-2 px-3 bg-layer-2 rounded-xl" key={a}>

View File

@ -4,10 +4,20 @@ import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import { Nip96Server } from "@/service/upload/nip96";
import { useMemo } from "react";
export const DefaultMediaServers = [
//"https://media.zap.stream",
new UnknownTag(["server", "https://nostr.build/"]),
new UnknownTag(["server", "https://nostrcheck.me/"]),
new UnknownTag(["server", "https://files.v0l.io/"]),
];
export function useMediaServerList() {
const login = useLogin();
const servers = login?.state?.getList(EventKind.StorageServerList) ?? [];
let servers = login?.state?.getList(EventKind.StorageServerList) ?? [];
if (servers.length === 0) {
servers = DefaultMediaServers;
}
return useMemo(
() => ({

View File

@ -906,9 +906,6 @@
"u6uD94": {
"defaultMessage": "Create an Account"
},
"uTonxS": {
"defaultMessage": "Avatar upload fialed"
},
"uYw2LD": {
"defaultMessage": "Stream"
},

View File

@ -26,7 +26,7 @@ export default function DashboardIntroFinal() {
}, []);
return (
<div className="flex flex-col items-center">
<div className="mx-auto flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">

View File

@ -12,6 +12,7 @@ export default function DashboardIntroStep1() {
const [title, setTitle] = useState<string>();
const [summary, setDescription] = useState<string>();
const [image, setImage] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
DefaultProvider.info().then(i => {
@ -48,8 +49,9 @@ export default function DashboardIntroStep1() {
onChange={e => setImage(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Cover image URL (optional)" })}
/>
<FileUploader onResult={setImage} />
<FileUploader onResult={setImage} onError={e => setError(e.toString())} />
</div>
{error && <b className="text-warning">{error}</b>}
<small className="text-layer-4">
<FormattedMessage defaultMessage="Recommended size: 1920x1080 (16:9)" />
</small>

View File

@ -3,11 +3,12 @@ import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider, StreamProviderForward } from "@/providers";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { AddForwardInputs } from "@/element/provider/nostr/fowards";
export default function DashboardIntroStep3() {
const navigate = useNavigate();
const location = useLocation();
const [forwards, setForwards] = useState<Array<StreamProviderForward>>([]);
async function loadInfo() {
@ -21,7 +22,7 @@ export default function DashboardIntroStep3() {
}, []);
return (
<div className="flex flex-col items-center">
<div className="mx-auto flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
@ -50,7 +51,9 @@ export default function DashboardIntroStep3() {
<AddForwardInputs provider={DefaultProvider} onAdd={loadInfo} />
<DefaultButton
onClick={async () => {
navigate("/dashboard/step-4");
navigate("/dashboard/step-4", {
state: location.state,
});
}}>
<FormattedMessage defaultMessage="Continue" />
</DefaultButton>

View File

@ -3,7 +3,7 @@ import StepHeader from "./step-header";
import { DefaultButton } from "@/element/buttons";
import { DefaultProvider } from "@/providers";
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { GoalSelector } from "@/element/stream-editor/goal-selector";
import AmountInput from "@/element/amount-input";
import { useLogin } from "@/hooks/login";
@ -12,6 +12,7 @@ import { SnortContext } from "@snort/system-react";
export default function DashboardIntroStep4() {
const navigate = useNavigate();
const location = useLocation();
const [goalName, setGoalName] = useState("");
const [goalAmount, setGoalMount] = useState(0);
const [goal, setGoal] = useState<string>();
@ -30,7 +31,7 @@ export default function DashboardIntroStep4() {
}, []);
return (
<div className="flex flex-col items-center">
<div className="mx-auto flex flex-col items-center">
<StepHeader />
<div className="flex flex-col gap-4 w-[30rem]">
<h2 className="text-center">
@ -67,15 +68,27 @@ export default function DashboardIntroStep4() {
.content(goalName);
});
await system.BroadcastEvent(goalEvent);
await DefaultProvider.updateStream({
const newState = {
...location.state,
goal: goalEvent.id,
};
await DefaultProvider.updateStream(newState);
navigate("/dashboard/final", {
state: newState,
});
navigate("/dashboard/final");
} else if (goal) {
await DefaultProvider.updateStream({
const newState = {
...location.state,
goal,
};
await DefaultProvider.updateStream(newState);
navigate("/dashboard/final", {
state: newState,
});
} else {
navigate("/dashboard/final", {
state: location.state,
});
navigate("/dashboard/final");
}
}}>
<FormattedMessage defaultMessage="Continue" />

View File

@ -298,7 +298,6 @@
"twCRVi": "Art",
"tzMNF3": "Status",
"u6uD94": "Create an Account",
"uTonxS": "Avatar upload fialed",
"uYw2LD": "Stream",
"uksRSi": "Latest Videos",
"vP4dFa": "Visit {link} to get some sweet zap.stream merch!",

View File

@ -3245,15 +3245,6 @@ __metadata:
languageName: node
linkType: hard
"@void-cat/api@npm:^1.0.12":
version: 1.0.12
resolution: "@void-cat/api@npm:1.0.12"
dependencies:
sjcl: "npm:^1.0.8"
checksum: 10c0/37d67e077a1ece0509a669d2ebd5a1edc26773b25417287ce333e0f854ae1c073ea3ca4e311c137faf2b9c0985a0ae630e52cbdd6f431cfe1691d42d2ebe0acc
languageName: node
linkType: hard
"@webbtc/webln-types@npm:^3.0.0":
version: 3.0.0
resolution: "@webbtc/webln-types@npm:3.0.0"
@ -7502,13 +7493,6 @@ __metadata:
languageName: node
linkType: hard
"sjcl@npm:^1.0.8":
version: 1.0.8
resolution: "sjcl@npm:1.0.8"
checksum: 10c0/deac03b931a26b710a521fc9dd5a10b124fb63970b7f5e22a91460ac46b14dd4ccfc2edbee4d553b8d586da332d9013a9ff40f5adfdb58050b73ebcd90235cba
languageName: node
linkType: hard
"slash@npm:^3.0.0":
version: 3.0.0
resolution: "slash@npm:3.0.0"
@ -7652,7 +7636,6 @@ __metadata:
"@typescript-eslint/eslint-plugin": "npm:^7.9.0"
"@typescript-eslint/parser": "npm:^7.9.0"
"@vitejs/plugin-react": "npm:^4.2.1"
"@void-cat/api": "npm:^1.0.12"
"@webbtc/webln-types": "npm:^3.0.0"
"@webscopeio/react-textarea-autocomplete": "npm:^4.9.2"
autoprefixer: "npm:^10.4.19"