diff --git a/README.md b/README.md index 9215c62..5cb6f98 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,72 @@ A decentralized messageboard built on the Nostr protocol. -This is a client from [silberengel@gitcitadel.com](https://aitherboard.imwald.eu/profile/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z) +![Aitherboard logo](/static/og-image.png) +**About**: [https://aitherboard.imwald.eu/about](https://aitherboard.imwald.eu/about) + +Created by [silberengel@gitcitadel.com](https://aitherboard.imwald.eu/profile/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z) + +## Installation + +### Prerequisites + +- Node.js 20+ +- npm or yarn + +### Development Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Start development server: +```bash +npm run dev +``` + +3. Open http://localhost:5173 + +### Building + +```bash +npm run build +``` + +### Docker Deployment + +```bash +docker-compose up --build +``` + +Or build manually: +```bash +docker build -t aitherboard . +docker run -p 9876:9876 aitherboard +``` + +## Core Features + +- **Threads & Discussions** - Create and participate in threaded conversations +- **Feed** - Twitter-like feed posts (kind 1) with real-time updates +- **Comments** - Flat-threaded comments on threads and posts +- **Reactions** - Upvote, downvote, and react to content with custom GIFs and emojis +- **Profiles** - View and manage user profiles with payment addresses +- **Offline Support** - Full offline access with IndexedDB caching and archiving +- **PWA** - Install as a Progressive Web App +- **Search** - Full-text search with advanced filters and parameters +- **Keyboard Shortcuts** - Navigate efficiently with keyboard shortcuts +- **Advanced Markup Suport** - Markdown and AsciiDoc editor, syntax highlighting of code with Highlight.js +- **Follows Support** - Use any list, including your contact list, to create feeds +- **Repo Viewer** - View and navigate git repositories directly in the app +- **Relay Feeds** - See what's happening on relays and explore new relays +- **Universal Write** - Create events for any kind with hints for required/optional fields +- **Universal Read** - View any event with all metadata and content (supports e-books and publications) +- **Hashtag Browsing** - Browse events by hashtags with real-time updates + +## Links + +- **Repository**: [https://git.imwald.eu/silberengel/aitherboard.git](https://git.imwald.eu/silberengel/aitherboard.git) +- **Homepage**: [https://gitcitadel.com/](https://gitcitadel.com/) +- **Nostr NIPs**: [https://github.com/nostr-protocol/nips](https://github.com/nostr-protocol/nips) \ No newline at end of file diff --git a/public/healthz.json b/public/healthz.json index f67986a..983010e 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.2.0", - "buildTime": "2026-02-07T07:06:24.119Z", + "buildTime": "2026-02-07T09:22:55.996Z", "gitCommit": "unknown", - "timestamp": 1770447984119 + "timestamp": 1770456175996 } \ No newline at end of file diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte index 1e3b798..d5542cc 100644 --- a/src/lib/components/content/GifPicker.svelte +++ b/src/lib/components/content/GifPicker.svelte @@ -94,10 +94,11 @@ onClose(); } - // Load GIFs when panel opens + // Load GIFs when panel opens - show cached immediately, refresh in background $effect(() => { if (open) { - loadGifs(); + // Load with forceRefresh=false to get cached results immediately + loadGifs(searchQuery, false); // Focus search input after a short delay setTimeout(() => { if (searchInput) { diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 6e5daef..cee0411 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -171,8 +171,9 @@ } // If no direct reference found, check if parent is root + // Only include if parent is explicitly the root thread, not if parent is null const parentId = getParentEventId(replyEvent); - return parentId === null || parentId === threadId; + return parentId === threadId; } // For other kinds, check e tag diff --git a/src/lib/modules/discussions/DiscussionCard.svelte b/src/lib/modules/discussions/DiscussionCard.svelte index ea18a49..e12992d 100644 --- a/src/lib/modules/discussions/DiscussionCard.svelte +++ b/src/lib/modules/discussions/DiscussionCard.svelte @@ -328,12 +328,13 @@ /> {/if} {#if !fullView} - {#if loadingStats} - Loading stats... - {:else} - {commentCount} {commentCount === 1 ? 'comment' : 'comments'} - {#if latestResponseTime} - Last: {getLatestResponseTime()} + {#if loadingStats} + Loading stats... + {:else} + {commentCount} {commentCount === 1 ? 'comment' : 'comments'} + {#if latestResponseTime} + Last: {getLatestResponseTime()} + {/if} {/if} {:else} {#if loadingStats} diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index 40313f2..05f3a59 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -55,10 +55,14 @@ virtualizerLoading = true; try { const module = await import('@tanstack/svelte-virtual'); - Virtualizer = module.Virtualizer; - return Virtualizer; + // Ensure we're getting the component, not a class constructor + if (module && module.Virtualizer && typeof module.Virtualizer === 'function') { + Virtualizer = module.Virtualizer; + return Virtualizer; + } + return null; } catch (error) { - // Virtual scrolling initialization failed + console.warn('Virtual scrolling initialization failed:', error); return null; } finally { virtualizerLoading = false; @@ -615,24 +619,15 @@

No posts found.

{:else} - {#if Virtualizer && events.length > 50} + {#if Virtualizer && typeof Virtualizer === 'function' && events.length > 50} - {@const V = Virtualizer} -
- virtualizerContainer} - estimateSize={() => 300} - overscan={5} - > - {#each Array(events.length) as _, i} - {@const event = events[i]} - {@const referencedEvent = getReferencedEventForPost(event)} -
- -
- {/each} -
+ + +
+ {#each events as event (event.id)} + {@const referencedEvent = getReferencedEventForPost(event)} + + {/each}
{:else} @@ -683,10 +678,6 @@ flex-direction: column; gap: 1rem; } - - .virtual-scroll-container { - position: relative; - } .relay-info { margin-bottom: 1.5rem; diff --git a/src/lib/services/cache/cache-prewarmer.ts b/src/lib/services/cache/cache-prewarmer.ts index 34b0bba..3db31de 100644 --- a/src/lib/services/cache/cache-prewarmer.ts +++ b/src/lib/services/cache/cache-prewarmer.ts @@ -60,6 +60,9 @@ export async function prewarmCaches( }; try { + // Get feed kinds once for use in multiple steps + const feedKinds = getFeedKinds(); + // Step 1: Initialize database updateProgress(0, 0); await getDB(); @@ -67,95 +70,157 @@ export async function prewarmCaches( // Step 2: Load user profile if logged in updateProgress(1, 0); - if (sessionManager.isLoggedIn()) { - const pubkey = sessionManager.getSession()?.pubkey; - if (pubkey) { - const profileRelays = relayManager.getProfileReadRelays(); - await nostrClient.fetchEvents( - [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], - profileRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ); + try { + if (sessionManager.isLoggedIn()) { + const pubkey = sessionManager.getSession()?.pubkey; + if (pubkey) { + const profileRelays = relayManager.getProfileReadRelays(); + // Use a shorter timeout for prewarming to avoid hanging + const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds + await Promise.race([ + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], + profileRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Profile fetch timeout')), prewarmTimeout + 1000) + ) + ]).catch(() => { + // Ignore errors - prewarming is non-critical + }); + } } + } catch (error) { + // Ignore errors - prewarming is non-critical } updateProgress(1, 100); // Step 3: Load user lists (contacts and follow sets) updateProgress(2, 0); - if (sessionManager.isLoggedIn()) { - const pubkey = sessionManager.getSession()?.pubkey; - if (pubkey) { - const relays = [ - ...config.defaultRelays, - ...config.profileRelays, - ...relayManager.getFeedReadRelays() - ]; - const uniqueRelays = [...new Set(relays)]; - - await Promise.all([ - nostrClient.fetchEvents( - [{ kinds: [KIND.CONTACTS], authors: [pubkey], limit: 1 }], - uniqueRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ), - nostrClient.fetchEvents( - [{ kinds: [KIND.FOLLOW_SET], authors: [pubkey] }], - uniqueRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ) - ]); + try { + if (sessionManager.isLoggedIn()) { + const pubkey = sessionManager.getSession()?.pubkey; + if (pubkey) { + const relays = [ + ...config.defaultRelays, + ...config.profileRelays, + ...relayManager.getFeedReadRelays() + ]; + const uniqueRelays = [...new Set(relays)]; + const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds + + await Promise.race([ + Promise.all([ + nostrClient.fetchEvents( + [{ kinds: [KIND.CONTACTS], authors: [pubkey], limit: 1 }], + uniqueRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ), + nostrClient.fetchEvents( + [{ kinds: [KIND.FOLLOW_SET], authors: [pubkey] }], + uniqueRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ) + ]), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Lists fetch timeout')), prewarmTimeout + 1000) + ) + ]).catch(() => { + // Ignore errors - prewarming is non-critical + }); + } } + } catch (error) { + // Ignore errors - prewarming is non-critical } updateProgress(2, 100); // Step 4: Load recent feed events updateProgress(3, 0); - const feedKinds = getFeedKinds(); - const feedRelays = relayManager.getFeedReadRelays(); - await nostrClient.fetchEvents( - [{ kinds: feedKinds.slice(0, 10), limit: 50 }], // Load first 10 feed kinds, limit 50 events - feedRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ); + try { + const feedRelays = relayManager.getFeedReadRelays(); + const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds + + await Promise.race([ + nostrClient.fetchEvents( + [{ kinds: feedKinds.slice(0, 10), limit: 50 }], // Load first 10 feed kinds, limit 50 events + feedRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Feed events fetch timeout')), prewarmTimeout + 1000) + ) + ]).catch(() => { + // Ignore errors - prewarming is non-critical + }); + } catch (error) { + // Ignore errors - prewarming is non-critical + } updateProgress(3, 100); // Step 5: Load profiles for recent events (if logged in) updateProgress(4, 0); - if (sessionManager.isLoggedIn()) { - // Get some recent events to extract pubkeys - const { getRecentCachedEvents } = await import('./event-cache.js'); - const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 20); - const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 20); - - if (pubkeys.length > 0) { - const profileRelays = relayManager.getProfileReadRelays(); - await nostrClient.fetchEvents( - [{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }], - profileRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ); + try { + if (sessionManager.isLoggedIn()) { + // Get some recent events to extract pubkeys + const { getRecentCachedEvents } = await import('./event-cache.js'); + const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 20); + const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 20); + + if (pubkeys.length > 0) { + const profileRelays = relayManager.getProfileReadRelays(); + const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds + + await Promise.race([ + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }], + profileRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Profiles fetch timeout')), prewarmTimeout + 1000) + ) + ]).catch(() => { + // Ignore errors - prewarming is non-critical + }); + } } + } catch (error) { + // Ignore errors - prewarming is non-critical } updateProgress(4, 100); // Step 6: Load RSS feeds (if logged in) updateProgress(5, 0); - if (sessionManager.isLoggedIn()) { - const pubkey = sessionManager.getSession()?.pubkey; - if (pubkey) { - const relays = [ - ...config.defaultRelays, - ...config.profileRelays, - ...relayManager.getFeedReadRelays() - ]; - const uniqueRelays = [...new Set(relays)]; - - await nostrClient.fetchEvents( - [{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 10 }], - uniqueRelays, - { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } - ); + try { + if (sessionManager.isLoggedIn()) { + const pubkey = sessionManager.getSession()?.pubkey; + if (pubkey) { + const relays = [ + ...config.defaultRelays, + ...config.profileRelays, + ...relayManager.getFeedReadRelays() + ]; + const uniqueRelays = [...new Set(relays)]; + const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds + + await Promise.race([ + nostrClient.fetchEvents( + [{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 10 }], + uniqueRelays, + { useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('RSS feeds fetch timeout')), prewarmTimeout + 1000) + ) + ]).catch(() => { + // Ignore errors - prewarming is non-critical + }); + } } + } catch (error) { + // Ignore errors - prewarming is non-critical } updateProgress(5, 100); diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index da57ada..a5c70fc 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -158,6 +158,7 @@ export async function deleteEvents(ids: string[]): Promise { /** * Get recent events from cache by kind(s) (within cache TTL) * Returns events that were cached recently and match the specified kinds + * Updates cached_at timestamp for accessed events to keep them fresh */ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 * 60 * 1000, limit: number = 50): Promise { try { @@ -167,6 +168,7 @@ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 const results: CachedEvent[] = []; const seen = new Set(); + const eventsToTouch: string[] = []; // Events to update cached_at // Optimized: Use single transaction for all kinds const tx = db.transaction('events', 'readonly'); @@ -186,24 +188,79 @@ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 await tx.done; // Flatten and filter by cache age and deduplicate + // Use a more lenient approach: if maxAge is >= 30 minutes, don't filter by cache age + // This ensures pages load quickly even if cache is "old" - the maxAge parameter + // is more about how fresh the data should be, not how recently it was accessed + const shouldFilterByAge = maxAge < 30 * 60 * 1000; // Only filter if maxAge < 30 minutes + for (const events of allKindResults) { for (const event of events) { - if (event.cached_at >= cutoffTime && !seen.has(event.id)) { - seen.add(event.id); - results.push(event); + if (!seen.has(event.id)) { + // If maxAge is >= 1 hour, include all events regardless of cache age + // Otherwise, filter by cache age + if (!shouldFilterByAge || event.cached_at >= cutoffTime) { + seen.add(event.id); + results.push(event); + // Mark for cache touch if it's older than 5 minutes (to avoid constant writes) + if (now - event.cached_at > 5 * 60 * 1000) { + eventsToTouch.push(event.id); + } + } } } } // Sort by created_at (newest first) and limit const sorted = results.sort((a, b) => b.created_at - a.created_at); - return sorted.slice(0, limit); + const limited = sorted.slice(0, limit); + + // Touch the cache for accessed events (update cached_at) in background + // This keeps frequently accessed events fresh without blocking + if (eventsToTouch.length > 0) { + touchCacheEvents(eventsToTouch).catch(() => { + // Silently fail - cache touch is non-critical + }); + } + + return limited; } catch (error) { // Cache read failed (non-critical) return []; } } +/** + * Touch cache events (update cached_at timestamp) to keep them fresh + */ +async function touchCacheEvents(eventIds: string[]): Promise { + if (eventIds.length === 0) return; + + try { + const db = await getDB(); + const tx = db.transaction('events', 'readwrite'); + const now = Date.now(); + + // Update cached_at for accessed events + // Limit to first 50 to avoid long transactions + const idsToTouch = eventIds.slice(0, 50); + await Promise.all(idsToTouch.map(async (id) => { + try { + const event = await tx.store.get(id); + if (event) { + event.cached_at = now; + await tx.store.put(event); + } + } catch (error) { + // Ignore individual errors + } + })); + + await tx.done; + } catch (error) { + // Cache touch failed (non-critical) + } +} + /** * Get recent feed events from cache (within cache TTL) * Returns events that were cached recently and match feed kinds diff --git a/src/lib/services/cache/gif-cache.ts b/src/lib/services/cache/gif-cache.ts new file mode 100644 index 0000000..97679c3 --- /dev/null +++ b/src/lib/services/cache/gif-cache.ts @@ -0,0 +1,145 @@ +/** + * GIF metadata cache using IndexedDB + * Stores parsed GIF metadata for fast retrieval + */ + +import { getDB } from './indexeddb-store.js'; +import type { GifMetadata } from '../nostr/gif-service.js'; + +export interface CachedGif extends GifMetadata { + cached_at: number; + url: string; // normalized URL (key) +} + +const CACHE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Normalize URL for use as cache key (remove query params and fragments) + */ +function normalizeUrl(url: string): string { + return url.split('?')[0].split('#')[0]; +} + +/** + * Store GIF metadata in cache + */ +export async function cacheGifs(gifs: GifMetadata[]): Promise { + if (gifs.length === 0) return; + + try { + const db = await getDB(); + const tx = db.transaction('gifs', 'readwrite'); + + const now = Date.now(); + const cachedGifs: CachedGif[] = gifs.map(gif => ({ + ...gif, + url: normalizeUrl(gif.url), // Use normalized URL as key + cached_at: now + })); + + // Batch put all GIFs + await Promise.all(cachedGifs.map(cached => tx.store.put(cached))); + await tx.done; + + console.debug(`[gif-cache] Cached ${gifs.length} GIFs`); + } catch (error) { + // Cache write failed (non-critical) + console.debug('[gif-cache] Failed to cache GIFs:', error); + } +} + +/** + * Get cached GIFs, optionally filtered by search query + */ +export async function getCachedGifs(searchQuery?: string, limit: number = 50): Promise { + try { + const db = await getDB(); + const tx = db.transaction('gifs', 'readonly'); + const index = tx.store.index('createdAt'); + + // Get all cached GIFs, sorted by creation date (newest first) + const allCached = await index.getAll(); + await tx.done; + + // Filter by age (remove stale entries) + const now = Date.now(); + const freshGifs = allCached + .filter((cached: CachedGif) => (now - cached.cached_at) < CACHE_MAX_AGE) + .map((cached: CachedGif) => { + // Remove cached_at from result + const { cached_at, ...gif } = cached; + return gif; + }); + + // Filter by search query if provided + let filtered = freshGifs; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = freshGifs.filter((gif: GifMetadata) => { + // Simple search - could be enhanced to search in tags/content if we stored that + return gif.url.toLowerCase().includes(query); + }); + } + + // Sort by creation date (newest first) and limit + filtered.sort((a: GifMetadata, b: GifMetadata) => b.createdAt - a.createdAt); + return filtered.slice(0, limit); + } catch (error) { + console.debug('[gif-cache] Failed to get cached GIFs:', error); + return []; + } +} + +/** + * Clear old cached GIFs (older than max age) + */ +export async function clearStaleGifs(): Promise { + try { + const db = await getDB(); + const tx = db.transaction('gifs', 'readwrite'); + const index = tx.store.index('cached_at'); + const now = Date.now(); + + // Get all cached GIFs + const allCached = await index.getAll(); + + // Delete stale entries + const staleKeys = allCached + .filter((cached: CachedGif) => (now - cached.cached_at) >= CACHE_MAX_AGE) + .map((cached: CachedGif) => cached.url); + + if (staleKeys.length > 0) { + await Promise.all(staleKeys.map(key => tx.store.delete(key))); + console.debug(`[gif-cache] Cleared ${staleKeys.length} stale GIFs`); + } + + await tx.done; + } catch (error) { + console.debug('[gif-cache] Failed to clear stale GIFs:', error); + } +} + +/** + * Get cache stats + */ +export async function getCacheStats(): Promise<{ total: number; fresh: number }> { + try { + const db = await getDB(); + const tx = db.transaction('gifs', 'readonly'); + const index = tx.store.index('cached_at'); + const now = Date.now(); + + const allCached = await index.getAll(); + await tx.done; + + const fresh = allCached.filter((cached: CachedGif) => (now - cached.cached_at) < CACHE_MAX_AGE).length; + + return { + total: allCached.length, + fresh + }; + } catch (error) { + console.debug('[gif-cache] Failed to get cache stats:', error); + return { total: 0, fresh: 0 }; + } +} diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts index 5594dca..0dda274 100644 --- a/src/lib/services/cache/indexeddb-store.ts +++ b/src/lib/services/cache/indexeddb-store.ts @@ -5,7 +5,7 @@ import { openDB, type IDBPDatabase } from 'idb'; const DB_NAME = 'aitherboard'; -const DB_VERSION = 9; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store +const DB_VERSION = 10; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store export interface DatabaseSchema { events: { @@ -48,6 +48,11 @@ export interface DatabaseSchema { value: unknown; indexes: { kind: number; pubkey: string; created_at: number }; }; + gifs: { + key: string; // normalized URL (without query params) + value: unknown; + indexes: { cached_at: number; createdAt: number }; + }; } let dbInstance: IDBPDatabase | null = null; @@ -118,6 +123,13 @@ export async function getDB(): Promise> { archiveStore.createIndex('pubkey', 'pubkey', { unique: false }); archiveStore.createIndex('created_at', 'created_at', { unique: false }); } + + // GIF cache store (parsed GIF metadata) + if (!db.objectStoreNames.contains('gifs')) { + const gifStore = db.createObjectStore('gifs', { keyPath: 'url' }); + gifStore.createIndex('cached_at', 'cached_at', { unique: false }); + gifStore.createIndex('createdAt', 'createdAt', { unique: false }); + } }, blocked() { // IndexedDB blocked (another tab may have it open) @@ -140,7 +152,8 @@ export async function getDB(): Promise> { !dbInstance.objectStoreNames.contains('drafts') || !dbInstance.objectStoreNames.contains('rss') || !dbInstance.objectStoreNames.contains('markdown') || - !dbInstance.objectStoreNames.contains('eventArchive')) { + !dbInstance.objectStoreNames.contains('eventArchive') || + !dbInstance.objectStoreNames.contains('gifs')) { // Database is corrupted - close and delete it, then recreate // Database schema outdated, recreating dbInstance.close(); @@ -180,6 +193,9 @@ export async function getDB(): Promise> { archiveStore.createIndex('kind', 'kind', { unique: false }); archiveStore.createIndex('pubkey', 'pubkey', { unique: false }); archiveStore.createIndex('created_at', 'created_at', { unique: false }); + const gifStore = db.createObjectStore('gifs', { keyPath: 'url' }); + gifStore.createIndex('cached_at', 'cached_at', { unique: false }); + gifStore.createIndex('createdAt', 'createdAt', { unique: false }); }, blocked() { // IndexedDB blocked (another tab may have it open) diff --git a/src/lib/services/nostr/gif-preloader.ts b/src/lib/services/nostr/gif-preloader.ts new file mode 100644 index 0000000..6f4e9df --- /dev/null +++ b/src/lib/services/nostr/gif-preloader.ts @@ -0,0 +1,54 @@ +/** + * Preload GIF metadata in the background + * This ensures GIFs are ready when the user opens the picker + */ + +import { fetchGifs } from './gif-service.js'; +import { getCachedGifs, getCacheStats } from '../cache/gif-cache.js'; + +let preloadInProgress = false; +let preloadPromise: Promise | null = null; + +/** + * Preload GIFs in the background + * Returns immediately if already preloading or if cache is fresh + */ +export async function preloadGifs(): Promise { + // Don't preload if already in progress + if (preloadInProgress) { + return preloadPromise || Promise.resolve(); + } + + // Check if we have fresh cached GIFs + const stats = await getCacheStats(); + if (stats.fresh >= 20) { + // We have at least 20 fresh cached GIFs, no need to preload + console.debug('[gif-preloader] Cache is fresh, skipping preload'); + return Promise.resolve(); + } + + preloadInProgress = true; + preloadPromise = (async () => { + try { + console.debug('[gif-preloader] Starting GIF preload...'); + // Fetch without search query to get popular GIFs + await fetchGifs(undefined, 50, false); + console.debug('[gif-preloader] GIF preload complete'); + } catch (error) { + console.debug('[gif-preloader] GIF preload error (non-critical):', error); + } finally { + preloadInProgress = false; + preloadPromise = null; + } + })(); + + return preloadPromise; +} + +/** + * Check if GIFs are already cached and ready + */ +export async function areGifsReady(): Promise { + const stats = await getCacheStats(); + return stats.fresh >= 10; // At least 10 cached GIFs +} diff --git a/src/lib/services/nostr/gif-service.ts b/src/lib/services/nostr/gif-service.ts index 7361041..00542ff 100644 --- a/src/lib/services/nostr/gif-service.ts +++ b/src/lib/services/nostr/gif-service.ts @@ -8,6 +8,7 @@ import { nostrClient } from './nostr-client.js'; import type { NostrEvent } from '../../types/nostr.js'; import { KIND_LOOKUP, getKindInfo, KIND } from '../../types/kind-lookup.js'; import { config } from './config.js'; +import { cacheGifs, getCachedGifs } from '../cache/gif-cache.js'; export interface GifMetadata { url: string; @@ -183,13 +184,52 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50, forceR // Ensure client is initialized await nostrClient.initialize(); + // First, try to get cached GIF metadata (fast - instant results) + if (!forceRefresh) { + const cachedGifs = await getCachedGifs(searchQuery, limit); + if (cachedGifs.length > 0) { + console.debug(`[gif-service] Returning ${cachedGifs.length} cached GIFs immediately`); + + // Refresh cache in background (don't wait) + refreshGifCache(searchQuery, limit).catch((error) => { + console.debug('[gif-service] Background cache refresh error:', error); + }); + + return cachedGifs; + } + } + + // No cache or force refresh: fetch from relays + return await refreshGifCache(searchQuery, limit, forceRefresh); + } catch (error) { + console.error('[gif-service] Error fetching GIFs:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[gif-service] Error details:', errorMessage); + + // Fallback to cache even on error + try { + const cachedGifs = await getCachedGifs(searchQuery, limit); + if (cachedGifs.length > 0) { + console.debug(`[gif-service] Using ${cachedGifs.length} cached GIFs as fallback`); + return cachedGifs; + } + } catch (cacheError) { + console.debug('[gif-service] Cache fallback failed:', cacheError); + } + + throw error; // Re-throw so the UI can show the error + } +} + +/** + * Refresh GIF cache from relays + */ +async function refreshGifCache(searchQuery?: string, limit: number = 50, forceRefresh: boolean = false): Promise { + try { // Use GIF relays from config, with fallback to default relays if GIF relays fail let relays = config.gifRelays; console.debug(`[gif-service] Fetching GIFs from ${relays.length} GIF relays:`, relays); - // Try GIF relays first, but if they all fail, we'll fall back to default relays - // This ensures we can still find GIFs even if GIF-specific relays are down - // Only fetch kind 1063 (NIP-94 file metadata) events - kind 1 floods the fetch const fileMetadataKind = KIND.FILE_METADATA; // NIP-94 File Metadata @@ -240,7 +280,7 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50, forceR console.debug('[gif-service] Background refresh error:', error); }); } - + // If no cached events, try default relays as fallback if (events.length === 0) { console.log('[gif-service] No cached events, trying default relays as fallback...'); @@ -249,7 +289,7 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50, forceR useCache: true, // Try cache first cacheResults: true, timeout: config.relayTimeout - }); + }); // If still no events, try querying relays directly if (events.length === 0) { @@ -338,12 +378,17 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50, forceR // Sort by creation date (newest first) and limit gifs.sort((a, b) => b.createdAt - a.createdAt); - return gifs.slice(0, limit); + const result = gifs.slice(0, limit); + + // Cache the parsed GIF metadata for fast retrieval next time + if (result.length > 0) { + await cacheGifs(result); + } + + return result; } catch (error) { - console.error('[gif-service] Error fetching GIFs:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[gif-service] Error details:', errorMessage); - throw error; // Re-throw so the UI can show the error + console.error('[gif-service] Error refreshing GIF cache:', error); + throw error; } } diff --git a/src/lib/services/version-manager.ts b/src/lib/services/version-manager.ts index 67e36e3..84b8167 100644 --- a/src/lib/services/version-manager.ts +++ b/src/lib/services/version-manager.ts @@ -3,10 +3,18 @@ */ const VERSION_STORAGE_KEY = 'aitherboard_version'; +const BUILD_TIMESTAMP_STORAGE_KEY = 'aitherboard_build_timestamp'; let cachedVersion: string | null = null; +let cachedBuildTimestamp: number | null = null; + +interface HealthzData { + version?: string; + buildTime?: string; + timestamp?: number; +} /** - * Get the current app version + * Get the current app version and build info * Reads from healthz.json generated at build time, or falls back to package.json version */ export async function getAppVersion(): Promise { @@ -16,8 +24,9 @@ export async function getAppVersion(): Promise { // Try to read from healthz.json (generated at build time) const response = await fetch('/healthz.json'); if (response.ok) { - const data = await response.json(); + const data: HealthzData = await response.json(); cachedVersion = (data.version || '0.2.0') as string; + cachedBuildTimestamp = data.timestamp || null; return cachedVersion; } } catch (error) { @@ -29,6 +38,26 @@ export async function getAppVersion(): Promise { return cachedVersion; } +/** + * Get the current build timestamp + */ +export async function getBuildTimestamp(): Promise { + if (cachedBuildTimestamp !== null) return cachedBuildTimestamp; + + try { + const response = await fetch('/healthz.json'); + if (response.ok) { + const data: HealthzData = await response.json(); + cachedBuildTimestamp = data.timestamp || null; + return cachedBuildTimestamp; + } + } catch (error) { + // Failed to fetch + } + + return null; +} + /** * Get the current app version synchronously (uses cached value or fallback) */ @@ -44,23 +73,60 @@ export function getStoredVersion(): string | null { return localStorage.getItem(VERSION_STORAGE_KEY); } +/** + * Get the stored build timestamp + */ +export function getStoredBuildTimestamp(): number | null { + if (typeof window === 'undefined') return null; + const stored = localStorage.getItem(BUILD_TIMESTAMP_STORAGE_KEY); + return stored ? parseInt(stored, 10) : null; +} + /** * Check if a new version is available + * In development, checks build timestamp; in production, checks version string */ export async function isNewVersionAvailable(): Promise { const stored = getStoredVersion(); if (!stored) return true; // First time user - const current = await getAppVersion(); - return stored !== current; + + // Check if we're in development mode (dev server typically runs on localhost or 127.0.0.1) + const isDevelopment = typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname === '[::1]' || + window.location.port === '5173'); + + if (isDevelopment) { + // In development, check build timestamp + const currentTimestamp = await getBuildTimestamp(); + const storedTimestamp = getStoredBuildTimestamp(); + + if (currentTimestamp && storedTimestamp) { + return currentTimestamp !== storedTimestamp; + } + // If no stored timestamp, treat as new version + return true; + } else { + // In production, check version string + const current = await getAppVersion(); + return stored !== current; + } } /** - * Store the current version + * Store the current version and build timestamp */ export async function storeVersion(version?: string): Promise { if (typeof window === 'undefined') return; const versionToStore = version || await getAppVersion(); localStorage.setItem(VERSION_STORAGE_KEY, versionToStore); + + // Also store build timestamp + const timestamp = await getBuildTimestamp(); + if (timestamp !== null) { + localStorage.setItem(BUILD_TIMESTAMP_STORAGE_KEY, timestamp.toString()); + } } /** diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 41a4d49..6e07694 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -69,10 +69,11 @@ } // Check for new version - const { isNewVersionAvailable, getStoredVersion } = await import('../lib/services/version-manager.js'); + const { isNewVersionAvailable, getStoredVersion, getAppVersion, storeVersion } = await import('../lib/services/version-manager.js'); const storedVersion = getStoredVersion(); + const currentVersion = await getAppVersion(); - // If no stored version, this is a first-time user - route to about page + // If no stored version, this is a first-time user - route to about page and store version if (!storedVersion) { const { goto } = await import('$app/navigation'); const currentPath = window.location.pathname; @@ -80,6 +81,8 @@ if (currentPath !== '/about' && currentPath !== '/login') { goto('/about'); } + // Store current version for future checks + await storeVersion(currentVersion); } else if (await isNewVersionAvailable()) { showUpdateModal = true; } @@ -88,6 +91,12 @@ // Start archive scheduler (background compression of old events) const { startArchiveScheduler } = await import('../lib/services/cache/archive-scheduler.js'); startArchiveScheduler(); + + // Preload GIFs in background for faster picker loading + const { preloadGifs } = await import('../lib/services/nostr/gif-preloader.js'); + preloadGifs().catch(() => { + // Non-critical, ignore errors + }); } catch (error) { console.error('Failed to restore session:', error); } diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 51f238d..108e4e3 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -69,6 +69,16 @@
+ +
+

Version Information

+
+

+ Current Version: {appVersion} +

+
+
+

Key Features

@@ -96,16 +106,6 @@
- -
-

Version Information

-
-

- Current Version: {appVersion} -

-
-
-

What's New in Version {appVersion}

diff --git a/src/routes/healthz.json/+server.ts b/src/routes/healthz.json/+server.ts new file mode 100644 index 0000000..615d7ac --- /dev/null +++ b/src/routes/healthz.json/+server.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + try { + // Try to read from public/healthz.json (generated at build time) + const healthzPath = join(process.cwd(), 'public', 'healthz.json'); + const healthzContent = readFileSync(healthzPath, 'utf-8'); + const healthz = JSON.parse(healthzContent); + + return new Response(JSON.stringify(healthz, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }); + } catch (error) { + // Fallback: read from package.json if healthz.json doesn't exist (dev mode) + try { + const packagePath = join(process.cwd(), 'package.json'); + const packageContent = readFileSync(packagePath, 'utf-8'); + const packageJson = JSON.parse(packageContent); + + const fallback = { + status: 'ok', + service: 'aitherboard', + version: packageJson.version || '0.2.0', + buildTime: new Date().toISOString(), + gitCommit: 'unknown', + timestamp: Date.now() + }; + + return new Response(JSON.stringify(fallback, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }); + } catch (packageError) { + // Ultimate fallback + const fallback = { + status: 'ok', + service: 'aitherboard', + version: '0.2.0', + buildTime: new Date().toISOString(), + gitCommit: 'unknown', + timestamp: Date.now() + }; + + return new Response(JSON.stringify(fallback, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }); + } + } +}; diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 28fafe1..7b6b400 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -284,9 +284,22 @@

Events ({searchResults.events.length})

{#each searchResults.events as event} - - - + {#if event.kind === KIND.REPO_ANNOUNCEMENT} + {@const naddr = getNaddr(event)} + {#if naddr} + + + + {:else} + + + + {/if} + {:else} + + + + {/if} {/each}
diff --git a/src/routes/rss/+page.svelte b/src/routes/rss/+page.svelte index c3cc7cf..8fa1b4b 100644 --- a/src/routes/rss/+page.svelte +++ b/src/routes/rss/+page.svelte @@ -442,9 +442,6 @@

Showing {paginatedItems.length} of {rssItems.length} items - {#if totalPages > 1} - (Page {currentPage} of {totalPages}) - {/if}

diff --git a/src/routes/uploads/[...path]/+server.ts b/src/routes/uploads/[...path]/+server.ts new file mode 100644 index 0000000..74dc07e --- /dev/null +++ b/src/routes/uploads/[...path]/+server.ts @@ -0,0 +1,8 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// Catch-all route for /uploads/* - return 404 for missing uploads +// This handles external Nostr content that references non-existent uploads +export const GET: RequestHandler = async () => { + throw error(404, 'File not found'); +}; diff --git a/static/icons/download.svg b/static/icons/download.svg new file mode 100644 index 0000000..b466641 --- /dev/null +++ b/static/icons/download.svg @@ -0,0 +1 @@ +