Browse Source

refactor

master
Silberengel 1 month ago
parent
commit
2a3f1c5bcf
  1. 1
      src/app.d.ts
  2. 7
      src/app.html
  3. 3
      src/lib/components/layout/ProfileBadge.svelte
  4. 234
      src/lib/modules/feed/Kind1Post.svelte
  5. 2
      src/lib/modules/feed/ReplyToKind1Form.svelte
  6. 2
      src/lib/modules/profiles/PaymentAddresses.svelte
  7. 4
      src/lib/modules/profiles/ProfilePage.svelte
  8. 2
      src/lib/services/auth/activity-tracker.ts
  9. 46
      src/lib/services/auth/bunker-signer.ts
  10. 94
      src/lib/services/auth/relay-list-fetcher.ts
  11. 24
      src/lib/services/auth/session-manager.ts
  12. 16
      src/lib/services/auth/user-preferences-fetcher.ts
  13. 50
      src/lib/services/auth/user-status-fetcher.ts
  14. 34
      src/lib/services/event-filter.ts
  15. 21
      src/lib/services/nostr/auth-handler.ts
  16. 7
      src/lib/services/nostr/config.ts
  17. 138
      src/lib/services/nostr/nostr-client.ts
  18. 2
      src/lib/services/nostr/relay-manager.ts
  19. 143
      src/lib/services/user-data.ts
  20. 5
      src/lib/types/nostr.js
  21. 1
      src/lib/types/nostr.js.map
  22. 2
      src/routes/login/+page.svelte
  23. 1
      src/vite-env.d.ts
  24. BIN
      static/aitherboard-og.jpg
  25. 18
      static/manifest.json
  26. 19
      static/og-image.svg

1
src/app.d.ts vendored

