fix: File uploads

fix: onboarding
closes #169 #166
This commit is contained in:
2024-07-11 12:04:50 +01:00
parent c1a018820f
commit 7d3f21da84
14 changed files with 97 additions and 158 deletions

View File

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

View File

@ -1,67 +1,57 @@
import { VoidApi } from "@void-cat/api";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Layer2Button } from "./buttons"; import { Layer2Button } from "./buttons";
import { openFile } from "@/utils"; import { openFile } from "@/utils";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { useMediaServerList } from "@/hooks/media-servers";
const voidCatHost = "https://void.cat"; import { Nip96Server } from "@/service/upload/nip96";
const fileExtensionRegex = /\.([\w]{1,7})$/i; import { useLogin } from "@/hooks/login";
const voidCatApi = new VoidApi(voidCatHost); import { ReactNode } from "react";
import { EventPublisher } from "@snort/system";
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,
};
}
}
interface FileUploaderProps { interface FileUploaderProps {
onResult(url: string | undefined): void; onResult(url: string | undefined): void;
onError(e: string | Error): void;
children?: ReactNode;
className?: string;
publisher?: EventPublisher;
} }
export function FileUploader({ onResult }: FileUploaderProps) { export function FileUploader({ onResult, onError, children, className, publisher }: FileUploaderProps) {
async function uploadFile() { const servers = useMediaServerList();
const pub = publisher ?? useLogin()?.publisher?.();
async function uploadFile(e: React.MouseEvent) {
e.stopPropagation();
const file = await openFile(); const file = await openFile();
if (file) { if (file && pub) {
try { 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) { if (upload.url) {
onResult(upload.url); onResult(upload.url);
} }
if (upload.error) { if (upload.error) {
console.error(upload.error); onError(upload.error);
} }
} catch (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 ( return (
<Layer2Button onClick={uploadFile}> <Layer2Button onClick={uploadFile} className={className}>
<FormattedMessage defaultMessage="Upload" /> <FormattedMessage defaultMessage="Upload" />
<Icon name="upload" size={14} /> <Icon name="upload" size={14} />
</Layer2Button> </Layer2Button>

View File

@ -11,21 +11,20 @@ import LoginWallet2x from "../login-wallet@2x.jpg";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; 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 { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils"; import { bytesToHex } from "@noble/curves/abstract/utils";
import { LNURL, bech32ToHex, getPublicKey, hexToBech32 } from "@snort/shared"; import { LNURL, bech32ToHex, getPublicKey, hexToBech32 } from "@snort/shared";
import { VoidApi } from "@void-cat/api";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { Login, LoginType } from "@/login"; import { Login, LoginType } from "@/login";
import { Icon } from "./icon"; import { Icon } from "./icon";
import Copy from "./copy"; import Copy from "./copy";
import { openFile } from "@/utils";
import { DefaultProvider, StreamProviderInfo } from "@/providers"; import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { NostrStreamProvider } from "@/providers/zsz"; import { NostrStreamProvider } from "@/providers/zsz";
import { DefaultButton, Layer1Button } from "./buttons"; import { DefaultButton, Layer1Button } from "./buttons";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
import { FileUploader } from "./file-uploader";
enum Stage { enum Stage {
Login = 0, Login = 0,
@ -46,6 +45,7 @@ export function LoginSignup({ close }: { close: () => void }) {
const [key, setNewKey] = useState(""); const [key, setNewKey] = useState("");
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const hasNostrExtension = "nostr" in window && window.nostr; const hasNostrExtension = "nostr" in window && window.nostr;
const signer = key ? new PrivateKeySigner(key) : undefined;
function doLoginNsec() { function doLoginNsec() {
try { try {
@ -89,34 +89,6 @@ export function LoginSignup({ close }: { close: () => void }) {
close(); 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() { async function setupProfile() {
const px = new NostrStreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key)); const px = new NostrStreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key));
const info = await px.info(); const info = await px.info();
@ -248,13 +220,17 @@ export function LoginSignup({ close }: { close: () => void }) {
<h2> <h2>
<FormattedMessage defaultMessage="Setup Profile" /> <FormattedMessage defaultMessage="Setup Profile" />
</h2> </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} />} {avatar && <img className="absolute object-fit w-full h-full" src={avatar} />}
<div {signer && (
className="absolute flex items-center justify-center w-full h-full hover:opacity-100 opacity-0 transition bg-layer-2/50 cursor-pointer" <FileUploader
onClick={uploadAvatar}> publisher={new EventPublisher(signer, signer.getPubKey())}
<Icon name="camera-plus" /> onResult={e => setAvatar(e ?? "")}
</div> 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> </div>
<input <input
type="text" type="text"

View File

@ -4,9 +4,9 @@ import { LNURL, fetchNip05Pubkey } from "@snort/shared";
import { mapEventToProfile } from "@snort/system"; import { mapEventToProfile } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react"; import { SnortContext, useUserProfile } from "@snort/system-react";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { debounce, openFile } from "@/utils"; import { debounce } from "@/utils";
import { PrimaryButton } from "./buttons"; import { PrimaryButton } from "./buttons";
import { VoidApi } from "@void-cat/api"; import { FileUploader } from "./file-uploader";
const MaxUsernameLength = 100; const MaxUsernameLength = 100;
const MaxAboutLength = 500; const MaxAboutLength = 500;
@ -116,35 +116,6 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
}); });
}, [formatMessage, login?.pubkey, nip05]); }, [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() { async function saveProfile() {
// copy user object and delete internal fields // copy user object and delete internal fields
const userCopy = { const userCopy = {
@ -225,11 +196,12 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="flex flex-col gap-4"> <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"> <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} /> <img className="absolute w-full h-full object-cover" src={picture} />
<div <FileUploader
className="flex items-center justify-center absolute w-full h-full opacity-0 hover:opacity-80 bg-foreground cursor-pointer" onResult={e => setPicture(e ?? "")}
onClick={() => uploadAvatar()}> 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" /> <FormattedMessage defaultMessage="Edit" />
</div> </FileUploader>
</div> </div>
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<h4> <h4>

View File

@ -47,6 +47,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [goalAmount, setGoalMount] = useState(0); const [goalAmount, setGoalMount] = useState(0);
const [game, setGame] = useState<GameInfo>(); const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>(); const [gameId, setGameId] = useState<string>();
const [error, setError] = useState("");
const login = useLogin(); const login = useLogin();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const system = useContext(SnortContext); 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" />} {image && <img src={image} className="mb-2 aspect-video object-cover rounded-xl" />}
<div className="flex gap-2"> <div className="flex gap-2">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} /> <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> </div>
{error && <b className="text-warning">{error}</b>}
</StreamInput> </StreamInput>
)} )}
{(options?.canSetStream ?? true) && ( {(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 { IconButton, PrimaryButton } from "../buttons";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useState } from "react"; import { useState } from "react";
@ -6,13 +6,6 @@ import { sanitizeRelayUrl } from "@snort/shared";
export function ServerList() { export function ServerList() {
const [newServer, setNewServer] = useState(""); const [newServer, setNewServer] = useState("");
const defaultServers = [
//"https://media.zap.stream",
"https://nostr.build/",
"https://nostrcheck.me/",
"https://files.v0l.io/",
];
const servers = useMediaServerList(); const servers = useMediaServerList();
async function tryAddServer(s: string) { async function tryAddServer(s: string) {
@ -51,7 +44,7 @@ export function ServerList() {
<h4> <h4>
<FormattedMessage defaultMessage="Suggested Servers" /> <FormattedMessage defaultMessage="Suggested Servers" />
</h4> </h4>
{defaultServers {DefaultMediaServers.map(a => a.value[1])
.filter(a => !servers.servers.includes(a)) .filter(a => !servers.servers.includes(a))
.map(a => ( .map(a => (
<div className="flex items-center justify-between py-2 px-3 bg-layer-2 rounded-xl" key={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 { Nip96Server } from "@/service/upload/nip96";
import { useMemo } from "react"; 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() { export function useMediaServerList() {
const login = useLogin(); 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( return useMemo(
() => ({ () => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3245,15 +3245,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@webbtc/webln-types@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "@webbtc/webln-types@npm:3.0.0" resolution: "@webbtc/webln-types@npm:3.0.0"
@ -7502,13 +7493,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "slash@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "slash@npm:3.0.0" resolution: "slash@npm:3.0.0"
@ -7652,7 +7636,6 @@ __metadata:
"@typescript-eslint/eslint-plugin": "npm:^7.9.0" "@typescript-eslint/eslint-plugin": "npm:^7.9.0"
"@typescript-eslint/parser": "npm:^7.9.0" "@typescript-eslint/parser": "npm:^7.9.0"
"@vitejs/plugin-react": "npm:^4.2.1" "@vitejs/plugin-react": "npm:^4.2.1"
"@void-cat/api": "npm:^1.0.12"
"@webbtc/webln-types": "npm:^3.0.0" "@webbtc/webln-types": "npm:^3.0.0"
"@webscopeio/react-textarea-autocomplete": "npm:^4.9.2" "@webscopeio/react-textarea-autocomplete": "npm:^4.9.2"
autoprefixer: "npm:^10.4.19" autoprefixer: "npm:^10.4.19"