= ({ isOpen, onClose, onSa
value={newServer}
onChange={e => setNewServer(e.target.value)}
/>
+
+
+
+
diff --git a/src/components/UploadOboarding.tsx b/src/components/UploadOboarding.tsx
new file mode 100644
index 0000000..df999aa
--- /dev/null
+++ b/src/components/UploadOboarding.tsx
@@ -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 (
+
+
You don't have any servers yet
+
Please choose some of the following options...
+
+
+ {defaultServers.map((server, index) => (
+
handleCheckboxChange(index)}
+ >
+
+ handleCheckboxChange(index)}
+ />
+
+
+
+
+ {server.name}
+
+ {server.type}
+
+
+ {server.description}{' '}
+ {server.buyUrl && (
+
+ Buy Storage
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index a36e24f..050688f 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -71,25 +71,21 @@ function Home() {
withVirtualServers={true}
>
- {selectedServer &&
- serverInfo[selectedServer] &&
- selectedServerBlobs &&
- selectedServerBlobs.length > 0 &&
- (
-
{
- for (const blob of blobs) {
- await deleteBlob.mutateAsync({
- server: serverInfo[selectedServer],
- hash: blob.sha256,
- });
- }
- }}
- >
- )}
+ {selectedServer && serverInfo[selectedServer] && selectedServerBlobs && selectedServerBlobs.length > 0 && (
+
{
+ for (const blob of blobs) {
+ await deleteBlob.mutateAsync({
+ server: serverInfo[selectedServer],
+ hash: blob.sha256,
+ });
+ }
+ }}
+ >
+ )}
);
}
diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx
index d5df90d..0c66fed 100644
--- a/src/pages/Upload.tsx
+++ b/src/pages/Upload.tsx
@@ -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
= {
publishedThumbnail: selfHostedThumbnail.url,
thumbnails: [selfHostedThumbnail.url],
- };
+ };
const publishedEvent = await publishVideoEvent({ ...fe, ...newData });
setFileEventsToPublish(prev =>
prev.map(f =>
@@ -391,75 +392,81 @@ function Upload() {
return (
-
- - = 0 ? 'step-primary' : ''}`}>Choose files
- - = 1 ? 'step-primary' : ''}`}>Upload
- - = 2 ? 'step-primary' : ''}`}>Add metadata
- - = 3 ? 'step-primary' : ''}`}>Publish to NOSTR
-
- {uploadStep <= 1 && (
-
- {uploadStep == 0 && (
-
- )}
+ {!servers || servers.length == 0 ? (
+
+ ) : (
+ <>
+
+ - = 0 ? 'step-primary' : ''}`}>Choose files
+ - = 1 ? 'step-primary' : ''}`}>Upload
+ - = 2 ? 'step-primary' : ''}`}>Add metadata
+ - = 3 ? 'step-primary' : ''}`}>Publish to NOSTR
+
+ {uploadStep <= 1 && (
+
+ {uploadStep == 0 && (
+
+ )}
- {uploadStep == 1 && }
-
- )}
- {uploadStep == 2 && fileEventsToPublish.length > 0 && (
-
-
Publish events
-
- {fileEventsToPublish.map(fe => (
-
- setFileEventsToPublish(prev => prev.map(f => (f.x === fe.x ? updatedFe : f)) as FileEventData[])
- }
- />
- ))}
-
- {audioCount > 0 && (
-
-
- Audio events are not widely supported yet. Currently they are only used by{' '}
-
- stemstr.app
-
+ {uploadStep == 1 &&
}
)}
-
-
- {publishCount > 0 && (
-
- )}
-
-
+ {uploadStep == 2 && fileEventsToPublish.length > 0 && (
+
+
Publish events
+
+ {fileEventsToPublish.map(fe => (
+
+ setFileEventsToPublish(prev => prev.map(f => (f.x === fe.x ? updatedFe : f)) as FileEventData[])
+ }
+ />
+ ))}
+
+ {audioCount > 0 && (
+
+
+ Audio events are not widely supported yet. Currently they are only used by{' '}
+
+ stemstr.app
+
+
+ )}
+
+
+ {publishCount > 0 && (
+
+ )}
+
+
+ )}
+ {uploadStep == 3 &&
}
+ >
)}
- {uploadStep == 3 &&
}
);
}
diff --git a/src/utils/blossom.ts b/src/utils/blossom.ts
index 4dcc0c6..3cee802 100644
--- a/src/utils/blossom.ts
+++ b/src/utils/blossom.ts
@@ -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 = [];
diff --git a/src/utils/useLocalStorageState.ts b/src/utils/useLocalStorageState.ts
index 03e4bf7..85c1479 100644
--- a/src/utils/useLocalStorageState.ts
+++ b/src/utils/useLocalStorageState.ts
@@ -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
()
+export const inMemoryData = new Map();
export type LocalStorageOptions = {
- 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,
- Dispatch>,
- {
- isPersistent: boolean
- removeItem: () => void
- },
-]
+ T,
+ Dispatch>,
+ {
+ isPersistent: boolean;
+ removeItem: () => void;
+ },
+];
export default function useLocalStorageState(
- key: string,
- options?: LocalStorageOptions,
-): LocalStorageState
+ key: string,
+ options?: LocalStorageOptions
+): LocalStorageState;
export default function useLocalStorageState(
- key: string,
- options?: Omit, 'defaultValue'>,
-): LocalStorageState
-export default function useLocalStorageState(
- key: string,
- options?: LocalStorageOptions,
-): LocalStorageState
+ key: string,
+ options?: Omit, 'defaultValue'>
+): LocalStorageState;
+export default function useLocalStorageState(key: string, options?: LocalStorageOptions): LocalStorageState;
export default function useLocalStorageState(
- key: string,
- options?: LocalStorageOptions,
+ key: string,
+ options?: LocalStorageOptions
): LocalStorageState {
- 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(
- 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 {
- // 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): 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): 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(tryFn: () => T): T | undefined {
- try {
- return tryFn()
- } catch {}
-}
\ No newline at end of file
+ try {
+ return tryFn();
+ } catch {}
+}
diff --git a/src/utils/useServerInfo.ts b/src/utils/useServerInfo.ts
index 5a8a3d2..43b8b5a 100644
--- a/src/utils/useServerInfo.ts
+++ b/src/utils/useServerInfo.ts
@@ -42,7 +42,7 @@ const mergeBlobs = (
};
export const useServerInfo = () => {
- const servers = useUserServers();
+ const { servers } = useUserServers();
const { user, signEventTemplate } = useNDK();
const [features, setFeatures] = useState({});
diff --git a/src/utils/useUserServers.ts b/src/utils/useUserServers.ts
index 1563184..b45cc14 100644
--- a/src/utils/useUserServers.ts
+++ b/src/utils/useUserServers.ts
@@ -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;
+} => {
+ 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 };
};