@ -17,7 +17,6 @@ interface ImportMetaEnv {
readonly VITE_DEFAULT_RELAYS?: string; readonly VITE_DEFAULT_RELAYS?: string;
readonly VITE_ZAP_THRESHOLD?: string; readonly VITE_ZAP_THRESHOLD?: string;
readonly VITE_THREAD_TIMEOUT_DAYS?: string; readonly VITE_THREAD_TIMEOUT_DAYS?: string;
readonly VITE_PWA_ENABLED?: string;
} }
interface ImportMeta { interface ImportMeta {

7
src/app.html

@ -20,7 +20,7 @@
<meta property="og:url" content="https://aitherboard.com/" /> <meta property="og:url" content="https://aitherboard.com/" />
<meta property="og:title" content="Aitherboard - Decentralized Messageboard on Nostr" /> <meta property="og:title" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." /> <meta property="og:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta property="og:image" content="%sveltekit.assets%/aither.png" /> <meta property="og:image" content="%sveltekit.assets%/aitherboard-og.jpg" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" /> <meta property="og:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" />
@ -32,16 +32,13 @@
<meta name="twitter:url" content="https://aitherboard.com/" /> <meta name="twitter:url" content="https://aitherboard.com/" />
<meta name="twitter:title" content="Aitherboard - Decentralized Messageboard on Nostr" /> <meta name="twitter:title" content="Aitherboard - Decentralized Messageboard on Nostr" />
<meta name="twitter:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." /> <meta name="twitter:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta name="twitter:image" content="%sveltekit.assets%/aither.png" /> <meta name="twitter:image" content="%sveltekit.assets%/aitherboard-og.jpg" />
<meta name="twitter:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" /> <meta name="twitter:image:alt" content="Aitherboard - Decentralized Messageboard on Nostr" />
<!-- Additional Meta Tags --> <!-- Additional Meta Tags -->
<meta name="theme-color" content="#f1f5f9" /> <meta name="theme-color" content="#f1f5f9" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<!-- PWA Manifest -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

3
src/lib/components/layout/ProfileBadge.svelte

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js'; import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js';
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; import { fetchProfile, fetchUserStatus } from '../../services/user-data.js';
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Props { interface Props {

234
src/lib/modules/feed/Kind1Post.svelte

@ -1,234 +0,0 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
post: NostrEvent;
parentEvent?: NostrEvent; // Optional parent event if already loaded
onReply?: (post: NostrEvent) => void;
}
let { post, parentEvent: providedParentEvent, onReply }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
// If provided parent event is available, use it
return;
}
// If no provided parent and this is a reply, try to load it
if (!loadedParentEvent && isReply()) {
loadParentEvent();
}
});
onMount(async () => {
// If parent not provided and this is a reply, try to load it
if (!providedParentEvent && !loadedParentEvent && isReply()) {
await loadParentEvent();
}
});
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - post.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
const minutes = Math.floor(diff / 60);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function getClientName(): string | null {
const clientTag = post.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function isReply(): boolean {
// Check if this is a reply (has e tag pointing to another event)
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id);
}
function getReplyEventId(): string | null {
// Find the 'e' tag that's not the root (the direct parent)
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (replyTag) return replyTag[1];
// Fallback: find any 'e' tag that's not the root
const rootId = getRootEventId();
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
return eTag?.[1] || null;
}
function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root');
return rootTag?.[1] || null;
}
async function loadParentEvent() {
const replyEventId = getReplyEventId();
if (!replyEventId || loadingParent) return;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [replyEventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedParentEvent = events[0];
}
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
}
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply() && parentEvent}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
{/if}
<div class="post-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
{/if}
</div>
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex items-center gap-4">
<FeedReactionButtons event={post} />
<ZapButton event={post} />
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
.Feed-post {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.post-content {
line-height: 1.6;
}
.post-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

2
src/lib/modules/feed/ReplyToKind1Form.svelte

@ -55,7 +55,7 @@
// Get target inbox if replying // Get target inbox if replying
let targetInbox: string[] | undefined; let targetInbox: string[] | undefined;
try { try {
const { fetchRelayLists } = await import('../../services/auth/relay-list-fetcher.js'); const { fetchRelayLists } = await import('../../services/user-data.js');
const { inbox } = await fetchRelayLists(parentEvent.pubkey); const { inbox } = await fetchRelayLists(parentEvent.pubkey);
targetInbox = inbox; targetInbox = inbox;
} catch { } catch {

2
src/lib/modules/profiles/PaymentAddresses.svelte

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; import { fetchProfile } from '../../services/user-data.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';

4
src/lib/modules/profiles/ProfilePage.svelte

@ -3,13 +3,11 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import PaymentAddresses from './PaymentAddresses.svelte'; import PaymentAddresses from './PaymentAddresses.svelte';
import FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { ProfileData } from '../../services/auth/profile-fetcher.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
let profile = $state<ProfileData | null>(null); let profile = $state<ProfileData | null>(null);

2
src/lib/services/auth/activity-tracker.ts

@ -14,7 +14,7 @@ export async function getLastActivity(pubkey: string): Promise<number | undefine
{ authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 } { authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 }
]; ];
const events = nostrClient.getByFilters(filters); const events = await nostrClient.getByFilters(filters);
if (events.length > 0) { if (events.length > 0) {
// Sort by created_at descending and return the most recent // Sort by created_at descending and return the most recent
const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);

46
src/lib/services/auth/bunker-signer.ts

@ -1,46 +0,0 @@
/**
* 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');
}

94
src/lib/services/auth/relay-list-fetcher.ts

@ -1,94 +0,0 @@
/**
* Relay list fetcher (kind 10002 and 10432)
*/
import { nostrClient } from '../nostr/nostr-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);
// If no markers, relay is both read and write
if (markers.length === 0) {
relays.push({ url, read: true, write: true });
continue;
}
// Check for explicit markers
const hasRead = markers.includes('read');
const hasWrite = markers.includes('write');
// If only 'read' marker: read=true, write=false
// If only 'write' marker: read=false, write=true
// If both or neither explicitly: both true (default behavior)
const read = hasRead || (!hasRead && !hasWrite);
const write = hasWrite || (!hasRead && !hasWrite);
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)]
};
}

24
src/lib/services/auth/session-manager.ts

