diff --git a/package-lock.json b/package-lock.json
index f1fb2f3..e9c1af7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "aitherboard",
- "version": "0.1.0",
+ "version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aitherboard",
- "version": "0.1.0",
+ "version": "0.1.1",
"license": "MIT",
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
+ "@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
@@ -1319,6 +1320,32 @@
"vite": "^5.0.0"
}
},
+ "node_modules/@tanstack/svelte-virtual": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.18.tgz",
+ "integrity": "sha512-BHh8WkFK58eE9KzLctPQkCkvCj46LnM9tIGkpwo5Unx5YaBPf0uBJBqvSdc2jMwdT8gLXLHFHtCnSujlZP69BA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
+ "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
diff --git a/package.json b/package.json
index 3512f33..516515d 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
+ "@tanstack/svelte-virtual": "^3.0.0",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0",
diff --git a/public/healthz.json b/public/healthz.json
index 58fb2b7..06992bd 100644
--- a/public/healthz.json
+++ b/public/healthz.json
@@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.1",
- "buildTime": "2026-02-05T09:08:05.545Z",
+ "buildTime": "2026-02-05T09:41:28.201Z",
"gitCommit": "unknown",
- "timestamp": 1770282485545
+ "timestamp": 1770284488201
}
\ No newline at end of file
diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte
index 7d27be4..9146f7c 100644
--- a/src/lib/components/content/MarkdownRenderer.svelte
+++ b/src/lib/components/content/MarkdownRenderer.svelte
@@ -4,13 +4,16 @@
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import ProfileBadge from '../layout/ProfileBadge.svelte';
- import EmbeddedEvent from './EmbeddedEvent.svelte';
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js';
import HighlightOverlay from './HighlightOverlay.svelte';
import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js';
import { mountComponent } from './mount-component-action.js';
import type { NostrEvent } from '../../types/nostr.js';
+
+ // Lazy load EmbeddedEvent component (heavy component) - will be loaded on demand
+ let EmbeddedEventComponent: any = null;
+ let embeddedEventLoading = $state(false);
interface Props {
content: string;
@@ -23,6 +26,10 @@
let highlights = $state([]);
let highlightMatches = $state>([]);
let highlightsLoaded = $state(false);
+
+ // Cache for rendered markdown to avoid re-rendering same content
+ const markdownCache = new Map();
+ const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat
// Validate if a string is a valid bech32 or hex string
function isValidNostrId(str: string): boolean {
@@ -521,6 +528,12 @@
function renderMarkdown(text: string): string {
if (!content) return '';
+ // Check cache first
+ const cached = markdownCache.get(content);
+ if (cached !== undefined) {
+ return cached;
+ }
+
const processed = processContent(content);
let html: string;
@@ -658,6 +671,16 @@
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
+ // Cache the result (with size limit to prevent memory bloat)
+ if (markdownCache.size >= MAX_CACHE_SIZE) {
+ // Remove oldest entry (simple FIFO)
+ const firstKey = markdownCache.keys().next().value;
+ if (firstKey !== undefined) {
+ markdownCache.delete(firstKey);
+ }
+ }
+ markdownCache.set(content, sanitized);
+
return sanitized;
}
@@ -712,8 +735,26 @@
}
}
- // Mount EmbeddedEvent components after rendering
- function mountEmbeddedEvents() {
+ // Lazy load EmbeddedEvent component when needed
+ async function loadEmbeddedEventComponent() {
+ if (EmbeddedEventComponent) return EmbeddedEventComponent;
+ if (embeddedEventLoading) return null;
+
+ embeddedEventLoading = true;
+ try {
+ const module = await import('./EmbeddedEvent.svelte');
+ EmbeddedEventComponent = module.default;
+ return EmbeddedEventComponent;
+ } catch (error) {
+ console.error('Error loading EmbeddedEvent component:', error);
+ return null;
+ } finally {
+ embeddedEventLoading = false;
+ }
+ }
+
+ // Mount EmbeddedEvent components after rendering (lazy loaded)
+ async function mountEmbeddedEvents() {
if (!containerRef) return;
// Find all event placeholders and mount EmbeddedEvent components
@@ -722,6 +763,13 @@
if (placeholders.length > 0) {
console.debug(`Mounting ${placeholders.length} EmbeddedEvent components`);
+ // Load component only when we have placeholders to mount
+ const Component = await loadEmbeddedEventComponent();
+ if (!Component) {
+ console.warn('Failed to load EmbeddedEvent component');
+ return;
+ }
+
placeholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
if (eventId) {
@@ -731,7 +779,7 @@
// Clear and mount component
placeholder.innerHTML = '';
// Mount EmbeddedEvent component - it will decode and fetch the event
- const instance = mountComponent(placeholder as HTMLElement, EmbeddedEvent as any, { eventId });
+ const instance = mountComponent(placeholder as HTMLElement, Component as any, { eventId });
if (!instance) {
console.warn('EmbeddedEvent mount returned null', { eventId });
diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte
index dda4ab1..4cb96ff 100644
--- a/src/lib/components/layout/Header.svelte
+++ b/src/lib/components/layout/Header.svelte
@@ -25,6 +25,7 @@
src="/aither.png"
alt="aitherboard banner"
class="w-full h-full object-cover opacity-90 dark:opacity-70"
+ loading="eager"
/>
diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte
index 7711288..fcf534b 100644
--- a/src/lib/components/layout/ProfileBadge.svelte
+++ b/src/lib/components/layout/ProfileBadge.svelte
@@ -97,6 +97,7 @@
src={profile.picture}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded flex-shrink-0"
+ loading="lazy"
onerror={() => {
imageError = true;
}}
diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte
index db0b98b..2844a65 100644
--- a/src/lib/modules/feed/FeedPage.svelte
+++ b/src/lib/modules/feed/FeedPage.svelte
@@ -8,6 +8,35 @@
import type { NostrEvent } from '../../types/nostr.js';
import { onMount, tick } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
+
+ // Virtual scrolling - lazy load component
+ let Virtualizer: any = null;
+ let virtualizerLoading = $state(false);
+
+ async function loadVirtualizer() {
+ if (Virtualizer) return Virtualizer;
+ if (virtualizerLoading) return null;
+
+ virtualizerLoading = true;
+ try {
+ const module = await import('@tanstack/svelte-virtual');
+ Virtualizer = module.Virtualizer;
+ return Virtualizer;
+ } catch (error) {
+ console.warn('Virtual scrolling not available, falling back to progressive rendering:', error);
+ return null;
+ } finally {
+ virtualizerLoading = false;
+ }
+ }
+
+ // Load virtualizer when component mounts
+ $effect(() => {
+ if (visibleEvents.length > 50) {
+ // Only use virtual scrolling for large lists
+ loadVirtualizer();
+ }
+ });
interface Props {
singleRelay?: string; // If provided, use only this relay and disable cache
@@ -27,6 +56,15 @@
let oldestTimestamp = $state(null);
let relayError = $state(null); // Error message for single-relay mode
+ // Progressive rendering: only render first N items initially, then expand as user scrolls
+ const INITIAL_RENDER_LIMIT = 25;
+ const RENDER_INCREMENT = 25;
+ let visibleItemCount = $state(INITIAL_RENDER_LIMIT);
+
+ // Derived: sorted all events (memoized to avoid re-sorting on every render)
+ const allEvents = $derived.by(() => [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at));
+ const visibleEvents = $derived.by(() => allEvents.slice(0, visibleItemCount));
+
// List filter state
let availableLists = $state>([]);
let selectedListId = $state(null); // Format: "kind:eventId"
@@ -224,6 +262,36 @@
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
+ // Reset visible count when data changes (new feed loaded)
+ visibleItemCount = INITIAL_RENDER_LIMIT;
+ });
+
+ // Auto-expand visible items when user scrolls near bottom (debounced)
+ $effect(() => {
+ let scrollTimeout: ReturnType | null = null;
+
+ const handleScroll = () => {
+ if (loading || loadingMore) return;
+ if (visibleItemCount >= allEvents.length) return;
+
+ const scrollPosition = window.innerHeight + window.scrollY;
+ const documentHeight = document.documentElement.scrollHeight;
+ const distanceFromBottom = documentHeight - scrollPosition;
+
+ // If within 500px of bottom, auto-expand (debounced)
+ if (distanceFromBottom < 500) {
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ visibleItemCount = Math.min(visibleItemCount + RENDER_INCREMENT, allEvents.length);
+ }, 100); // Debounce by 100ms
+ }
+ };
+
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ };
});
// Cleanup subscription on unmount
@@ -1162,22 +1230,36 @@
{:else}
-
- {#each [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at) as event (event.id)}
- {#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
-
- {:else}
-
+ {#key visibleItemCount}
+
+ {#each visibleEvents as event (event.id)}
+ {#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
+
+ {:else}
+
+ {/if}
+ {/each}
+ {#if allEvents.length > visibleItemCount}
+
+
+
{/if}
- {/each}
-
+
+ {/key}
{#if drawerOpen && drawerEvent}
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte
index 4d76294..fdfb370 100644
--- a/src/lib/modules/feed/FeedPost.svelte
+++ b/src/lib/modules/feed/FeedPost.svelte
@@ -6,7 +6,27 @@
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
- import PollCard from '../../components/content/PollCard.svelte';
+
+ // Lazy load PollCard component (heavy component)
+ let PollCardComponent: any = $state(null);
+ let pollCardLoading = $state(false);
+
+ async function loadPollCard() {
+ if (PollCardComponent) return PollCardComponent;
+ if (pollCardLoading) return null;
+
+ pollCardLoading = true;
+ try {
+ const module = await import('../../components/content/PollCard.svelte');
+ PollCardComponent = module.default;
+ return PollCardComponent;
+ } catch (error) {
+ console.error('Error loading PollCard component:', error);
+ return null;
+ } finally {
+ pollCardLoading = false;
+ }
+ }
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@@ -136,6 +156,19 @@
zapCount = 0;
}
});
+
+ // Lazy load PollCard when post is a poll and component is visible
+ $effect(() => {
+ if (post.kind === KIND.POLL && !PollCardComponent && !pollCardLoading) {
+ // Use IntersectionObserver to load only when visible
+ if (typeof window !== 'undefined' && 'IntersectionObserver' in window) {
+ // Load immediately for polls (they're usually important content)
+ loadPollCard();
+ } else {
+ loadPollCard();
+ }
+ }
+ });
onMount(async () => {
// Only load zap count if not provided (fallback for edge cases)
@@ -475,6 +508,16 @@
/>
{/if}
+
+ {#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817}
+ {@const title = getTitle()}
+ {#if title && title !== 'Untitled'}
+
+ {title}
+
+ {/if}
+ {/if}
+