diff --git a/public/healthz.json b/public/healthz.json index 063de27..2f36db0 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.2.0", - "buildTime": "2026-02-06T19:28:59.767Z", + "buildTime": "2026-02-06T23:46:07.275Z", "gitCommit": "unknown", - "timestamp": 1770406139768 + "timestamp": 1770421567275 } \ No newline at end of file diff --git a/src/app.css b/src/app.css index 2eeb9eb..1272609 100644 --- a/src/app.css +++ b/src/app.css @@ -11,6 +11,7 @@ --content-width: 800px; } +/* Base text size preferences - will be overridden by media queries if not specified */ [data-text-size='small'] { --text-size: 10px; } @@ -70,23 +71,67 @@ body { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; } -/* Responsive font sizing based on screen size */ +/* Responsive font sizing based on screen size - defaults only */ @media (max-width: 640px) { - :root { + :root:not([data-text-size]) { --text-size: clamp(14px, 4vw, 16px); } } @media (min-width: 641px) and (max-width: 1024px) { - :root { + :root:not([data-text-size]) { --text-size: clamp(15px, 1.5vw, 18px); } } @media (min-width: 1025px) { - :root { + :root:not([data-text-size]) { + --text-size: clamp(16px, 1.2vw, 20px); + } +} + +/* User text size preferences - override media queries with higher specificity */ +/* These come after media queries to ensure they take precedence */ +@media (max-width: 640px) { + [data-text-size='small'] { + --text-size: clamp(12px, 3.5vw, 14px); + } + + [data-text-size='medium'] { + --text-size: clamp(14px, 4vw, 16px); + } + + [data-text-size='large'] { + --text-size: clamp(16px, 4.5vw, 18px); + } +} + +@media (min-width: 641px) and (max-width: 1024px) { + [data-text-size='small'] { + --text-size: clamp(13px, 1.3vw, 16px); + } + + [data-text-size='medium'] { + --text-size: clamp(15px, 1.5vw, 18px); + } + + [data-text-size='large'] { + --text-size: clamp(17px, 1.7vw, 20px); + } +} + +@media (min-width: 1025px) { + [data-text-size='small'] { + --text-size: clamp(14px, 1.1vw, 18px); + } + + [data-text-size='medium'] { --text-size: clamp(16px, 1.2vw, 20px); } + + [data-text-size='large'] { + --text-size: clamp(18px, 1.3vw, 22px); + } } /* Paragraph spacing - increased for better readability */ diff --git a/src/lib/components/content/VoteCount.svelte b/src/lib/components/content/VoteCount.svelte index a2eb319..383c449 100644 --- a/src/lib/components/content/VoteCount.svelte +++ b/src/lib/components/content/VoteCount.svelte @@ -93,9 +93,11 @@ color: inherit; display: inline-flex; align-items: center; + justify-content: center; gap: 0.25rem; border-radius: 0.25rem; opacity: 0.7; + min-width: 2.5rem; /* Ensure consistent button width */ } .vote-emoji.disabled { diff --git a/src/lib/modules/discussions/DiscussionList.svelte b/src/lib/modules/discussions/DiscussionList.svelte index d64936f..2a02ca5 100644 --- a/src/lib/modules/discussions/DiscussionList.svelte +++ b/src/lib/modules/discussions/DiscussionList.svelte @@ -100,30 +100,34 @@ }; }); - // Only reload when sortBy, showOlder, or selectedTopic changes (after initial values are set) + // Only reload when sortBy or selectedTopic changes (after initial values are set) + // Note: showOlder only affects view filtering, not data fetching $effect(() => { // Skip if we haven't set initial values yet (onMount hasn't run) if (prevSortBy === null) return; - // Read showOlder to ensure it's tracked by the effect + // Read showOlder to ensure it's tracked by the effect (for view filtering) const currentShowOlder = showOlder; const currentSortBy = sortBy; const currentSelectedTopic = selectedTopic; - // Check if any filter parameter actually changed - if (currentSortBy !== prevSortBy || currentShowOlder !== prevShowOlder || currentSelectedTopic !== prevSelectedTopic) { - prevSortBy = currentSortBy; - prevShowOlder = currentShowOlder; - prevSelectedTopic = currentSelectedTopic; - + // Check if sortBy or topic changed (showOlder only affects filtering, not fetching) + const sortByChanged = currentSortBy !== prevSortBy; + const topicChanged = currentSelectedTopic !== prevSelectedTopic; + + if (sortByChanged || topicChanged) { // Only reload if not already loading if (!isLoading) { - // If showOlder changed, also reload from cache to get older threads - if (currentShowOlder !== prevShowOlder) { - loadCachedThreads(); - } loadAllData(); } + + // Update previous values AFTER checking for changes + prevSortBy = currentSortBy; + prevShowOlder = currentShowOlder; // Track but don't reload on change + prevSelectedTopic = currentSelectedTopic; + } else { + // Update showOlder tracking even if nothing else changed + prevShowOlder = currentShowOlder; } }); @@ -131,24 +135,13 @@ if (!isMounted || isLoading) return; try { - const config = nostrClient.getConfig(); - const since = showOlder - ? undefined - : Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400; - - // Load cached threads (within 15 minutes) - const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 50); + // Always load all cached threads (limit 100), filtering happens in the view + const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 100); if (cachedThreads.length > 0 && isMounted) { - // Filter by time if needed - let filtered = cachedThreads; - if (since) { - filtered = cachedThreads.filter(e => e.created_at >= since); - } - - // Build threads map from cached results + // Build threads map from cached results (no time filtering here) const newThreadsMap = new Map(); - for (const event of filtered) { + for (const event of cachedThreads) { newThreadsMap.set(event.id, event); } @@ -174,9 +167,8 @@ voteCountsReady = false; // Reset vote counts ready state try { const config = nostrClient.getConfig(); - const since = showOlder - ? undefined - : Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400; + // Always fetch limit 100 without time filter - filtering happens in the view + // No 'since' parameter - fetch all threads const threadRelays = relayManager.getThreadReadRelays(); // Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays @@ -185,8 +177,9 @@ const commentRelays = relayManager.getCommentReadRelays(); // Query relays first with 3-second timeout, then fill from cache if needed + // Always fetch limit 100, no time filtering const fetchPromise = nostrClient.fetchEvents( - [{ kinds: [KIND.DISCUSSION_THREAD], since, limit: config.feedLimit }], + [{ kinds: [KIND.DISCUSSION_THREAD], limit: 100 }], threadRelays, { useCache: 'relay-first', // Query relays first with timeout, fill from cache if relay query returns nothing diff --git a/src/lib/modules/discussions/DiscussionVoteButtons.svelte b/src/lib/modules/discussions/DiscussionVoteButtons.svelte index edde9ea..372225f 100644 --- a/src/lib/modules/discussions/DiscussionVoteButtons.svelte +++ b/src/lib/modules/discussions/DiscussionVoteButtons.svelte @@ -65,22 +65,37 @@ if (event.id) { // Use pre-loaded reactions if available, otherwise fetch if (preloadedReactions && preloadedReactions.length > 0) { - const filtered = await filterDeletedReactions(preloadedReactions); - - // Update the map to only contain non-deleted reactions - // Reassign map to trigger reactivity in Svelte 5 - const filteredMap = new Map(); - for (const reaction of filtered) { - filteredMap.set(reaction.id, reaction); + try { + const filtered = await filterDeletedReactions(preloadedReactions); + + // Update the map to only contain non-deleted reactions + // Reassign map to trigger reactivity in Svelte 5 + const filteredMap = new Map(); + for (const reaction of filtered) { + filteredMap.set(reaction.id, reaction); + } + allReactionsMap = filteredMap; + + processReactions(filtered); + initialLoadComplete = true; // Mark initial load as complete + } catch (error) { + console.error('[DiscussionVoteButtons] Error processing preloaded reactions:', error); + initialLoadComplete = true; // Mark as complete even on error + } finally { + loading = false; // Always set loading to false } - allReactionsMap = filteredMap; - - processReactions(filtered); - initialLoadComplete = true; // Mark initial load as complete } else { loadReactions(); } + } else { + // No event ID, mark as complete + loading = false; + initialLoadComplete = true; } + }).catch((error) => { + console.error('[DiscussionVoteButtons] Error initializing client:', error); + loading = false; + initialLoadComplete = true; // Mark as complete even on error }); }); @@ -98,6 +113,7 @@ // Use pre-loaded reactions if available, otherwise fetch if (preloadedReactions && preloadedReactions.length > 0) { + loading = true; // Set loading while processing const newMap = new Map(); for (const r of preloadedReactions) { newMap.set(r.id, r); @@ -114,6 +130,11 @@ processReactions(filtered); initialLoadComplete = true; // Mark initial load as complete + loading = false; // Set loading to false after processing + }).catch((error) => { + console.error('[DiscussionVoteButtons] Error processing preloaded reactions in effect:', error); + initialLoadComplete = true; // Mark as complete even on error + loading = false; // Always set loading to false }); } else { loadReactions(); @@ -185,6 +206,10 @@ async function loadReactions() { // Prevent concurrent loads for the same event if (loadingReactions) { + // If already loading, ensure loading state is set correctly + if (!loading) { + loading = true; + } return; } loadingReactions = true; @@ -440,7 +465,16 @@ } } - const isLoggedIn = $derived(sessionManager.isLoggedIn()); + // Subscribe to session changes for reactive login state + let currentSession = $state(sessionManager.session.value); + let isLoggedIn = $derived(currentSession !== null); + + $effect(() => { + const unsubscribe = sessionManager.session.subscribe((session) => { + currentSession = session; + }); + return unsubscribe; + }); // Get user's current vote (normalize to + or -) let userVote = $derived(userReaction === '+' || userReaction === '-' ? userReaction : null); diff --git a/src/lib/modules/events/EventView.svelte b/src/lib/modules/events/EventView.svelte index 657564d..8545f73 100644 --- a/src/lib/modules/events/EventView.svelte +++ b/src/lib/modules/events/EventView.svelte @@ -40,6 +40,7 @@ let rootEvent = $state(null); let loading = $state(true); let error = $state(null); + let lastLoadedEventId = $state(null); // Track last loaded eventId to prevent loops // For kind 30040: event index hierarchy let isEventIndex = $derived(rootEvent?.kind === 30040); @@ -80,17 +81,24 @@ }); $effect(() => { - if (eventId) { + // Only load if eventId changed and we're not already loading + if (eventId && eventId !== lastLoadedEventId && !loading) { loadEvent(); } }); async function loadEvent() { + // Prevent loading if already loading the same event + if (loading && lastLoadedEventId === eventId) { + return; + } + loading = true; error = null; indexError = null; eventIndexItems = []; rootEvent = null; + lastLoadedEventId = eventId; // Track that we're loading this eventId try { const threadRelays = relayManager.getThreadReadRelays(); diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index a0d89ed..c153522 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -887,16 +887,6 @@ {/if} - - {#if isReply()} - - {/if} - diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 1847477..b364378 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -4,7 +4,6 @@ import PaymentAddresses from './PaymentAddresses.svelte'; import FeedPost from '../feed/FeedPost.svelte'; import CommentComponent from '../comments/Comment.svelte'; - import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte'; import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; @@ -41,9 +40,6 @@ // Get current logged-in user's pubkey let currentUserPubkey = $state(sessionManager.getCurrentPubkey()); - // Profile events panel state - let profileEventsPanelOpen = $state(false); - // Pins state let pins = $state([]); @@ -71,14 +67,6 @@ }; }); - function openProfileEventsPanel() { - profileEventsPanelOpen = true; - } - - function closeProfileEventsPanel() { - profileEventsPanelOpen = false; - } - const isOwnProfile = $derived.by(() => { const pubkey = decodePubkey($page.params.pubkey); return currentUserPubkey && pubkey && currentUserPubkey === pubkey; @@ -804,17 +792,6 @@ {/if} - - {#if isOwnProfile} -
- -
- {/if}
@@ -949,14 +926,6 @@ {:else}