@ -4,7 +4,7 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
export type AuthMethod = 'nip07' | 'nsec' | 'bunker' | 'anonymous'; export type AuthMethod = 'nip07' | 'nsec' | 'anonymous';
export interface UserSession { export interface UserSession {
pubkey: string; pubkey: string;
@ -143,28 +143,6 @@ class SessionManager {
} }
return false; return false;
} }
case 'bunker': {
// For bunker, restore if we have the bunker URI
if (metadata?.bunkerUri) {
const { connectBunker, signEventWithBunker } = await import('./bunker-signer.js');
try {
const connection = await connectBunker(metadata.bunkerUri);
if (connection.pubkey === pubkey) {
this.setSession({
pubkey,
method: 'bunker',
signer: async (event) => signEventWithBunker(event, connection),
createdAt: data.createdAt || Date.now()
}, { bunkerUri: metadata.bunkerUri });
return true;
}
} catch {
// Bunker connection failed
return false;
}
}
return false;
}
case 'anonymous': { case 'anonymous': {
// For anonymous, we can restore if the encrypted key is stored // For anonymous, we can restore if the encrypted key is stored
// The key is stored in IndexedDB, we just need to verify it exists // The key is stored in IndexedDB, we just need to verify it exists

16
src/lib/services/auth/user-preferences-fetcher.ts

@ -1,16 +0,0 @@
/**
* 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;
}

50
src/lib/services/auth/user-status-fetcher.ts

@ -1,50 +0,0 @@
/**
* User status fetcher (kind 30315, NIP-38)
*/
import { nostrClient } from '../nostr/nostr-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
} as any // NIP-38 uses #d tag for parameterized replaceable events
],
relayList,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) return null;
return parseUserStatus(events[0]);
}

34
src/lib/services/event-filter.ts

@ -0,0 +1,34 @@
/**
* Unified event filtering service
* Handles content filtering, mute lists, and NSFW detection
*/
import type { NostrEvent } from '../../types/nostr.js';
import { getMuteList } from './nostr/auth-handler.js';
/**
* Check if event should be hidden (content filtering + mute list)
*/
export function shouldHideEvent(event: NostrEvent): boolean {
// Check mute list
const muteList = getMuteList();
if (muteList.has(event.pubkey)) return true;
// 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;
}
/**
* Filter events (remove hidden content)
*/
export function filterEvents(events: NostrEvent[]): NostrEvent[] {
return events.filter((event) => !shouldHideEvent(event));
}

21
src/lib/services/nostr/auth-handler.ts

@ -4,14 +4,13 @@
import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js'; import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js';
import { signEventWithNsec, getPublicKeyFromNsec } from '../auth/nsec-signer.js'; import { signEventWithNsec, getPublicKeyFromNsec } from '../auth/nsec-signer.js';
import { signEventWithBunker, connectBunker } from '../auth/bunker-signer.js';
import { import {
signEventWithAnonymous, signEventWithAnonymous,
generateAnonymousKey generateAnonymousKey
} from '../auth/anonymous-signer.js'; } from '../auth/anonymous-signer.js';
import { decryptPrivateKey } from '../security/key-management.js'; import { decryptPrivateKey } from '../security/key-management.js';
import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; import { sessionManager, type AuthMethod } from '../auth/session-manager.js';
import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; import { fetchRelayLists } from '../user-data.js';
import { nostrClient } from './nostr-client.js'; import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js'; import { relayManager } from './relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -64,24 +63,6 @@ export async function authenticateWithNsec(
return 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()
}, { bunkerUri }); // Store bunker URI for restoration
await loadUserPreferences(connection.pubkey);
return connection.pubkey;
}
/** /**
* Authenticate as anonymous * Authenticate as anonymous
*/ */

7
src/lib/services/nostr/config.ts

