diff --git a/README.md b/README.md index 0455cd7..11768f4 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,7 @@ aitherboard/ **Components**: - `ReactionButtons.svelte` - For threads/comments (kind 11/1111) -- `Kind1ReactionButtons.svelte` - For kind 1 feed +- `FeedReactionButtons.svelte` - For kind 1 feed **REQUIREMENTS**: - **Kind 11/1111**: Only `+` and `-` allowed @@ -629,12 +629,12 @@ aitherboard/ ### Kind 1 Feed Module (`src/lib/modules/feed/`) **Components**: -- `Kind1FeedPage.svelte` - Main feed page -- `Kind1Post.svelte` - Individual kind 1 post -- `Kind1Reply.svelte` - Kind 1 reply display +- `FeedPage.svelte` - Main feed page +- `FeedPost.svelte` - Individual kind 1 post +- `FeedReply.svelte` - Kind 1 reply display - `ZapReceiptReply.svelte` - Zap receipt as reply (with ⚡) -- `CreateKind1Form.svelte` - Create new kind 1 events -- `ReplyToKind1Form.svelte` - Reply to kind 1 events +- `CreateFeedForm.svelte` - Create new kind 1 events +- `ReplyToFeedForm.svelte` - Reply to kind 1 events **REQUIREMENTS**: - "View feed" button on landing page opens `/feed` diff --git a/public/healthz.json b/public/healthz.json index da510e3..f26830a 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-02T14:49:18.071Z", + "buildTime": "2026-02-02T15:23:47.430Z", "gitCommit": "unknown", - "timestamp": 1770043758071 + "timestamp": 1770045827431 } \ No newline at end of file diff --git a/src/app.css b/src/app.css index b9d2056..e40728f 100644 --- a/src/app.css +++ b/src/app.css @@ -117,12 +117,12 @@ img[src*="emoji" i] { /* Apply grayscale filter to reaction buttons containing emojis */ .reaction-btn, -.kind1-reaction-buttons button { +.Feed-reaction-buttons button { filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); } .dark .reaction-btn, -.dark .kind1-reaction-buttons button { +.dark .Feed-reaction-buttons button { filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); } diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte new file mode 100644 index 0000000..5cee1b5 --- /dev/null +++ b/src/lib/components/content/ReplyContext.svelte @@ -0,0 +1,63 @@ + + +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + scrollToParent(); + } + }} +> + Replying to: {getParentPreview()} +
+ + diff --git a/src/lib/modules/comments/Comment.svelte b/src/lib/modules/comments/Comment.svelte index 17ceae7..b08c4d1 100644 --- a/src/lib/modules/comments/Comment.svelte +++ b/src/lib/modules/comments/Comment.svelte @@ -1,9 +1,8 @@
- {#if parentEvent && parentPreview} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - scrollToParent(); - } - }} - role="button" - tabindex="0" - > - - ↑ Replying to: {parentPreview} - +
+ {#if parentEvent} + + {/if} + +
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if}
- {/if} -
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} -
+
+ +
-
- +
+ +
- -
+ + {#if needsExpansion} -
+ {/if}
diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index b8f7231..1e3f8b6 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -1,7 +1,9 @@ -
+
{#if parentEvent}
Replying to: {parentEvent.content.slice(0, 100)}... @@ -132,7 +132,7 @@
diff --git a/src/lib/modules/feed/Kind1Post.svelte b/src/lib/modules/feed/Kind1Post.svelte index ead8944..774693c 100644 --- a/src/lib/modules/feed/Kind1Post.svelte +++ b/src/lib/modules/feed/Kind1Post.svelte @@ -1,17 +1,51 @@ -
-
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} - {#if isReply()} - ↳ Reply +
+
+ {#if isReply() && parentEvent} + {/if} -
-
- -
+
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} + {#if isReply()} + ↳ Reply + {/if} +
-
- - - - {#if onReply} - - {/if} +
+ +
+ +
+ + + + {#if onReply} + + {/if} +
+ + {#if needsExpansion} + + {/if}
diff --git a/src/lib/modules/feed/Kind1Reply.svelte b/src/lib/modules/feed/Kind1Reply.svelte deleted file mode 100644 index d763dcc..0000000 --- a/src/lib/modules/feed/Kind1Reply.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - -
- {#if parentEvent} -
- Replying to: {getParentPreview()} -
- {/if} - -
- - {getRelativeTime()} - {#if getClientName()} - via {getClientName()} - {/if} - ↳ Reply -
- -
- -
- -
- - - - {#if onReply} - - {/if} -
-
- - diff --git a/src/lib/modules/feed/Reply.svelte b/src/lib/modules/feed/Reply.svelte new file mode 100644 index 0000000..0ed175d --- /dev/null +++ b/src/lib/modules/feed/Reply.svelte @@ -0,0 +1,181 @@ + + +
+
+ {#if parentEvent} +
+ Replying to: {getParentPreview()} +
+ {/if} + +
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} + ↳ Reply +
+ +
+ +
+ +
+ + + + {#if onReply} + + {/if} +
+
+ + {#if needsExpansion} + + {/if} +
+ + diff --git a/src/lib/modules/feed/ReplyToKind1Form.svelte b/src/lib/modules/feed/ReplyToKind1Form.svelte index 98b2b1d..97b0e63 100644 --- a/src/lib/modules/feed/ReplyToKind1Form.svelte +++ b/src/lib/modules/feed/ReplyToKind1Form.svelte @@ -5,7 +5,7 @@ import type { NostrEvent } from '../../types/nostr.js'; interface Props { - parentEvent: NostrEvent; // The kind 1 event to reply to + parentEvent: NostrEvent; // The event to reply to onPublished?: () => void; onCancel?: () => void; } @@ -62,7 +62,7 @@ // Ignore errors, just use default relays } - const relays = relayManager.getKind1PublishRelays(targetInbox); + const relays = relayManager.getFeedPublishRelays(targetInbox); const result = await signAndPublish(event, relays); if (result.success.length > 0) { @@ -80,7 +80,7 @@ } -
+
Replying to: {parentEvent.content.slice(0, 100)}...
@@ -125,7 +125,7 @@
diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index f8e175c..96b1706 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -2,7 +2,7 @@ import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import PaymentAddresses from './PaymentAddresses.svelte'; - import Kind1Post from '../feed/Kind1Post.svelte'; + import FeedPost from '../feed/FeedPost.svelte'; 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'; @@ -56,7 +56,7 @@ 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 responseRelays = relayManager.getFeedResponseReadRelays(); const responseEvents = await nostrClient.fetchEvents( [{ kinds: [1], '#p': [pubkey], limit: 20 }], responseRelays, @@ -147,7 +147,7 @@ {:else}
{#each posts as post (post.id)} - + {/each}
{/if} @@ -157,7 +157,7 @@ {:else}
{#each responses as response (response.id)} - + {/each}
{/if} diff --git a/src/lib/modules/reactions/Kind1ReactionButtons.svelte b/src/lib/modules/reactions/FeedReactionButtons.svelte similarity index 97% rename from src/lib/modules/reactions/Kind1ReactionButtons.svelte rename to src/lib/modules/reactions/FeedReactionButtons.svelte index 053517f..82e35ee 100644 --- a/src/lib/modules/reactions/Kind1ReactionButtons.svelte +++ b/src/lib/modules/reactions/FeedReactionButtons.svelte @@ -6,7 +6,7 @@ import type { NostrEvent } from '../../types/nostr.js'; interface Props { - event: NostrEvent; // Kind 1 event + event: NostrEvent; // Feed event } let { event }: Props = $props(); @@ -138,7 +138,7 @@ let includeClientTag = $state(true); -
+
{#each commonReactions as reaction} {@const count = getReactionCount(reaction)} {#if count > 0 || reaction === '+' || showMore} @@ -182,7 +182,7 @@
diff --git a/src/lib/modules/threads/ThreadView.svelte b/src/lib/modules/threads/ThreadView.svelte index 64ecd2c..0036e0d 100644 --- a/src/lib/modules/threads/ThreadView.svelte +++ b/src/lib/modules/threads/ThreadView.svelte @@ -19,6 +19,9 @@ let thread = $state(null); let loading = $state(true); + let expanded = $state(false); + let contentElement: HTMLElement | null = $state(null); + let needsExpansion = $state(false); onMount(async () => { await nostrClient.initialize(); @@ -72,6 +75,33 @@ if (hours > 0) return `${hours}h ago`; return 'just now'; } + + $effect(() => { + if (contentElement && thread) { + 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; + } {#if loading} @@ -96,17 +126,28 @@ {/if}
-
- - -
+
+
+ + +
-
- - - +
+ + + +
+ {#if needsExpansion} + + {/if} +
@@ -143,4 +184,23 @@ :global(.dark) .comments-section { 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; + } diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 3b32bd6..17985e5 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -27,14 +27,43 @@ class NostrClient { async initialize(): Promise { if (this.initialized) return; - // Connect to default relays - for (const url of config.defaultRelays) { + // Set up global error handler for unhandled promise rejections from relays + if (typeof window !== 'undefined' && !(window as any).__nostrErrorHandlerSet) { + (window as any).__nostrErrorHandlerSet = true; + window.addEventListener('unhandledrejection', (event) => { + const error = event.reason; + if (error && typeof error === 'object') { + const errorMessage = error.message || String(error); + if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed')) { + // Suppress these errors as they're handled by our connection management + event.preventDefault(); + console.debug('Suppressed closed connection error:', errorMessage); + } + } + }); + } + + // Connect to default relays with timeout + const connectionPromises = config.defaultRelays.map(async (url) => { try { - await this.addRelay(url); + // Add timeout to each connection attempt + await Promise.race([ + this.addRelay(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), 10000) + ) + ]); + console.log(`Connected to relay: ${url}`); } catch (error) { - console.error(`Failed to connect to relay ${url}:`, error); + console.warn(`Failed to connect to relay ${url}:`, error); } - } + }); + + // Wait for all connection attempts (don't fail if some fail) + await Promise.allSettled(connectionPromises); + + const connectedCount = this.relays.size; + console.log(`Initialized with ${connectedCount}/${config.defaultRelays.length} relays connected`); this.initialized = true; } @@ -191,9 +220,12 @@ class NostrClient { ): string { const subId = `sub_${this.nextSubId++}_${Date.now()}`; + // Filter to only active relays + const activeRelays = relays.filter(url => this.relays.has(url)); + for (const url of relays) { - const relay = this.relays.get(url); - if (!relay) { + // Skip if relay is not in pool (will try to reconnect below) + if (!this.relays.has(url)) { // Try to connect if not already connected this.addRelay(url).then(() => { const newRelay = this.relays.get(url); @@ -201,12 +233,26 @@ class NostrClient { this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); } }).catch((error) => { - console.error(`Failed to connect to relay ${url}:`, error); + console.debug(`Failed to connect to relay ${url}:`, error); }); continue; } - this.setupSubscription(relay, url, subId, filters, onEvent, onEose); + const relay = this.relays.get(url); + if (!relay) continue; // Double-check (shouldn't happen, but safety check) + + // Try to subscribe, handle errors if relay is closed + try { + this.setupSubscription(relay, url, subId, filters, onEvent, onEose); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) { + console.debug(`Relay ${url} is closed, removing from pool`); + this.relays.delete(url); + } else { + console.error(`Error subscribing to relay ${url}:`, error); + } + } } return subId; @@ -223,20 +269,65 @@ class NostrClient { onEvent: (event: NostrEvent, relay: string) => void, onEose?: (relay: string) => void ): void { - const client = this; - const sub = relay.subscribe(filters, { - onevent(event: NostrEvent) { - // Add to cache - client.addToCache(event); - // Call callback - onEvent(event, url); - }, - oneose() { - onEose?.(url); - } - }); + // Check if relay is still in the pool (might have been removed due to close) + if (!this.relays.has(url)) { + console.warn(`Relay ${url} not in pool, skipping subscription`); + return; + } + + // Wrap subscription in try-catch and handle both sync and async errors + try { + const client = this; + const sub = relay.subscribe(filters, { + onevent(event: NostrEvent) { + try { + // Check if relay is still in pool before processing + if (!client.relays.has(url)) return; + // Add to cache + client.addToCache(event); + // Call callback + onEvent(event, url); + } catch (err) { + console.error(`Error handling event from relay ${url}:`, err); + } + }, + oneose() { + try { + // Check if relay is still in pool before processing + if (!client.relays.has(url)) return; + onEose?.(url); + } catch (err) { + console.error(`Error handling EOSE from relay ${url}:`, err); + } + } + }); - this.subscriptions.set(`${url}_${subId}`, { relay, sub }); + // Wrap subscription in a promise to catch async errors + Promise.resolve(sub).catch((err) => { + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) { + console.warn(`Relay ${url} subscription error (closed connection), removing from pool`); + this.relays.delete(url); + // Clean up this subscription + this.subscriptions.delete(`${url}_${subId}`); + } else { + console.error(`Relay ${url} subscription error:`, err); + } + }); + + this.subscriptions.set(`${url}_${subId}`, { relay, sub }); + } catch (error) { + // Handle any other errors gracefully + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) { + console.warn(`Relay ${url} connection is closed, removing from pool`); + this.relays.delete(url); + return; + } else { + console.error(`Error setting up subscription on relay ${url}:`, error); + return; + } + } } /** @@ -327,9 +418,12 @@ class NostrClient { return new Promise((resolve, reject) => { const events: Map = new Map(); const relayCount = new Set(); + const connectedRelays = new Set(); let resolved = false; let eoseTimeout: ReturnType | null = null; let timeoutId: ReturnType | null = null; + let subId: string | null = null; // Declare subId at function scope + const client = this; const finish = (eventArray: NostrEvent[]) => { if (resolved) return; @@ -344,6 +438,11 @@ class NostrClient { eoseTimeout = null; } + if (subId) { + client.unsubscribe(subId); + subId = null; + } + const eventArrayValues = Array.from(eventArray); const filtered = client.filterEvents(eventArrayValues); @@ -367,30 +466,84 @@ class NostrClient { events.set(event.id, event); relayCount.add(relayUrl); + connectedRelays.add(relayUrl); }; const onEose = (relayUrl: string) => { relayCount.add(relayUrl); + connectedRelays.add(relayUrl); + + // If we got EOSE from at least one relay, wait a bit for more events, then finish if (eoseTimeout) { clearTimeout(eoseTimeout); } eoseTimeout = setTimeout(() => { - if (!resolved && relayCount.size >= Math.min(relays.length, 3)) { + if (!resolved && subId) { finish(Array.from(events.values())); } - }, 1000); + }, 2000); // Wait 2 seconds after first EOSE }; + // Ensure we have at least some connected relays + let availableRelays = relays.filter(url => { + const relay = this.relays.get(url); + // Check if relay exists + return relay !== undefined; + }); + + // If no relays connected, try to connect + if (availableRelays.length === 0 && relays.length > 0) { + Promise.all(relays.map(url => { + return this.addRelay(url).catch(err => { + console.warn(`Failed to connect to relay ${url}:`, err); + return null; + }); + })).then(() => { + // Re-check available relays after connection attempts + availableRelays = relays.filter(url => { + const relay = this.relays.get(url); + return relay !== undefined; + }); + + if (availableRelays.length === 0) { + // Still no relays, return empty after a short delay + console.warn('No relays available for fetchEvents'); + setTimeout(() => finish([]), 100); + return; + } + + // Subscribe to available relays + subId = this.subscribe(filters, availableRelays, onEvent, onEose); + + // Timeout after 30 seconds + timeoutId = setTimeout(() => { + if (!resolved) { + console.warn('fetchEvents timeout after 30 seconds'); + finish(Array.from(events.values())); + } + }, 30000); + }).catch(() => { + // If connection fails completely, return empty + finish([]); + }); + return; + } + // Subscribe to events - const subId = this.subscribe(filters, relays, onEvent, onEose); + if (availableRelays.length > 0) { + subId = this.subscribe(filters, availableRelays, onEvent, onEose); - // Timeout after 10 seconds - timeoutId = setTimeout(() => { - if (!resolved) { - finish(Array.from(events.values())); - this.unsubscribe(subId); - } - }, 10000); + // Timeout after 30 seconds + timeoutId = setTimeout(() => { + if (!resolved) { + console.warn('fetchEvents timeout after 30 seconds'); + finish(Array.from(events.values())); + } + }, 30000); + } else { + // No relays available, return empty immediately + finish([]); + } }); } diff --git a/src/lib/services/nostr/relay-manager.ts b/src/lib/services/nostr/relay-manager.ts index b599978..c2f5016 100644 --- a/src/lib/services/nostr/relay-manager.ts +++ b/src/lib/services/nostr/relay-manager.ts @@ -113,14 +113,14 @@ class RelayManager { /** * Get relays for reading kind 1 feed */ - getKind1ReadRelays(): string[] { + getFeedReadRelays(): string[] { return this.getReadRelays(config.defaultRelays); } /** * Get relays for reading kind 1 responses */ - getKind1ResponseReadRelays(): string[] { + getFeedResponseReadRelays(): string[] { return this.getReadRelays([ ...config.defaultRelays, 'wss://aggr.nostr.land' @@ -195,7 +195,7 @@ class RelayManager { * Get relays for publishing kind 1 posts * If replying, include target's inbox */ - getKind1PublishRelays(targetInbox?: string[]): string[] { + getFeedPublishRelays(targetInbox?: string[]): string[] { let relays = this.getPublishRelays(config.defaultRelays); // If replying, add target's inbox diff --git a/src/routes/feed/+page.svelte b/src/routes/feed/+page.svelte index c3b1c26..d915d65 100644 --- a/src/routes/feed/+page.svelte +++ b/src/routes/feed/+page.svelte @@ -1,6 +1,6 @@