From edb295f7c66e5478167681fce1fdc5585d4b6b6d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 2 Feb 2026 15:48:48 +0100 Subject: [PATCH] more features --- .../content/MarkdownRenderer.svelte | 136 ++++++++++- src/lib/modules/feed/CreateKind1Form.svelte | 19 +- src/lib/modules/feed/Kind1FeedPage.svelte | 42 +++- src/lib/modules/feed/Kind1Reply.svelte | 121 ++++++++++ src/lib/modules/feed/ReplyToKind1Form.svelte | 141 +++++++++++ src/lib/modules/feed/ZapReceiptReply.svelte | 112 +++++++++ src/lib/modules/profiles/ProfilePage.svelte | 68 +++++- .../modules/threads/CreateThreadForm.svelte | 6 +- src/lib/modules/threads/ThreadList.svelte | 10 +- src/lib/modules/threads/ThreadView.svelte | 146 +++++++++++ src/lib/services/nostr/auth-handler.ts | 14 +- src/lib/services/nostr/nip21-parser.ts | 76 ++++++ src/lib/services/nostr/nostr-client.ts | 7 +- src/lib/services/nostr/relay-manager.ts | 226 ++++++++++++++++++ src/routes/thread/[id]/+page.svelte | 137 +---------- 15 files changed, 1094 insertions(+), 167 deletions(-) create mode 100644 src/lib/modules/feed/Kind1Reply.svelte create mode 100644 src/lib/modules/feed/ReplyToKind1Form.svelte create mode 100644 src/lib/modules/feed/ZapReceiptReply.svelte create mode 100644 src/lib/modules/threads/ThreadView.svelte create mode 100644 src/lib/services/nostr/nip21-parser.ts create mode 100644 src/lib/services/nostr/relay-manager.ts diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 7ebe820..bb1ae8b 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -1,6 +1,9 @@ -
+
{@html rendered}
@@ -56,6 +166,24 @@ color: #cbd5e1; } + .markdown-content :global(.nostr-link) { + color: #3b82f6; + text-decoration: underline; + cursor: pointer; + } + + .dark .markdown-content :global(.nostr-link) { + color: #60a5fa; + } + + .markdown-content :global(.nostr-link:hover) { + color: #2563eb; + } + + .dark .markdown-content :global(.nostr-link:hover) { + color: #93c5fd; + } + .markdown-content :global(code) { background: #e2e8f0; padding: 0.2em 0.4em; diff --git a/src/lib/modules/feed/CreateKind1Form.svelte b/src/lib/modules/feed/CreateKind1Form.svelte index 7b6387a..453471a 100644 --- a/src/lib/modules/feed/CreateKind1Form.svelte +++ b/src/lib/modules/feed/CreateKind1Form.svelte @@ -1,7 +1,7 @@
@@ -140,6 +164,16 @@ {:else if posts.length === 0}

No posts yet. Be the first to post!