@ -29,7 +29,6 @@ export interface NostrConfig {
profileRelays: string[]; profileRelays: string[];
zapThreshold: number; zapThreshold: number;
threadTimeoutDays: number; threadTimeoutDays: number;
pwaEnabled: boolean;
threadPublishRelays: string[]; threadPublishRelays: string[];
relayTimeout: number; relayTimeout: number;
} }
@ -50,18 +49,12 @@ function parseIntEnv(envVar: string | undefined, fallback: number, min: number =
return parsed; return parsed;
} }
function parseBoolEnv(envVar: string | undefined, fallback: boolean): boolean {
if (!envVar) return fallback;
return envVar.toLowerCase() === 'true' || envVar === '1';
}
export function getConfig(): NostrConfig { export function getConfig(): NostrConfig {
return { return {
defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS), defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS),
profileRelays: PROFILE_RELAYS, profileRelays: PROFILE_RELAYS,
zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0), zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0),
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
pwaEnabled: parseBoolEnv(import.meta.env.VITE_PWA_ENABLED, true),
threadPublishRelays: THREAD_PUBLISH_RELAYS, threadPublishRelays: THREAD_PUBLISH_RELAYS,
relayTimeout: RELAY_TIMEOUT relayTimeout: RELAY_TIMEOUT
}; };

138
src/lib/services/nostr/nostr-client.ts

