Browse Source

more features

master
Silberengel 1 month ago
parent
commit
edb295f7c6
  1. 136
      src/lib/components/content/MarkdownRenderer.svelte
  2. 19
      src/lib/modules/feed/CreateKind1Form.svelte
  3. 40
      src/lib/modules/feed/Kind1FeedPage.svelte
  4. 121
      src/lib/modules/feed/Kind1Reply.svelte
  5. 141
      src/lib/modules/feed/ReplyToKind1Form.svelte
  6. 112
      src/lib/modules/feed/ZapReceiptReply.svelte
  7. 54
      src/lib/modules/profiles/ProfilePage.svelte
  8. 6
      src/lib/modules/threads/CreateThreadForm.svelte
  9. 10
      src/lib/modules/threads/ThreadList.svelte
  10. 146
      src/lib/modules/threads/ThreadView.svelte
  11. 14
      src/lib/services/nostr/auth-handler.ts
  12. 76
      src/lib/services/nostr/nip21-parser.ts
  13. 7
      src/lib/services/nostr/nostr-client.ts
  14. 226
      src/lib/services/nostr/relay-manager.ts
  15. 137
      src/routes/thread/[id]/+page.svelte

136
src/lib/components/content/MarkdownRenderer.svelte

@ -1,6 +1,9 @@ @@ -1,6 +1,9 @@
<script lang="ts">
import { marked } from 'marked';
import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { onMount } from 'svelte';
interface Props {
content?: string;
@ -9,16 +12,123 @@ @@ -9,16 +12,123 @@
let { content = '' }: Props = $props();
let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null);
$effect(() => {
if (content) {
const parseResult = marked.parse(content);
// Process NIP-21 links before markdown parsing
let processed = content;
const links = findNIP21Links(content);
// Replace links with placeholders, then restore after markdown parsing
const placeholders: Map<string, { uri: string; parsed: any }> = new Map();
let offset = 0;
// Process in reverse order to maintain indices
const sortedLinks = [...links].sort((a, b) => b.start - a.start);
for (const link of sortedLinks) {
const placeholder = `__NIP21_LINK_${offset}__`;
const before = processed.slice(0, link.start);
const after = processed.slice(link.end);
processed = before + placeholder + after;
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed });
offset++;
}
const parseResult = marked.parse(processed);
if (parseResult instanceof Promise) {
parseResult.then((html) => {
rendered = sanitizeMarkdown(html);
let finalHtml = sanitizeMarkdown(html);
// Replace placeholders with actual NIP-21 links
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
if (parsed.type === 'npub') {
// Render as profile link
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
}
} catch {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
}
} else if (parsed.type === 'note' || parsed.type === 'nevent') {
// Render as event link
try {
const decoded = nip19.decode(parsed.data);
let eventId = '';
if (decoded.type === 'note') {
eventId = decoded.data;
} else if (decoded.type === 'nevent') {
eventId = decoded.data.id;
}
if (eventId) {
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} else {
// Generic link
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement);
}
rendered = finalHtml;
});
} else {
rendered = sanitizeMarkdown(parseResult);
let finalHtml = sanitizeMarkdown(parseResult);
// Replace placeholders with actual NIP-21 links
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
if (parsed.type === 'npub') {
// Render as profile link
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
}
} catch {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
}
} else if (parsed.type === 'note' || parsed.type === 'nevent') {
// Render as event link
try {
const decoded = nip19.decode(parsed.data);
let eventId = '';
if (decoded.type === 'note') {
eventId = decoded.data;
} else if (decoded.type === 'nevent') {
eventId = decoded.data.id;
}
if (eventId) {
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} else {
// Generic link
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement);
}
rendered = finalHtml;
}
} else {
rendered = '';
@ -26,7 +136,7 @@ @@ -26,7 +136,7 @@
});
</script>
<div class="markdown-content anon-content">
<div class="markdown-content anon-content" bind:this={containerElement}>
{@html rendered}
</div>
@ -56,6 +166,24 @@ @@ -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;