{:else} + {#if newPostsCount > 0} +
+ +
+ {/if}
{#each posts as post (post.id)} diff --git a/src/lib/modules/feed/Kind1Reply.svelte b/src/lib/modules/feed/Kind1Reply.svelte new file mode 100644 index 0000000..a4d3ed6 --- /dev/null +++ b/src/lib/modules/feed/Kind1Reply.svelte @@ -0,0 +1,121 @@ + + +
+ {#if parentEvent} +
+ Replying to: {getParentPreview()} +
+ {/if} + +
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} + ↳ Reply +
+ +
+ +
+ +
+ + + + {#if onReply} + + {/if} +
+
+ + diff --git a/src/lib/modules/feed/ReplyToKind1Form.svelte b/src/lib/modules/feed/ReplyToKind1Form.svelte new file mode 100644 index 0000000..ca7e6f0 --- /dev/null +++ b/src/lib/modules/feed/ReplyToKind1Form.svelte @@ -0,0 +1,141 @@ + + +
+
+ Replying to: {parentEvent.content.slice(0, 100)}... +
+ + + +
+ + +
+ {#if onCancel} + + {/if} + +
+
+
+ + diff --git a/src/lib/modules/feed/ZapReceiptReply.svelte b/src/lib/modules/feed/ZapReceiptReply.svelte new file mode 100644 index 0000000..5d0226e --- /dev/null +++ b/src/lib/modules/feed/ZapReceiptReply.svelte @@ -0,0 +1,112 @@ + + +
+
+ + + {getAmount().toLocaleString()} sats + {getRelativeTime()} + {#if getZappedPubkey()} + + to + + {/if} +
+ + {#if zapReceipt.content} +
+ {zapReceipt.content} +
+ {/if} + +
+ {#if onReply} + + {/if} +
+
+ + diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 1b425fa..f8e175c 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -6,14 +6,18 @@ import { fetchProfile } from '../../services/auth/profile-fetcher.js'; import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; + import { relayManager } from '../../services/nostr/relay-manager.js'; import { onMount } from 'svelte'; import { page } from '$app/stores'; import type { ProfileData } from '../../services/auth/profile-fetcher.js'; + import type { NostrEvent } from '../../types/nostr.js'; let profile = $state(null); let userStatus = $state(null); - let posts = $state([]); + let posts = $state([]); + let responses = $state([]); let loading = $state(true); + let activeTab = $state<'posts' | 'responses'>('posts'); onMount(async () => { await nostrClient.initialize(); @@ -43,13 +47,29 @@ userStatus = status; // Load kind 1 posts - const config = nostrClient.getConfig(); + const profileRelays = relayManager.getProfileReadRelays(); const feedEvents = await nostrClient.fetchEvents( [{ kinds: [1], authors: [pubkey], limit: 20 }], - [...config.defaultRelays, ...config.profileRelays], + profileRelays, { useCache: true, cacheResults: true } ); posts = feedEvents.sort((a, b) => b.created_at - a.created_at); + + // Load kind 1 responses (replies to this user's posts) + const responseRelays = relayManager.getKind1ResponseReadRelays(); + const responseEvents = await nostrClient.fetchEvents( + [{ kinds: [1], '#p': [pubkey], limit: 20 }], + responseRelays, + { useCache: true, cacheResults: true } + ); + // Filter to only include actual replies (have e tag pointing to user's posts) + const userPostIds = new Set(posts.map(p => p.id)); + responses = responseEvents + .filter(e => { + const eTag = e.tags.find(t => t[0] === 'e'); + return eTag && userPostIds.has(eTag[1]); + }) + .sort((a, b) => b.created_at - a.created_at); } catch (error) { console.error('Error loading profile:', error); } finally { @@ -106,15 +126,41 @@
-

Posts

- {#if posts.length === 0} -

No posts yet.

+
+ + +
+ + {#if activeTab === 'posts'} + {#if posts.length === 0} +

No posts yet.

+ {:else} +
+ {#each posts as post (post.id)} + + {/each} +
+ {/if} {:else} -
- {#each posts as post (post.id)} - - {/each} -
+ {#if responses.length === 0} +

No responses yet.

+ {:else} +
+ {#each responses as response (response.id)} + + {/each} +
+ {/if} {/if}
{:else} diff --git a/src/lib/modules/threads/CreateThreadForm.svelte b/src/lib/modules/threads/CreateThreadForm.svelte index a66b334..d7db1a9 100644 --- a/src/lib/modules/threads/CreateThreadForm.svelte +++ b/src/lib/modules/threads/CreateThreadForm.svelte @@ -1,6 +1,7 @@ + +{#if loading} +

Loading thread...

+{:else if thread} +
+
+

{getTitle()}

+
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} +
+ {#if getTopics().length > 0} +
+ {#each getTopics() as topic} + {topic} + {/each} +
+ {/if} +
+ +
+ + +
+ +
+ + + +
+ +
+ +
+
+{:else} +

Thread not found

+{/if} + + diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index a73359a..fc2b2a3 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -13,6 +13,7 @@ import { decryptPrivateKey } from '../security/key-management.js'; import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; import { nostrClient } from './nostr-client.js'; +import { relayManager } from './relay-manager.js'; import type { NostrEvent } from '../../types/nostr.js'; // Mute list and blocked relays management @@ -107,14 +108,13 @@ export async function authenticateAsAnonymous(password: string): Promise * Load user preferences (relay lists, mute list, blocked relays) */ async function loadUserPreferences(pubkey: string): Promise { - // Fetch relay lists - const { inbox, outbox } = await fetchRelayLists(pubkey); - // Relay lists would be used by relay selection logic + // Fetch relay lists and load into relay manager + await relayManager.loadUserPreferences(pubkey); // Fetch mute list (kind 10000) const muteEvents = await nostrClient.fetchEvents( [{ kinds: [10000], authors: [pubkey], limit: 1 }], - [...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays], + relayManager.getProfileReadRelays(), { useCache: true, cacheResults: true } ); @@ -130,7 +130,7 @@ async function loadUserPreferences(pubkey: string): Promise { // Fetch blocked relays (kind 10006) const blockedRelayEvents = await nostrClient.fetchEvents( [{ kinds: [10006], authors: [pubkey], limit: 1 }], - [...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays], + relayManager.getProfileReadRelays(), { useCache: true, cacheResults: true } ); @@ -141,6 +141,9 @@ async function loadUserPreferences(pubkey: string): Promise { .filter(Boolean) as string[]; blockedRelays.clear(); blocked.forEach(r => blockedRelays.add(r)); + + // Update relay manager with blocked relays + relayManager.updateBlockedRelays(blockedRelays); } } @@ -165,6 +168,7 @@ export function logout(): void { sessionManager.clearSession(); muteList.clear(); blockedRelays.clear(); + relayManager.clearUserPreferences(); } /** diff --git a/src/lib/services/nostr/nip21-parser.ts b/src/lib/services/nostr/nip21-parser.ts new file mode 100644 index 0000000..e76e28c --- /dev/null +++ b/src/lib/services/nostr/nip21-parser.ts @@ -0,0 +1,76 @@ +/** + * NIP-21 URI parser + * Parses nostr: URIs and extracts bech32 entities + */ + +import { nip19 } from 'nostr-tools'; + +export interface ParsedNIP21 { + type: 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile'; + data: string; // The bech32 string without nostr: prefix + entity?: any; // Decoded entity data +} + +/** + * Parse a NIP-21 URI (nostr:...) + */ +export function parseNIP21(uri: string): ParsedNIP21 | null { + // Check if it's a nostr: URI + if (!uri.startsWith('nostr:')) { + return null; + } + + const bech32 = uri.slice(6); // Remove 'nostr:' prefix + + // Validate bech32 format + if (!/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32)) { + return null; + } + + // Extract type + const typeMatch = bech32.match(/^(npub|note|nevent|naddr|nprofile)/); + if (!typeMatch) return null; + + const type = typeMatch[1] as ParsedNIP21['type']; + + // Try to decode (optional, for validation) + let entity: any = null; + try { + const decoded = nip19.decode(bech32); + entity = decoded; + } catch { + // If decoding fails, we can still use the bech32 string + } + + return { + type, + data: bech32, + entity + }; +} + +/** + * Find all NIP-21 URIs in text + */ +export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> { + const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = []; + + // Match nostr: URIs (case-insensitive) + const regex = /nostr:(npub|note|nevent|naddr|nprofile)1[a-z0-9]+/gi; + let match; + + while ((match = regex.exec(text)) !== null) { + const uri = match[0]; + const parsed = parseNIP21(uri); + if (parsed) { + links.push({ + uri, + start: match.index, + end: match.index + uri.length, + parsed + }); + } + } + + return links; +} diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 27d3335..3b32bd6 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -7,6 +7,7 @@ import { Relay, type Filter, matchFilter } from 'nostr-tools'; import { config } from './config.js'; import type { NostrEvent } from '../../types/nostr.js'; import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; +import { getMuteList } from './auth-handler.js'; export interface PublishOptions { relays?: string[]; @@ -76,9 +77,13 @@ class NostrClient { } /** - * Check if event should be hidden (content filtering) + * 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; diff --git a/src/lib/services/nostr/relay-manager.ts b/src/lib/services/nostr/relay-manager.ts new file mode 100644 index 0000000..b599978 --- /dev/null +++ b/src/lib/services/nostr/relay-manager.ts @@ -0,0 +1,226 @@ +/** + * Relay manager for user relay preferences + * Handles inbox/outbox relays and blocked relays + */ + +import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; +import { getBlockedRelays } from '../nostr/auth-handler.js'; +import { config } from './config.js'; +import { sessionManager } from '../auth/session-manager.js'; + +class RelayManager { + private userInbox: string[] = []; + private userOutbox: string[] = []; + private blockedRelays: Set = new Set(); + + /** + * Load user relay preferences + */ + async loadUserPreferences(pubkey: string): Promise { + // Fetch relay lists + const { inbox, outbox } = await fetchRelayLists(pubkey); + this.userInbox = inbox; + this.userOutbox = outbox; + + // Get blocked relays + this.blockedRelays = getBlockedRelays(); + } + + /** + * Clear user preferences (on logout) + */ + clearUserPreferences(): void { + this.userInbox = []; + this.userOutbox = []; + this.blockedRelays.clear(); + } + + /** + * Filter out blocked relays + */ + private filterBlocked(relays: string[]): string[] { + if (this.blockedRelays.size === 0) return relays; + return relays.filter((r) => !this.blockedRelays.has(r)); + } + + /** + * Normalize and deduplicate relay URLs + */ + private normalizeRelays(relays: string[]): string[] { + // Normalize URLs (remove trailing slashes, etc.) + const normalized = relays.map((r) => { + let url = r.trim(); + if (url.endsWith('/')) { + url = url.slice(0, -1); + } + return url; + }); + + // Deduplicate + return [...new Set(normalized)]; + } + + /** + * Get relays for reading operations + */ + getReadRelays(baseRelays: string[], includeUserInbox = true): string[] { + let relays = [...baseRelays]; + + // Add user inbox if logged in + if (includeUserInbox && sessionManager.isLoggedIn() && this.userInbox.length > 0) { + relays = [...relays, ...this.userInbox]; + } + + // Normalize and deduplicate + relays = this.normalizeRelays(relays); + + // Filter blocked relays + return this.filterBlocked(relays); + } + + /** + * Get relays for publishing operations + */ + getPublishRelays(baseRelays: string[], includeUserOutbox = true): string[] { + let relays = [...baseRelays]; + + // Add user outbox if logged in + if (includeUserOutbox && sessionManager.isLoggedIn() && this.userOutbox.length > 0) { + relays = [...relays, ...this.userOutbox]; + } + + // Normalize and deduplicate + relays = this.normalizeRelays(relays); + + // Filter blocked relays + return this.filterBlocked(relays); + } + + /** + * Get relays for reading threads (kind 11) + */ + getThreadReadRelays(): string[] { + return this.getReadRelays(config.defaultRelays); + } + + /** + * Get relays for reading comments (kind 1111) + */ + getCommentReadRelays(): string[] { + return this.getReadRelays(config.defaultRelays); + } + + /** + * Get relays for reading kind 1 feed + */ + getKind1ReadRelays(): string[] { + return this.getReadRelays(config.defaultRelays); + } + + /** + * Get relays for reading kind 1 responses + */ + getKind1ResponseReadRelays(): string[] { + return this.getReadRelays([ + ...config.defaultRelays, + 'wss://aggr.nostr.land' + ]); + } + + /** + * Get relays for reading zap receipts (kind 9735) + */ + getZapReceiptReadRelays(): string[] { + return this.getReadRelays(config.defaultRelays); + } + + /** + * Get relays for reading profiles (kind 0) + */ + getProfileReadRelays(): string[] { + return this.getReadRelays([ + ...config.defaultRelays, + ...config.profileRelays + ]); + } + + /** + * Get relays for reading payment targets (kind 10133) + */ + getPaymentTargetReadRelays(): string[] { + return this.getReadRelays([ + ...config.defaultRelays, + ...config.profileRelays + ]); + } + + /** + * Get relays for reading user status (kind 30315) + */ + getUserStatusReadRelays(): string[] { + return this.getReadRelays([ + ...config.defaultRelays, + ...config.profileRelays + ]); + } + + /** + * Get relays for publishing threads (kind 11) + */ + getThreadPublishRelays(): string[] { + return this.getPublishRelays([ + ...config.defaultRelays, + 'wss://thecitadel.nostr1.com' + ]); + } + + /** + * Get relays for publishing comments (kind 1111) + * If replying, include target's inbox + */ + getCommentPublishRelays(targetInbox?: string[]): string[] { + let relays = this.getPublishRelays(config.defaultRelays); + + // If replying, add target's inbox + if (targetInbox && targetInbox.length > 0) { + relays = [...relays, ...targetInbox]; + relays = this.normalizeRelays(relays); + relays = this.filterBlocked(relays); + } + + return relays; + } + + /** + * Get relays for publishing kind 1 posts + * If replying, include target's inbox + */ + getKind1PublishRelays(targetInbox?: string[]): string[] { + let relays = this.getPublishRelays(config.defaultRelays); + + // If replying, add target's inbox + if (targetInbox && targetInbox.length > 0) { + relays = [...relays, ...targetInbox]; + relays = this.normalizeRelays(relays); + relays = this.filterBlocked(relays); + } + + return relays; + } + + /** + * Get relays for publishing reactions (kind 7) + */ + getReactionPublishRelays(): string[] { + return this.getPublishRelays(config.defaultRelays); + } + + /** + * Update blocked relays (called when user preferences change) + */ + updateBlockedRelays(blocked: Set): void { + this.blockedRelays = blocked; + } +} + +export const relayManager = new RelayManager(); diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index fd4cb15..dac8124 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -1,150 +1,21 @@
- {#if loading} -

Loading thread...

- {:else if thread} -
-
-

{getTitle()}

-
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} -
- {#if getTopics().length > 0} -
- {#each getTopics() as topic} - {topic} - {/each} -
- {/if} -
- -
- - -
- -
- - - -
- -
- -
-
+ {#if $page.params.id} + {:else} -

Thread not found

+

Thread ID required

{/if}
- -