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)
+
+**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}
-
- {#if latestResponseTime}
- Last: {getLatestResponseTime()}
+ {#if loadingStats}
+ Loading stats...
+ {:else}
+
+ {#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 @@
+
+