Profile not found

{/if} - - {#if isOwnProfile} - - {/if}
diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index 108ede2..9025f96 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -142,7 +142,22 @@ const relays = getAllRelays(); const feedKinds = getFeedKinds(); // Get all kinds with showInFeed: true + // Create a map to track events by ID for deduplication + const eventsMap = new Map(); + + // Function to merge new events into the view + const mergeEvents = (newEvents: NostrEvent[]) => { + for (const event of newEvents) { + eventsMap.set(event.id, event); + } + // Convert map to array, sort by created_at descending (newest first) + const sortedEvents = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at); + events = sortedEvents; + }; + // Fetch events from all pubkeys in the list, with showInFeed kinds + // useCache: true will return cached events immediately, then fetch from relays in background + // onUpdate will be called as new events arrive from relays const fetchedEvents = await nostrClient.fetchEvents( [{ kinds: feedKinds, @@ -150,13 +165,15 @@ limit: 100 }], relays, - { useCache: true, cacheResults: true } + { + useCache: true, + cacheResults: true, + onUpdate: mergeEvents // Update view as new events arrive from relays + } ); - // Sort by created_at descending (newest first) - fetchedEvents.sort((a, b) => b.created_at - a.created_at); - - events = fetchedEvents; + // Initial merge of cached events + mergeEvents(fetchedEvents); } catch (error) { console.error('Error loading list events:', error); events = []; diff --git a/src/routes/topics/[name]/+page.svelte b/src/routes/topics/[name]/+page.svelte index ab620d6..66a5c8a 100644 --- a/src/routes/topics/[name]/+page.svelte +++ b/src/routes/topics/[name]/+page.svelte @@ -14,6 +14,7 @@ let loading = $state(true); let topicName = $derived($page.params.name); let loadingEvents = $state(false); // Guard to prevent concurrent loads + let lastLoadedTopic = $state(null); // Track which topic we've loaded // Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement) const EVENTS_PER_PAGE = 100; @@ -34,24 +35,24 @@ return Math.ceil(totalEvents / EVENTS_PER_PAGE); }); - // Reset to page 1 when topic changes - $effect(() => { - if ($page.params.name) { - currentPage = 1; - events = []; // Clear events when topic changes - } - }); - onMount(async () => { await nostrClient.initialize(); if (topicName) { - await loadCachedTopicEvents(); - await loadTopicEvents(); + await loadCachedTopicEvents(); + await loadTopicEvents(); + lastLoadedTopic = topicName; } }); + // Only reload when topic name actually changes, not on every reactive update $effect(() => { - if ($page.params.name && !loadingEvents) { + const currentTopic = $page.params.name; + // Only load if topic changed and we're not already loading + if (currentTopic && currentTopic !== lastLoadedTopic && !loadingEvents) { + // Reset pagination and clear events when topic changes + currentPage = 1; + events = []; + lastLoadedTopic = currentTopic; loadCachedTopicEvents(); loadTopicEvents(); } @@ -123,33 +124,11 @@ { useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` } ); - // Also search for hashtags in content, but only fetch a smaller sample - // We'll only fetch events that might have the hashtag (limited to reduce bandwidth) - const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i'); + // Use t-tag events as the primary source + // Content-based hashtag search is too inefficient (would require fetching all events) + // Users should use t-tags for proper topic organization const allEvents: NostrEvent[] = [...tTagEvents]; - // For content-based hashtag search, fetch a smaller sample and filter - // This is less efficient but catches events without t-tags - // Limit to 50 events to reduce bandwidth usage - const contentEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 50 }], - relays, - { useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (content)` } - ); - - // Filter events that contain the hashtag in content but don't already have a t-tag - const tTagEventIds = new Set(tTagEvents.map(e => e.id)); - for (const event of contentEvents) { - // Only add if it has the hashtag in content and isn't already in tTagEvents - if (!tTagEventIds.has(event.id) && hashtagPattern.test(event.content)) { - // Also check if it doesn't have a t-tag (to avoid duplicates) - const hasTTag = event.tags.some(t => t[0] === 't' && t[1] === topicName); - if (!hasTTag) { - allEvents.push(event); - } - } - } - // Merge with existing cached events and deduplicate const eventMap = new Map();