chore: Initial commit

This commit is contained in:
florian 2024-03-26 23:58:11 +01:00
commit 3a8ba07903
32 changed files with 8581 additions and 0 deletions

18
.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"rangeStart": 0,
"rangeEnd": 9007199254740991,
"requirePragma": false,
"insertPragma": false,
"proseWrap": "preserve"
}

30
README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/bouquet.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bouquet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7338
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "blob-manager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier -w src/",
"analyze": "vite-bundle-visualizer"
},
"dependencies": {
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.3",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "^2.5.1",
"@nostr-dev-kit/ndk-cache-dexie": "^2.2.8",
"@tanstack/react-query": "^5.28.6",
"@tanstack/react-query-devtools": "^5.28.6",
"blossom-client-sdk": "^0.4.0",
"dayjs": "^1.11.10",
"nostr-tools": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.28.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-bundle-visualizer": "^1.1.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/bouquet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

7
src/App.css Normal file
View File

@ -0,0 +1,7 @@
body h2 {
@apply text-2xl text-white py-4;
}
body h2 svg {
@apply w-8 inline align-text-bottom;
}

116
src/App.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from 'react';
import './App.css';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
import { useNDK } from './ndk';
import BlobList from './components/BlobList/BlobList';
import { useServerInfo } from './utils/useServerInfo';
import { ServerList } from './components/ServerList/ServerList';
import { Layout } from './components/Layout/Layout';
import { Transfer } from './components/Transfer/Transfer';
/* BOUQUET Blob Organizer Update Quality Use Enhancement Tool */
// TODOs
/*
- multi threaded sync
- upload to single/multi servers
- upload exif data removal
- upload image resize
- upload & publish as file event to nostr
- thumbnail gallery
- check blobs (download & sha256 sum check), maybe limit max size
*/
function App() {
const { loginWithExtension, signEventTemplate } = useNDK();
const [selectedServer, setSelectedServer] = useState<string | undefined>();
const [transferSource, setTransferSource] = useState<string | undefined>();
const serverInfo = useServerInfo();
useEffect(() => {
loginWithExtension();
}, []);
/*,
combine: (results) => {
const dict: BlobDictionary = {};
results.forEach((server) =>
server.data && server.data.forEach((blob: BlobDescriptor) => {
if (dict[blob.sha256]) {
dict[blob.sha256].urls.push(blob.url);
} else {
dict[blob.sha256] = {
...blob,
urls: [blob.url],
};
}
})
);
return {
data: dict,
};
},*/
const queryClient = useQueryClient();
const deleteBlob = useMutation({
mutationFn: async ({ serverUrl, hash }: { serverName: string; serverUrl: string; hash: string }) => {
const deleteAuth = await BlossomClient.getDeleteAuth(hash, signEventTemplate, 'Delete Blob');
return BlossomClient.deleteBlob(serverUrl, hash, deleteAuth);
},
onSuccess(_, variables) {
queryClient.setQueryData(['blobs', variables.serverName], (oldData: BlobDescriptor[]) =>
oldData ? oldData.filter(b => b.sha256 !== variables.hash) : oldData
);
console.log({ key: ['blobs', variables.serverName] });
},
});
const selectedServerBlobs = useMemo(
() =>
selectedServer != undefined
? serverInfo[selectedServer].blobs?.sort(
(a, b) => (a.created > b.created ? -1 : 1) // descending
)
: undefined,
[serverInfo, selectedServer]
);
return (
<Layout>
{transferSource ? (
<Transfer transferSource={transferSource} onCancel={() => setTransferSource(undefined)} />
) : (
<>
<h2>Servers</h2>
<ServerList
servers={Object.values(serverInfo).sort()}
selectedServer={selectedServer}
setSelectedServer={setSelectedServer}
onTransfer={server => setTransferSource(server)}
></ServerList>
{selectedServer && serverInfo[selectedServer] && selectedServerBlobs && (
<>
<h2>Your objects on {serverInfo[selectedServer].name}</h2>
<BlobList
blobs={selectedServerBlobs}
onDelete={blob =>
deleteBlob.mutate({
serverName: serverInfo[selectedServer].name,
serverUrl: serverInfo[selectedServer].url,
hash: blob.sha256,
})
}
></BlobList>
</>
)}
</>
)}
</Layout>
);
}
export default App;

View File

@ -0,0 +1,24 @@
.blob-list svg {
@apply w-5 inline align-text-bottom mr-1;
}
.blob-list a {
@apply text-pink-500 hover:text-white;
}
.blob-list {
@apply bg-neutral-800 p-4 text-neutral-300 rounded-lg;
}
.blob-list .blob {
@apply p-1 hover:bg-neutral-700 rounded-md grid pr-4;
grid-template-columns: 2em auto 6em 10em 7em 1em;
}
.blob-list .blob span {
@apply overflow-ellipsis overflow-hidden text-nowrap;
}
.blob-list .blob a {
@apply cursor-pointer;
}

View File

@ -0,0 +1,39 @@
import { DocumentIcon, TrashIcon } from '@heroicons/react/24/outline';
import { BlobDescriptor } from 'blossom-client-sdk';
import { formatDate, formatFileSize } from '../../utils';
import './BlobList.css';
type BlobListProps = {
blobs: BlobDescriptor[];
onDelete?: (blob: BlobDescriptor) => void;
};
const BlobList = ({ blobs, onDelete }: BlobListProps) => {
return (
<div className="blob-list">
{blobs.map((blob: BlobDescriptor) => (
<div className="blob" key={blob.sha256}>
<span>
<DocumentIcon />
</span>
<span>
<a href={blob.url} target="_blank">
{blob.sha256}
</a>
</span>
<span>{formatFileSize(blob.size)}</span>
<span>{blob.type && `${blob.type}`}</span>
<span>{formatDate(blob.created)}</span>
{onDelete && (
<span>
<a onClick={() => onDelete(blob)}>
<TrashIcon />
</a>
</span>
)}
</div>
))}
</div>
);
};
export default BlobList;

View File

@ -0,0 +1,31 @@
.main {
@apply flex flex-col justify-center;
}
.content {
@apply flex flex-col self-center sm:w-4/6 w-full;
}
.title {
@apply text-white text-4xl flex flex-row items-center gap-2 p-4 w-4/6 self-center;
}
.title img {
@apply w-10;
}
.title span {
@apply flex-grow;
}
.avatar {
@apply flex-shrink;
}
.avatar img {
@apply w-10 h-10 rounded-full;
}
.footer {
@apply self-center text-neutral-600 pt-12 pb-6;
}

View File

@ -0,0 +1,22 @@
import { useNDK } from '../../ndk';
import './Layout.css';
export const Layout = ({ children }: { children: React.ReactElement }) => {
const { user } = useNDK();
return (
<div className="main">
<div className="title">
<img src="/bouquet.png" /> <span>bouquet</span>
<div className="avatar">
<img src={user?.profile?.image} />
</div>
</div>
<div className="content">{children}</div>
<div className="footer">
made with 💜 by{' '}
<a href="https://njump.me/npub1klr0dy2ul2dx9llk58czvpx73rprcmrvd5dc7ck8esg8f8es06qs427gxc">florian</a>
</div>
</div>
);
};

View File

@ -0,0 +1,51 @@
.server-list {
@apply flex flex-col gap-4;
}
.server {
@apply bg-neutral-800 text-neutral-300 rounded-lg p-4 gap-4 cursor-pointer hover:bg-neutral-700 flex flex-row items-center;
}
.server.selected {
@apply bg-pink-700 cursor-default;
}
.server-name {
@apply text-2xl mb-2 text-white;
}
.server-stats {
@apply flex flex-row gap-8;
}
.server-stat svg {
@apply w-5 inline align-text-bottom;
}
.server-icon {
@apply shrink-0;
}
.server-icon svg,
.server-actions svg {
@apply w-10;
}
.server-actions a {
@apply cursor-pointer text-center flex flex-col items-center hover:text-white opacity-80 hover:opacity-100;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading {
@apply w-6 ml-2 inline align-text-bottom;
transform-origin: center;
animation: spin 3s linear infinite;
}

View File

@ -0,0 +1,72 @@
import {
ArrowPathIcon,
ArrowUpOnSquareStackIcon,
ClockIcon,
CubeIcon,
DocumentDuplicateIcon,
ServerIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { formatDate, formatFileSize } from '../../utils';
import { useServerInfo } from '../../utils/useServerInfo';
import './ServerList.css';
import { Server } from '../../utils/useServers';
type ServerListProps = {
servers: Server[];
selectedServer?: string | undefined;
setSelectedServer?: React.Dispatch<React.SetStateAction<string | undefined>>;
onTransfer?: (server: string) => void;
onCancel?: () => void;
};
export const ServerList = ({ servers, selectedServer, setSelectedServer, onTransfer, onCancel }: ServerListProps) => {
const serverInfo = useServerInfo();
//
return (
<div className="server-list">
{servers.map((server, sx) => (
<div
className={`server ${selectedServer == server.name ? 'selected' : ''}`}
key={sx}
onClick={() => setSelectedServer && setSelectedServer(server.name)}
>
<div className="server-icon">
<ServerIcon />
</div>
<div className="flex flex-col grow">
<div className="server-name">
{server.name}
{serverInfo[server.name].isLoading && <ArrowPathIcon className="loading" />}
</div>
<div className="server-stats">
<div className="server-stat">
<DocumentDuplicateIcon /> {serverInfo[server.name].count}
</div>
<div className="server-stat">
<CubeIcon /> {formatFileSize(serverInfo[server.name].size)}
</div>
<div className="server-stat">
<ClockIcon /> {formatDate(serverInfo[server.name].lastChange)}
</div>
</div>
</div>
{((selectedServer == server.name && onTransfer) || onCancel) && (
<div className="server-actions">
{selectedServer == server.name && onTransfer && (
<a onClick={() => onTransfer(server.name)}>
<ArrowUpOnSquareStackIcon /> Transfer
</a>
)}
{onCancel && (
<a onClick={() => onCancel()}>
<XMarkIcon />
</a>
)}
</div>
)}
</div>
))}
</div>
);
};

View File

@ -0,0 +1,54 @@
.message {
@apply text-3xl text-center text-white p-12;
}
.message svg {
@apply w-8 inline;
}
.action-button {
@apply bg-white text-lg text-black hover:bg-pink-600 hover:text-white inline-block rounded-lg p-2 pr-4 pl-3;
}
.action-button svg {
@apply w-5 inline align-text-bottom;
}
.error-log {
@apply bg-neutral-800 p-4 text-neutral-300 rounded-lg;
}
.error-log svg {
@apply w-5 inline align-text-bottom mr-1;
}
.error-log div {
@apply p-1 hover:bg-neutral-700 rounded-md grid pr-4;
grid-template-columns: 2em auto 6em 10em 7em 1em;
}
.error-log div span {
@apply overflow-ellipsis overflow-hidden text-nowrap;
}
.blob-list svg {
@apply w-5 inline align-text-bottom mr-1;
}
.blob-list a {
@apply text-pink-500 hover:text-white;
}
.blob-list {
}
.blob-list .blob {
}
.blob-list .blob a {
@apply cursor-pointer;
}

View File

@ -0,0 +1,171 @@
import { ArrowDownOnSquareIcon, ArrowUpOnSquareIcon, CheckBadgeIcon, DocumentIcon } from '@heroicons/react/24/outline';
import { ServerList } from '../ServerList/ServerList';
import { useServerInfo } from '../../utils/useServerInfo';
import { useMemo, useState } from 'react';
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
import { useNDK } from '../../ndk';
import { useQueryClient } from '@tanstack/react-query';
import { formatFileSize } from '../../utils';
import BlobList from '../BlobList/BlobList';
import './Transfer.css';
type TransferStatus = {
[key: string]: {
sha256: string;
status: 'pending' | 'done' | 'error';
message?: string;
size: number;
};
};
export const Transfer = ({ transferSource, onCancel }: { transferSource: string; onCancel?: () => void }) => {
const serverInfo = useServerInfo();
const [transferTarget, setTransferTarget] = useState<string | undefined>();
const { signEventTemplate } = useNDK();
const queryClient = useQueryClient();
const [started, setStarted] = useState(false);
const [transferLog, setTransferLog] = useState<TransferStatus>({});
const closeTransferMode = () => {
queryClient.invalidateQueries({ queryKey: ['blobs', transferTarget] });
setTransferTarget(undefined);
setTransferLog({});
setStarted(false);
onCancel && onCancel();
};
const transferJobs = useMemo(() => {
if (transferSource && transferTarget) {
const sourceBlobs = serverInfo[transferSource].blobs;
const targetBlobs = serverInfo[transferTarget].blobs;
return sourceBlobs?.filter(src => targetBlobs?.find(tgt => tgt.sha256 == src.sha256) == undefined);
}
return [];
}, [serverInfo, transferSource, transferTarget]);
// https://github.com/sindresorhus/p-limit
//
const performTransfer = async (sourceServer: string, targetServer: string, blobs: BlobDescriptor[]) => {
setTransferLog({});
setStarted(true);
for (const b of blobs) {
try {
// BlossomClient.getGetAuth()
setTransferLog(ts => ({ ...ts, [b.sha256]: { sha256: b.sha256, status: 'pending', size: b.size } }));
const data = await BlossomClient.getBlob(serverInfo[sourceServer].url, b.sha256);
const file = new File([data], b.sha256, { type: b.type, lastModified: b.created });
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
await BlossomClient.uploadBlob(serverInfo[targetServer].url, file, uploadAuth);
setTransferLog(ts => ({ ...ts, [b.sha256]: { sha256: b.sha256, status: 'done', size: b.size } }));
} catch (e) {
setTransferLog(ts => ({
...ts,
[b.sha256]: { sha256: b.sha256, status: 'error', message: (e as Error).message, size: blobs.length },
}));
console.warn(e);
}
}
// if (Object.values(transferLog).filter(b => b.status == 'error').length == 0) {
// closeTransferMode();
// }
};
const transferStatus = useMemo(() => {
const stats = Object.values(transferLog).reduce(
(acc, t) => {
if (t.status === 'done') {
acc.done += 1;
} else if (t.status === 'error') {
acc.error += 1;
} else {
acc.pending += 1;
}
acc.size += t.size;
return acc;
},
{ pending: 0, done: 0, error: 0, size: 0 }
);
return { ...stats, fullSize: transferJobs?.reduce((acc, b) => acc + b.size, 0) || 0 };
}, [transferLog, transferJobs]);
return (
<>
<h2>
<ArrowUpOnSquareIcon /> Transfer Source
</h2>
<ServerList
servers={Object.values(serverInfo).filter(s => s.name == transferSource)}
onCancel={() => closeTransferMode()}
></ServerList>
<h2>
<ArrowDownOnSquareIcon /> Transfer Target
</h2>
<ServerList
servers={Object.values(serverInfo)
.filter(s => s.name != transferSource)
.sort()}
selectedServer={transferTarget}
setSelectedServer={setTransferTarget}
></ServerList>
{transferTarget && transferJobs && transferJobs.length > 0 ? (
<>
<div className="message">
{transferJobs.length} object{transferJobs.length > 1 ? 's' : ''} to transfer{' '}
{!started && (
<button
className="action-button"
onClick={() => performTransfer(transferSource, transferTarget, transferJobs)}
>
<ArrowUpOnSquareIcon />
Start
</button>
)}
</div>
<div>
<div className="w-full bg-gray-200 rounded-lg dark:bg-neutral-800">
<div
className="bg-pink-600 text-sm font-medium text-pink-100 text-center p-1 leading-none rounded-lg"
style={{ width: `${Math.floor((transferStatus.size * 100) / transferStatus.fullSize)}%` }}
>
{Math.floor((transferStatus.size * 100) / transferStatus.fullSize)}&nbsp;%
</div>
</div>
{
<div className="message">
{formatFileSize(transferStatus.size)} / {formatFileSize(transferStatus.fullSize)} transfered.
</div>
}
<div className="error-log">
{Object.values(transferLog)
.filter(b => b.status == 'error')
.map(t => (
<div>
<span>
<DocumentIcon />
</span>
<span>{t.sha256}</span>
<span>{formatFileSize(t.size)}</span>
<span>{t.status && `${t.status}`}</span>
<span>{t.message}</span>
</div>
))}
</div>
</div>
{!started && <BlobList blobs={transferJobs}></BlobList>}
</>
) : (
<div className="message">
{transferTarget ? (
<>
<CheckBadgeIcon /> no missing objects to transfer
</>
) : (
<>choose a transfer target above</>
)}
</div>
)}
</>
);
};

47
src/exif.ts Normal file
View File

@ -0,0 +1,47 @@
/* Source: https://stackoverflow.com/a/77472484/47324 */
const cleanBuffer = (arrayBuffer: ArrayBuffer) => {
let dataView = new DataView(arrayBuffer);
const exifMarker = 0xffe1;
let offset = 2; // Skip the first two bytes (0xFFD8)
while (offset < dataView.byteLength) {
if (dataView.getUint16(offset) === exifMarker) {
// Found an EXIF marker
const segmentLength = dataView.getUint16(offset + 2, false) + 2;
// Update the arrayBuffer and dataView
arrayBuffer = removeSegment(arrayBuffer, offset, segmentLength);
dataView = new DataView(arrayBuffer);
} else {
// Move to the next marker
offset += 2 + dataView.getUint16(offset + 2, false);
}
}
return arrayBuffer;
};
const removeSegment = (buffer: ArrayBuffer, offset: number, length: number) => {
// Create a new buffer without the specified segment
const modifiedBuffer = new Uint8Array(buffer.byteLength - length);
modifiedBuffer.set(new Uint8Array(buffer.slice(0, offset)), 0);
modifiedBuffer.set(new Uint8Array(buffer.slice(offset + length)), offset);
return modifiedBuffer.buffer;
};
export const removeExifData = (file: File): Promise<File> => {
return new Promise(resolve => {
if (file && file.type.startsWith('image/')) {
const fr = new FileReader();
fr.onload = function (this: FileReader) {
const cleanedBuffer = cleanBuffer(this.result as ArrayBuffer);
const blob = new Blob([cleanedBuffer], { type: file.type });
const newFile = new File([blob], file.name, { type: file.type });
resolve(newFile);
};
fr.readAsArrayBuffer(file);
} else resolve(file);
});
};

7
src/index.css Normal file
View File

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-neutral-900 text-slate-500 box-border;
}

20
src/main.tsx Normal file
View File

@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { NDKContextProvider } from './ndk.tsx';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<NDKContextProvider>
<App />
</NDKContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);

184
src/ndk.tsx Normal file
View File

@ -0,0 +1,184 @@
import type { EventTemplate, SignedEvent } from 'blossom-client-sdk';
import NDK, { NDKEvent, NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner, NDKUser } from '@nostr-dev-kit/ndk';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { decrypt } from 'nostr-tools/nip49';
import { bytesToHex } from '@noble/hashes/utils';
import React, { createContext, useContext, useEffect, useState } from 'react';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
type NDKContextType = {
ndk: NDK;
user?: NDKUser;
logout: () => void;
loginWithExtension: () => Promise<void>;
loginWithNostrAddress: (connectionString: string) => Promise<void>;
loginWithPrivateKey: (key: string) => Promise<void>;
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>;
publishSignedEvent: (signedEvent: SignedEvent) => Promise<void>;
};
export const NDKContext = createContext<NDKContextType>({
ndk: new NDK({ explicitRelayUrls: [] }),
logout: () => {},
loginWithExtension: () => Promise.reject(),
loginWithNostrAddress: () => Promise.reject(),
loginWithPrivateKey: () => Promise.reject(),
signEventTemplate: () => Promise.reject(),
publishSignedEvent: () => Promise.reject(),
});
const cacheAdapter = new NDKCacheAdapterDexie({ dbName: 'ndk-cache' });
const ndk = new NDK({
explicitRelayUrls: ['wss://nostrue.com/', 'wss://relay.damus.io/', 'wss://nos.lol/'],
cacheAdapter,
});
export const NDKContextProvider = ({ children }: { children: React.ReactElement }) => {
const [user, setUser] = useState(ndk.activeUser);
const fetchUserData = async function () {
if (!ndk.signer) return;
console.log('Fetching user');
const user = await ndk.signer.user();
console.log('Fetching profile');
user.fetchProfile();
setUser(user);
};
const loginWithExtension = async function () {
const signer: NDKNip07Signer = new NDKNip07Signer();
console.log('Waiting for NIP-07 signer');
await signer.blockUntilReady();
await signer.user();
ndk.signer = signer;
await fetchUserData();
};
const loginWithNostrAddress = async function (connectionString: string) {
const localKey = localStorage.getItem('local-signer') || bytesToHex(generateSecretKey());
const localSigner = new NDKPrivateKeySigner(localKey);
let signer: NDKNip46Signer;
// manually set remote user and pubkey if using NIP05
if (connectionString.includes('@')) {
const user = await ndk.getUserFromNip05(connectionString);
if (!user?.pubkey) throw new Error('Cant find user');
console.log('Found user', user);
signer = new NDKNip46Signer(ndk, connectionString, localSigner);
signer.remoteUser = user;
signer.remotePubkey = user.pubkey;
} else if (connectionString.startsWith('bunker://')) {
const uri = new URL(connectionString);
const pubkey = uri.host || uri.pathname.replace('//', '');
const relays = uri.searchParams.getAll('relay');
for (const relay of relays) ndk.addExplicitRelay(relay);
if (relays.length === 0) throw new Error('Missing relays');
signer = new NDKNip46Signer(ndk, pubkey, localSigner);
signer.relayUrls = relays;
} else {
signer = new NDKNip46Signer(ndk, connectionString, localSigner);
}
signer.rpc.on('authUrl', (url: string) => {
window.open(url, '_blank');
});
await signer.blockUntilReady();
await signer.user();
ndk.signer = signer;
localStorage.setItem('local-signer', localSigner.privateKey ?? '');
await fetchUserData();
};
const loginWithPrivateKey = async function (key: string) {
if (key.startsWith('ncryptsec')) {
const password = prompt('Enter your private key password');
if (password === null) throw new Error('No password provided');
const plaintext = bytesToHex(decrypt(key, password));
console.log(plaintext);
ndk.signer = new NDKPrivateKeySigner(plaintext);
await ndk.signer.blockUntilReady();
localStorage.setItem('private-key', key);
} else if (key.startsWith('nsec')) {
const decoded = nip19.decode(key);
if (decoded.type !== 'nsec') throw new Error('Not nsec');
ndk.signer = new NDKPrivateKeySigner(bytesToHex(decoded.data));
await ndk.signer.blockUntilReady();
} else throw new Error('Unknown private format');
await fetchUserData();
};
const logout = function logout() {
localStorage.clear();
location.reload();
};
const signEventTemplate = async function signEventTemplate(template: EventTemplate): Promise<SignedEvent> {
console.log('signEventTemplate called');
const e = new NDKEvent(ndk);
e.kind = template.kind;
e.content = template.content;
e.tags = template.tags;
e.created_at = template.created_at;
await e.sign();
return e.rawEvent() as SignedEvent;
};
const publishSignedEvent = async function (signedEvent: SignedEvent) {
const e = new NDKEvent(ndk);
e.content = signedEvent.content;
e.tags = signedEvent.tags;
e.created_at = signedEvent.created_at;
e.kind = signedEvent.kind;
e.id = signedEvent.id;
e.pubkey = signedEvent.pubkey;
e.sig = signedEvent.sig;
await e.publish();
};
ndk.connect();
const performAutoLogin = async () => {
const autoLogin = localStorage.getItem('auto-login');
if (autoLogin) {
try {
if (autoLogin === 'nip07') {
await loginWithExtension().catch(() => {});
} else if (autoLogin === 'nsec') {
const key = localStorage.getItem('private-key');
if (key) await loginWithPrivateKey(key);
} else if (autoLogin.includes('@') || autoLogin.startsWith('bunker://') || autoLogin.includes('#')) {
await loginWithNostrAddress(autoLogin).catch(() => {});
}
} catch (e) {}
}
};
useEffect(() => {
performAutoLogin();
}, []);
const value = {
ndk,
user,
logout,
loginWithExtension,
loginWithNostrAddress,
loginWithPrivateKey,
signEventTemplate,
publishSignedEvent,
};
return <NDKContext.Provider value={value}>{children}</NDKContext.Provider>;
};
export const useNDK = () => useContext(NDKContext);

41
src/useEvent.ts Normal file
View File

@ -0,0 +1,41 @@
import {
NDKEvent,
NDKFilter,
NDKRelaySet,
NDKSubscriptionCacheUsage,
NDKSubscriptionOptions,
} from '@nostr-dev-kit/ndk';
import { useNDK } from './ndk';
import { useMemo } from 'react';
import { UseQueryResult, useQuery } from '@tanstack/react-query';
import { hashSha256 } from './utils';
export interface SubscriptionOptions extends NDKSubscriptionOptions {
disable?: boolean;
}
export default function useEvent(filter: NDKFilter, opts?: SubscriptionOptions, relays?: string[]) {
const { ndk } = useNDK();
const id = useMemo(() => {
return hashSha256(filter);
}, [filter]);
const query: UseQueryResult<NDKEvent, any> = useQuery({
queryKey: ['use-event', id],
queryFn: () => {
const relaySet = relays?.length ?? 0 > 0 ? NDKRelaySet.fromRelayUrls(relays as string[], ndk) : undefined;
return ndk.fetchEvent(
filter,
{
groupable: true,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
...(opts ? opts : {}),
},
relaySet
);
},
enabled: !opts?.disable,
});
return query.data;
}

46
src/utils.ts Normal file
View File

@ -0,0 +1,46 @@
import { sha256 } from '@noble/hashes/sha256';
import dayjs from 'dayjs';
export const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B';
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + ' KB';
} else if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(1) + ' MB';
} else {
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
}
};
interface MyObject {
[key: string]: any;
}
export function hashSha256(obj: MyObject): string {
const jsonString = JSON.stringify(obj);
const hashBuffer = sha256(new TextEncoder().encode(jsonString));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
return hashHex;
}
export const uniqAndSort = (values: string[]): string[] => {
return Array.from(new Set(values)).sort();
};
export const pr = (value: string | number, len: number) => {
return value.toString().padEnd(len);
};
export const pl = (value: string | number, len: number) => {
return `${value}`.padStart(len);
};
export const formatDate = (unixTimeStamp: number): string => {
const ts = unixTimeStamp > 1711200000000 ? unixTimeStamp / 1000 : unixTimeStamp;
if (ts == 0) return 'never';
return dayjs(ts * 1000).format('YYYY-MM-DD');
};