19
src/lib/modules/feed/CreateKind1Form.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
@ -54,8 +54,21 @@ @@ -54,8 +54,21 @@
content: content.trim()
};
const config = nostrClient.getConfig();
const result = await signAndPublish(event, [...config.defaultRelays]);
// Get target inbox if replying
let targetInbox: string[] | undefined;
if (parentEvent) {
// Try to get target's inbox from their relay list
try {
const { fetchRelayLists } = await import('../../services/auth/relay-list-fetcher.js');
const { inbox } = await fetchRelayLists(parentEvent.pubkey);
targetInbox = inbox;
} catch {
// Ignore errors, just use default relays
}
}
const relays = relayManager.getKind1PublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
content = '';

40
src/lib/modules/feed/Kind1FeedPage.svelte

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import Kind1Post from './Kind1Post.svelte';
import CreateKind1Form from './CreateKind1Form.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';
@ -11,6 +12,8 @@ @@ -11,6 +12,8 @@
let showNewPostForm = $state(false);
let hasMore = $state(true);
let loadingMore = $state(false);
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
onMount(async () => {
await nostrClient.initialize();
@ -48,23 +51,37 @@ @@ -48,23 +51,37 @@
}
];
const relays = relayManager.getKind1ReadRelays();
const events = await nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
relays,
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
const sorted = sortPosts(updated);
if (reset) {
posts = sortPosts(updated);
posts = sorted;
lastPostId = sorted.length > 0 ? sorted[0].id : null;
} else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
const newPosts = updated.filter(e => !existingIds.has(e.id));
const newPosts = sorted.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Count new posts that are newer than the last seen post
if (lastPostId) {
const newCount = sorted.filter(e => e.id !== lastPostId && !existingIds.has(e.id)).length;
if (newCount > 0) {
newPostsCount += newCount;
}
}
posts = sortPosts([...posts, ...newPosts]);
}
}
}}
);
if (reset) {
posts = sortPosts(events);
lastPostId = events.length > 0 ? events[0].id : null;
newPostsCount = 0;
} else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
@ -109,6 +126,13 @@ @@ -109,6 +126,13 @@
showNewPostForm = false;
loadFeed();
}
function handleShowNewPosts() {
// Scroll to top and reset new posts count
window.scrollTo({ top: 0, behavior: 'smooth' });
newPostsCount = 0;
lastPostId = posts.length > 0 ? posts[0].id : null;
}
</script>
<div class="kind1-feed">
@ -140,6 +164,16 @@ @@ -140,6 +164,16 @@
{:else if posts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p>
{:else}
{#if newPostsCount > 0}
<div class="new-posts-indicator mb-4">
<button
onclick={handleShowNewPosts}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm"
>
{newPostsCount} new {newPostsCount === 1 ? 'post' : 'posts'} - Click to view
</button>
</div>
{/if}
<div class="posts-list">
{#each posts as post (post.id)}
<Kind1Post {post} onReply={handleReply} />

121
src/lib/modules/feed/Kind1Reply.svelte

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import Kind1ReactionButtons from '../reactions/Kind1ReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
reply: NostrEvent;
parentEvent?: NostrEvent; // The event this is replying to
onReply?: (post: NostrEvent) => void;
}
let { reply, parentEvent, onReply }: Props = $props();
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - reply.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 = reply.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getParentPreview(): string {
if (parentEvent) {
return parentEvent.content.slice(0, 100) + (parentEvent.content.length > 100 ? '...' : '');
}
// Try to extract from reply tags
const eTag = reply.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (eTag) {
return 'Replying to...';
}
return '';
}
</script>
<article class="kind1-reply">
{#if parentEvent}
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="font-semibold">Replying to:</span> {getParentPreview()}
</div>
{/if}
<div class="reply-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={reply.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}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
</div>
<div class="reply-content mb-2">
<MarkdownRenderer content={reply.content} />
</div>
<div class="reply-actions flex items-center gap-4">
<Kind1ReactionButtons event={reply} />
<ZapButton event={reply} />
<ZapReceipt eventId={reply.id} pubkey={reply.pubkey} />
{#if onReply}
<button
onclick={() => onReply(reply)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</article>
<style>
.kind1-reply {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid var(--fog-accent, #3b82f6);
}
:global(.dark) .kind1-reply {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: var(--fog-dark-accent, #60a5fa);
}
.reply-content {
line-height: 1.6;
}
.reply-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .reply-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.reply-context {
cursor: pointer;
transition: opacity 0.2s;
}
.reply-context:hover {
opacity: 0.8;
}
</style>

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

@ -0,0 +1,141 @@ @@ -0,0 +1,141 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
parentEvent: NostrEvent; // The kind 1 event to reply to
onPublished?: () => void;
onCancel?: () => void;
}
let { parentEvent, onPublished, onCancel }: Props = $props();
let content = $state('');
let publishing = $state(false);
let includeClientTag = $state(true);
async function publish() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to reply');
return;
}
if (!content.trim()) {
alert('Reply cannot be empty');
return;
}
publishing = true;
try {
const tags: string[][] = [];
// Add NIP-10 threading tags for reply
const rootTag = parentEvent.tags.find((t) => t[0] === 'root');
const rootId = rootTag?.[1] || parentEvent.id;
tags.push(['e', parentEvent.id, '', 'reply']);
tags.push(['p', parentEvent.pubkey]);
tags.push(['root', rootId]);
if (includeClientTag) {
tags.push(['client', 'Aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 1,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
};
// Get target inbox if replying
let targetInbox: string[] | undefined;
try {
const { fetchRelayLists } = await import('../../services/auth/relay-list-fetcher.js');
const { inbox } = await fetchRelayLists(parentEvent.pubkey);
targetInbox = inbox;
} catch {
// Ignore errors, just use default relays
}
const relays = relayManager.getKind1PublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
content = '';
onPublished?.();
} else {
alert('Failed to publish reply');
}
} catch (error) {
console.error('Error publishing reply:', error);
alert('Error publishing reply');
} finally {
publishing = false;
}
}
</script>
<div class="reply-to-kind1-form">
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm">
<span class="font-semibold">Replying to:</span> {parentEvent.content.slice(0, 100)}...
</div>
<textarea
bind:value={content}
placeholder="Write a reply..."
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
rows="6"
disabled={publishing}
></textarea>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text">
<input
type="checkbox"
bind:checked={includeClientTag}
class="rounded"
/>
Include client tag
</label>
<div class="flex gap-2">
{#if onCancel}
<button
onclick={onCancel}
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight"
disabled={publishing}
>
Cancel
</button>
{/if}
<button
onclick={publish}
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50"
disabled={publishing || !content.trim()}
>
{publishing ? 'Publishing...' : 'Reply'}
</button>
</div>
</div>
</div>
<style>
.reply-to-kind1-form {
margin-bottom: 1rem;
}
textarea {
resize: vertical;
min-height: 120px;
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #3b82f6);
}
</style>

112
src/lib/modules/feed/ZapReceiptReply.svelte

@ -0,0 +1,112 @@ @@ -0,0 +1,112 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
zapReceipt: NostrEvent; // Kind 9735 zap receipt
parentEvent?: NostrEvent; // The event this zap receipt is for
onReply?: (receipt: NostrEvent) => void;
}
let { zapReceipt, parentEvent, onReply }: Props = $props();
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - zapReceipt.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 getAmount(): number {
const amountTag = zapReceipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return isNaN(amount) ? 0 : amount;
}
return 0;
}
function getZappedPubkey(): string | null {
const pTag = zapReceipt.tags.find((t) => t[0] === 'p');
return pTag?.[1] || null;
}
function getZappedEventId(): string | null {
const eTag = zapReceipt.tags.find((t) => t[0] === 'e');
return eTag?.[1] || null;
}
function getZapperPubkey(): string {
return zapReceipt.pubkey;
}
</script>
<article class="zap-receipt-reply">
<div class="zap-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={getZapperPubkey()} />
<span class="text-lg"></span>
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getZappedPubkey()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={getZappedPubkey()} />
</span>
{/if}
</div>
{#if zapReceipt.content}
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text">
{zapReceipt.content}
</div>
{/if}
<div class="zap-actions flex items-center gap-4">
{#if onReply}
<button
onclick={() => onReply(zapReceipt)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</article>
<style>
.zap-receipt-reply {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */
}
:global(.dark) .zap-receipt-reply {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: #fbbf24;
}
.zap-content {
line-height: 1.6;
}
.zap-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .zap-actions {
border-top-color: var(--fog-dark-border, #374151);
}
</style>

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

@ -6,14 +6,18 @@ @@ -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<ProfileData | null>(null);
let userStatus = $state<string | null>(null);
let posts = $state<any[]>([]);
let posts = $state<NostrEvent[]>([]);
let responses = $state<NostrEvent[]>([]);
let loading = $state(true);
let activeTab = $state<'posts' | 'responses'>('posts');
onMount(async () => {
await nostrClient.initialize();
@ -43,13 +47,29 @@ @@ -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,7 +126,22 @@ @@ -106,7 +126,22 @@
</div>
<div class="profile-posts">
<h2 class="text-xl font-bold mb-4">Posts</h2>
<div class="tabs mb-4 flex gap-4 border-b border-fog-border dark:border-fog-dark-border">
<button
onclick={() => activeTab = 'posts'}
class="px-4 py-2 font-semibold {activeTab === 'posts' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Posts ({posts.length})
</button>
<button
onclick={() => activeTab = 'responses'}
class="px-4 py-2 font-semibold {activeTab === 'responses' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Responses ({responses.length})
</button>
</div>
{#if activeTab === 'posts'}
{#if posts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet.</p>
{:else}
@ -116,6 +151,17 @@ @@ -116,6 +151,17 @@
{/each}
</div>
{/if}
{:else}
{#if responses.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No responses yet.</p>
{:else}
<div class="responses-list">
{#each responses as response (response.id)}
<Kind1Post post={response} />
{/each}
</div>
{/if}
{/if}
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p>

6
src/lib/modules/threads/CreateThreadForm.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -15,9 +16,8 @@ @@ -15,9 +16,8 @@
let selectedRelays = $state<Set<string>>(new Set());
$effect(() => {
// Initialize selected relays with default + thecitadel
const config = nostrClient.getConfig();
const defaultRelays = [...config.defaultRelays, 'wss://thecitadel.nostr1.com'];
// Initialize selected relays with thread publish relays
const defaultRelays = relayManager.getThreadPublishRelays();
selectedRelays = new Set(defaultRelays);
});

10
src/lib/modules/threads/ThreadList.svelte

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import ThreadCard from './ThreadCard.svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -24,9 +25,10 @@ @@ -24,9 +25,10 @@
// Fetch with cache-first, background refresh
// onUpdate callback will refresh the UI when new data arrives
const relays = relayManager.getThreadReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [11], since, limit: 50 }],
[...config.defaultRelays],
relays,
{
useCache: true,
cacheResults: true,
@ -65,9 +67,10 @@ @@ -65,9 +67,10 @@
const activeSorted = await Promise.all(
events.map(async (event) => {
const config = nostrClient.getConfig();
const commentRelays = relayManager.getCommentReadRelays();
const comments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': [event.id], '#K': ['11'], limit: 1 }],
[...config.defaultRelays],
commentRelays,
{ useCache: true }
);
const lastCommentTime = comments.length > 0
@ -84,9 +87,10 @@ @@ -84,9 +87,10 @@
const upvotedSorted = await Promise.all(
events.map(async (event) => {
const config = nostrClient.getConfig();
const reactionRelays = relayManager.getThreadReadRelays();
const reactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }],
[...config.defaultRelays],
reactionRelays,
{ useCache: true }
);
const upvoteCount = reactions.filter(

146
src/lib/modules/threads/ThreadView.svelte

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import ReactionButtons from '../reactions/ReactionButtons.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 {
threadId: string;
}
let { threadId }: Props = $props();
let thread = $state<NostrEvent | null>(null);
let loading = $state(true);
onMount(async () => {
await nostrClient.initialize();
loadThread();
});
$effect(() => {
if (threadId) {
loadThread();
}
});
async function loadThread() {
loading = true;
try {
const relays = relayManager.getThreadReadRelays();
const event = await nostrClient.getEventById(threadId, relays);
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';
}
function getTopics(): string[] {
if (!thread) return [];
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3);
}
function getClientName(): string | null {
if (!thread) return null;
const clientTag = thread.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getRelativeTime(): string {
if (!thread) return '';
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';
}
</script>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if thread}
<article class="thread-view">
<div class="thread-header mb-4">
<h1 class="text-2xl font-bold mb-2">{getTitle()}</h1>
<div class="flex items-center gap-2 mb-2">
<ProfileBadge pubkey={thread.pubkey} />
<span class="text-sm 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}
</div>
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
</div>
<div class="thread-content mb-4">
<MediaAttachments event={thread} />
<MarkdownRenderer content={thread.content} />
</div>
<div class="thread-actions flex items-center gap-4 mb-6">
<ReactionButtons event={thread} />
<ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
<div class="comments-section">
<CommentThread threadId={thread.id} />
</div>
</article>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
{/if}
<style>
.thread-view {
max-width: var(--content-width);
margin: 0 auto;
}
.thread-content {
line-height: 1.6;
}
.thread-actions {
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .thread-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.comments-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .comments-section {
border-top-color: var(--fog-dark-border, #374151);
}
</style>

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

@ -13,6 +13,7 @@ import { decryptPrivateKey } from '../security/key-management.js'; @@ -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<string> @@ -107,14 +108,13 @@ export async function authenticateAsAnonymous(password: string): Promise<string>
* 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 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<void> { @@ -130,7 +130,7 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
// 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<void> { @@ -141,6 +141,9 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
.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 { @@ -165,6 +168,7 @@ export function logout(): void {
sessionManager.clearSession();
muteList.clear();
blockedRelays.clear();
relayManager.clearUserPreferences();
}
/**

76
src/lib/services/nostr/nip21-parser.ts

@ -0,0 +1,76 @@ @@ -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;
}

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

@ -7,6 +7,7 @@ import { Relay, type Filter, matchFilter } from 'nostr-tools'; @@ -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 { @@ -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;

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

@ -0,0 +1,226 @@ @@ -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<string> = new Set();
/**
* Load user relay preferences
*/
async loadUserPreferences(pubkey: string): Promise<void> {
// 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<string>): void {
this.blockedRelays = blocked;
}
}
export const relayManager = new RelayManager();

137
src/routes/thread/[id]/+page.svelte

@ -1,150 +1,21 @@ @@ -1,150 +1,21 @@
<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 MediaAttachments from '../../../lib/components/content/MediaAttachments.svelte';
import CommentThread from '../../../lib/modules/comments/CommentThread.svelte';
import ReactionButtons from '../../../lib/modules/reactions/ReactionButtons.svelte';
import ZapButton from '../../../lib/modules/zaps/ZapButton.svelte';
import ZapReceipt from '../../../lib/modules/zaps/ZapReceipt.svelte';
import ThreadView from '../../../lib/modules/threads/ThreadView.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-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';
}
function getTopics(): string[] {
if (!thread) return [];
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3);
}
function getClientName(): string | null {
if (!thread) return null;
const clientTag = thread.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getRelativeTime(): string {
if (!thread) return '';
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';
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if thread}
<article class="thread-view">
<div class="thread-header mb-4">
<h1 class="text-2xl font-bold mb-2">{getTitle()}</h1>
<div class="flex items-center gap-2 mb-2">
<ProfileBadge pubkey={thread.pubkey} />
<span class="text-sm 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}
</div>
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
</div>
<div class="thread-content mb-4">
<MediaAttachments event={thread} />
<MarkdownRenderer content={thread.content} />
</div>
<div class="thread-actions flex items-center gap-4 mb-6">
<ReactionButtons event={thread} />
<ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
<div class="comments-section">
<CommentThread threadId={thread.id} />
</div>
</article>
{#if $page.params.id}
<ThreadView threadId={$page.params.id} />
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
<p class="text-fog-text dark:text-fog-dark-text">Thread ID required</p>
{/if}
</main>
<style>
.thread-view {
max-width: var(--content-width);
margin: 0 auto;
}
.thread-content {
line-height: 1.6;
}
.thread-actions {
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .thread-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.comments-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .comments-section {
border-top-color: var(--fog-dark-border, #374151);
}
</style>

Loading…
Cancel
Save