diff --git a/src/app.css b/src/app.css index 44cca1d..5b2e466 100644 --- a/src/app.css +++ b/src/app.css @@ -472,12 +472,13 @@ img.emoji-inline { } /* Responsive images and media - max 600px, scale down on smaller screens */ -img:not(.profile-picture):not([alt*="profile" i]):not([alt*="avatar" i]):not([src*="avatar" i]):not([src*="profile" i]), +img:not(.profile-picture):not([alt*="profile" i]):not([alt*="avatar" i]):not([src*="avatar" i]):not([src*="profile" i]):not(.relay-icon):not(.relay-favorite-pic), video, audio { - max-width: 600px; - width: 100%; - height: auto; + max-width: 600px !important; + width: 100% !important; + height: auto !important; + display: block; } /* Ensure media in markdown content is responsive */ diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index fcde065..7cae309 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -1,6 +1,5 @@ - - {#if !inline} + + {#if !inline || pictureOnly} {#if profile?.picture && !imageError} {profile.name { imageError = true; @@ -159,28 +153,28 @@ {:else}
{avatarInitials}
{/if} {/if} -
-
- - {profile?.name || shortenedNpub} - - {#if profile?.nip05 && profile.nip05.length > 0} - - {profile.nip05[0]} + {#if !pictureOnly} +
+
+ + {profile?.name || shortenedNpub} - {/if} + {#if profile?.nip05 && profile.nip05.length > 0} + + {profile.nip05[0]} + + {/if} +
- {#if status && status.trim()} - {status} - {/if} -
+ {/if}
diff --git a/src/lib/components/relay/RelayInfo.svelte b/src/lib/components/relay/RelayInfo.svelte index 532641a..07a60bb 100644 --- a/src/lib/components/relay/RelayInfo.svelte +++ b/src/lib/components/relay/RelayInfo.svelte @@ -483,11 +483,12 @@ } .relay-icon { - width: 2rem; - height: 2rem; - border-radius: 0.375rem; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; object-fit: cover; border: 1px solid var(--fog-border, #e5e7eb); + flex-shrink: 0; } :global(.dark) .relay-icon { diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index c130b3f..cb02a1e 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -31,6 +31,7 @@ let subscriptionId: string | null = $state(null); let isMounted = $state(true); let initialLoadComplete = $state(false); + let loadInProgress = $state(false); // Load waiting room events into feed function loadWaitingRoomEvents() { @@ -102,8 +103,9 @@ // Initial feed load async function loadFeed() { - if (!isMounted) return; + if (!isMounted || loadInProgress) return; + loadInProgress = true; loading = true; relayError = null; @@ -182,6 +184,7 @@ } finally { loading = false; initialLoadComplete = true; + loadInProgress = false; } } @@ -211,18 +214,27 @@ onMount(() => { isMounted = true; - nostrClient.initialize().then(() => { - if (isMounted) { - loadFeed().then(() => { - if (isMounted) { - setupSubscription(); - } - }); - } - }); + loadInProgress = false; + + // Use a small delay to ensure previous page cleanup completes + const initTimeout = setTimeout(() => { + if (!isMounted) return; + + nostrClient.initialize().then(() => { + if (isMounted && !loadInProgress) { + loadFeed().then(() => { + if (isMounted) { + setupSubscription(); + } + }); + } + }); + }, 50); return () => { + clearTimeout(initTimeout); isMounted = false; + loadInProgress = false; if (subscriptionId) { nostrClient.unsubscribe(subscriptionId); subscriptionId = null; diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 93dabd2..a833fb3 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -43,15 +43,23 @@ let showReplyForm = $state(false); // Check if card should be collapsed (only in feed view) + let isMounted = $state(true); + $effect(() => { if (fullView) { shouldCollapse = false; return; } + isMounted = true; + // Wait for content to render, then check height + let timeoutId: ReturnType | null = null; + let rafId1: number | null = null; + let rafId2: number | null = null; + const checkHeight = () => { - if (!cardElement) return; + if (!isMounted || !cardElement) return; // Measure the full card height (using scrollHeight to get full content height) const cardHeight = cardElement.scrollHeight; @@ -59,13 +67,20 @@ }; // Check after content is rendered - const timeoutId = setTimeout(() => { - requestAnimationFrame(() => { - requestAnimationFrame(checkHeight); + timeoutId = setTimeout(() => { + if (!isMounted) return; + rafId1 = requestAnimationFrame(() => { + if (!isMounted) return; + rafId2 = requestAnimationFrame(checkHeight); }); }, 200); - return () => clearTimeout(timeoutId); + return () => { + isMounted = false; + if (timeoutId) clearTimeout(timeoutId); + if (rafId1) cancelAnimationFrame(rafId1); + if (rafId2) cancelAnimationFrame(rafId2); + }; }); function toggleExpand() { @@ -73,13 +88,21 @@ } $effect(() => { + let cancelled = false; + if (isLoggedIn) { isBookmarked(post.id).then(b => { - bookmarked = b; + if (!cancelled) { + bookmarked = b; + } }); } else { bookmarked = false; } + + return () => { + cancelled = true; + }; }); function getRelativeTime(): string { @@ -583,9 +606,11 @@
-
- - {getRelativeTime()} +
+
+ +
+ {getRelativeTime()} {#if getClientName()} via {getClientName()} {/if} @@ -614,9 +639,11 @@ {:else}
-
- - {getRelativeTime()} +
+
+ +
+ {getRelativeTime()} {#if getClientName()} via {getClientName()} {/if} @@ -964,6 +991,8 @@ align-items: center; vertical-align: middle; line-height: 1.5; + width: auto; + max-width: none; } diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 65c0a99..8af0435 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -3,11 +3,9 @@ import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import PaymentAddresses from './PaymentAddresses.svelte'; import FeedPost from '../feed/FeedPost.svelte'; - import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte'; import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte'; - import { getPinnedEvents } from '../../services/user-actions.js'; import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; @@ -17,27 +15,15 @@ import { page } from '$app/stores'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '../../types/nostr.js'; - import { KIND, getFeedKinds } from '../../types/kind-lookup.js'; + import { KIND } from '../../types/kind-lookup.js'; - // Progressive rendering for profile posts - const INITIAL_RENDER_LIMIT = 25; - const RENDER_INCREMENT = 25; - let visiblePostsCount = $state(INITIAL_RENDER_LIMIT); - let visibleResponsesCount = $state(INITIAL_RENDER_LIMIT); - let visibleInteractionsCount = $state(INITIAL_RENDER_LIMIT); - - // Derived: visible items for each tab - const visiblePosts = $derived.by(() => posts.slice(0, visiblePostsCount)); - const visibleResponses = $derived.by(() => responses.slice(0, visibleResponsesCount)); - const visibleInteractions = $derived.by(() => interactionsWithMe.slice(0, visibleInteractionsCount)); let profile = $state(null); let userStatus = $state(null); - let posts = $state([]); - let responses = $state([]); + let notifications = $state([]); let interactionsWithMe = $state([]); let loading = $state(true); - let activeTab = $state<'posts' | 'responses' | 'interactions' | 'pins'>('posts'); + let activeTab = $state<'pins' | 'notifications' | 'interactions'>('pins'); let nip05Validations = $state>({}); // null = checking, true = valid, false = invalid // Compute pubkey from route params let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); @@ -49,10 +35,6 @@ // Get current logged-in user's pubkey let currentUserPubkey = $state(sessionManager.getCurrentPubkey()); - // Drawer state for viewing threads - let drawerOpen = $state(false); - let drawerEvent = $state(null); - // Profile events panel state let profileEventsPanelOpen = $state(false); @@ -82,16 +64,6 @@ }; }); - function openDrawer(event: NostrEvent) { - drawerEvent = event; - drawerOpen = true; - } - - function closeDrawer() { - drawerOpen = false; - drawerEvent = null; - } - function openProfileEventsPanel() { profileEventsPanelOpen = true; } @@ -117,14 +89,17 @@ $effect(() => { const unsubscribe = sessionManager.session.subscribe((session) => { currentUserPubkey = session?.pubkey || null; - // Reload interactions if session changes and we're viewing another user's profile + // Reload notifications/interactions if session changes if (profile) { const pubkey = decodePubkey($page.params.pubkey); - if (pubkey && currentUserPubkey && currentUserPubkey !== pubkey) { - // Reload interactions tab data - loadInteractionsWithMe(pubkey, currentUserPubkey); - } else { - interactionsWithMe = []; + if (pubkey && currentUserPubkey) { + if (currentUserPubkey === pubkey) { + // Reload notifications for own profile + loadNotifications(pubkey); + } else { + // Reload interactions for other user's profile + loadInteractionsWithMe(pubkey, currentUserPubkey); + } } } }); @@ -134,13 +109,33 @@ async function loadPins(pubkey: string) { if (!isMounted) return; try { - const pinnedIds = await getPinnedEvents(); - if (!isMounted || pinnedIds.size === 0) { + // Fetch the user's pin list (kind 10001) + const profileRelays = relayManager.getProfileReadRelays(); + const pinLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.PIN_LIST], authors: [pubkey], limit: 1 }], + profileRelays, + { useCache: true, cacheResults: true, timeout: config.mediumTimeout } + ); + + if (!isMounted || pinLists.length === 0) { if (isMounted) pins = []; return; } - const profileRelays = relayManager.getProfileReadRelays(); + // Extract event IDs from pin list + const pinnedIds = new Set(); + for (const tag of pinLists[0].tags) { + if (tag[0] === 'e' && tag[1]) { + pinnedIds.add(tag[1]); + } + } + + if (pinnedIds.size === 0) { + if (isMounted) pins = []; + return; + } + + // Fetch the actual pinned events const fetchPromise = nostrClient.fetchEvents( [{ ids: Array.from(pinnedIds), limit: config.feedLimit }], profileRelays, @@ -160,6 +155,53 @@ } } + async function loadNotifications(pubkey: string) { + if (!isMounted) return; + try { + const notificationRelays = relayManager.getFeedReadRelays(); + + // Fetch user's posts to find replies + const userPosts = await nostrClient.fetchEvents( + [{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 100 }], + notificationRelays, + { useCache: true, cacheResults: true, timeout: config.mediumTimeout } + ); + + if (!isMounted) return; + + const userPostIds = new Set(userPosts.map(p => p.id)); + + // Fetch notifications: replies, mentions, reactions, zaps + const notificationEvents = await nostrClient.fetchEvents( + [ + { kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(userPostIds).slice(0, 50), limit: 100 }, // Replies to user's posts + { kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 100 }, // Mentions + { kinds: [KIND.REACTION], '#p': [pubkey], limit: 100 }, // Reactions + { kinds: [KIND.ZAP_RECEIPT], '#p': [pubkey], limit: 100 } // Zaps + ], + notificationRelays, + { useCache: true, cacheResults: true, timeout: config.mediumTimeout } + ); + + if (!isMounted) return; + + // Deduplicate and sort by created_at descending + const seenIds = new Set(); + notifications = notificationEvents + .filter(e => { + if (seenIds.has(e.id)) return false; + seenIds.add(e.id); + // Exclude user's own events + return e.pubkey !== pubkey; + }) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 100); // Limit to 100 most recent + } catch (error) { + console.error('Error loading notifications:', error); + notifications = []; + } + } + async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { if (!isMounted || !currentUserPubkey || currentUserPubkey === profilePubkey) { if (isMounted) interactionsWithMe = []; @@ -230,6 +272,7 @@ interactionsWithMe = []; } } + onMount(async () => { await nostrClient.initialize(); @@ -480,73 +523,31 @@ } } - // Step 2: Load posts and responses in parallel (non-blocking, update when ready) - const profileRelays = relayManager.getProfileReadRelays(); - const responseRelays = relayManager.getFeedResponseReadRelays(); - - // Check again before loading posts - if (abortController.signal.aborted || currentLoadPubkey !== pubkey) { - return; - } - - // Load posts first (needed for response filtering) - // Fetch all feed kinds (not just kind 1) - const feedKinds = getFeedKinds(); - const feedEvents = await nostrClient.fetchEvents( - [{ kinds: feedKinds, authors: [pubkey], limit: config.feedLimit }], - profileRelays, - { useCache: true, cacheResults: true, timeout: config.mediumTimeout } - ); - - // Check again after posts load - if (abortController.signal.aborted || currentLoadPubkey !== pubkey) { - return; - } - - posts = feedEvents.sort((a, b) => b.created_at - a.created_at); + // Step 2: Load pins for the profile being viewed + await loadPins(pubkey); - // Reset visible counts when new data loads - visiblePostsCount = INITIAL_RENDER_LIMIT; - visibleResponsesCount = INITIAL_RENDER_LIMIT; - visibleInteractionsCount = INITIAL_RENDER_LIMIT; - - // Load responses in parallel with posts (but filter after posts are loaded) - const userPostIds = new Set(posts.map(p => p.id)); - const responseEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: config.feedLimit }], // Fetch more to account for filtering - responseRelays, - { useCache: true, cacheResults: true, timeout: config.mediumTimeout } - ); - - // Check again after responses load - if (abortController.signal.aborted || currentLoadPubkey !== pubkey) { - return; - } - - // Filter responses (exclude self-replies, only include replies to user's posts) - responses = responseEvents - .filter(e => { - if (e.pubkey === pubkey) return false; // Exclude self-replies - const eTag = e.tags.find(t => t[0] === 'e'); - return eTag && userPostIds.has(eTag[1]); - }) - .sort((a, b) => b.created_at - a.created_at) - .slice(0, 20); // Limit to 20 after filtering - - // Step 3: Load interactions in background (non-blocking) - if (currentUserPubkey && currentUserPubkey !== pubkey) { - loadInteractionsWithMe(pubkey, currentUserPubkey).catch(err => { - console.debug('Error loading interactions:', err); - }); - } else { - interactionsWithMe = []; - } - - // Step 4: Load pins if viewing own profile + // Step 3: Load notifications or interactions if (isOwnProfile) { - loadPins(pubkey); + await loadNotifications(pubkey); + interactionsWithMe = []; + // Set default tab: if no pins, default to notifications + if (pins.length === 0 && notifications.length > 0) { + activeTab = 'notifications'; + } else if (pins.length > 0) { + activeTab = 'pins'; + } } else { - pins = []; + notifications = []; + // Load interactions if logged in and viewing another user's profile + if (currentUserPubkey) { + await loadInteractionsWithMe(pubkey, currentUserPubkey); + } else { + interactionsWithMe = []; + } + // Set default tab to pins if available + if (pins.length > 0) { + activeTab = 'pins'; + } } } catch (error) { // Only update state if this load wasn't aborted @@ -581,7 +582,7 @@ {#if profile.about}

{profile.about}

{/if} - {#if userStatus} + {#if userStatus && userStatus.trim()}

{userStatus}

@@ -656,77 +657,46 @@
- - {#if currentUserPubkey && currentUserPubkey !== decodePubkey($page.params.pubkey)} + {#if isOwnProfile} - {/if} - {#if isOwnProfile} + {:else if currentUserPubkey && currentUserPubkey !== profilePubkey} {/if}
- {#if activeTab === 'posts'} - {#if posts.length === 0} -

No posts yet.

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

No pinned posts yet.

{:else} -
- {#each visiblePosts as post (post.id)} - +
+ {#each pins as pin (pin.id)} + {/each} - {#if posts.length > visiblePostsCount} -
- -
- {/if}
{/if} - {:else if activeTab === 'responses'} - {#if responses.length === 0} -

No responses yet.

+ {:else if activeTab === 'notifications'} + {#if notifications.length === 0} +

No notifications yet.

{:else} -
- {#each visibleResponses as response (response.id)} - +
+ {#each notifications as notification (notification.id)} + {/each} - {#if responses.length > visibleResponsesCount} -
- -
- {/if}
{/if} {:else if activeTab === 'interactions'} @@ -734,30 +704,8 @@

No interactions with you yet.

{:else}
- {#each visibleInteractions as interaction (interaction.id)} - - {/each} - {#if interactionsWithMe.length > visibleInteractionsCount} -
- -
- {/if} -
- {/if} - {:else if activeTab === 'pins'} - {#if pins.length === 0} -

No pinned posts yet.

- {:else} -
- {#each pins as pin (pin.id)} - + {#each interactionsWithMe as interaction (interaction.id)} + {/each}
{/if} @@ -767,8 +715,6 @@

Profile not found

{/if} - - {#if isOwnProfile} + import Header from '../../lib/components/layout/Header.svelte'; + import FeedPost from '../../lib/modules/feed/FeedPost.svelte'; + import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; + import { relayManager } from '../../lib/services/nostr/relay-manager.js'; + import { config } from '../../lib/services/nostr/config.js'; + import { onMount } from 'svelte'; + import type { NostrEvent } from '../../lib/types/nostr.js'; + import { KIND } from '../../lib/types/kind-lookup.js'; + + let events = $state([]); + let loading = $state(true); + let error = $state(null); + + async function loadBookmarks() { + loading = true; + error = null; + events = []; + + try { + // Fetch all kind 10003 (bookmark) events from relays + const relays = relayManager.getFeedReadRelays(); + + const fetchedBookmarkLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.BOOKMARKS], limit: config.feedLimit }], + relays, + { + useCache: true, + cacheResults: true, + timeout: config.standardTimeout + } + ); + + // Extract all event IDs from bookmark lists + const bookmarkedIds = new Set(); + for (const bookmarkList of fetchedBookmarkLists) { + for (const tag of bookmarkList.tags) { + if (tag[0] === 'e' && tag[1]) { + bookmarkedIds.add(tag[1]); + } + } + } + + console.log(`[Bookmarks] Found ${bookmarkedIds.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`); + + if (bookmarkedIds.size === 0) { + loading = false; + return; + } + + // Fetch the actual events - batch to avoid relay limits + const eventIds = Array.from(bookmarkedIds); + const batchSize = config.veryLargeBatchLimit; // Use config batch limit + const allFetchedEvents: NostrEvent[] = []; + + console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`); + + for (let i = 0; i < eventIds.length; i += batchSize) { + const batch = eventIds.slice(i, i + batchSize); + const filters = [{ ids: batch }]; + + console.log(`[Bookmarks] Fetching batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(eventIds.length / batchSize)} (${batch.length} events)`); + + const batchEvents = await nostrClient.fetchEvents( + filters, + relays, + { + useCache: true, + cacheResults: true, + timeout: config.mediumTimeout + } + ); + + console.log(`[Bookmarks] Batch ${Math.floor(i / batchSize) + 1} returned ${batchEvents.length} events`); + allFetchedEvents.push(...batchEvents); + } + + console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`); + + // Sort by created_at (newest first) + events = allFetchedEvents.sort((a, b) => b.created_at - a.created_at); + } catch (err) { + console.error('Error loading bookmarks:', err); + error = err instanceof Error ? err.message : 'Failed to load bookmarks'; + } finally { + loading = false; + } + } + + onMount(async () => { + await nostrClient.initialize(); + await loadBookmarks(); + }); + + +
+ +
+
+

/Bookmarks

+ + {#if loading} +
+

Loading bookmarks...

+
+ {:else if error} +
+

{error}

+
+ {:else if events.length === 0} +
+

No bookmarks found.

+
+ {:else} +
+ {#each events as event (event.id)} + + {/each} +
+ {/if} +
+
+ + diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index a28a6a3..869dc71 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -16,28 +16,23 @@ let includeClientTag = $state(true); onMount(() => { - // Load preferences from localStorage + // Read current preferences from DOM/localStorage (don't apply, just read) const storedTextSize = localStorage.getItem('textSize') as TextSize | null; const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null; const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null; - const storedTheme = localStorage.getItem('theme'); textSize = storedTextSize || 'medium'; lineSpacing = storedLineSpacing || 'normal'; contentWidth = storedContentWidth || 'medium'; - // Check theme preference - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - isDark = storedTheme === 'dark' || (!storedTheme && prefersDark); + // Read current theme from DOM (don't change it) + isDark = document.documentElement.classList.contains('dark'); // Load expiring events preference expiringEvents = hasExpiringEventsEnabled(); // Load client tag preference includeClientTag = shouldIncludeClientTag(); - - // Apply preferences - applyPreferences(); }); function applyPreferences() { @@ -96,8 +91,9 @@
-
-

Settings

+
+
+

/Settings

@@ -259,9 +255,15 @@

+