@ -7,7 +7,7 @@ import { Relay, type Filter, matchFilter } from 'nostr-tools';
import { config } from './config.js'; import { config } from './config.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js';
import { getMuteList } from './auth-handler.js'; import { filterEvents, shouldHideEvent } from '../event-filter.js';
export interface PublishOptions { export interface PublishOptions {
relays?: string[]; relays?: string[];
@ -19,7 +19,6 @@ class NostrClient {
private relays: Map<string, Relay> = new Map(); private relays: Map<string, Relay> = new Map();
private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map(); private subscriptions: Map<string, { relay: Relay; sub: any }> = new Map();
private nextSubId = 1; private nextSubId = 1;
private eventCache: Map<string, NostrEvent> = new Map(); // In-memory cache
/** /**
* Initialize the client * Initialize the client
@ -120,58 +119,44 @@ class NostrClient {
* Add event to cache * Add event to cache
*/ */
private addToCache(event: NostrEvent): void { private addToCache(event: NostrEvent): void {
this.eventCache.set(event.id, event); // Cache to IndexedDB
// Also cache to IndexedDB
cacheEvent(event).catch((error) => { cacheEvent(event).catch((error) => {
console.error('Error caching event:', error); console.error('Error caching event:', error);
}); });
} }
/**
* Check if event should be hidden (content filtering + mute list)
*/
private shouldHideEvent(event: NostrEvent): boolean {
// Check mute list
const muteList = getMuteList();
if (muteList.has(event.pubkey)) return true;
// 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;
}
/**
* Filter events (remove hidden content)
*/
private filterEvents(events: NostrEvent[]): NostrEvent[] {
return events.filter((event) => !this.shouldHideEvent(event));
}
/** /**
* Get events from cache that match filters * Get events from cache that match filters
*/ */
private getCachedEvents(filters: Filter[]): NostrEvent[] { private async getCachedEvents(filters: Filter[]): Promise<NostrEvent[]> {
const results: NostrEvent[] = []; const results: NostrEvent[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
// Query IndexedDB for each filter
for (const filter of filters) { for (const filter of filters) {
for (const event of this.eventCache.values()) { if (filter.kinds && filter.kinds.length === 1) {
if (seen.has(event.id)) continue; const events = await getEventsByKind(filter.kinds[0], filter.limit || 50);
if (matchFilter(filter, event)) { for (const event of events) {
results.push(event); if (seen.has(event.id)) continue;
seen.add(event.id); if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
}
}
}
if (filter.authors && filter.authors.length === 1) {
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50);
for (const event of events) {
if (seen.has(event.id)) continue;
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
}
} }
} }
} }
return this.filterEvents(results); return filterEvents(results);
} }
/** /**
@ -376,52 +361,28 @@ class NostrClient {
// Query from cache first if enabled // Query from cache first if enabled
if (useCache) { if (useCache) {
// Try in-memory cache first try {
let cachedEvents = this.getCachedEvents(filters); const cachedEvents = await this.getCachedEvents(filters);
// If no results in memory, try IndexedDB if (cachedEvents.length > 0) {
if (cachedEvents.length === 0) { // Return cached events immediately
try { if (onUpdate) {
const dbEvents: NostrEvent[] = []; setTimeout(() => onUpdate(cachedEvents), 0);
// Try to get from IndexedDB based on filter
for (const filter of filters) {
if (filter.kinds && filter.kinds.length === 1) {
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50);
dbEvents.push(...events);
}
if (filter.authors && filter.authors.length === 1) {
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50);
dbEvents.push(...events);
}
}
// Filter and add to in-memory cache
const filteredDbEvents = this.filterEvents(dbEvents);
for (const event of filteredDbEvents) {
this.eventCache.set(event.id, event);
} }
cachedEvents = this.getCachedEvents(filters); // Re-query after adding to cache
} catch (error) {
console.error('Error loading from IndexedDB:', error);
}
}
if (cachedEvents.length > 0) { // Fetch fresh data in background
// Return cached events immediately if (cacheResults) {
if (onUpdate) { setTimeout(() => {
setTimeout(() => onUpdate(cachedEvents), 0); this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }).catch((error) => {
} console.error('Error fetching fresh events from relays:', error);
});
}, 0);
}
// Fetch fresh data in background return cachedEvents;
if (cacheResults) {
setTimeout(() => {
this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }).catch((error) => {
console.error('Error fetching fresh events from relays:', error);
});
}, 0);
} }
} catch (error) {
return cachedEvents; console.error('Error loading from cache:', error);
} }
} }
@ -485,7 +446,7 @@ class NostrClient {
const sub = relay.subscribe(filters, { const sub = relay.subscribe(filters, {
onevent(event: NostrEvent) { onevent(event: NostrEvent) {
if (!client.relays.has(relayUrl)) return; if (!client.relays.has(relayUrl)) return;
if (client.shouldHideEvent(event)) return; if (shouldHideEvent(event)) return;
events.set(event.id, event); events.set(event.id, event);
client.addToCache(event); client.addToCache(event);
}, },
@ -521,7 +482,7 @@ class NostrClient {
await Promise.allSettled(relayPromises); await Promise.allSettled(relayPromises);
const eventArray = Array.from(events.values()); const eventArray = Array.from(events.values());
const filtered = this.filterEvents(eventArray); const filtered = filterEvents(eventArray);
// Cache results in background // Cache results in background
if (options.cacheResults && filtered.length > 0) { if (options.cacheResults && filtered.length > 0) {
@ -543,17 +504,10 @@ class NostrClient {
* Get event by ID * Get event by ID
*/ */
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> { async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> {
// Try in-memory cache first // Try IndexedDB cache first
const cached = this.eventCache.get(id);
if (cached) return cached;
// Try IndexedDB
try { try {
const dbEvent = await getEvent(id); const dbEvent = await getEvent(id);
if (dbEvent) { if (dbEvent) return dbEvent;
this.eventCache.set(dbEvent.id, dbEvent);
return dbEvent;
}
} catch (error) { } catch (error) {
console.error('Error loading from IndexedDB:', error); console.error('Error loading from IndexedDB:', error);
} }
@ -567,7 +521,7 @@ class NostrClient {
/** /**
* Get events by filters (from cache only) * Get events by filters (from cache only)
*/ */
getByFilters(filters: Filter[]): NostrEvent[] { async getByFilters(filters: Filter[]): Promise<NostrEvent[]> {
return this.getCachedEvents(filters); return this.getCachedEvents(filters);
} }

2
src/lib/services/nostr/relay-manager.ts

@ -3,7 +3,7 @@
* Handles inbox/outbox relays and blocked relays * Handles inbox/outbox relays and blocked relays
*/ */
import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; import { fetchRelayLists } from '../user-data.js';
import { getBlockedRelays } from '../nostr/auth-handler.js'; import { getBlockedRelays } from '../nostr/auth-handler.js';
import { config } from './config.js'; import { config } from './config.js';
import { sessionManager } from '../auth/session-manager.js'; import { sessionManager } from '../auth/session-manager.js';

143
src/lib/services/auth/profile-fetcher.ts → src/lib/services/user-data.ts

@ -1,12 +1,15 @@
/** /**
* Profile fetcher (kind 0 events) * Unified user data fetcher
* Consolidates profile, status, and relay list fetching
*/ */
import { nostrClient } from '../nostr/nostr-client.js'; import { nostrClient } from './nostr/nostr-client.js';
import { cacheProfile, getProfile, getProfiles } from '../cache/profile-cache.js'; import { relayManager } from './nostr/relay-manager.js';
import { config } from '../nostr/config.js'; import { cacheProfile, getProfile, getProfiles } from './cache/profile-cache.js';
import type { NostrEvent } from '../../types/nostr.js'; import { config } from './nostr/config.js';
import type { NostrEvent } from '../types/nostr.js';
// Re-export profile types and functions
export interface ProfileData { export interface ProfileData {
name?: string; name?: string;
about?: string; about?: string;
@ -131,3 +134,133 @@ export async function fetchProfiles(
return profiles; return profiles;
} }
/**
* 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
} as any // NIP-38 uses #d tag for parameterized replaceable events
],
relayList,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) return null;
return parseUserStatus(events[0]);
}
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);
// If no markers, relay is both read and write
if (markers.length === 0) {
relays.push({ url, read: true, write: true });
continue;
}
// Check for explicit markers
const hasRead = markers.includes('read');
const hasWrite = markers.includes('write');
// If only 'read' marker: read=true, write=false
// If only 'write' marker: read=false, write=true
// If both or neither explicitly: both true (default behavior)
const read = hasRead || (!hasRead && !hasWrite);
const write = hasWrite || (!hasRead && !hasWrite);
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)]
};
}

5
src/lib/types/nostr.js

@ -1,5 +0,0 @@
/**
* Nostr type definitions
*/
export {};
//# sourceMappingURL=nostr.js.map

1
src/lib/types/nostr.js.map

@ -1 +0,0 @@
{"version":3,"file":"nostr.js","sourceRoot":"","sources":["nostr.ts"],"names":[],"mappings":"AAAA;;GAEG"}

2
src/routes/login/+page.svelte

@ -51,7 +51,7 @@
</button> </button>
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light"> <p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Other authentication methods (nsec, bunker, anonymous) coming soon... Other authentication methods (nsec, anonymous) coming soon...
</p> </p>
</div> </div>
</main> </main>

1
src/vite-env.d.ts vendored

@ -4,7 +4,6 @@ interface ImportMetaEnv {
readonly VITE_DEFAULT_RELAYS?: string; readonly VITE_DEFAULT_RELAYS?: string;
readonly VITE_ZAP_THRESHOLD?: string; readonly VITE_ZAP_THRESHOLD?: string;
readonly VITE_THREAD_TIMEOUT_DAYS?: string; readonly VITE_THREAD_TIMEOUT_DAYS?: string;
readonly VITE_PWA_ENABLED?: string;
} }
interface ImportMeta { interface ImportMeta {

BIN
static/aitherboard-og.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

18
static/manifest.json

@ -1,18 +0,0 @@
{
"name": "Aitherboard",
"short_name": "Aitherboard",
"description": "A decentralized messageboard built on the Nostr protocol",
"start_url": "/",
"display": "standalone",
"background_color": "#f1f5f9",
"theme_color": "#f1f5f9",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

19
static/og-image.svg

@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<!-- Background -->
<rect width="1200" height="630" fill="#d6daf0"/>
<!-- Content Area -->
<rect x="100" y="100" width="1000" height="430" fill="#eef2ff" stroke="#b7c5d9" stroke-width="4" rx="8"/>
<!-- Title -->
<text x="600" y="280" font-family="system-ui, -apple-system, sans-serif" font-size="72" font-weight="bold" text-anchor="middle" fill="#1e293b">Aitherboard</text>
<!-- Subtitle -->
<text x="600" y="340" font-family="system-ui, -apple-system, sans-serif" font-size="32" text-anchor="middle" fill="#475569">Decentralized Messageboard on Nostr</text>
<!-- Decorative elements -->
<circle cx="200" cy="200" r="40" fill="#3b82f6" opacity="0.3"/>
<circle cx="1000" cy="430" r="60" fill="#3b82f6" opacity="0.2"/>
<rect x="150" y="450" width="80" height="4" fill="#b7c5d9" rx="2"/>
<rect x="970" y="450" width="80" height="4" fill="#b7c5d9" rx="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 988 B

Loading…
Cancel
Save