mirror of
https://git.v0l.io/florian/bouquet.git
synced 2025-03-17 21:13:02 +01:00
feat: Added onboarding, nip96 server management
This commit is contained in:
parent
495b2ee0f6
commit
3bad526737
@ -181,12 +181,7 @@ const FileEventEditor = ({
|
||||
<div className="carousel w-full">
|
||||
{fileEventData.thumbnails.map((t, i) => (
|
||||
<div id={`item${i + 1}`} key={`item${i + 1}`} className="carousel-item w-full">
|
||||
<img
|
||||
width={300}
|
||||
height={300}
|
||||
src={getProxyUrl(t)}
|
||||
className="w-full"
|
||||
/>
|
||||
<img width={300} height={300} src={getProxyUrl(t)} className="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -76,7 +76,8 @@ export const Layout = () => {
|
||||
{user && (
|
||||
<div className="avatar px-4">
|
||||
<div className="w-12 rounded-full">
|
||||
<a className='link'
|
||||
<a
|
||||
className="link"
|
||||
onClick={() => {
|
||||
setAutoLogin(false);
|
||||
logout();
|
||||
|
@ -1,11 +1,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useNDK } from '../../utils/ndk';
|
||||
import useLocalStorageState from '../../utils/useLocalStorageState';
|
||||
import { useUserServers } from '../../utils/useUserServers';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { loginWithExtension } = useNDK();
|
||||
const [_, setAutoLogin] = useLocalStorageState('autologin', {defaultValue: false});
|
||||
const [_, setAutoLogin] = useLocalStorageState('autologin', { defaultValue: false });
|
||||
const userServers = useUserServers();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
@ -17,11 +18,13 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(userServers);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-[80vh] gap-4">
|
||||
<img src="/bouquet.png" alt="logo" className="w-28" />
|
||||
<h1 className="text-4xl font-bold">bouquet</h1>
|
||||
<h2 className="text-xl">organize assets your way</h2>
|
||||
<img src="/bouquet.png" alt="logo" className="w-28" />
|
||||
<h1 className="text-4xl font-bold">bouquet</h1>
|
||||
<h2 className="text-xl">organize assets your way</h2>
|
||||
<button className="btn btn-primary mt-8" onClick={handleLogin}>
|
||||
Login with extension (NIP07)
|
||||
</button>
|
||||
|
@ -1,14 +1,10 @@
|
||||
import { ArrowPathRoundedSquareIcon, Cog8ToothIcon } from '@heroicons/react/24/outline';
|
||||
import { ServerInfo, useServerInfo } from '../../utils/useServerInfo';
|
||||
import { Server as ServerType } from '../../utils/useUserServers';
|
||||
import { Server as ServerType, useUserServers } from '../../utils/useUserServers';
|
||||
import Server from './Server';
|
||||
import './ServerList.css';
|
||||
import ServerListPopup from '../ServerListPopup';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNDK } from '../../utils/ndk';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import dayjs from 'dayjs';
|
||||
import { USER_BLOSSOM_SERVER_LIST_KIND } from 'blossom-client-sdk';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type ServerListProps = {
|
||||
@ -33,7 +29,7 @@ export const ServerList = ({
|
||||
manageServers = false,
|
||||
withVirtualServers = false,
|
||||
}: ServerListProps) => {
|
||||
const { ndk, user } = useNDK();
|
||||
const { storeUserServers } = useUserServers();
|
||||
const { distribution } = useServerInfo();
|
||||
const queryClient = useQueryClient();
|
||||
const blobsWithOnlyOneOccurance = Object.values(distribution)
|
||||
@ -51,16 +47,7 @@ export const ServerList = ({
|
||||
};
|
||||
|
||||
const handleSaveServers = async (newServers: ServerType[]) => {
|
||||
const ev = new NDKEvent(ndk, {
|
||||
kind: USER_BLOSSOM_SERVER_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: '',
|
||||
pubkey: user?.pubkey || '',
|
||||
tags: newServers.filter(s => s.type == 'blossom').map(s => ['server', `${s.url}`]),
|
||||
});
|
||||
await ev.sign();
|
||||
console.log(ev.rawEvent());
|
||||
await ev.publish();
|
||||
await storeUserServers(newServers);
|
||||
};
|
||||
|
||||
const serversToList = useMemo(
|
||||
|
@ -12,6 +12,7 @@ interface ServerListPopupProps {
|
||||
const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSave, initialServers }) => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [newServer, setNewServer] = useState('');
|
||||
const [newServerType, setNewServerType] = useState<'blossom' | 'nip96'>('blossom');
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -28,7 +29,7 @@ const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSa
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (newServer.trim()) {
|
||||
setServers([...servers, { name: newServer.trim(), url: newServer.trim(), type: 'blossom' }]);
|
||||
setServers([...servers, { name: newServer.trim(), url: newServer.trim(), type: newServerType }]);
|
||||
setNewServer('');
|
||||
}
|
||||
};
|
||||
@ -72,25 +73,13 @@ const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSa
|
||||
{server.url} <div className="badge badge-neutral">{server.type}</div>
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={server.type != 'blossom'}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveUp(index)}>
|
||||
<ArrowUpIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={server.type != 'blossom'}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveDown(index)}>
|
||||
<ArrowDownIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={server.type != 'blossom'}
|
||||
onClick={() => handleDeleteServer(server.url)}
|
||||
>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => handleDeleteServer(server.url)}>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@ -98,7 +87,7 @@ const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSa
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 flex flex-row gap-2">
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered w-full"
|
||||
@ -106,6 +95,30 @@ const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSa
|
||||
value={newServer}
|
||||
onChange={e => setNewServer(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2 my-2">
|
||||
<label className="flex items-center space-x-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="serverType"
|
||||
value="blossom"
|
||||
checked={newServerType === 'blossom'}
|
||||
onChange={() => setNewServerType('blossom')}
|
||||
className="radio radio-primary"
|
||||
/>
|
||||
<span>Blossom</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="serverType"
|
||||
value="nip96"
|
||||
checked={newServerType === 'nip96'}
|
||||
onChange={() => setNewServerType('nip96')}
|
||||
className="radio radio-primary"
|
||||
/>
|
||||
<span>NIP-96</span>
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleAddServer}>
|
||||
Add Server
|
||||
</button>
|
||||
|
92
src/components/UploadOboarding.tsx
Normal file
92
src/components/UploadOboarding.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { ServerIcon } from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
import { Server, useUserServers } from '../utils/useUserServers';
|
||||
|
||||
const defaultServers: (Server & { description: string; buyUrl?: string })[] = [
|
||||
{
|
||||
name: 'nostr.build',
|
||||
url: 'https://nostr.build',
|
||||
buyUrl: 'https://nostr.build/plans/',
|
||||
description: 'Free tier is limited to 20MB upload size.',
|
||||
type: 'nip96',
|
||||
},
|
||||
{
|
||||
name: 'nostrcheck.me',
|
||||
url: 'https://nostrcheck.me',
|
||||
description: 'A server for checking Nostr keys and addresses.',
|
||||
type: 'nip96',
|
||||
},
|
||||
{
|
||||
name: 'satellite.earth',
|
||||
url: 'https://cdn.satellite.earth',
|
||||
description: 'A payed server with cheap prices 0.05 USD per GB.',
|
||||
buyUrl: 'https://cdn.satellite.earth/',
|
||||
type: 'blossom',
|
||||
},
|
||||
];
|
||||
export default function UploadOnboarding() {
|
||||
const [checkedState, setCheckedState] = useState(new Array(defaultServers.length).fill(true));
|
||||
const { storeUserServers } = useUserServers();
|
||||
|
||||
const handleCheckboxChange = (index: number) => {
|
||||
const updatedCheckedState = checkedState.map((item, pos) => (pos === index ? !item : item));
|
||||
setCheckedState(updatedCheckedState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl py-4">You don't have any servers yet</h2>
|
||||
<p className="py-4">Please choose some of the following options...</p>
|
||||
|
||||
<div>
|
||||
{defaultServers.map((server, index) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="flex flex-row items-start gap-2 my-2 p-4 bg-base-200 rounded-md cursor-pointer"
|
||||
onClick={() => handleCheckboxChange(index)}
|
||||
>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={checkedState[index]}
|
||||
onChange={() => handleCheckboxChange(index)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 text-accent">
|
||||
<ServerIcon className="w-4 h-4" /> {server.name}
|
||||
</span>
|
||||
<span className="badge badge-primary">{server.type}</span>
|
||||
</span>
|
||||
<p>
|
||||
{server.description}{' '}
|
||||
{server.buyUrl && (
|
||||
<a
|
||||
href={server.buyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-sm btn-neutral mt-1"
|
||||
>
|
||||
Buy Storage
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
storeUserServers(defaultServers);
|
||||
}}
|
||||
>
|
||||
Use these servers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -71,25 +71,21 @@ function Home() {
|
||||
withVirtualServers={true}
|
||||
></ServerList>
|
||||
|
||||
{selectedServer &&
|
||||
serverInfo[selectedServer] &&
|
||||
selectedServerBlobs &&
|
||||
selectedServerBlobs.length > 0 &&
|
||||
(
|
||||
<BlobList
|
||||
className="mt-4"
|
||||
title={`Content on ${serverInfo[selectedServer].name}`}
|
||||
blobs={selectedServerBlobs}
|
||||
onDelete={async blobs => {
|
||||
for (const blob of blobs) {
|
||||
await deleteBlob.mutateAsync({
|
||||
server: serverInfo[selectedServer],
|
||||
hash: blob.sha256,
|
||||
});
|
||||
}
|
||||
}}
|
||||
></BlobList>
|
||||
)}
|
||||
{selectedServer && serverInfo[selectedServer] && selectedServerBlobs && selectedServerBlobs.length > 0 && (
|
||||
<BlobList
|
||||
className="mt-4"
|
||||
title={`Content on ${serverInfo[selectedServer].name}`}
|
||||
blobs={selectedServerBlobs}
|
||||
onDelete={async blobs => {
|
||||
for (const blob of blobs) {
|
||||
await deleteBlob.mutateAsync({
|
||||
server: serverInfo[selectedServer],
|
||||
hash: blob.sha256,
|
||||
});
|
||||
}
|
||||
}}
|
||||
></BlobList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -20,9 +20,10 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import UploadPublished from '../components/UploadPublished';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import UploadOnboarding from '../components/UploadOboarding';
|
||||
|
||||
function Upload() {
|
||||
const servers = useUserServers();
|
||||
const { servers } = useUserServers();
|
||||
const { signEventTemplate } = useNDK();
|
||||
const { serverInfo } = useServerInfo();
|
||||
const queryClient = useQueryClient();
|
||||
@ -59,7 +60,7 @@ function Upload() {
|
||||
}
|
||||
|
||||
async function createThumbnailForImage(file: File, width: number, height: number) {
|
||||
const thumbnailFile = (width > 300 || height > 300) ? await resizeImage(file, 300, 300) : undefined
|
||||
const thumbnailFile = width > 300 || height > 300 ? await resizeImage(file, 300, 300) : undefined;
|
||||
return thumbnailFile && URL.createObjectURL(thumbnailFile);
|
||||
}
|
||||
|
||||
@ -308,7 +309,7 @@ function Upload() {
|
||||
setFileEventsToPublish(prev =>
|
||||
prev.map(f => (f.x === fe.x ? { ...f, events: [...f.events, publishedEvent] } : f))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fe.publish.audio) {
|
||||
if (!fe.publishedThumbnail) {
|
||||
@ -347,7 +348,7 @@ function Upload() {
|
||||
const newData: Partial<FileEventData> = {
|
||||
publishedThumbnail: selfHostedThumbnail.url,
|
||||
thumbnails: [selfHostedThumbnail.url],
|
||||
};
|
||||
};
|
||||
const publishedEvent = await publishVideoEvent({ ...fe, ...newData });
|
||||
setFileEventsToPublish(prev =>
|
||||
prev.map(f =>
|
||||
@ -391,75 +392,81 @@ function Upload() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[80em] w-full">
|
||||
<ul className="steps pt-8 pb-4 md:p-8">
|
||||
<li className={`step ${uploadStep >= 0 ? 'step-primary' : ''}`}>Choose files</li>
|
||||
<li className={`step ${uploadStep >= 1 ? 'step-primary' : ''}`}>Upload</li>
|
||||
<li className={`step ${uploadStep >= 2 ? 'step-primary' : ''}`}>Add metadata</li>
|
||||
<li className={`step ${uploadStep >= 3 ? 'step-primary' : ''}`}>Publish to NOSTR</li>
|
||||
</ul>
|
||||
{uploadStep <= 1 && (
|
||||
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-col">
|
||||
{uploadStep == 0 && (
|
||||
<UploadFileSelection
|
||||
servers={servers}
|
||||
transfers={transfers}
|
||||
setTransfers={setTransfers}
|
||||
cleanPrivateData={cleanPrivateData}
|
||||
setCleanPrivateData={setCleanPrivateData}
|
||||
imageResize={imageResize}
|
||||
setImageResize={setImageResize}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
clearTransfers={clearTransfers}
|
||||
uploadBusy={uploadBusy}
|
||||
upload={upload}
|
||||
/>
|
||||
)}
|
||||
{!servers || servers.length == 0 ? (
|
||||
<UploadOnboarding />
|
||||
) : (
|
||||
<>
|
||||
<ul className="steps pt-8 pb-4 md:p-8">
|
||||
<li className={`step ${uploadStep >= 0 ? 'step-primary' : ''}`}>Choose files</li>
|
||||
<li className={`step ${uploadStep >= 1 ? 'step-primary' : ''}`}>Upload</li>
|
||||
<li className={`step ${uploadStep >= 2 ? 'step-primary' : ''}`}>Add metadata</li>
|
||||
<li className={`step ${uploadStep >= 3 ? 'step-primary' : ''}`}>Publish to NOSTR</li>
|
||||
</ul>
|
||||
{uploadStep <= 1 && (
|
||||
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-col">
|
||||
{uploadStep == 0 && (
|
||||
<UploadFileSelection
|
||||
servers={servers}
|
||||
transfers={transfers}
|
||||
setTransfers={setTransfers}
|
||||
cleanPrivateData={cleanPrivateData}
|
||||
setCleanPrivateData={setCleanPrivateData}
|
||||
imageResize={imageResize}
|
||||
setImageResize={setImageResize}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
clearTransfers={clearTransfers}
|
||||
uploadBusy={uploadBusy}
|
||||
upload={upload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{uploadStep == 1 && <UploadProgress servers={servers} transfers={transfers} />}
|
||||
</div>
|
||||
)}
|
||||
{uploadStep == 2 && fileEventsToPublish.length > 0 && (
|
||||
<div className="gap-4 flex flex-col">
|
||||
<h2 className="">Publish events</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fileEventsToPublish.map(fe => (
|
||||
<FileEventEditor
|
||||
key={fe.x}
|
||||
fileEventData={fe}
|
||||
setFileEventData={updatedFe =>
|
||||
setFileEventsToPublish(prev => prev.map(f => (f.x === fe.x ? updatedFe : f)) as FileEventData[])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{audioCount > 0 && (
|
||||
<div className="text-sm text-neutral-content flex flex-row gap-2 items-center pl-4">
|
||||
<InformationCircleIcon className="w-6 h-6 text-info" />
|
||||
Audio events are not widely supported yet. Currently they are only used by{' '}
|
||||
<a className="link link-primary" href="https://stemstr.app/" target="_blank">
|
||||
stemstr.app
|
||||
</a>
|
||||
{uploadStep == 1 && <UploadProgress servers={servers} transfers={transfers} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row justify-center">
|
||||
<button
|
||||
className={`btn ${publishCount === 0 ? 'btn-primary' : 'btn-neutral'} w-40`}
|
||||
onClick={() => {
|
||||
navigate('/browse');
|
||||
}}
|
||||
>
|
||||
Skip publishing
|
||||
</button>
|
||||
{publishCount > 0 && (
|
||||
<button className="btn btn-primary w-40" onClick={() => publishAll()}>
|
||||
Publish ({publishCount} event{publishCount > 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{uploadStep == 2 && fileEventsToPublish.length > 0 && (
|
||||
<div className="gap-4 flex flex-col">
|
||||
<h2 className="">Publish events</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fileEventsToPublish.map(fe => (
|
||||
<FileEventEditor
|
||||
key={fe.x}
|
||||
fileEventData={fe}
|
||||
setFileEventData={updatedFe =>
|
||||
setFileEventsToPublish(prev => prev.map(f => (f.x === fe.x ? updatedFe : f)) as FileEventData[])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{audioCount > 0 && (
|
||||
<div className="text-sm text-neutral-content flex flex-row gap-2 items-center pl-4">
|
||||
<InformationCircleIcon className="w-6 h-6 text-info" />
|
||||
Audio events are not widely supported yet. Currently they are only used by{' '}
|
||||
<a className="link link-primary" href="https://stemstr.app/" target="_blank">
|
||||
stemstr.app
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row justify-center">
|
||||
<button
|
||||
className={`btn ${publishCount === 0 ? 'btn-primary' : 'btn-neutral'} w-40`}
|
||||
onClick={() => {
|
||||
navigate('/browse');
|
||||
}}
|
||||
>
|
||||
Skip publishing
|
||||
</button>
|
||||
{publishCount > 0 && (
|
||||
<button className="btn btn-primary w-40" onClick={() => publishAll()}>
|
||||
Publish ({publishCount} event{publishCount > 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadStep == 3 && <UploadPublished fileEventsToPublish={fileEventsToPublish} />}
|
||||
</>
|
||||
)}
|
||||
{uploadStep == 3 && <UploadPublished fileEventsToPublish={fileEventsToPublish} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import dayjs from 'dayjs';
|
||||
|
||||
const blossomUrlRegex = /https?:\/\/(?:www\.)?[^\s/]+\/([a-fA-F0-9]{64})(?:\.[a-zA-Z0-9]+)?/g;
|
||||
|
||||
|
||||
export function extractHashesFromContent(text: string) {
|
||||
let match;
|
||||
const hashes = [];
|
||||
|
@ -1,210 +1,200 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
// in memory fallback used when `localStorage` throws an error
|
||||
export const inMemoryData = new Map<string, unknown>()
|
||||
export const inMemoryData = new Map<string, unknown>();
|
||||
|
||||
export type LocalStorageOptions<T> = {
|
||||
defaultValue?: T | (() => T)
|
||||
storageSync?: boolean
|
||||
serializer?: {
|
||||
stringify: (value: unknown) => string
|
||||
parse: (value: string) => unknown
|
||||
}
|
||||
}
|
||||
defaultValue?: T | (() => T);
|
||||
storageSync?: boolean;
|
||||
serializer?: {
|
||||
stringify: (value: unknown) => string;
|
||||
parse: (value: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
// - `useLocalStorageState()` return type
|
||||
// - first two values are the same as `useState`
|
||||
export type LocalStorageState<T> = [
|
||||
T,
|
||||
Dispatch<SetStateAction<T>>,
|
||||
{
|
||||
isPersistent: boolean
|
||||
removeItem: () => void
|
||||
},
|
||||
]
|
||||
T,
|
||||
Dispatch<SetStateAction<T>>,
|
||||
{
|
||||
isPersistent: boolean;
|
||||
removeItem: () => void;
|
||||
},
|
||||
];
|
||||
|
||||
export default function useLocalStorageState(
|
||||
key: string,
|
||||
options?: LocalStorageOptions<undefined>,
|
||||
): LocalStorageState<unknown>
|
||||
key: string,
|
||||
options?: LocalStorageOptions<undefined>
|
||||
): LocalStorageState<unknown>;
|
||||
export default function useLocalStorageState<T>(
|
||||
key: string,
|
||||
options?: Omit<LocalStorageOptions<T | undefined>, 'defaultValue'>,
|
||||
): LocalStorageState<T | undefined>
|
||||
export default function useLocalStorageState<T>(
|
||||
key: string,
|
||||
options?: LocalStorageOptions<T>,
|
||||
): LocalStorageState<T>
|
||||
key: string,
|
||||
options?: Omit<LocalStorageOptions<T | undefined>, 'defaultValue'>
|
||||
): LocalStorageState<T | undefined>;
|
||||
export default function useLocalStorageState<T>(key: string, options?: LocalStorageOptions<T>): LocalStorageState<T>;
|
||||
export default function useLocalStorageState<T = undefined>(
|
||||
key: string,
|
||||
options?: LocalStorageOptions<T | undefined>,
|
||||
key: string,
|
||||
options?: LocalStorageOptions<T | undefined>
|
||||
): LocalStorageState<T | undefined> {
|
||||
const serializer = options?.serializer
|
||||
const [defaultValue] = useState(options?.defaultValue)
|
||||
return useLocalStorage(
|
||||
key,
|
||||
defaultValue,
|
||||
options?.storageSync,
|
||||
serializer?.parse,
|
||||
serializer?.stringify,
|
||||
)
|
||||
const serializer = options?.serializer;
|
||||
const [defaultValue] = useState(options?.defaultValue);
|
||||
return useLocalStorage(key, defaultValue, options?.storageSync, serializer?.parse, serializer?.stringify);
|
||||
}
|
||||
|
||||
function useLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T | undefined,
|
||||
storageSync: boolean = true,
|
||||
parse: (value: string) => unknown = parseJSON,
|
||||
stringify: (value: unknown) => string = JSON.stringify,
|
||||
key: string,
|
||||
defaultValue: T | undefined,
|
||||
storageSync: boolean = true,
|
||||
parse: (value: string) => unknown = parseJSON,
|
||||
stringify: (value: unknown) => string = JSON.stringify
|
||||
): LocalStorageState<T | undefined> {
|
||||
// we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version
|
||||
const storageItem = useRef<{ string: string | null; parsed: T | undefined }>({
|
||||
string: null,
|
||||
parsed: undefined,
|
||||
})
|
||||
// we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version
|
||||
const storageItem = useRef<{ string: string | null; parsed: T | undefined }>({
|
||||
string: null,
|
||||
parsed: undefined,
|
||||
});
|
||||
|
||||
const value = useSyncExternalStore(
|
||||
// useSyncExternalStore.subscribe
|
||||
useCallback(
|
||||
(onStoreChange) => {
|
||||
const onChange = (localKey: string): void => {
|
||||
if (key === localKey) {
|
||||
onStoreChange()
|
||||
}
|
||||
}
|
||||
callbacks.add(onChange)
|
||||
return (): void => {
|
||||
callbacks.delete(onChange)
|
||||
}
|
||||
},
|
||||
[key],
|
||||
),
|
||||
const value = useSyncExternalStore(
|
||||
// useSyncExternalStore.subscribe
|
||||
useCallback(
|
||||
onStoreChange => {
|
||||
const onChange = (localKey: string): void => {
|
||||
if (key === localKey) {
|
||||
onStoreChange();
|
||||
}
|
||||
};
|
||||
callbacks.add(onChange);
|
||||
return (): void => {
|
||||
callbacks.delete(onChange);
|
||||
};
|
||||
},
|
||||
[key]
|
||||
),
|
||||
|
||||
// useSyncExternalStore.getSnapshot
|
||||
() => {
|
||||
const string = goodTry(() => localStorage.getItem(key)) ?? null
|
||||
// useSyncExternalStore.getSnapshot
|
||||
() => {
|
||||
const string = goodTry(() => localStorage.getItem(key)) ?? null;
|
||||
|
||||
if (inMemoryData.has(key)) {
|
||||
storageItem.current.parsed = inMemoryData.get(key) as T | undefined
|
||||
} else if (string !== storageItem.current.string) {
|
||||
let parsed: T | undefined
|
||||
if (inMemoryData.has(key)) {
|
||||
storageItem.current.parsed = inMemoryData.get(key) as T | undefined;
|
||||
} else if (string !== storageItem.current.string) {
|
||||
let parsed: T | undefined;
|
||||
|
||||
try {
|
||||
parsed = string === null ? defaultValue : (parse(string) as T)
|
||||
} catch {
|
||||
parsed = defaultValue
|
||||
}
|
||||
|
||||
storageItem.current.parsed = parsed
|
||||
}
|
||||
|
||||
storageItem.current.string = string
|
||||
|
||||
// store default value in localStorage:
|
||||
// - initial issue: https://github.com/astoilkov/use-local-storage-state/issues/26
|
||||
// issues that were caused by incorrect initial and secondary implementations:
|
||||
// - https://github.com/astoilkov/use-local-storage-state/issues/30
|
||||
// - https://github.com/astoilkov/use-local-storage-state/issues/33
|
||||
if (defaultValue !== undefined && string === null) {
|
||||
// reasons for `localStorage` to throw an error:
|
||||
// - maximum quota is exceeded
|
||||
// - under Mobile Safari (since iOS 5) when the user enters private mode
|
||||
// `localStorage.setItem()` will throw
|
||||
// - trying to access localStorage object when cookies are disabled in Safari throws
|
||||
// "SecurityError: The operation is insecure."
|
||||
// eslint-disable-next-line no-console
|
||||
goodTry(() => {
|
||||
const string = stringify(defaultValue)
|
||||
localStorage.setItem(key, string)
|
||||
storageItem.current = { string, parsed: defaultValue }
|
||||
})
|
||||
}
|
||||
|
||||
return storageItem.current.parsed
|
||||
},
|
||||
|
||||
// useSyncExternalStore.getServerSnapshot
|
||||
() => defaultValue,
|
||||
)
|
||||
const setState = useCallback(
|
||||
(newValue: SetStateAction<T | undefined>): void => {
|
||||
const value =
|
||||
newValue instanceof Function ? newValue(storageItem.current.parsed) : newValue
|
||||
|
||||
// reasons for `localStorage` to throw an error:
|
||||
// - maximum quota is exceeded
|
||||
// - under Mobile Safari (since iOS 5) when the user enters private mode
|
||||
// `localStorage.setItem()` will throw
|
||||
// - trying to access `localStorage` object when cookies are disabled in Safari throws
|
||||
// "SecurityError: The operation is insecure."
|
||||
try {
|
||||
localStorage.setItem(key, stringify(value))
|
||||
|
||||
inMemoryData.delete(key)
|
||||
} catch {
|
||||
inMemoryData.set(key, value)
|
||||
}
|
||||
|
||||
triggerCallbacks(key)
|
||||
},
|
||||
[key, stringify],
|
||||
)
|
||||
|
||||
// - syncs change across tabs, windows, iframes
|
||||
// - the `storage` event is called only in all tabs, windows, iframe's except the one that
|
||||
// triggered the change
|
||||
useEffect(() => {
|
||||
if (!storageSync) {
|
||||
return undefined
|
||||
try {
|
||||
parsed = string === null ? defaultValue : (parse(string) as T);
|
||||
} catch {
|
||||
parsed = defaultValue;
|
||||
}
|
||||
|
||||
const onStorage = (e: StorageEvent): void => {
|
||||
if (e.key === key && e.storageArea === goodTry(() => localStorage)) {
|
||||
triggerCallbacks(key)
|
||||
}
|
||||
}
|
||||
storageItem.current.parsed = parsed;
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
storageItem.current.string = string;
|
||||
|
||||
return (): void => window.removeEventListener('storage', onStorage)
|
||||
}, [key, storageSync])
|
||||
// store default value in localStorage:
|
||||
// - initial issue: https://github.com/astoilkov/use-local-storage-state/issues/26
|
||||
// issues that were caused by incorrect initial and secondary implementations:
|
||||
// - https://github.com/astoilkov/use-local-storage-state/issues/30
|
||||
// - https://github.com/astoilkov/use-local-storage-state/issues/33
|
||||
if (defaultValue !== undefined && string === null) {
|
||||
// reasons for `localStorage` to throw an error:
|
||||
// - maximum quota is exceeded
|
||||
// - under Mobile Safari (since iOS 5) when the user enters private mode
|
||||
// `localStorage.setItem()` will throw
|
||||
// - trying to access localStorage object when cookies are disabled in Safari throws
|
||||
// "SecurityError: The operation is insecure."
|
||||
// eslint-disable-next-line no-console
|
||||
goodTry(() => {
|
||||
const string = stringify(defaultValue);
|
||||
localStorage.setItem(key, string);
|
||||
storageItem.current = { string, parsed: defaultValue };
|
||||
});
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
value,
|
||||
setState,
|
||||
{
|
||||
isPersistent: value === defaultValue || !inMemoryData.has(key),
|
||||
removeItem(): void {
|
||||
goodTry(() => localStorage.removeItem(key))
|
||||
return storageItem.current.parsed;
|
||||
},
|
||||
|
||||
inMemoryData.delete(key)
|
||||
// useSyncExternalStore.getServerSnapshot
|
||||
() => defaultValue
|
||||
);
|
||||
const setState = useCallback(
|
||||
(newValue: SetStateAction<T | undefined>): void => {
|
||||
const value = newValue instanceof Function ? newValue(storageItem.current.parsed) : newValue;
|
||||
|
||||
triggerCallbacks(key)
|
||||
},
|
||||
},
|
||||
],
|
||||
[key, setState, value, defaultValue],
|
||||
)
|
||||
// reasons for `localStorage` to throw an error:
|
||||
// - maximum quota is exceeded
|
||||
// - under Mobile Safari (since iOS 5) when the user enters private mode
|
||||
// `localStorage.setItem()` will throw
|
||||
// - trying to access `localStorage` object when cookies are disabled in Safari throws
|
||||
// "SecurityError: The operation is insecure."
|
||||
try {
|
||||
localStorage.setItem(key, stringify(value));
|
||||
|
||||
inMemoryData.delete(key);
|
||||
} catch {
|
||||
inMemoryData.set(key, value);
|
||||
}
|
||||
|
||||
triggerCallbacks(key);
|
||||
},
|
||||
[key, stringify]
|
||||
);
|
||||
|
||||
// - syncs change across tabs, windows, iframes
|
||||
// - the `storage` event is called only in all tabs, windows, iframe's except the one that
|
||||
// triggered the change
|
||||
useEffect(() => {
|
||||
if (!storageSync) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onStorage = (e: StorageEvent): void => {
|
||||
if (e.key === key && e.storageArea === goodTry(() => localStorage)) {
|
||||
triggerCallbacks(key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return (): void => window.removeEventListener('storage', onStorage);
|
||||
}, [key, storageSync]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
value,
|
||||
setState,
|
||||
{
|
||||
isPersistent: value === defaultValue || !inMemoryData.has(key),
|
||||
removeItem(): void {
|
||||
goodTry(() => localStorage.removeItem(key));
|
||||
|
||||
inMemoryData.delete(key);
|
||||
|
||||
triggerCallbacks(key);
|
||||
},
|
||||
},
|
||||
],
|
||||
[key, setState, value, defaultValue]
|
||||
);
|
||||
}
|
||||
|
||||
// notifies all instances using the same `key` to update
|
||||
const callbacks = new Set<(key: string) => void>()
|
||||
const callbacks = new Set<(key: string) => void>();
|
||||
function triggerCallbacks(key: string): void {
|
||||
for (const callback of [...callbacks]) {
|
||||
callback(key)
|
||||
}
|
||||
for (const callback of [...callbacks]) {
|
||||
callback(key);
|
||||
}
|
||||
}
|
||||
|
||||
// a wrapper for `JSON.parse()` that supports "undefined" value. otherwise,
|
||||
// `JSON.parse(JSON.stringify(undefined))` returns the string "undefined" not the value `undefined`
|
||||
function parseJSON(value: string): unknown {
|
||||
return value === 'undefined' ? undefined : JSON.parse(value)
|
||||
return value === 'undefined' ? undefined : JSON.parse(value);
|
||||
}
|
||||
|
||||
function goodTry<T>(tryFn: () => T): T | undefined {
|
||||
try {
|
||||
return tryFn()
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
return tryFn();
|
||||
} catch {}
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ const mergeBlobs = (
|
||||
};
|
||||
|
||||
export const useServerInfo = () => {
|
||||
const servers = useUserServers();
|
||||
const { servers } = useUserServers();
|
||||
const { user, signEventTemplate } = useNDK();
|
||||
const [features, setFeatures] = useState<SupportedFeatures>({});
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNDK } from '../utils/ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { USER_BLOSSOM_SERVER_LIST_KIND } from 'blossom-client-sdk';
|
||||
import useEvent from './useEvent';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { Nip96ServerConfig, fetchNip96ServerConfig } from './nip96';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type ServerType = 'blossom' | 'nip96';
|
||||
|
||||
@ -17,12 +18,40 @@ export type Server = {
|
||||
nip96?: Nip96ServerConfig;
|
||||
};
|
||||
|
||||
const USER_NIP96_SERVER_LIST_KIND = 10096;
|
||||
export const USER_NIP96_SERVER_LIST_KIND = 10096;
|
||||
|
||||
export const useUserServers = (): Server[] => {
|
||||
const { user } = useNDK();
|
||||
export const useUserServers = (): {
|
||||
servers: Server[];
|
||||
storeUserServers: (newServers: Server[]) => Promise<void>;
|
||||
} => {
|
||||
const { user, ndk } = useNDK();
|
||||
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
|
||||
|
||||
const storeUserServers = async (newServers: Server[]) => {
|
||||
if (!pubkey) return;
|
||||
const ev = new NDKEvent(ndk, {
|
||||
kind: USER_BLOSSOM_SERVER_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: '',
|
||||
pubkey,
|
||||
tags: newServers.filter(s => s.type == 'blossom').map(s => ['server', `${s.url}`]),
|
||||
});
|
||||
await ev.sign();
|
||||
console.log(ev.rawEvent());
|
||||
await ev.publish();
|
||||
|
||||
const evNip96 = new NDKEvent(ndk, {
|
||||
kind: USER_NIP96_SERVER_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: '',
|
||||
pubkey,
|
||||
tags: newServers.filter(s => s.type == 'nip96').map(s => ['server', `${s.url}`]),
|
||||
});
|
||||
await evNip96.sign();
|
||||
console.log(evNip96.rawEvent());
|
||||
await evNip96.publish();
|
||||
};
|
||||
|
||||
const blossomServerListEvent = useEvent(
|
||||
{ kinds: [USER_BLOSSOM_SERVER_LIST_KIND as NDKKind], authors: [pubkey!] },
|
||||
{ disable: !pubkey }
|
||||
@ -46,16 +75,17 @@ export const useUserServers = (): Server[] => {
|
||||
}, [blossomServerListEvent]);
|
||||
|
||||
const nip96Servers = useMemo((): Server[] => {
|
||||
if (!user) return [];
|
||||
return [
|
||||
/*...(nip96ServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => {
|
||||
const url = s.toLocaleLowerCase().replace(/\/$/, '');
|
||||
...(nip96ServerListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s => {
|
||||
const url = s.toLocaleLowerCase().replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
url,
|
||||
name: url.replace(/https?:\/\//, ''),
|
||||
type: 'nip96' as ServerType,
|
||||
};
|
||||
}),*/ {
|
||||
return {
|
||||
url,
|
||||
name: url.replace(/https?:\/\//, ''),
|
||||
type: 'nip96' as ServerType,
|
||||
};
|
||||
}) /* {
|
||||
url: 'https://nostrcheck.me',
|
||||
name: 'nostrcheck.me',
|
||||
type: 'nip96' as ServerType,
|
||||
@ -66,6 +96,7 @@ export const useUserServers = (): Server[] => {
|
||||
type: 'nip96' as ServerType,
|
||||
message: 'nostr.build does currently not support listing files',
|
||||
},
|
||||
*/,
|
||||
];
|
||||
}, [nip96ServerListEvent]);
|
||||
|
||||
@ -83,5 +114,5 @@ export const useUserServers = (): Server[] => {
|
||||
];
|
||||
}, [blossomServers, nip96Servers, nip96InfoQueries]);
|
||||
|
||||
return servers;
|
||||
return { servers, storeUserServers };
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user