60 changed files with 4262 additions and 584 deletions
@ -0,0 +1,32 @@ |
|||||||
|
module.exports = { |
||||||
|
root: true, |
||||||
|
extends: [ |
||||||
|
'eslint:recommended', |
||||||
|
'plugin:@typescript-eslint/recommended', |
||||||
|
'plugin:svelte/recommended', |
||||||
|
'prettier' |
||||||
|
], |
||||||
|
parser: '@typescript-eslint/parser', |
||||||
|
plugins: ['@typescript-eslint'], |
||||||
|
parserOptions: { |
||||||
|
sourceType: 'module', |
||||||
|
ecmaVersion: 2022 |
||||||
|
}, |
||||||
|
env: { |
||||||
|
browser: true, |
||||||
|
es2022: true, |
||||||
|
node: true |
||||||
|
}, |
||||||
|
overrides: [ |
||||||
|
{ |
||||||
|
files: ['*.svelte'], |
||||||
|
parser: 'svelte-eslint-parser', |
||||||
|
parserOptions: { |
||||||
|
parser: '@typescript-eslint/parser' |
||||||
|
} |
||||||
|
} |
||||||
|
], |
||||||
|
rules: { |
||||||
|
'@typescript-eslint/no-explicit-any': 'warn' |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
.DS_Store |
||||||
|
node_modules |
||||||
|
/build |
||||||
|
/.svelte-kit |
||||||
|
/package |
||||||
|
.env |
||||||
|
.env.* |
||||||
|
!.env.example |
||||||
|
vite.config.js.timestamp-* |
||||||
|
vite.config.ts.timestamp-* |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{ |
||||||
|
"useTabs": false, |
||||||
|
"tabWidth": 2, |
||||||
|
"semi": true, |
||||||
|
"singleQuote": true, |
||||||
|
"trailingComma": "none", |
||||||
|
"printWidth": 100, |
||||||
|
"plugins": ["prettier-plugin-svelte"], |
||||||
|
"overrides": [ |
||||||
|
{ |
||||||
|
"files": "*.svelte", |
||||||
|
"options": { |
||||||
|
"parser": "svelte" |
||||||
|
} |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
# Multi-stage build |
||||||
|
FROM node:20-alpine AS builder |
||||||
|
WORKDIR /app |
||||||
|
COPY package*.json ./ |
||||||
|
RUN npm ci |
||||||
|
COPY . . |
||||||
|
ARG VITE_DEFAULT_RELAYS |
||||||
|
ARG VITE_ZAP_THRESHOLD |
||||||
|
ARG VITE_THREAD_TIMEOUT_DAYS |
||||||
|
ARG VITE_PWA_ENABLED |
||||||
|
ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS} |
||||||
|
ENV VITE_ZAP_THRESHOLD=${VITE_ZAP_THRESHOLD} |
||||||
|
ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS} |
||||||
|
ENV VITE_PWA_ENABLED=${VITE_PWA_ENABLED} |
||||||
|
RUN npm run build |
||||||
|
|
||||||
|
FROM httpd:alpine |
||||||
|
COPY --from=builder /app/build /usr/local/apache2/htdocs/ |
||||||
|
COPY httpd.conf.template /usr/local/apache2/conf/httpd.conf.template |
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/ |
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh |
||||||
|
ARG PORT=9876 |
||||||
|
ENV PORT=${PORT} |
||||||
|
EXPOSE ${PORT} |
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
# Aitherboard Setup Guide |
||||||
|
|
||||||
|
## Prerequisites |
||||||
|
|
||||||
|
- Node.js 20+ |
||||||
|
- npm or yarn |
||||||
|
- Docker (for deployment) |
||||||
|
|
||||||
|
## Development Setup |
||||||
|
|
||||||
|
1. Install dependencies: |
||||||
|
```bash |
||||||
|
npm install |
||||||
|
``` |
||||||
|
|
||||||
|
2. Create `.env` file (optional, uses defaults if not provided): |
||||||
|
```bash |
||||||
|
cp .env.example .env |
||||||
|
``` |
||||||
|
|
||||||
|
3. Start development server: |
||||||
|
```bash |
||||||
|
npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
4. Open http://localhost:5173 |
||||||
|
|
||||||
|
## Building |
||||||
|
|
||||||
|
```bash |
||||||
|
npm run build |
||||||
|
``` |
||||||
|
|
||||||
|
## Docker Deployment |
||||||
|
|
||||||
|
1. Build and run with docker-compose: |
||||||
|
```bash |
||||||
|
docker-compose up --build |
||||||
|
``` |
||||||
|
|
||||||
|
2. Or build manually: |
||||||
|
```bash |
||||||
|
docker build -t aitherboard . |
||||||
|
docker run -p 9876:9876 aitherboard |
||||||
|
``` |
||||||
|
|
||||||
|
## Project Structure |
||||||
|
|
||||||
|
- `src/lib/services/` - Core services (Nostr, auth, cache, security) |
||||||
|
- `src/lib/modules/` - Feature modules (threads, comments, zaps, etc.) |
||||||
|
- `src/lib/components/` - Reusable UI components |
||||||
|
- `src/routes/` - SvelteKit routes |
||||||
|
|
||||||
|
## Notes |
||||||
|
|
||||||
|
- This is a work in progress. Many features are placeholders and need full implementation. |
||||||
|
- NIP-49 encryption, event signing, and bech32 encoding need proper cryptographic libraries. |
||||||
|
- The applesauce-core library integration needs to be completed. |
||||||
|
- Full implementation of all modules (comments, zaps, reactions, profiles, feed) is ongoing. |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
version: '3.8' |
||||||
|
|
||||||
|
services: |
||||||
|
aitherboard: |
||||||
|
build: |
||||||
|
context: . |
||||||
|
args: |
||||||
|
VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host" |
||||||
|
VITE_ZAP_THRESHOLD: "1" |
||||||
|
VITE_THREAD_TIMEOUT_DAYS: "30" |
||||||
|
VITE_PWA_ENABLED: "true" |
||||||
|
ports: |
||||||
|
- "9876:9876" |
||||||
|
environment: |
||||||
|
- PORT=9876 |
||||||
|
restart: unless-stopped |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
#!/bin/sh |
||||||
|
set -e |
||||||
|
|
||||||
|
PORT=${PORT:-9876} |
||||||
|
if ! [ "$PORT" -ge 1 ] 2>/dev/null || ! [ "$PORT" -le 65535 ] 2>/dev/null; then |
||||||
|
echo "Warning: Invalid PORT '$PORT', using default 9876" |
||||||
|
PORT=9876 |
||||||
|
fi |
||||||
|
|
||||||
|
envsubst '${PORT}' < /usr/local/apache2/conf/httpd.conf.template > /usr/local/apache2/conf/httpd.conf |
||||||
|
|
||||||
|
exec httpd -D FOREGROUND |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
Listen ${PORT} |
||||||
|
ServerName localhost |
||||||
|
|
||||||
|
<Directory "/usr/local/apache2/htdocs"> |
||||||
|
Options Indexes FollowSymLinks |
||||||
|
AllowOverride All |
||||||
|
Require all granted |
||||||
|
</Directory> |
||||||
|
|
||||||
|
<Location "/healthz"> |
||||||
|
Header set Content-Type "application/json" |
||||||
|
Header set Cache-Control "public, max-age=5" |
||||||
|
</Location> |
||||||
|
|
||||||
|
RewriteEngine On |
||||||
|
RewriteBase / |
||||||
|
RewriteRule ^healthz$ /healthz.json [L] |
||||||
|
|
||||||
|
RewriteRule ^index\.html$ - [L] |
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f |
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d |
||||||
|
RewriteRule . /index.html [L] |
||||||
|
|
||||||
|
<IfModule mod_headers.c> |
||||||
|
Header set Service-Worker-Allowed "/" |
||||||
|
</IfModule> |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
{ |
||||||
|
"name": "aitherboard", |
||||||
|
"version": "0.1.0", |
||||||
|
"type": "module", |
||||||
|
"author": "silberengel@gitcitadel.com", |
||||||
|
"description": "A decentralized messageboard built on the Nostr protocol.", |
||||||
|
"homepage": "https://gitcitadel.com/", |
||||||
|
"license": "MIT", |
||||||
|
"repository": { |
||||||
|
"type": "git", |
||||||
|
"url": "https://git.imwald.eu/silberengel/aitherboard.git" |
||||||
|
}, |
||||||
|
"bugs": { |
||||||
|
"url": "https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria" |
||||||
|
}, |
||||||
|
"scripts": { |
||||||
|
"dev": "vite dev", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview", |
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", |
||||||
|
"lint": "prettier --check . && eslint .", |
||||||
|
"format": "prettier --write ." |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@sveltejs/kit": "^2.0.0", |
||||||
|
"@sveltejs/vite-plugin-svelte": "^3.0.0", |
||||||
|
"applesauce-core": "github:hzrd149/applesauce", |
||||||
|
"dompurify": "^3.0.6", |
||||||
|
"idb": "^8.0.0", |
||||||
|
"marked": "^11.1.1", |
||||||
|
"svelte": "^5.0.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@sveltejs/adapter-static": "^3.0.0", |
||||||
|
"@types/dompurify": "^3.0.5", |
||||||
|
"@types/marked": "^6.0.0", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||||
|
"@typescript-eslint/parser": "^6.0.0", |
||||||
|
"autoprefixer": "^10.4.16", |
||||||
|
"eslint": "^8.57.0", |
||||||
|
"eslint-config-prettier": "^9.1.0", |
||||||
|
"eslint-plugin-svelte": "^2.35.1", |
||||||
|
"postcss": "^8.4.32", |
||||||
|
"prettier": "^3.2.5", |
||||||
|
"prettier-plugin-svelte": "^3.2.2", |
||||||
|
"tailwindcss": "^3.4.1", |
||||||
|
"typescript": "^5.3.3", |
||||||
|
"vite": "^5.1.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
export default { |
||||||
|
plugins: { |
||||||
|
tailwindcss: {}, |
||||||
|
autoprefixer: {} |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
{ |
||||||
|
"status": "ok", |
||||||
|
"service": "aitherboard", |
||||||
|
"version": "0.1.0", |
||||||
|
"buildTime": "2024-01-01T00:00:00.000Z", |
||||||
|
"gitCommit": "unknown", |
||||||
|
"timestamp": 1704067200000 |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
/** |
||||||
|
* Generate health check JSON file at build time |
||||||
|
*/ |
||||||
|
|
||||||
|
import { writeFileSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
|
||||||
|
const healthz = { |
||||||
|
status: 'ok', |
||||||
|
service: 'aitherboard', |
||||||
|
version: process.env.npm_package_version || '0.1.0', |
||||||
|
buildTime: new Date().toISOString(), |
||||||
|
gitCommit: process.env.GIT_COMMIT || 'unknown', |
||||||
|
timestamp: Date.now() |
||||||
|
}; |
||||||
|
|
||||||
|
const outputPath = join(process.cwd(), 'public', 'healthz.json'); |
||||||
|
writeFileSync(outputPath, JSON.stringify(healthz, null, 2)); |
||||||
|
|
||||||
|
console.log('Generated healthz.json'); |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
@tailwind base; |
||||||
|
@tailwind components; |
||||||
|
@tailwind utilities; |
||||||
|
|
||||||
|
:root { |
||||||
|
--text-size: 16px; |
||||||
|
--line-height: 1.6; |
||||||
|
--content-width: 800px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-text-size='small'] { |
||||||
|
--text-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-text-size='medium'] { |
||||||
|
--text-size: 16px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-text-size='large'] { |
||||||
|
--text-size: 18px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-line-spacing='tight'] { |
||||||
|
--line-height: 1.4; |
||||||
|
} |
||||||
|
|
||||||
|
[data-line-spacing='normal'] { |
||||||
|
--line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
[data-line-spacing='loose'] { |
||||||
|
--line-height: 1.8; |
||||||
|
} |
||||||
|
|
||||||
|
[data-content-width='narrow'] { |
||||||
|
--content-width: 600px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-content-width='medium'] { |
||||||
|
--content-width: 800px; |
||||||
|
} |
||||||
|
|
||||||
|
[data-content-width='wide'] { |
||||||
|
--content-width: 1200px; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
font-size: var(--text-size); |
||||||
|
line-height: var(--line-height); |
||||||
|
} |
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) { |
||||||
|
*, |
||||||
|
*::before, |
||||||
|
*::after { |
||||||
|
animation-duration: 0.01ms !important; |
||||||
|
animation-iteration-count: 1 !important; |
||||||
|
transition-duration: 0.01ms !important; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global { |
||||||
|
namespace App { |
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface Platform {}
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export {}; |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { marked } from 'marked'; |
||||||
|
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
export let content: string = ''; |
||||||
|
|
||||||
|
let rendered = $state(''); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (content) { |
||||||
|
const html = marked.parse(content); |
||||||
|
rendered = sanitizeMarkdown(html); |
||||||
|
} else { |
||||||
|
rendered = ''; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="markdown-content"> |
||||||
|
{@html rendered} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.markdown-content { |
||||||
|
line-height: var(--line-height); |
||||||
|
} |
||||||
|
|
||||||
|
.markdown-content :global(p) { |
||||||
|
margin: 0.5em 0; |
||||||
|
} |
||||||
|
|
||||||
|
.markdown-content :global(a) { |
||||||
|
color: #0066cc; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.markdown-content :global(code) { |
||||||
|
background: #f0f0f0; |
||||||
|
padding: 0.2em 0.4em; |
||||||
|
border-radius: 3px; |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
|
||||||
|
.markdown-content :global(pre) { |
||||||
|
background: #f0f0f0; |
||||||
|
padding: 1em; |
||||||
|
border-radius: 5px; |
||||||
|
overflow-x: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.markdown-content :global(img) { |
||||||
|
max-width: 100%; |
||||||
|
height: auto; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
</script> |
||||||
|
|
||||||
|
<header class="bg-board-post border-b border-board-border p-4"> |
||||||
|
<nav class="flex items-center justify-between"> |
||||||
|
<a href="/" class="text-xl font-bold">Aitherboard</a> |
||||||
|
<div class="flex gap-4"> |
||||||
|
{#if $sessionManager.isLoggedIn()} |
||||||
|
<span>Logged in as: {$sessionManager.getCurrentPubkey()?.slice(0, 16)}...</span> |
||||||
|
<button on:click={() => sessionManager.clearSession()}>Logout</button> |
||||||
|
{:else} |
||||||
|
<a href="/login">Login</a> |
||||||
|
{/if} |
||||||
|
<a href="/feed">Feed</a> |
||||||
|
<a href="/threads">Threads</a> |
||||||
|
</div> |
||||||
|
</nav> |
||||||
|
</header> |
||||||
|
|
||||||
|
<style> |
||||||
|
header { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getActivityStatus } from '../../services/auth/activity-tracker.js'; |
||||||
|
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; |
||||||
|
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
export let pubkey: string; |
||||||
|
|
||||||
|
let profile = $state<{ name?: string; picture?: string } | null>(null); |
||||||
|
let status = $state<string | null>(null); |
||||||
|
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (pubkey) { |
||||||
|
loadProfile(); |
||||||
|
loadStatus(); |
||||||
|
updateActivityStatus(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadProfile() { |
||||||
|
const p = await fetchProfile(pubkey); |
||||||
|
if (p) { |
||||||
|
profile = p; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadStatus() { |
||||||
|
status = await fetchUserStatus(pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
function updateActivityStatus() { |
||||||
|
activityStatus = getActivityStatus(pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
function getActivityColor(): string { |
||||||
|
switch (activityStatus) { |
||||||
|
case 'red': |
||||||
|
return '#ef4444'; |
||||||
|
case 'yellow': |
||||||
|
return '#eab308'; |
||||||
|
case 'green': |
||||||
|
return '#22c55e'; |
||||||
|
default: |
||||||
|
return '#9ca3af'; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2"> |
||||||
|
{#if profile?.picture} |
||||||
|
<img src={profile.picture} alt={profile.name || pubkey} class="w-6 h-6 rounded" /> |
||||||
|
{:else} |
||||||
|
<div class="w-6 h-6 rounded bg-gray-300"></div> |
||||||
|
{/if} |
||||||
|
<span>{profile?.name || pubkey.slice(0, 16)}...</span> |
||||||
|
{#if activityStatus} |
||||||
|
<span |
||||||
|
class="w-2 h-2 rounded-full" |
||||||
|
style="background-color: {getActivityColor()}" |
||||||
|
title="Activity indicator" |
||||||
|
></span> |
||||||
|
{/if} |
||||||
|
{#if status} |
||||||
|
<span class="text-sm text-gray-600">({status})</span> |
||||||
|
{/if} |
||||||
|
</a> |
||||||
|
|
||||||
|
<style> |
||||||
|
.profile-badge { |
||||||
|
text-decoration: none; |
||||||
|
color: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-badge:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
export let open = $state(false); |
||||||
|
export let results: { |
||||||
|
success: string[]; |
||||||
|
failed: Array<{ relay: string; error: string }>; |
||||||
|
} | null = $state(null); |
||||||
|
|
||||||
|
let autoCloseTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (open && results) { |
||||||
|
// Auto-close after 30 seconds |
||||||
|
autoCloseTimeout = setTimeout(() => { |
||||||
|
open = false; |
||||||
|
}, 30000); |
||||||
|
} |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (autoCloseTimeout) { |
||||||
|
clearTimeout(autoCloseTimeout); |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
function close() { |
||||||
|
open = false; |
||||||
|
if (autoCloseTimeout) { |
||||||
|
clearTimeout(autoCloseTimeout); |
||||||
|
autoCloseTimeout = null; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if open && results} |
||||||
|
<div class="modal-overlay" on:click={close} on:keydown={(e) => e.key === 'Escape' && close()}> |
||||||
|
<div class="modal-content" on:click|stopPropagation> |
||||||
|
<div class="modal-header"> |
||||||
|
<h2>Publication Status</h2> |
||||||
|
<button on:click={close} class="close-button">×</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-body"> |
||||||
|
{#if results.success.length > 0} |
||||||
|
<div class="success-section"> |
||||||
|
<h3>Success ({results.success.length})</h3> |
||||||
|
<ul> |
||||||
|
{#each results.success as relay} |
||||||
|
<li>{relay}</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if results.failed.length > 0} |
||||||
|
<div class="failed-section"> |
||||||
|
<h3>Failed ({results.failed.length})</h3> |
||||||
|
<ul> |
||||||
|
{#each results.failed as { relay, error }} |
||||||
|
<li> |
||||||
|
<strong>{relay}:</strong> {error} |
||||||
|
</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-footer"> |
||||||
|
<button on:click={close}>Close</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: white; |
||||||
|
border-radius: 8px; |
||||||
|
max-width: 600px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
overflow: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid #e5e7eb; |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.success-section, |
||||||
|
.failed-section { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.success-section h3 { |
||||||
|
color: #22c55e; |
||||||
|
} |
||||||
|
|
||||||
|
.failed-section h3 { |
||||||
|
color: #ef4444; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body ul { |
||||||
|
list-style: none; |
||||||
|
padding: 0; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body li { |
||||||
|
padding: 0.25rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer { |
||||||
|
padding: 1rem; |
||||||
|
border-top: 1px solid #e5e7eb; |
||||||
|
text-align: right; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: #3b82f6; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,142 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/applesauce-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
let title = $state(''); |
||||||
|
let content = $state(''); |
||||||
|
let topics = $state<string[]>([]); |
||||||
|
let topicInput = $state(''); |
||||||
|
let includeClientTag = $state(true); |
||||||
|
let publishing = $state(false); |
||||||
|
|
||||||
|
function addTopic() { |
||||||
|
if (topicInput.trim() && topics.length < 3) { |
||||||
|
topics = [...topics, topicInput.trim()]; |
||||||
|
topicInput = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function removeTopic(index: number) { |
||||||
|
topics = topics.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
async function publish() { |
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to create a thread'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!title.trim() || !content.trim()) { |
||||||
|
alert('Title and content are required'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
publishing = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const tags: string[][] = [['title', title]]; |
||||||
|
topics.forEach((topic) => tags.push(['t', topic])); |
||||||
|
if (includeClientTag) { |
||||||
|
tags.push(['client', 'Aitherboard']); |
||||||
|
} |
||||||
|
|
||||||
|
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
||||||
|
kind: 11, |
||||||
|
pubkey: sessionManager.getCurrentPubkey()!, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content |
||||||
|
}; |
||||||
|
|
||||||
|
const signed = await sessionManager.signEvent(event); |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const result = await nostrClient.publish(signed, { |
||||||
|
relays: [...config.defaultRelays, 'wss://thecitadel.nostr1.com'] |
||||||
|
}); |
||||||
|
|
||||||
|
if (result.success.length > 0) { |
||||||
|
alert(`Thread published to ${result.success.length} relay(s)`); |
||||||
|
// Reset form |
||||||
|
title = ''; |
||||||
|
content = ''; |
||||||
|
topics = []; |
||||||
|
} else { |
||||||
|
alert('Failed to publish thread'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error publishing thread:', error); |
||||||
|
alert('Error publishing thread'); |
||||||
|
} finally { |
||||||
|
publishing = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<form on:submit|preventDefault={publish} class="create-thread-form"> |
||||||
|
<div class="mb-4"> |
||||||
|
<label for="title" class="block mb-2">Title</label> |
||||||
|
<input |
||||||
|
id="title" |
||||||
|
type="text" |
||||||
|
bind:value={title} |
||||||
|
class="w-full p-2 border border-board-border" |
||||||
|
required |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-4"> |
||||||
|
<label for="content" class="block mb-2">Content</label> |
||||||
|
<textarea |
||||||
|
id="content" |
||||||
|
bind:value={content} |
||||||
|
class="w-full p-2 border border-board-border" |
||||||
|
rows="10" |
||||||
|
required |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-4"> |
||||||
|
<label for="topics" class="block mb-2">Topics (max 3)</label> |
||||||
|
<div class="flex gap-2 mb-2"> |
||||||
|
<input |
||||||
|
id="topics" |
||||||
|
type="text" |
||||||
|
bind:value={topicInput} |
||||||
|
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTopic())} |
||||||
|
class="flex-1 p-2 border border-board-border" |
||||||
|
disabled={topics.length >= 3} |
||||||
|
/> |
||||||
|
<button type="button" on:click={addTopic} disabled={topics.length >= 3}> |
||||||
|
Add |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div class="flex gap-2 flex-wrap"> |
||||||
|
{#each topics as topic, i} |
||||||
|
<span class="bg-gray-200 px-2 py-1 rounded"> |
||||||
|
{topic} |
||||||
|
<button type="button" on:click={() => removeTopic(i)} class="ml-2">×</button> |
||||||
|
</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-4"> |
||||||
|
<label> |
||||||
|
<input type="checkbox" bind:checked={includeClientTag} /> |
||||||
|
Include client tag |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button type="submit" disabled={publishing} class="px-4 py-2 bg-blue-500 text-white"> |
||||||
|
{publishing ? 'Publishing...' : 'Create Thread'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
<style> |
||||||
|
.create-thread-form { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export let thread: NostrEvent; |
||||||
|
|
||||||
|
function getTitle(): string { |
||||||
|
const titleTag = thread.tags.find((t) => t[0] === 'title'); |
||||||
|
return titleTag?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
|
||||||
|
function getTopics(): string[] { |
||||||
|
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3); |
||||||
|
} |
||||||
|
|
||||||
|
function getPreview(): string { |
||||||
|
// First 250 chars, plaintext (no markdown/images) |
||||||
|
const plaintext = thread.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' '); |
||||||
|
return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : ''); |
||||||
|
} |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - thread.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
const clientTag = thread.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article class="thread-card bg-board-post border border-board-border p-4 mb-4"> |
||||||
|
<div class="flex justify-between items-start mb-2"> |
||||||
|
<h3 class="text-lg font-semibold"> |
||||||
|
<a href="/thread/{thread.id}">{getTitle()}</a> |
||||||
|
</h3> |
||||||
|
<span class="text-sm text-gray-600">{getRelativeTime()}</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-2"> |
||||||
|
<ProfileBadge pubkey={thread.pubkey} /> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-gray-500 ml-2">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="text-sm mb-2">{getPreview()}</p> |
||||||
|
|
||||||
|
{#if getTopics().length > 0} |
||||||
|
<div class="flex gap-2 mb-2"> |
||||||
|
{#each getTopics() as topic} |
||||||
|
<span class="text-xs bg-gray-200 px-2 py-1 rounded">{topic}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="text-xs text-gray-600"> |
||||||
|
<a href="/thread/{thread.id}">View thread →</a> |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.thread-card { |
||||||
|
max-width: var(--content-width); |
||||||
|
} |
||||||
|
|
||||||
|
.thread-card a { |
||||||
|
color: inherit; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.thread-card a:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,108 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/applesauce-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import ThreadCard from './ThreadCard.svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
let threads = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest'); |
||||||
|
let showOlder = $state(false); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
loadThreads(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadThreads() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const since = showOlder |
||||||
|
? undefined |
||||||
|
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [11], since, limit: 50 }], |
||||||
|
[...config.defaultRelays], |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
threads = sortThreads(events); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading threads:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function sortThreads(events: NostrEvent[]): NostrEvent[] { |
||||||
|
switch (sortBy) { |
||||||
|
case 'newest': |
||||||
|
return [...events].sort((a, b) => b.created_at - a.created_at); |
||||||
|
case 'active': |
||||||
|
// Placeholder - would need to count comments |
||||||
|
return [...events].sort((a, b) => b.created_at - a.created_at); |
||||||
|
case 'upvoted': |
||||||
|
// Placeholder - would need to count reactions |
||||||
|
return [...events].sort((a, b) => b.created_at - a.created_at); |
||||||
|
default: |
||||||
|
return events; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getTopics(): string[] { |
||||||
|
const topicSet = new Set<string>(); |
||||||
|
for (const thread of threads) { |
||||||
|
const topics = thread.tags.filter((t) => t[0] === 't').map((t) => t[1]); |
||||||
|
topics.forEach((t) => topicSet.add(t)); |
||||||
|
} |
||||||
|
return Array.from(topicSet).sort(); |
||||||
|
} |
||||||
|
|
||||||
|
function getThreadsByTopic(topic: string | null): NostrEvent[] { |
||||||
|
if (topic === null) { |
||||||
|
return threads.filter((t) => !t.tags.some((tag) => tag[0] === 't')); |
||||||
|
} |
||||||
|
return threads.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic)); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="thread-list"> |
||||||
|
<div class="controls mb-4 flex gap-4 items-center"> |
||||||
|
<label> |
||||||
|
<input type="checkbox" bind:checked={showOlder} on:change={loadThreads} /> |
||||||
|
Show older threads |
||||||
|
</label> |
||||||
|
<select bind:value={sortBy} on:change={() => (threads = sortThreads(threads))}> |
||||||
|
<option value="newest">Newest</option> |
||||||
|
<option value="active">Most Active</option> |
||||||
|
<option value="upvoted">Most Upvoted</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<p>Loading threads...</p> |
||||||
|
{:else} |
||||||
|
<div> |
||||||
|
<h2 class="text-xl font-bold mb-4">General</h2> |
||||||
|
{#each getThreadsByTopic(null) as thread} |
||||||
|
<ThreadCard {thread} /> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#each getTopics() as topic} |
||||||
|
<h2 class="text-xl font-bold mb-4 mt-8">{topic}</h2> |
||||||
|
{#each getThreadsByTopic(topic) as thread} |
||||||
|
<ThreadCard {thread} /> |
||||||
|
{/each} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.thread-list { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
/** |
||||||
|
* Activity tracker - tracks last activity per pubkey |
||||||
|
*/ |
||||||
|
|
||||||
|
import { eventStore } from '../nostr/event-store.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get last activity timestamp for a pubkey |
||||||
|
*/ |
||||||
|
export function getLastActivity(pubkey: string): number | undefined { |
||||||
|
return eventStore.getLastActivity(pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get activity status color |
||||||
|
* Red: ≥168 hours (7 days) |
||||||
|
* Yellow: ≥48 hours (2 days) but <168 hours |
||||||
|
* Green: <48 hours |
||||||
|
*/ |
||||||
|
export function getActivityStatus(pubkey: string): 'red' | 'yellow' | 'green' | null { |
||||||
|
const lastActivity = getLastActivity(pubkey); |
||||||
|
if (!lastActivity) return null; |
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const hoursSince = (now - lastActivity) / 3600; |
||||||
|
|
||||||
|
if (hoursSince >= 168) return 'red'; |
||||||
|
if (hoursSince >= 48) return 'yellow'; |
||||||
|
return 'green'; |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
/** |
||||||
|
* Anonymous signer (generated keys, NIP-49 encrypted) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { generatePrivateKey } from '../security/key-management.js'; |
||||||
|
import { storeAnonymousKey, getAnonymousKey } from '../cache/anonymous-key-store.js'; |
||||||
|
import { getPublicKeyFromNsec } from './nsec-signer.js'; |
||||||
|
import { signEventWithNsec } from './nsec-signer.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate and store anonymous key |
||||||
|
*/ |
||||||
|
export async function generateAnonymousKey(password: string): Promise<{ |
||||||
|
pubkey: string; |
||||||
|
nsec: string; |
||||||
|
}> { |
||||||
|
const nsec = generatePrivateKey(); |
||||||
|
const pubkey = getPublicKeyFromNsec(nsec); |
||||||
|
|
||||||
|
// Store encrypted
|
||||||
|
await storeAnonymousKey(nsec, password, pubkey); |
||||||
|
|
||||||
|
return { pubkey, nsec }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get stored anonymous key |
||||||
|
*/ |
||||||
|
export async function getStoredAnonymousKey( |
||||||
|
pubkey: string, |
||||||
|
password: string |
||||||
|
): Promise<string | null> { |
||||||
|
return getAnonymousKey(pubkey, password); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event with anonymous key |
||||||
|
*/ |
||||||
|
export async function signEventWithAnonymous( |
||||||
|
event: Omit<NostrEvent, 'sig' | 'id'>, |
||||||
|
pubkey: string, |
||||||
|
password: string |
||||||
|
): Promise<NostrEvent> { |
||||||
|
const nsec = await getStoredAnonymousKey(pubkey, password); |
||||||
|
if (!nsec) { |
||||||
|
throw new Error('Anonymous key not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// For anonymous keys, we need the ncryptsec format
|
||||||
|
// This is simplified - in practice we'd store ncryptsec and decrypt it
|
||||||
|
// For now, assume we have the plain nsec after decryption
|
||||||
|
return signEventWithNsec(event, nsec, password); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate anonymous handle |
||||||
|
*/ |
||||||
|
export function generateAnonymousHandle(pubkey: string): string { |
||||||
|
// Use last 6 characters of pubkey for uniqueness
|
||||||
|
const suffix = pubkey.slice(-6); |
||||||
|
return `Aitherite${suffix}`; |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
/** |
||||||
|
* NIP-46 Bunker signer (remote signer) |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface BunkerConnection { |
||||||
|
bunkerUrl: string; |
||||||
|
pubkey: string; |
||||||
|
token?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Connect to bunker signer |
||||||
|
*/ |
||||||
|
export async function connectBunker(bunkerUri: string): Promise<BunkerConnection> { |
||||||
|
// Parse bunker:// URI
|
||||||
|
// Format: bunker://<pubkey>@<relay>?token=<token>
|
||||||
|
const match = bunkerUri.match(/^bunker:\/\/([^@]+)@([^?]+)(?:\?token=([^&]+))?$/); |
||||||
|
if (!match) { |
||||||
|
throw new Error('Invalid bunker URI'); |
||||||
|
} |
||||||
|
|
||||||
|
const [, pubkey, relay, token] = match; |
||||||
|
|
||||||
|
return { |
||||||
|
bunkerUrl: relay, |
||||||
|
pubkey, |
||||||
|
token |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event with bunker |
||||||
|
*/ |
||||||
|
export async function signEventWithBunker( |
||||||
|
event: Omit<NostrEvent, 'sig' | 'id'>, |
||||||
|
connection: BunkerConnection |
||||||
|
): Promise<NostrEvent> { |
||||||
|
// Placeholder - would:
|
||||||
|
// 1. Send NIP-46 request to bunker
|
||||||
|
// 2. Wait for response
|
||||||
|
// 3. Return signed event
|
||||||
|
|
||||||
|
throw new Error('Bunker signing not yet implemented'); |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
/** |
||||||
|
* NIP-07 signer (browser extension) |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface NIP07Signer { |
||||||
|
getPublicKey(): Promise<string>; |
||||||
|
signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if NIP-07 is available |
||||||
|
*/ |
||||||
|
export function isNIP07Available(): boolean { |
||||||
|
return typeof window !== 'undefined' && 'nostr' in window; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get NIP-07 signer |
||||||
|
*/ |
||||||
|
export function getNIP07Signer(): NIP07Signer | null { |
||||||
|
if (!isNIP07Available()) return null; |
||||||
|
|
||||||
|
const nostr = (window as { nostr?: NIP07Signer }).nostr; |
||||||
|
return nostr || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event with NIP-07 |
||||||
|
*/ |
||||||
|
export async function signEventWithNIP07( |
||||||
|
event: Omit<NostrEvent, 'sig' | 'id'> |
||||||
|
): Promise<NostrEvent> { |
||||||
|
const signer = getNIP07Signer(); |
||||||
|
if (!signer) { |
||||||
|
throw new Error('NIP-07 not available'); |
||||||
|
} |
||||||
|
|
||||||
|
return signer.signEvent(event); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get public key with NIP-07 |
||||||
|
*/ |
||||||
|
export async function getPublicKeyWithNIP07(): Promise<string> { |
||||||
|
const signer = getNIP07Signer(); |
||||||
|
if (!signer) { |
||||||
|
throw new Error('NIP-07 not available'); |
||||||
|
} |
||||||
|
|
||||||
|
return signer.getPublicKey(); |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
/** |
||||||
|
* Nsec signer (direct private key, NIP-49 encrypted) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { decryptPrivateKey } from '../security/key-management.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event with nsec (private key) |
||||||
|
* This is a placeholder - full implementation requires: |
||||||
|
* - secp256k1 cryptography library |
||||||
|
* - Event ID computation (SHA256) |
||||||
|
* - Signature computation |
||||||
|
*/ |
||||||
|
export async function signEventWithNsec( |
||||||
|
event: Omit<NostrEvent, 'sig' | 'id'>, |
||||||
|
ncryptsec: string, |
||||||
|
password: string |
||||||
|
): Promise<NostrEvent> { |
||||||
|
// Decrypt private key
|
||||||
|
const nsec = await decryptPrivateKey(ncryptsec, password); |
||||||
|
|
||||||
|
// Placeholder - would compute event ID and signature
|
||||||
|
// For now, return event with placeholder sig/id
|
||||||
|
const signedEvent: NostrEvent = { |
||||||
|
...event, |
||||||
|
id: 'placeholder_id_' + Date.now(), // Would be SHA256 of serialized event
|
||||||
|
sig: 'placeholder_sig_' + Date.now() // Would be secp256k1 signature
|
||||||
|
}; |
||||||
|
|
||||||
|
return signedEvent; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get public key from private key |
||||||
|
*/ |
||||||
|
export function getPublicKeyFromNsec(nsec: string): string { |
||||||
|
// Placeholder - would derive public key from private key using secp256k1
|
||||||
|
// For now, return placeholder
|
||||||
|
return 'placeholder_pubkey'; |
||||||
|
} |
||||||
@ -0,0 +1,133 @@ |
|||||||
|
/** |
||||||
|
* Profile fetcher (kind 0 events) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nostrClient } from '../nostr/applesauce-client.js'; |
||||||
|
import { cacheProfile, getProfile, getProfiles } from '../cache/profile-cache.js'; |
||||||
|
import { config } from '../nostr/config.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface ProfileData { |
||||||
|
name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
website?: string[]; |
||||||
|
nip05?: string[]; |
||||||
|
lud16?: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse profile from kind 0 event |
||||||
|
*/ |
||||||
|
export function parseProfile(event: NostrEvent): ProfileData { |
||||||
|
const profile: ProfileData = {}; |
||||||
|
|
||||||
|
// Try to parse from tags first (preferred)
|
||||||
|
const nameTag = event.tags.find((t) => t[0] === 'name'); |
||||||
|
if (nameTag && nameTag[1]) profile.name = nameTag[1]; |
||||||
|
|
||||||
|
const aboutTag = event.tags.find((t) => t[0] === 'about'); |
||||||
|
if (aboutTag && aboutTag[1]) profile.about = aboutTag[1]; |
||||||
|
|
||||||
|
const pictureTag = event.tags.find((t) => t[0] === 'picture'); |
||||||
|
if (pictureTag && pictureTag[1]) profile.picture = pictureTag[1]; |
||||||
|
|
||||||
|
// Multiple tags for website, nip05, lud16
|
||||||
|
profile.website = event.tags.filter((t) => t[0] === 'website').map((t) => t[1]).filter(Boolean); |
||||||
|
profile.nip05 = event.tags.filter((t) => t[0] === 'nip05').map((t) => t[1]).filter(Boolean); |
||||||
|
profile.lud16 = event.tags.filter((t) => t[0] === 'lud16').map((t) => t[1]).filter(Boolean); |
||||||
|
|
||||||
|
// Fallback to JSON content if tags not found
|
||||||
|
if (!profile.name || !profile.about) { |
||||||
|
try { |
||||||
|
const json = JSON.parse(event.content); |
||||||
|
if (json.name && !profile.name) profile.name = json.name; |
||||||
|
if (json.about && !profile.about) profile.about = json.about; |
||||||
|
if (json.picture && !profile.picture) profile.picture = json.picture; |
||||||
|
if (json.website && profile.website.length === 0) { |
||||||
|
profile.website = Array.isArray(json.website) ? json.website : [json.website]; |
||||||
|
} |
||||||
|
if (json.nip05 && profile.nip05.length === 0) { |
||||||
|
profile.nip05 = Array.isArray(json.nip05) ? json.nip05 : [json.nip05]; |
||||||
|
} |
||||||
|
if (json.lud16 && profile.lud16.length === 0) { |
||||||
|
profile.lud16 = Array.isArray(json.lud16) ? json.lud16 : [json.lud16]; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Invalid JSON, ignore
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return profile; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch profile for a pubkey |
||||||
|
*/ |
||||||
|
export async function fetchProfile( |
||||||
|
pubkey: string, |
||||||
|
relays?: string[] |
||||||
|
): Promise<ProfileData | null> { |
||||||
|
// Try cache first
|
||||||
|
const cached = await getProfile(pubkey); |
||||||
|
if (cached) { |
||||||
|
return parseProfile(cached.event); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
const relayList = relays || [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [0], authors: [pubkey], limit: 1 }], |
||||||
|
relayList, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length === 0) return null; |
||||||
|
|
||||||
|
const event = events[0]; |
||||||
|
await cacheProfile(event); |
||||||
|
|
||||||
|
return parseProfile(event); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch multiple profiles |
||||||
|
*/ |
||||||
|
export async function fetchProfiles( |
||||||
|
pubkeys: string[], |
||||||
|
relays?: string[] |
||||||
|
): Promise<Map<string, ProfileData>> { |
||||||
|
const profiles = new Map<string, ProfileData>(); |
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = await getProfiles(pubkeys); |
||||||
|
for (const [pubkey, cachedProfile] of cached.entries()) { |
||||||
|
profiles.set(pubkey, parseProfile(cachedProfile.event)); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch missing profiles
|
||||||
|
const missing = pubkeys.filter((p) => !profiles.has(p)); |
||||||
|
if (missing.length === 0) return profiles; |
||||||
|
|
||||||
|
const relayList = relays || [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [0], authors: missing, limit: 1 }], |
||||||
|
relayList, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
await cacheProfile(event); |
||||||
|
profiles.set(event.pubkey, parseProfile(event)); |
||||||
|
} |
||||||
|
|
||||||
|
return profiles; |
||||||
|
} |
||||||
@ -0,0 +1,81 @@ |
|||||||
|
/** |
||||||
|
* Relay list fetcher (kind 10002 and 10432) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nostrClient } from '../nostr/applesauce-client.js'; |
||||||
|
import { config } from '../nostr/config.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface RelayInfo { |
||||||
|
url: string; |
||||||
|
read: boolean; |
||||||
|
write: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse relay list from event |
||||||
|
*/ |
||||||
|
export function parseRelayList(event: NostrEvent): RelayInfo[] { |
||||||
|
const relays: RelayInfo[] = []; |
||||||
|
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'r' && tag[1]) { |
||||||
|
const url = tag[1]; |
||||||
|
const markers = tag.slice(2); |
||||||
|
|
||||||
|
const read = markers.length === 0 || markers.includes('read') || !markers.includes('write'); |
||||||
|
const write = markers.length === 0 || markers.includes('write') || !markers.includes('read'); |
||||||
|
|
||||||
|
relays.push({ url, read, write }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return relays; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch relay lists for a pubkey (kind 10002 and 10432) |
||||||
|
*/ |
||||||
|
export async function fetchRelayLists( |
||||||
|
pubkey: string, |
||||||
|
relays?: string[] |
||||||
|
): Promise<{ |
||||||
|
inbox: string[]; |
||||||
|
outbox: string[]; |
||||||
|
}> { |
||||||
|
const relayList = relays || [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]; |
||||||
|
|
||||||
|
// Fetch both kind 10002 and 10432
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[ |
||||||
|
{ kinds: [10002], authors: [pubkey], limit: 1 }, |
||||||
|
{ kinds: [10432], authors: [pubkey], limit: 1 } |
||||||
|
], |
||||||
|
relayList, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
const inbox: string[] = []; |
||||||
|
const outbox: string[] = []; |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
const relayInfos = parseRelayList(event); |
||||||
|
for (const info of relayInfos) { |
||||||
|
if (info.read && !inbox.includes(info.url)) { |
||||||
|
inbox.push(info.url); |
||||||
|
} |
||||||
|
if (info.write && !outbox.includes(info.url)) { |
||||||
|
outbox.push(info.url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
return { |
||||||
|
inbox: [...new Set(inbox)], |
||||||
|
outbox: [...new Set(outbox)] |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
/** |
||||||
|
* Session manager for active user sessions |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export type AuthMethod = 'nip07' | 'nsec' | 'bunker' | 'anonymous'; |
||||||
|
|
||||||
|
export interface UserSession { |
||||||
|
pubkey: string; |
||||||
|
method: AuthMethod; |
||||||
|
signer: (event: Omit<NostrEvent, 'sig' | 'id'>) => Promise<NostrEvent>; |
||||||
|
createdAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
class SessionManager { |
||||||
|
private currentSession: UserSession | null = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Set current session |
||||||
|
*/ |
||||||
|
setSession(session: UserSession): void { |
||||||
|
this.currentSession = session; |
||||||
|
// Store in localStorage for persistence
|
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
localStorage.setItem('aitherboard_session', JSON.stringify({ |
||||||
|
pubkey: session.pubkey, |
||||||
|
method: session.method, |
||||||
|
createdAt: session.createdAt |
||||||
|
})); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get current session |
||||||
|
*/ |
||||||
|
getSession(): UserSession | null { |
||||||
|
return this.currentSession; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if user is logged in |
||||||
|
*/ |
||||||
|
isLoggedIn(): boolean { |
||||||
|
return this.currentSession !== null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get current pubkey |
||||||
|
*/ |
||||||
|
getCurrentPubkey(): string | null { |
||||||
|
return this.currentSession?.pubkey || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event with current session |
||||||
|
*/ |
||||||
|
async signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> { |
||||||
|
if (!this.currentSession) { |
||||||
|
throw new Error('No active session'); |
||||||
|
} |
||||||
|
|
||||||
|
return this.currentSession.signer(event); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear session |
||||||
|
*/ |
||||||
|
clearSession(): void { |
||||||
|
this.currentSession = null; |
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
localStorage.removeItem('aitherboard_session'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Restore session from localStorage |
||||||
|
*/ |
||||||
|
async restoreSession(): Promise<boolean> { |
||||||
|
if (typeof window === 'undefined') return false; |
||||||
|
|
||||||
|
const stored = localStorage.getItem('aitherboard_session'); |
||||||
|
if (!stored) return false; |
||||||
|
|
||||||
|
try { |
||||||
|
const data = JSON.parse(stored); |
||||||
|
// Session restoration would require re-initializing the signer
|
||||||
|
// This is simplified - full implementation would restore the signer
|
||||||
|
return false; |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const sessionManager = new SessionManager(); |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
/** |
||||||
|
* User preferences fetcher |
||||||
|
* Placeholder for future user preference events |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface UserPreferences { |
||||||
|
// Placeholder - would be defined based on preference event kinds
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch user preferences |
||||||
|
*/ |
||||||
|
export async function fetchUserPreferences(pubkey: string): Promise<UserPreferences | null> { |
||||||
|
// Placeholder - would fetch preference events
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
/** |
||||||
|
* User status fetcher (kind 30315, NIP-38) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nostrClient } from '../nostr/applesauce-client.js'; |
||||||
|
import { config } from '../nostr/config.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse user status from kind 30315 event |
||||||
|
*/ |
||||||
|
export function parseUserStatus(event: NostrEvent): string | null { |
||||||
|
if (event.kind !== 30315) return null; |
||||||
|
|
||||||
|
// Check for d tag with value "general"
|
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general'); |
||||||
|
if (!dTag) return null; |
||||||
|
|
||||||
|
return event.content || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch user status for a pubkey |
||||||
|
*/ |
||||||
|
export async function fetchUserStatus( |
||||||
|
pubkey: string, |
||||||
|
relays?: string[] |
||||||
|
): Promise<string | null> { |
||||||
|
const relayList = relays || [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[ |
||||||
|
{ |
||||||
|
kinds: [30315], |
||||||
|
authors: [pubkey], |
||||||
|
'#d': ['general'], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
], |
||||||
|
relayList, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length === 0) return null; |
||||||
|
|
||||||
|
return parseUserStatus(events[0]); |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
/** |
||||||
|
* Anonymous key storage (NIP-49 encrypted) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
import { encryptPrivateKey, decryptPrivateKey } from '../security/key-management.js'; |
||||||
|
|
||||||
|
export interface StoredAnonymousKey { |
||||||
|
id: string; |
||||||
|
ncryptsec: string; // NIP-49 encrypted key
|
||||||
|
pubkey: string; // Public key for identification
|
||||||
|
created_at: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store an anonymous key (encrypted) |
||||||
|
*/ |
||||||
|
export async function storeAnonymousKey( |
||||||
|
nsec: string, |
||||||
|
password: string, |
||||||
|
pubkey: string |
||||||
|
): Promise<void> { |
||||||
|
const ncryptsec = await encryptPrivateKey(nsec, password); |
||||||
|
const db = await getDB(); |
||||||
|
const stored: StoredAnonymousKey = { |
||||||
|
id: pubkey, |
||||||
|
ncryptsec, |
||||||
|
pubkey, |
||||||
|
created_at: Date.now() |
||||||
|
}; |
||||||
|
await db.put('keys', stored); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retrieve and decrypt an anonymous key |
||||||
|
*/ |
||||||
|
export async function getAnonymousKey( |
||||||
|
pubkey: string, |
||||||
|
password: string |
||||||
|
): Promise<string | null> { |
||||||
|
const db = await getDB(); |
||||||
|
const stored = await db.get('keys', pubkey); |
||||||
|
if (!stored) return null; |
||||||
|
|
||||||
|
const key = stored as StoredAnonymousKey; |
||||||
|
return decryptPrivateKey(key.ncryptsec, password); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* List all stored anonymous keys (pubkeys only) |
||||||
|
*/ |
||||||
|
export async function listAnonymousKeys(): Promise<string[]> { |
||||||
|
const db = await getDB(); |
||||||
|
const keys: string[] = []; |
||||||
|
const tx = db.transaction('keys', 'readonly'); |
||||||
|
|
||||||
|
for await (const cursor of tx.store.iterate()) { |
||||||
|
const key = cursor.value as StoredAnonymousKey; |
||||||
|
keys.push(key.pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
await tx.done; |
||||||
|
return keys; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete an anonymous key |
||||||
|
*/ |
||||||
|
export async function deleteAnonymousKey(pubkey: string): Promise<void> { |
||||||
|
const db = await getDB(); |
||||||
|
await db.delete('keys', pubkey); |
||||||
|
} |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
/** |
||||||
|
* Event caching with IndexedDB |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface CachedEvent extends NostrEvent { |
||||||
|
cached_at: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store an event in cache |
||||||
|
*/ |
||||||
|
export async function cacheEvent(event: NostrEvent): Promise<void> { |
||||||
|
const db = await getDB(); |
||||||
|
const cached: CachedEvent = { |
||||||
|
...event, |
||||||
|
cached_at: Date.now() |
||||||
|
}; |
||||||
|
await db.put('events', cached); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store multiple events in cache |
||||||
|
*/ |
||||||
|
export async function cacheEvents(events: NostrEvent[]): Promise<void> { |
||||||
|
const db = await getDB(); |
||||||
|
const tx = db.transaction('events', 'readwrite'); |
||||||
|
for (const event of events) { |
||||||
|
const cached: CachedEvent = { |
||||||
|
...event, |
||||||
|
cached_at: Date.now() |
||||||
|
}; |
||||||
|
await tx.store.put(cached); |
||||||
|
} |
||||||
|
await tx.done; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get event by ID from cache |
||||||
|
*/ |
||||||
|
export async function getEvent(id: string): Promise<CachedEvent | undefined> { |
||||||
|
const db = await getDB(); |
||||||
|
return db.get('events', id); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get events by kind |
||||||
|
*/ |
||||||
|
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> { |
||||||
|
const db = await getDB(); |
||||||
|
const index = db.transaction('events').store.index('kind'); |
||||||
|
const events: CachedEvent[] = []; |
||||||
|
let count = 0; |
||||||
|
|
||||||
|
for await (const cursor of index.iterate(kind)) { |
||||||
|
if (limit && count >= limit) break; |
||||||
|
events.push(cursor.value); |
||||||
|
count++; |
||||||
|
} |
||||||
|
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get events by pubkey |
||||||
|
*/ |
||||||
|
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> { |
||||||
|
const db = await getDB(); |
||||||
|
const index = db.transaction('events').store.index('pubkey'); |
||||||
|
const events: CachedEvent[] = []; |
||||||
|
let count = 0; |
||||||
|
|
||||||
|
for await (const cursor of index.iterate(pubkey)) { |
||||||
|
if (limit && count >= limit) break; |
||||||
|
events.push(cursor.value); |
||||||
|
count++; |
||||||
|
} |
||||||
|
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear old events (older than specified timestamp) |
||||||
|
*/ |
||||||
|
export async function clearOldEvents(olderThan: number): Promise<void> { |
||||||
|
const db = await getDB(); |
||||||
|
const tx = db.transaction('events', 'readwrite'); |
||||||
|
const index = tx.store.index('created_at'); |
||||||
|
|
||||||
|
for await (const cursor of index.iterate()) { |
||||||
|
if (cursor.value.created_at < olderThan) { |
||||||
|
await cursor.delete(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await tx.done; |
||||||
|
} |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
/** |
||||||
|
* Base IndexedDB store operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import { openDB, type IDBPDatabase } from 'idb'; |
||||||
|
|
||||||
|
const DB_NAME = 'aitherboard'; |
||||||
|
const DB_VERSION = 1; |
||||||
|
|
||||||
|
export interface DatabaseSchema { |
||||||
|
events: { |
||||||
|
key: string; // event id
|
||||||
|
value: unknown; |
||||||
|
indexes: { kind: number; pubkey: string; created_at: number }; |
||||||
|
}; |
||||||
|
profiles: { |
||||||
|
key: string; // pubkey
|
||||||
|
value: unknown; |
||||||
|
}; |
||||||
|
keys: { |
||||||
|
key: string; // key id
|
||||||
|
value: unknown; |
||||||
|
}; |
||||||
|
search: { |
||||||
|
key: string; |
||||||
|
value: unknown; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get or create database instance |
||||||
|
*/ |
||||||
|
export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { |
||||||
|
if (dbInstance) return dbInstance; |
||||||
|
|
||||||
|
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, { |
||||||
|
upgrade(db) { |
||||||
|
// Events store
|
||||||
|
if (!db.objectStoreNames.contains('events')) { |
||||||
|
const eventStore = db.createObjectStore('events', { keyPath: 'id' }); |
||||||
|
eventStore.createIndex('kind', 'kind', { unique: false }); |
||||||
|
eventStore.createIndex('pubkey', 'pubkey', { unique: false }); |
||||||
|
eventStore.createIndex('created_at', 'created_at', { unique: false }); |
||||||
|
} |
||||||
|
|
||||||
|
// Profiles store
|
||||||
|
if (!db.objectStoreNames.contains('profiles')) { |
||||||
|
db.createObjectStore('profiles', { keyPath: 'pubkey' }); |
||||||
|
} |
||||||
|
|
||||||
|
// Keys store
|
||||||
|
if (!db.objectStoreNames.contains('keys')) { |
||||||
|
db.createObjectStore('keys', { keyPath: 'id' }); |
||||||
|
} |
||||||
|
|
||||||
|
// Search index store
|
||||||
|
if (!db.objectStoreNames.contains('search')) { |
||||||
|
db.createObjectStore('search', { keyPath: 'id' }); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return dbInstance; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close database connection |
||||||
|
*/ |
||||||
|
export async function closeDB(): Promise<void> { |
||||||
|
if (dbInstance) { |
||||||
|
dbInstance.close(); |
||||||
|
dbInstance = null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
/** |
||||||
|
* Profile caching (kind 0 events) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface CachedProfile { |
||||||
|
pubkey: string; |
||||||
|
event: NostrEvent; |
||||||
|
cached_at: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store a profile in cache |
||||||
|
*/ |
||||||
|
export async function cacheProfile(event: NostrEvent): Promise<void> { |
||||||
|
if (event.kind !== 0) throw new Error('Not a profile event'); |
||||||
|
const db = await getDB(); |
||||||
|
const cached: CachedProfile = { |
||||||
|
pubkey: event.pubkey, |
||||||
|
event, |
||||||
|
cached_at: Date.now() |
||||||
|
}; |
||||||
|
await db.put('profiles', cached); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get profile by pubkey from cache |
||||||
|
*/ |
||||||
|
export async function getProfile(pubkey: string): Promise<CachedProfile | undefined> { |
||||||
|
const db = await getDB(); |
||||||
|
return db.get('profiles', pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get multiple profiles |
||||||
|
*/ |
||||||
|
export async function getProfiles(pubkeys: string[]): Promise<Map<string, CachedProfile>> { |
||||||
|
const db = await getDB(); |
||||||
|
const profiles = new Map<string, CachedProfile>(); |
||||||
|
const tx = db.transaction('profiles', 'readonly'); |
||||||
|
|
||||||
|
for (const pubkey of pubkeys) { |
||||||
|
const profile = await tx.store.get(pubkey); |
||||||
|
if (profile) { |
||||||
|
profiles.set(pubkey, profile); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await tx.done; |
||||||
|
return profiles; |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
/** |
||||||
|
* Full-text search index (deferred implementation) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Index event content for search |
||||||
|
*/ |
||||||
|
export async function indexEvent(eventId: string, content: string): Promise<void> { |
||||||
|
// Placeholder - full implementation would:
|
||||||
|
// 1. Tokenize content
|
||||||
|
// 2. Create inverted index
|
||||||
|
// 3. Store in IndexedDB
|
||||||
|
const db = await getDB(); |
||||||
|
await db.put('search', { |
||||||
|
id: eventId, |
||||||
|
content: content.toLowerCase() |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search events by query |
||||||
|
*/ |
||||||
|
export async function searchEvents(query: string, limit: number = 50): Promise<string[]> { |
||||||
|
// Placeholder - full implementation would:
|
||||||
|
// 1. Tokenize query
|
||||||
|
// 2. Look up in inverted index
|
||||||
|
// 3. Rank results
|
||||||
|
// 4. Return event IDs
|
||||||
|
const db = await getDB(); |
||||||
|
const results: string[] = []; |
||||||
|
const lowerQuery = query.toLowerCase(); |
||||||
|
const tx = db.transaction('search', 'readonly'); |
||||||
|
|
||||||
|
for await (const cursor of tx.store.iterate()) { |
||||||
|
if (results.length >= limit) break; |
||||||
|
const content = (cursor.value as { content: string }).content; |
||||||
|
if (content.includes(lowerQuery)) { |
||||||
|
results.push(cursor.key as string); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await tx.done; |
||||||
|
return results; |
||||||
|
} |
||||||
@ -0,0 +1,150 @@ |
|||||||
|
/** |
||||||
|
* Applesauce-core client wrapper |
||||||
|
* Main interface for Nostr operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import { initializeRelayPool, relayPool } from './relay-pool.js'; |
||||||
|
import { subscriptionManager } from './subscription-manager.js'; |
||||||
|
import { eventStore } from './event-store.js'; |
||||||
|
import { config } from './config.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface PublishOptions { |
||||||
|
relays?: string[]; |
||||||
|
skipRelayValidation?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
class ApplesauceClient { |
||||||
|
private initialized = false; |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize the client |
||||||
|
*/ |
||||||
|
async initialize(): Promise<void> { |
||||||
|
if (this.initialized) return; |
||||||
|
|
||||||
|
await initializeRelayPool(); |
||||||
|
this.initialized = true; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publish an event to relays |
||||||
|
*/ |
||||||
|
async publish(event: NostrEvent, options: PublishOptions = {}): Promise<{ |
||||||
|
success: string[]; |
||||||
|
failed: Array<{ relay: string; error: string }>; |
||||||
|
}> { |
||||||
|
const relays = options.relays || relayPool.getConnectedRelays(); |
||||||
|
const message = JSON.stringify(['EVENT', event]); |
||||||
|
|
||||||
|
const results = { |
||||||
|
success: [] as string[], |
||||||
|
failed: [] as Array<{ relay: string; error: string }> |
||||||
|
}; |
||||||
|
|
||||||
|
for (const relay of relays) { |
||||||
|
try { |
||||||
|
const sent = relayPool.send(relay, message); |
||||||
|
if (sent) { |
||||||
|
results.success.push(relay); |
||||||
|
} else { |
||||||
|
results.failed.push({ relay, error: 'Not connected' }); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
results.failed.push({ |
||||||
|
relay, |
||||||
|
error: error instanceof Error ? error.message : 'Unknown error' |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
if (results.success.length > 0) { |
||||||
|
await eventStore.storeEvent(event); |
||||||
|
} |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Subscribe to events |
||||||
|
*/ |
||||||
|
subscribe( |
||||||
|
filters: Array<{ |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
'#e'?: string[]; |
||||||
|
'#p'?: string[]; |
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
}>, |
||||||
|
relays: string[], |
||||||
|
onEvent: (event: NostrEvent, relay: string) => void, |
||||||
|
onEose?: (relay: string) => void |
||||||
|
): string { |
||||||
|
const subId = subscriptionManager.generateSubId(); |
||||||
|
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose); |
||||||
|
return subId; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Unsubscribe |
||||||
|
*/ |
||||||
|
unsubscribe(subId: string): void { |
||||||
|
subscriptionManager.unsubscribe(subId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch events |
||||||
|
*/ |
||||||
|
async fetchEvents( |
||||||
|
filters: Array<{ |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
'#e'?: string[]; |
||||||
|
'#p'?: string[]; |
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
}>, |
||||||
|
relays: string[], |
||||||
|
options?: { useCache?: boolean; cacheResults?: boolean } |
||||||
|
): Promise<NostrEvent[]> { |
||||||
|
return eventStore.fetchEvents(filters, relays, options || {}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get event by ID |
||||||
|
*/ |
||||||
|
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> { |
||||||
|
return eventStore.getEventById(id, relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay pool |
||||||
|
*/ |
||||||
|
getRelayPool() { |
||||||
|
return relayPool; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get config |
||||||
|
*/ |
||||||
|
getConfig() { |
||||||
|
return config; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close all connections |
||||||
|
*/ |
||||||
|
close(): void { |
||||||
|
subscriptionManager.closeAll(); |
||||||
|
relayPool.closeAll(); |
||||||
|
this.initialized = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const nostrClient = new ApplesauceClient(); |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
/** |
||||||
|
* Unified authentication handler |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js'; |
||||||
|
import { signEventWithNsec } from '../auth/nsec-signer.js'; |
||||||
|
import { signEventWithBunker, connectBunker } from '../auth/bunker-signer.js'; |
||||||
|
import { |
||||||
|
signEventWithAnonymous, |
||||||
|
generateAnonymousKey |
||||||
|
} from '../auth/anonymous-signer.js'; |
||||||
|
import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; |
||||||
|
import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; |
||||||
|
import { eventStore } from './event-store.js'; |
||||||
|
import { nostrClient } from './applesauce-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Authenticate with NIP-07 |
||||||
|
*/ |
||||||
|
export async function authenticateWithNIP07(): Promise<string> { |
||||||
|
const pubkey = await getPublicKeyWithNIP07(); |
||||||
|
|
||||||
|
sessionManager.setSession({ |
||||||
|
pubkey, |
||||||
|
method: 'nip07', |
||||||
|
signer: signEventWithNIP07, |
||||||
|
createdAt: Date.now() |
||||||
|
}); |
||||||
|
|
||||||
|
// Fetch user relay lists and mute list
|
||||||
|
await loadUserPreferences(pubkey); |
||||||
|
|
||||||
|
return pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Authenticate with nsec |
||||||
|
*/ |
||||||
|
export async function authenticateWithNsec( |
||||||
|
ncryptsec: string, |
||||||
|
password: string |
||||||
|
): Promise<string> { |
||||||
|
// Decrypt and derive pubkey
|
||||||
|
// This is simplified - would need full implementation
|
||||||
|
const pubkey = 'placeholder_pubkey'; |
||||||
|
|
||||||
|
sessionManager.setSession({ |
||||||
|
pubkey, |
||||||
|
method: 'nsec', |
||||||
|
signer: async (event) => signEventWithNsec(event, ncryptsec, password), |
||||||
|
createdAt: Date.now() |
||||||
|
}); |
||||||
|
|
||||||
|
await loadUserPreferences(pubkey); |
||||||
|
|
||||||
|
return pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Authenticate with bunker |
||||||
|
*/ |
||||||
|
export async function authenticateWithBunker(bunkerUri: string): Promise<string> { |
||||||
|
const connection = await connectBunker(bunkerUri); |
||||||
|
|
||||||
|
sessionManager.setSession({ |
||||||
|
pubkey: connection.pubkey, |
||||||
|
method: 'bunker', |
||||||
|
signer: async (event) => signEventWithBunker(event, connection), |
||||||
|
createdAt: Date.now() |
||||||
|
}); |
||||||
|
|
||||||
|
await loadUserPreferences(connection.pubkey); |
||||||
|
|
||||||
|
return connection.pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Authenticate as anonymous |
||||||
|
*/ |
||||||
|
export async function authenticateAsAnonymous(password: string): Promise<string> { |
||||||
|
const { pubkey, nsec } = await generateAnonymousKey(password); |
||||||
|
|
||||||
|
// Store the key for later use
|
||||||
|
// In practice, we'd need to store the ncryptsec and decrypt when needed
|
||||||
|
// For now, this is simplified
|
||||||
|
sessionManager.setSession({ |
||||||
|
pubkey, |
||||||
|
method: 'anonymous', |
||||||
|
signer: async (event) => { |
||||||
|
// Simplified - would decrypt and sign
|
||||||
|
return signEventWithAnonymous(event, pubkey, password); |
||||||
|
}, |
||||||
|
createdAt: Date.now() |
||||||
|
}); |
||||||
|
|
||||||
|
return pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load user preferences (relay lists, mute list, blocked relays) |
||||||
|
*/ |
||||||
|
async function loadUserPreferences(pubkey: string): Promise<void> { |
||||||
|
// Fetch relay lists
|
||||||
|
const { inbox, outbox } = await fetchRelayLists(pubkey); |
||||||
|
// Relay lists would be used by relay selection logic
|
||||||
|
|
||||||
|
// Fetch mute list (kind 10000)
|
||||||
|
const muteEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [10000], authors: [pubkey], limit: 1 }], |
||||||
|
[...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays], |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (muteEvents.length > 0) { |
||||||
|
const mutedPubkeys = muteEvents[0].tags |
||||||
|
.filter((t) => t[0] === 'p') |
||||||
|
.map((t) => t[1]) |
||||||
|
.filter(Boolean) as string[]; |
||||||
|
eventStore.setMuteList(mutedPubkeys); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch blocked relays (kind 10006)
|
||||||
|
const blockedRelayEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [10006], authors: [pubkey], limit: 1 }], |
||||||
|
[...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays], |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (blockedRelayEvents.length > 0) { |
||||||
|
const blockedRelays = blockedRelayEvents[0].tags |
||||||
|
.filter((t) => t[0] === 'relay') |
||||||
|
.map((t) => t[1]) |
||||||
|
.filter(Boolean) as string[]; |
||||||
|
eventStore.setBlockedRelays(blockedRelays); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign and publish event |
||||||
|
*/ |
||||||
|
export async function signAndPublish( |
||||||
|
event: Omit<NostrEvent, 'sig' | 'id'>, |
||||||
|
relays?: string[] |
||||||
|
): Promise<{ |
||||||
|
success: string[]; |
||||||
|
failed: Array<{ relay: string; error: string }>; |
||||||
|
}> { |
||||||
|
const signed = await sessionManager.signEvent(event); |
||||||
|
return nostrClient.publish(signed, { relays }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Logout |
||||||
|
*/ |
||||||
|
export function logout(): void { |
||||||
|
sessionManager.clearSession(); |
||||||
|
eventStore.setMuteList([]); |
||||||
|
eventStore.setBlockedRelays([]); |
||||||
|
} |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
/** |
||||||
|
* Configuration for Nostr services |
||||||
|
* Handles environment variables and defaults |
||||||
|
*/ |
||||||
|
|
||||||
|
const DEFAULT_RELAYS = [ |
||||||
|
'wss://theforest.nostr1.com', |
||||||
|
'wss://nostr21.com', |
||||||
|
'wss://nostr.land', |
||||||
|
'wss://nostr.wine', |
||||||
|
'wss://nostr.sovbit.host' |
||||||
|
]; |
||||||
|
|
||||||
|
const PROFILE_RELAYS = [ |
||||||
|
'wss://relay.damus.io', |
||||||
|
'wss://aggr.nostr.land', |
||||||
|
'wss://profiles.nostr1.com' |
||||||
|
]; |
||||||
|
|
||||||
|
export interface NostrConfig { |
||||||
|
defaultRelays: string[]; |
||||||
|
profileRelays: string[]; |
||||||
|
zapThreshold: number; |
||||||
|
threadTimeoutDays: number; |
||||||
|
pwaEnabled: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
function parseRelays(envVar: string | undefined, fallback: string[]): string[] { |
||||||
|
if (!envVar) return fallback; |
||||||
|
const relays = envVar |
||||||
|
.split(',') |
||||||
|
.map((r) => r.trim()) |
||||||
|
.filter((r) => r.length > 0); |
||||||
|
return relays.length > 0 ? relays : fallback; |
||||||
|
} |
||||||
|
|
||||||
|
function parseIntEnv(envVar: string | undefined, fallback: number, min: number = 0): number { |
||||||
|
if (!envVar) return fallback; |
||||||
|
const parsed = parseInt(envVar, 10); |
||||||
|
if (isNaN(parsed) || parsed < min) return fallback; |
||||||
|
return parsed; |
||||||
|
} |
||||||
|
|
||||||
|
function parseBoolEnv(envVar: string | undefined, fallback: boolean): boolean { |
||||||
|
if (!envVar) return fallback; |
||||||
|
return envVar.toLowerCase() === 'true' || envVar === '1'; |
||||||
|
} |
||||||
|
|
||||||
|
export function getConfig(): NostrConfig { |
||||||
|
return { |
||||||
|
defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS), |
||||||
|
profileRelays: PROFILE_RELAYS, |
||||||
|
zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0), |
||||||
|
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), |
||||||
|
pwaEnabled: parseBoolEnv(import.meta.env.VITE_PWA_ENABLED, true) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export const config = getConfig(); |
||||||
@ -0,0 +1,214 @@ |
|||||||
|
/** |
||||||
|
* Event store with IndexedDB caching and filtering |
||||||
|
*/ |
||||||
|
|
||||||
|
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; |
||||||
|
import { subscriptionManager, type NostrFilter } from './subscription-manager.js'; |
||||||
|
import { relayPool } from './relay-pool.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface EventStoreOptions { |
||||||
|
muteList?: string[]; // Pubkeys to mute (from kind 10000)
|
||||||
|
blockedRelays?: string[]; // Relays to block (from kind 10006)
|
||||||
|
} |
||||||
|
|
||||||
|
class EventStore { |
||||||
|
private muteList: Set<string> = new Set(); |
||||||
|
private blockedRelays: Set<string> = new Set(); |
||||||
|
private activityTracker: Map<string, number> = new Map(); // pubkey -> last activity timestamp
|
||||||
|
|
||||||
|
/** |
||||||
|
* Update mute list |
||||||
|
*/ |
||||||
|
setMuteList(pubkeys: string[]): void { |
||||||
|
this.muteList = new Set(pubkeys); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update blocked relays |
||||||
|
*/ |
||||||
|
setBlockedRelays(relays: string[]): void { |
||||||
|
this.blockedRelays = new Set(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Filter out muted events |
||||||
|
*/ |
||||||
|
private isMuted(event: NostrEvent): boolean { |
||||||
|
return this.muteList.has(event.pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Filter out blocked relays |
||||||
|
*/ |
||||||
|
private filterBlockedRelays(relays: string[]): string[] { |
||||||
|
return relays.filter((r) => !this.blockedRelays.has(r)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Track activity for a pubkey |
||||||
|
*/ |
||||||
|
private trackActivity(pubkey: string, timestamp: number): void { |
||||||
|
const current = this.activityTracker.get(pubkey) || 0; |
||||||
|
if (timestamp > current) { |
||||||
|
this.activityTracker.set(pubkey, timestamp); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get last activity timestamp for a pubkey |
||||||
|
*/ |
||||||
|
getLastActivity(pubkey: string): number | undefined { |
||||||
|
return this.activityTracker.get(pubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if event should be hidden (content filtering) |
||||||
|
*/ |
||||||
|
private shouldHideEvent(event: NostrEvent): boolean { |
||||||
|
// Check for content-warning or sensitive tags
|
||||||
|
const hasContentWarning = event.tags.some((t) => t[0] === 'content-warning' || t[0] === 'sensitive'); |
||||||
|
if (hasContentWarning) return true; |
||||||
|
|
||||||
|
// Check for #NSFW in content or tags
|
||||||
|
const content = event.content.toLowerCase(); |
||||||
|
const hasNSFW = content.includes('#nsfw') || event.tags.some((t) => t[1]?.toLowerCase() === 'nsfw'); |
||||||
|
if (hasNSFW) return true; |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch events with filters |
||||||
|
*/ |
||||||
|
async fetchEvents( |
||||||
|
filters: NostrFilter[], |
||||||
|
relays: string[], |
||||||
|
options: { useCache?: boolean; cacheResults?: boolean } = {} |
||||||
|
): Promise<NostrEvent[]> { |
||||||
|
const { useCache = true, cacheResults = true } = options; |
||||||
|
|
||||||
|
// Filter out blocked relays
|
||||||
|
const filteredRelays = this.filterBlockedRelays(relays); |
||||||
|
|
||||||
|
// Try cache first if enabled
|
||||||
|
if (useCache) { |
||||||
|
// Simple cache lookup - could be improved
|
||||||
|
const cachedEvents: NostrEvent[] = []; |
||||||
|
for (const filter of filters) { |
||||||
|
if (filter.kinds && filter.kinds.length === 1) { |
||||||
|
const events = await getEventsByKind(filter.kinds[0], filter.limit); |
||||||
|
cachedEvents.push(...events); |
||||||
|
} |
||||||
|
if (filter.authors && filter.authors.length === 1) { |
||||||
|
const events = await getEventsByPubkey(filter.authors[0], filter.limit); |
||||||
|
cachedEvents.push(...events); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (cachedEvents.length > 0) { |
||||||
|
// Return cached events immediately (progressive loading)
|
||||||
|
// Continue fetching fresh data in background
|
||||||
|
this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults }); |
||||||
|
return this.filterEvents(cachedEvents); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
return this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch events from relays |
||||||
|
*/ |
||||||
|
private async fetchEventsFromRelays( |
||||||
|
filters: NostrFilter[], |
||||||
|
relays: string[], |
||||||
|
options: { cacheResults: boolean } |
||||||
|
): Promise<NostrEvent[]> { |
||||||
|
return new Promise((resolve) => { |
||||||
|
const events: NostrEvent[] = new Map(); |
||||||
|
const subId = subscriptionManager.generateSubId(); |
||||||
|
const relayCount = new Set<string>(); |
||||||
|
|
||||||
|
const onEvent = (event: NostrEvent, relay: string) => { |
||||||
|
// Skip muted events
|
||||||
|
if (this.isMuted(event)) return; |
||||||
|
|
||||||
|
// Skip hidden events
|
||||||
|
if (this.shouldHideEvent(event)) return; |
||||||
|
|
||||||
|
// Track activity
|
||||||
|
this.trackActivity(event.pubkey, event.created_at); |
||||||
|
|
||||||
|
// Deduplicate by event ID
|
||||||
|
events.set(event.id, event); |
||||||
|
relayCount.add(relay); |
||||||
|
}; |
||||||
|
|
||||||
|
const onEose = (relay: string) => { |
||||||
|
relayCount.add(relay); |
||||||
|
// Wait a bit for all relays to respond
|
||||||
|
setTimeout(() => { |
||||||
|
if (relayCount.size >= Math.min(relays.length, 3)) { |
||||||
|
// Got responses from enough relays
|
||||||
|
const eventArray = Array.from(events.values()); |
||||||
|
if (options.cacheResults) { |
||||||
|
cacheEvents(eventArray); |
||||||
|
} |
||||||
|
subscriptionManager.unsubscribe(subId); |
||||||
|
resolve(this.filterEvents(eventArray)); |
||||||
|
} |
||||||
|
}, 1000); |
||||||
|
}; |
||||||
|
|
||||||
|
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose); |
||||||
|
|
||||||
|
// Timeout after 10 seconds
|
||||||
|
setTimeout(() => { |
||||||
|
subscriptionManager.unsubscribe(subId); |
||||||
|
const eventArray = Array.from(events.values()); |
||||||
|
if (options.cacheResults) { |
||||||
|
cacheEvents(eventArray); |
||||||
|
} |
||||||
|
resolve(this.filterEvents(eventArray)); |
||||||
|
}, 10000); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Filter events (remove muted, hidden, etc.) |
||||||
|
*/ |
||||||
|
private filterEvents(events: NostrEvent[]): NostrEvent[] { |
||||||
|
return events.filter((event) => { |
||||||
|
if (this.isMuted(event)) return false; |
||||||
|
if (this.shouldHideEvent(event)) return false; |
||||||
|
return true; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get event by ID (from cache or fetch) |
||||||
|
*/ |
||||||
|
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> { |
||||||
|
// Try cache first
|
||||||
|
const cached = await getEvent(id); |
||||||
|
if (cached) return cached; |
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
const filters: NostrFilter[] = [{ ids: [id] }]; |
||||||
|
const events = await this.fetchEvents(filters, relays, { useCache: false }); |
||||||
|
return events[0] || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store event in cache |
||||||
|
*/ |
||||||
|
async storeEvent(event: NostrEvent): Promise<void> { |
||||||
|
if (this.isMuted(event) || this.shouldHideEvent(event)) return; |
||||||
|
this.trackActivity(event.pubkey, event.created_at); |
||||||
|
await cacheEvent(event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const eventStore = new EventStore(); |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
/** |
||||||
|
* Event utilities for creating and signing events |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Create event ID (SHA256 of serialized event) |
||||||
|
* This is a placeholder - full implementation requires crypto |
||||||
|
*/ |
||||||
|
export function createEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string { |
||||||
|
// Placeholder - would compute SHA256
|
||||||
|
const serialized = JSON.stringify([ |
||||||
|
0, |
||||||
|
event.pubkey, |
||||||
|
event.created_at, |
||||||
|
event.kind, |
||||||
|
event.tags, |
||||||
|
event.content |
||||||
|
]); |
||||||
|
// In production, use: crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized))
|
||||||
|
return 'placeholder_id_' + Date.now(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sign event (placeholder) |
||||||
|
*/ |
||||||
|
export async function signEvent( |
||||||
|
event: Omit<NostrEvent, 'id' | 'sig'> |
||||||
|
): Promise<NostrEvent> { |
||||||
|
const id = createEventId(event); |
||||||
|
// Placeholder signature
|
||||||
|
const sig = 'placeholder_sig_' + Date.now(); |
||||||
|
return { ...event, id, sig }; |
||||||
|
} |
||||||
@ -0,0 +1,214 @@ |
|||||||
|
/** |
||||||
|
* Relay pool management |
||||||
|
* Manages WebSocket connections to Nostr relays |
||||||
|
*/ |
||||||
|
|
||||||
|
import { config } from './config.js'; |
||||||
|
|
||||||
|
export interface RelayStatus { |
||||||
|
url: string; |
||||||
|
connected: boolean; |
||||||
|
latency?: number; |
||||||
|
lastError?: string; |
||||||
|
lastConnected?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export type RelayStatusCallback = (status: RelayStatus) => void; |
||||||
|
|
||||||
|
class RelayPool { |
||||||
|
private relays: Map<string, WebSocket | null> = new Map(); |
||||||
|
private status: Map<string, RelayStatus> = new Map(); |
||||||
|
private statusCallbacks: Set<RelayStatusCallback> = new Set(); |
||||||
|
private reconnectTimeouts: Map<string, NodeJS.Timeout> = new Map(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Add relay to pool |
||||||
|
*/ |
||||||
|
async addRelay(url: string): Promise<void> { |
||||||
|
if (this.relays.has(url)) return; |
||||||
|
|
||||||
|
this.relays.set(url, null); |
||||||
|
this.updateStatus(url, { connected: false }); |
||||||
|
|
||||||
|
await this.connect(url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove relay from pool |
||||||
|
*/ |
||||||
|
removeRelay(url: string): void { |
||||||
|
const ws = this.relays.get(url); |
||||||
|
if (ws) { |
||||||
|
ws.close(); |
||||||
|
} |
||||||
|
this.relays.delete(url); |
||||||
|
this.status.delete(url); |
||||||
|
|
||||||
|
const timeout = this.reconnectTimeouts.get(url); |
||||||
|
if (timeout) { |
||||||
|
clearTimeout(timeout); |
||||||
|
this.reconnectTimeouts.delete(url); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Connect to a relay |
||||||
|
*/ |
||||||
|
private async connect(url: string): Promise<void> { |
||||||
|
try { |
||||||
|
const ws = new WebSocket(url); |
||||||
|
const startTime = Date.now(); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
const latency = Date.now() - startTime; |
||||||
|
this.relays.set(url, ws); |
||||||
|
this.updateStatus(url, { |
||||||
|
connected: true, |
||||||
|
latency, |
||||||
|
lastConnected: Date.now() |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = (error) => { |
||||||
|
this.updateStatus(url, { |
||||||
|
connected: false, |
||||||
|
lastError: error.message || 'Connection error' |
||||||
|
}); |
||||||
|
this.scheduleReconnect(url); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onclose = () => { |
||||||
|
this.relays.set(url, null); |
||||||
|
this.updateStatus(url, { connected: false }); |
||||||
|
this.scheduleReconnect(url); |
||||||
|
}; |
||||||
|
|
||||||
|
// Store WebSocket for message sending
|
||||||
|
this.relays.set(url, ws); |
||||||
|
} catch (error) { |
||||||
|
this.updateStatus(url, { |
||||||
|
connected: false, |
||||||
|
lastError: error instanceof Error ? error.message : 'Unknown error' |
||||||
|
}); |
||||||
|
this.scheduleReconnect(url); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Schedule reconnection attempt |
||||||
|
*/ |
||||||
|
private scheduleReconnect(url: string): void { |
||||||
|
const existing = this.reconnectTimeouts.get(url); |
||||||
|
if (existing) clearTimeout(existing); |
||||||
|
|
||||||
|
const timeout = setTimeout(() => { |
||||||
|
this.reconnectTimeouts.delete(url); |
||||||
|
this.connect(url); |
||||||
|
}, 5000); // 5 second delay
|
||||||
|
|
||||||
|
this.reconnectTimeouts.set(url, timeout); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update relay status and notify callbacks |
||||||
|
*/ |
||||||
|
private updateStatus(url: string, updates: Partial<RelayStatus>): void { |
||||||
|
const current = this.status.get(url) || { url, connected: false }; |
||||||
|
const updated = { ...current, ...updates }; |
||||||
|
this.status.set(url, updated); |
||||||
|
|
||||||
|
// Notify callbacks
|
||||||
|
this.statusCallbacks.forEach((cb) => cb(updated)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get WebSocket for a relay |
||||||
|
*/ |
||||||
|
getRelay(url: string): WebSocket | null { |
||||||
|
return this.relays.get(url) || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all connected relays |
||||||
|
*/ |
||||||
|
getConnectedRelays(): string[] { |
||||||
|
return Array.from(this.relays.entries()) |
||||||
|
.filter(([, ws]) => ws && ws.readyState === WebSocket.OPEN) |
||||||
|
.map(([url]) => url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay status |
||||||
|
*/ |
||||||
|
getStatus(url: string): RelayStatus | undefined { |
||||||
|
return this.status.get(url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all relay statuses |
||||||
|
*/ |
||||||
|
getAllStatuses(): RelayStatus[] { |
||||||
|
return Array.from(this.status.values()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Subscribe to status updates |
||||||
|
*/ |
||||||
|
onStatusUpdate(callback: RelayStatusCallback): () => void { |
||||||
|
this.statusCallbacks.add(callback); |
||||||
|
return () => this.statusCallbacks.delete(callback); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Send message to relay |
||||||
|
*/ |
||||||
|
send(url: string, message: string): boolean { |
||||||
|
const ws = this.relays.get(url); |
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) { |
||||||
|
ws.send(message); |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Send message to all connected relays |
||||||
|
*/ |
||||||
|
broadcast(message: string): string[] { |
||||||
|
const sent: string[] = []; |
||||||
|
for (const [url, ws] of this.relays.entries()) { |
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) { |
||||||
|
ws.send(message); |
||||||
|
sent.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
return sent; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close all connections |
||||||
|
*/ |
||||||
|
closeAll(): void { |
||||||
|
for (const [url, ws] of this.relays.entries()) { |
||||||
|
if (ws) { |
||||||
|
ws.close(); |
||||||
|
} |
||||||
|
const timeout = this.reconnectTimeouts.get(url); |
||||||
|
if (timeout) { |
||||||
|
clearTimeout(timeout); |
||||||
|
this.reconnectTimeouts.delete(url); |
||||||
|
} |
||||||
|
} |
||||||
|
this.relays.clear(); |
||||||
|
this.status.clear(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const relayPool = new RelayPool(); |
||||||
|
|
||||||
|
// Initialize with default relays
|
||||||
|
export async function initializeRelayPool(): Promise<void> { |
||||||
|
for (const url of config.defaultRelays) { |
||||||
|
await relayPool.addRelay(url); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
/** |
||||||
|
* Subscription manager for Nostr subscriptions |
||||||
|
*/ |
||||||
|
|
||||||
|
import { relayPool } from './relay-pool.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface NostrFilter { |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
'#e'?: string[]; |
||||||
|
'#p'?: string[]; |
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export type EventCallback = (event: NostrEvent, relay: string) => void; |
||||||
|
export type EoseCallback = (relay: string) => void; |
||||||
|
|
||||||
|
class SubscriptionManager { |
||||||
|
private subscriptions: Map<string, Subscription> = new Map(); |
||||||
|
private nextSubId = 1; |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a new subscription |
||||||
|
*/ |
||||||
|
subscribe( |
||||||
|
subId: string, |
||||||
|
relays: string[], |
||||||
|
filters: NostrFilter[], |
||||||
|
onEvent: EventCallback, |
||||||
|
onEose?: EoseCallback |
||||||
|
): void { |
||||||
|
// Close existing subscription if any
|
||||||
|
this.unsubscribe(subId); |
||||||
|
|
||||||
|
const subscription: Subscription = { |
||||||
|
id: subId, |
||||||
|
relays, |
||||||
|
filters, |
||||||
|
onEvent, |
||||||
|
onEose, |
||||||
|
messageHandlers: new Map() |
||||||
|
}; |
||||||
|
|
||||||
|
// Set up message handlers for each relay
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
const ws = relayPool.getRelay(relayUrl); |
||||||
|
if (!ws) continue; |
||||||
|
|
||||||
|
const handler = (event: MessageEvent) => { |
||||||
|
try { |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
if (Array.isArray(data)) { |
||||||
|
const [type, ...rest] = data; |
||||||
|
|
||||||
|
if (type === 'EVENT' && rest[0] === subId) { |
||||||
|
const event = rest[1] as NostrEvent; |
||||||
|
if (this.matchesFilters(event, filters)) { |
||||||
|
onEvent(event, relayUrl); |
||||||
|
} |
||||||
|
} else if (type === 'EOSE' && rest[0] === subId) { |
||||||
|
onEose?.(relayUrl); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error parsing relay message:', error); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.addEventListener('message', handler); |
||||||
|
subscription.messageHandlers.set(relayUrl, handler); |
||||||
|
|
||||||
|
// Send subscription request
|
||||||
|
const message = JSON.stringify(['REQ', subId, ...filters]); |
||||||
|
relayPool.send(relayUrl, message); |
||||||
|
} |
||||||
|
|
||||||
|
this.subscriptions.set(subId, subscription); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if event matches filters |
||||||
|
*/ |
||||||
|
private matchesFilters(event: NostrEvent, filters: NostrFilter[]): boolean { |
||||||
|
return filters.some((filter) => { |
||||||
|
if (filter.ids && !filter.ids.includes(event.id)) return false; |
||||||
|
if (filter.authors && !filter.authors.includes(event.pubkey)) return false; |
||||||
|
if (filter.kinds && !filter.kinds.includes(event.kind)) return false; |
||||||
|
if (filter.since && event.created_at < filter.since) return false; |
||||||
|
if (filter.until && event.created_at > filter.until) return false; |
||||||
|
|
||||||
|
// Tag filters
|
||||||
|
if (filter['#e']) { |
||||||
|
const hasE = event.tags.some((t) => t[0] === 'e' && filter['#e']!.includes(t[1])); |
||||||
|
if (!hasE) return false; |
||||||
|
} |
||||||
|
if (filter['#p']) { |
||||||
|
const hasP = event.tags.some((t) => t[0] === 'p' && filter['#p']!.includes(t[1])); |
||||||
|
if (!hasP) return false; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Unsubscribe from a subscription |
||||||
|
*/ |
||||||
|
unsubscribe(subId: string): void { |
||||||
|
const subscription = this.subscriptions.get(subId); |
||||||
|
if (!subscription) return; |
||||||
|
|
||||||
|
// Remove message handlers
|
||||||
|
for (const [relayUrl, handler] of subscription.messageHandlers.entries()) { |
||||||
|
const ws = relayPool.getRelay(relayUrl); |
||||||
|
if (ws) { |
||||||
|
ws.removeEventListener('message', handler); |
||||||
|
} |
||||||
|
|
||||||
|
// Send close message
|
||||||
|
const message = JSON.stringify(['CLOSE', subId]); |
||||||
|
relayPool.send(relayUrl, message); |
||||||
|
} |
||||||
|
|
||||||
|
this.subscriptions.delete(subId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a unique subscription ID |
||||||
|
*/ |
||||||
|
generateSubId(): string { |
||||||
|
return `sub_${this.nextSubId++}_${Date.now()}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close all subscriptions |
||||||
|
*/ |
||||||
|
closeAll(): void { |
||||||
|
for (const subId of this.subscriptions.keys()) { |
||||||
|
this.unsubscribe(subId); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
interface Subscription { |
||||||
|
id: string; |
||||||
|
relays: string[]; |
||||||
|
filters: NostrFilter[]; |
||||||
|
onEvent: EventCallback; |
||||||
|
onEose?: EoseCallback; |
||||||
|
messageHandlers: Map<string, (event: MessageEvent) => void>; |
||||||
|
} |
||||||
|
|
||||||
|
export const subscriptionManager = new SubscriptionManager(); |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
/** |
||||||
|
* Bech32 utilities for NIP-19 encoding/decoding |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface DecodedBech32 { |
||||||
|
type: 'npub' | 'nsec' | 'note' | 'nevent' | 'naddr' | 'nprofile'; |
||||||
|
data: Uint8Array; |
||||||
|
relay?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Decode a bech32 string (simplified - full implementation would use bech32 library) |
||||||
|
* This is a placeholder - in production, use a proper bech32 library |
||||||
|
*/ |
||||||
|
export function decodeBech32(bech32: string): DecodedBech32 | null { |
||||||
|
try { |
||||||
|
const prefix = bech32.split('1')[0]; |
||||||
|
if (!prefix) return null; |
||||||
|
|
||||||
|
// Basic validation - full implementation needed
|
||||||
|
if (prefix === 'npub' || prefix === 'nsec' || prefix === 'note') { |
||||||
|
return { |
||||||
|
type: prefix as 'npub' | 'nsec' | 'note', |
||||||
|
data: new Uint8Array(32) // Placeholder
|
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Encode data to bech32 format |
||||||
|
*/ |
||||||
|
export function encodeBech32(type: string, data: Uint8Array, relay?: string): string { |
||||||
|
// Placeholder - full implementation needed with bech32 library
|
||||||
|
// For now, return hex representation
|
||||||
|
return `${type}1${Array.from(data) |
||||||
|
.map((b) => b.toString(16).padStart(2, '0')) |
||||||
|
.join('')}`;
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate bech32 string format |
||||||
|
*/ |
||||||
|
export function isValidBech32(bech32: string): boolean { |
||||||
|
return /^(npub|nsec|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32); |
||||||
|
} |
||||||
@ -0,0 +1,55 @@ |
|||||||
|
/** |
||||||
|
* Event validation utilities |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate event structure |
||||||
|
*/ |
||||||
|
export function isValidEvent(event: unknown): event is NostrEvent { |
||||||
|
if (!event || typeof event !== 'object') return false; |
||||||
|
|
||||||
|
const e = event as Record<string, unknown>; |
||||||
|
|
||||||
|
return ( |
||||||
|
typeof e.kind === 'number' && |
||||||
|
typeof e.pubkey === 'string' && |
||||||
|
typeof e.created_at === 'number' && |
||||||
|
typeof e.content === 'string' && |
||||||
|
typeof e.id === 'string' && |
||||||
|
typeof e.sig === 'string' && |
||||||
|
Array.isArray(e.tags) && |
||||||
|
e.pubkey.length === 64 && |
||||||
|
e.id.length === 64 && |
||||||
|
e.sig.length === 128 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if event has required tags for a kind |
||||||
|
*/ |
||||||
|
export function hasRequiredTags(event: NostrEvent, kind: number): boolean { |
||||||
|
switch (kind) { |
||||||
|
case 0: |
||||||
|
// Kind 0 can have tags or JSON content
|
||||||
|
return true; |
||||||
|
case 11: |
||||||
|
// Thread - should have title tag
|
||||||
|
return true; |
||||||
|
case 1111: |
||||||
|
// Comment - should have K and E tags
|
||||||
|
return event.tags.some((t) => t[0] === 'K' || t[0] === 'E'); |
||||||
|
default: |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate event signature (placeholder - would need crypto library) |
||||||
|
*/ |
||||||
|
export function isValidSignature(event: NostrEvent): boolean { |
||||||
|
// Placeholder - full implementation would verify signature
|
||||||
|
// using secp256k1 cryptography
|
||||||
|
return event.sig.length === 128; |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
/** |
||||||
|
* Key management with NIP-49 encryption |
||||||
|
* All private keys MUST be encrypted before storage |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Encrypt a private key using NIP-49 (password-based encryption) |
||||||
|
* This is a placeholder - full implementation requires: |
||||||
|
* - scrypt for key derivation |
||||||
|
* - AES-256-GCM for encryption |
||||||
|
* - Base64 encoding |
||||||
|
*/ |
||||||
|
export async function encryptPrivateKey(nsec: string, password: string): Promise<string> { |
||||||
|
// Placeholder implementation
|
||||||
|
// Full NIP-49 implementation would:
|
||||||
|
// 1. Derive key from password using scrypt
|
||||||
|
// 2. Generate random salt and nonce
|
||||||
|
// 3. Encrypt nsec with AES-256-GCM
|
||||||
|
// 4. Encode as ncryptsec format
|
||||||
|
throw new Error('NIP-49 encryption not yet implemented'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Decrypt a private key using NIP-49 |
||||||
|
*/ |
||||||
|
export async function decryptPrivateKey(ncryptsec: string, password: string): Promise<string> { |
||||||
|
// Placeholder implementation
|
||||||
|
// Full NIP-49 implementation would:
|
||||||
|
// 1. Decode ncryptsec format
|
||||||
|
// 2. Derive key from password using scrypt
|
||||||
|
// 3. Decrypt with AES-256-GCM
|
||||||
|
// 4. Return plain nsec
|
||||||
|
throw new Error('NIP-49 decryption not yet implemented'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a new private key |
||||||
|
*/ |
||||||
|
export function generatePrivateKey(): string { |
||||||
|
// Placeholder - would use crypto.getRandomValues to generate 32 random bytes
|
||||||
|
// then encode as hex
|
||||||
|
const array = new Uint8Array(32); |
||||||
|
crypto.getRandomValues(array); |
||||||
|
return Array.from(array) |
||||||
|
.map((b) => b.toString(16).padStart(2, '0')) |
||||||
|
.join(''); |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
/** |
||||||
|
* HTML sanitization using DOMPurify |
||||||
|
*/ |
||||||
|
|
||||||
|
import DOMPurify from 'dompurify'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Sanitize HTML content |
||||||
|
*/ |
||||||
|
export function sanitizeHtml(dirty: string): string { |
||||||
|
return DOMPurify.sanitize(dirty, { |
||||||
|
ALLOWED_TAGS: [ |
||||||
|
'p', |
||||||
|
'br', |
||||||
|
'strong', |
||||||
|
'em', |
||||||
|
'u', |
||||||
|
's', |
||||||
|
'code', |
||||||
|
'pre', |
||||||
|
'a', |
||||||
|
'ul', |
||||||
|
'ol', |
||||||
|
'li', |
||||||
|
'blockquote', |
||||||
|
'h1', |
||||||
|
'h2', |
||||||
|
'h3', |
||||||
|
'h4', |
||||||
|
'h5', |
||||||
|
'h6', |
||||||
|
'img', |
||||||
|
'video', |
||||||
|
'audio' |
||||||
|
], |
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload'], |
||||||
|
ALLOW_DATA_ATTR: false |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sanitize markdown-rendered HTML |
||||||
|
*/ |
||||||
|
export function sanitizeMarkdown(html: string): string { |
||||||
|
return sanitizeHtml(html); |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
/** |
||||||
|
* Nostr type definitions |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface NostrEvent { |
||||||
|
id: string; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
sig: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface NostrFilter { |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
'#e'?: string[]; |
||||||
|
'#p'?: string[]; |
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import '../app.css'; |
||||||
|
</script> |
||||||
|
|
||||||
|
<slot /> |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export const prerender = true; |
||||||
|
export const ssr = false; |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../lib/components/layout/Header.svelte'; |
||||||
|
import ThreadList from '../lib/modules/threads/ThreadList.svelte'; |
||||||
|
import { nostrClient } from '../lib/services/nostr/applesauce-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">Aitherboard</h1> |
||||||
|
<p class="mb-4">Decentralized messageboard on Nostr</p> |
||||||
|
<a href="/feed" class="text-blue-500 underline mb-4 block">View feed →</a> |
||||||
|
<ThreadList /> |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
main { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../lib/components/layout/Header.svelte'; |
||||||
|
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">Kind 1 Feed</h1> |
||||||
|
<p>Feed implementation coming soon...</p> |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
main { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { authenticateWithNIP07 } from '../../lib/services/nostr/auth-handler.js'; |
||||||
|
import { isNIP07Available } from '../../lib/services/auth/nip07-signer.js'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js'; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
}); |
||||||
|
|
||||||
|
let error = $state<string | null>(null); |
||||||
|
let loading = $state(false); |
||||||
|
|
||||||
|
async function loginWithNIP07() { |
||||||
|
if (!isNIP07Available()) { |
||||||
|
error = 'NIP-07 extension not available. Please install a Nostr extension like Alby or nos2x.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
await authenticateWithNIP07(); |
||||||
|
goto('/'); |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Authentication failed'; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-md"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">Login</h1> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="space-y-4"> |
||||||
|
<button |
||||||
|
on:click={loginWithNIP07} |
||||||
|
disabled={loading} |
||||||
|
class="w-full px-4 py-2 bg-blue-500 text-white disabled:opacity-50" |
||||||
|
> |
||||||
|
{loading ? 'Connecting...' : 'Login with NIP-07'} |
||||||
|
</button> |
||||||
|
|
||||||
|
<p class="text-sm text-gray-600"> |
||||||
|
Other authentication methods (nsec, bunker, anonymous) coming soon... |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</main> |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../../lib/components/layout/Header.svelte'; |
||||||
|
import ProfileBadge from '../../../lib/components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte'; |
||||||
|
import { nostrClient } from '../../../lib/services/nostr/applesauce-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../../lib/types/nostr.js'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
|
||||||
|
let thread = $state<NostrEvent | null>(null); |
||||||
|
let loading = $state(true); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
if ($page.params.id) { |
||||||
|
loadThread(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if ($page.params.id && !loading) { |
||||||
|
loadThread(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadThread() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const event = await nostrClient.getEventById($page.params.id, [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]); |
||||||
|
thread = event; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading thread:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getTitle(): string { |
||||||
|
if (!thread) return ''; |
||||||
|
const titleTag = thread.tags.find((t) => t[0] === 'title'); |
||||||
|
return titleTag?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
{#if loading} |
||||||
|
<p>Loading thread...</p> |
||||||
|
{:else if thread} |
||||||
|
<article class="thread-view"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">{getTitle()}</h1> |
||||||
|
<div class="mb-4"> |
||||||
|
<ProfileBadge pubkey={thread.pubkey} /> |
||||||
|
</div> |
||||||
|
<div class="mb-4"> |
||||||
|
<MarkdownRenderer content={thread.content} /> |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
{:else} |
||||||
|
<p>Thread not found</p> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
.thread-view { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../lib/components/layout/Header.svelte'; |
||||||
|
import ThreadList from '../../lib/modules/threads/ThreadList.svelte'; |
||||||
|
import CreateThreadForm from '../../lib/modules/threads/CreateThreadForm.svelte'; |
||||||
|
import { sessionManager } from '../../lib/services/auth/session-manager.js'; |
||||||
|
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
let showCreateForm = $state(false); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<div class="mb-4"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">Threads</h1> |
||||||
|
{#if sessionManager.isLoggedIn()} |
||||||
|
<button |
||||||
|
on:click={() => (showCreateForm = !showCreateForm)} |
||||||
|
class="mb-4 px-4 py-2 bg-blue-500 text-white" |
||||||
|
> |
||||||
|
{showCreateForm ? 'Cancel' : 'Create Thread'} |
||||||
|
</button> |
||||||
|
{#if showCreateForm} |
||||||
|
<CreateThreadForm /> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<ThreadList /> |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
main { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
import adapter from '@sveltejs/adapter-static'; |
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; |
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */ |
||||||
|
const config = { |
||||||
|
preprocess: vitePreprocess(), |
||||||
|
kit: { |
||||||
|
adapter: adapter({ |
||||||
|
pages: 'build', |
||||||
|
assets: 'build', |
||||||
|
fallback: 'index.html', |
||||||
|
precompress: false, |
||||||
|
strict: true |
||||||
|
}) |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
export default config; |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
/** @type {import('tailwindcss').Config} */ |
||||||
|
export default { |
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'], |
||||||
|
theme: { |
||||||
|
extend: { |
||||||
|
colors: { |
||||||
|
// 4chan-style minimal color palette
|
||||||
|
board: { |
||||||
|
bg: '#d6daf0', |
||||||
|
post: '#eef2ff', |
||||||
|
highlight: '#fffecc', |
||||||
|
border: '#b7c5d9' |
||||||
|
} |
||||||
|
}, |
||||||
|
fontFamily: { |
||||||
|
sans: ['system-ui', '-apple-system', 'sans-serif'] |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
plugins: [] |
||||||
|
}; |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
{ |
||||||
|
"extends": "./.svelte-kit/tsconfig.json", |
||||||
|
"compilerOptions": { |
||||||
|
"allowJs": true, |
||||||
|
"checkJs": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"forceConsistentCasingInFileNames": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"sourceMap": true, |
||||||
|
"strict": true, |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"target": "ES2022", |
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"] |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite'; |
||||||
|
import { defineConfig } from 'vite'; |
||||||
|
import { execSync } from 'child_process'; |
||||||
|
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [ |
||||||
|
sveltekit(), |
||||||
|
{ |
||||||
|
name: 'generate-healthz', |
||||||
|
buildStart() { |
||||||
|
try { |
||||||
|
execSync('node scripts/generate-healthz.js', { stdio: 'inherit' }); |
||||||
|
} catch (error) { |
||||||
|
console.warn('Failed to generate healthz.json:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
], |
||||||
|
server: { |
||||||
|
port: 5173, |
||||||
|
strictPort: false |
||||||
|
}, |
||||||
|
build: { |
||||||
|
target: 'esnext', |
||||||
|
sourcemap: true |
||||||
|
} |
||||||
|
}); |
||||||
Loading…
Reference in new issue