View File

@ -0,0 +1,62 @@
import { useMemo } from 'react';
import { useServers } from './useServers';
import { useQueries } from '@tanstack/react-query';
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
import { useNDK } from '../ndk';
import { nip19 } from 'nostr-tools';
export type ServerInfo = {
count: number;
size: number;
lastChange: number;
isLoading: boolean;
name: string;
url: string;
blobs?: BlobDescriptor[];
};
export const useServerInfo = () => {
const servers = useServers();
const { user, signEventTemplate } = useNDK();
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
const blobs = useQueries({
queries: servers.map(server => ({
queryKey: ['blobs', server.name],
queryFn: async () => {
const listAuthEvent = await BlossomClient.getListAuth(signEventTemplate, 'List Blobs');
return await BlossomClient.listBlobs(server.url, pubkey!, undefined, listAuthEvent);
},
enabled: !!pubkey && servers.length > 0,
staleTime: 1000 * 60 * 5,
})),
});
const serverInfo = useMemo(() => {
const info: { [key: string]: ServerInfo } = {};
servers.forEach((server, sx) => {
info[server.name] = {
...server,
blobs: blobs[sx].data,
isLoading: blobs[sx].isLoading,
count: blobs[sx].data?.length || 0,
size: blobs[sx].data?.reduce((acc, blob) => acc + blob.size, 0) || 0,
lastChange:
blobs[sx].data?.reduce(
(acc, blob) =>
Math.max(
acc,
blob.created > 1711200000000 // fix for wrong timestamps on media-server.slidestr.net (remove)
? blob.created / 1000
: blob.created
),
0
) || 0,
};
});
return info;
}, [servers, blobs]);
return serverInfo;
};

39
src/utils/useServers.ts Normal file
View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { uniqAndSort } from '../utils';
import { useNDK } from '../ndk';
import { nip19 } from 'nostr-tools';
import { NDKKind } from '@nostr-dev-kit/ndk';
import useEvent from '../useEvent';
const additionalServers = [
'https://media-server.slidestr.net',
//'https://cdn.hzrd149.com',
'https://cdn.satellite.earth',
];
export type Server = {
name: string;
url: string;
};
export const useServers = (): Server[] => {
const { user } = useNDK();
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
const serverListEvent = useEvent({ kinds: [10063 as NDKKind], authors: [pubkey!] }, { disable: !pubkey });
console.log(serverListEvent);
const servers = useMemo(() => {
const serverUrls = uniqAndSort(
[...(serverListEvent?.getMatchingTags('r').map(t => t[1]) || []), ...additionalServers].map(s =>
s.toLocaleLowerCase().replace(/\/$/, '')
)
);
return serverUrls.map(s => ({
name: s.replace(/https?:\/\//, ''),
url: s,
}));
}, [serverListEvent]);
return servers;
};

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})