Browse Source

simplified readme

bug-fixes
master
Silberengel 1 month ago
parent
commit
f3c36928bc
  1. 69
      README.md
  2. 4
      public/healthz.json
  3. 5
      src/lib/components/content/GifPicker.svelte
  4. 3
      src/lib/modules/comments/CommentThread.svelte
  5. 13
      src/lib/modules/discussions/DiscussionCard.svelte
  6. 39
      src/lib/modules/feed/FeedPage.svelte
  7. 197
      src/lib/services/cache/cache-prewarmer.ts
  8. 65
      src/lib/services/cache/event-cache.ts
  9. 145
      src/lib/services/cache/gif-cache.ts
  10. 20
      src/lib/services/cache/indexeddb-store.ts
  11. 54
      src/lib/services/nostr/gif-preloader.ts
  12. 65
      src/lib/services/nostr/gif-service.ts
  13. 76
      src/lib/services/version-manager.ts
  14. 13
      src/routes/+layout.svelte
  15. 20
      src/routes/about/+page.svelte
  16. 59
      src/routes/healthz.json/+server.ts
  17. 19
      src/routes/repos/+page.svelte
  18. 3
      src/routes/rss/+page.svelte
  19. 8
      src/routes/uploads/[...path]/+server.ts
  20. 1
      static/icons/download.svg

69
README.md

@ -2,5 +2,72 @@ @@ -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)

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -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
}

5
src/lib/components/content/GifPicker.svelte

@ -94,10 +94,11 @@ @@ -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) {

3
src/lib/modules/comments/CommentThread.svelte

@ -171,8 +171,9 @@ @@ -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

13
src/lib/modules/discussions/DiscussionCard.svelte

@ -328,12 +328,13 @@ @@ -328,12 +328,13 @@
/>
{/if}
{#if !fullView}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{/if}
{:else}
{#if loadingStats}

39
src/lib/modules/feed/FeedPage.svelte

@ -55,10 +55,14 @@ @@ -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 @@ @@ -615,24 +619,15 @@
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div>
{:else}
{#if Virtualizer && events.length > 50}
{#if Virtualizer && typeof Virtualizer === 'function' && events.length > 50}
<!-- Use virtual scrolling for large feeds (50+ items) -->
{@const V = Virtualizer}
<div bind:this={virtualizerContainer} class="feed-posts virtual-scroll-container" style="height: 80vh; overflow: auto;">
<V
count={events.length}
getScrollElement={() => virtualizerContainer}
estimateSize={() => 300}
overscan={5}
>
{#each Array(events.length) as _, i}
{@const event = events[i]}
{@const referencedEvent = getReferencedEventForPost(event)}
<div data-post-id={event.id}>
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
</div>
{/each}
</V>
<!-- Note: Virtualizer is disabled due to compatibility issues -->
<!-- Fallback to regular rendering -->
<div class="feed-posts">
{#each events as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
{/each}
</div>
{:else}
<!-- Fallback to regular rendering for small feeds or when virtualizer not loaded -->
@ -683,10 +678,6 @@ @@ -683,10 +678,6 @@
flex-direction: column;
gap: 1rem;
}
.virtual-scroll-container {
position: relative;
}
.relay-info {
margin-bottom: 1.5rem;

197
src/lib/services/cache/cache-prewarmer.ts vendored

@ -60,6 +60,9 @@ export async function prewarmCaches( @@ -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( @@ -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);

65
src/lib/services/cache/event-cache.ts vendored

@ -158,6 +158,7 @@ export async function deleteEvents(ids: string[]): Promise<void> { @@ -158,6 +158,7 @@ export async function deleteEvents(ids: string[]): Promise<void> {
/**
* 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<CachedEvent[]> {
try {
@ -167,6 +168,7 @@ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 @@ -167,6 +168,7 @@ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15
const results: CachedEvent[] = [];
const seen = new Set<string>();
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 @@ -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<void> {
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

145
src/lib/services/cache/gif-cache.ts vendored

@ -0,0 +1,145 @@ @@ -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<void> {
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<GifMetadata[]> {
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<void> {
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 };
}
}

20
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -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 { @@ -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<DatabaseSchema> | null = null;
@ -118,6 +123,13 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -118,6 +123,13 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
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<IDBPDatabase<DatabaseSchema>> { @@ -140,7 +152,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
!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<IDBPDatabase<DatabaseSchema>> { @@ -180,6 +193,9 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
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)

54
src/lib/services/nostr/gif-preloader.ts

@ -0,0 +1,54 @@ @@ -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<void> | null = null;
/**
* Preload GIFs in the background
* Returns immediately if already preloading or if cache is fresh
*/
export async function preloadGifs(): Promise<void> {
// 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<boolean> {
const stats = await getCacheStats();
return stats.fresh >= 10; // At least 10 cached GIFs
}

65
src/lib/services/nostr/gif-service.ts

@ -8,6 +8,7 @@ import { nostrClient } from './nostr-client.js'; @@ -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 @@ -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<GifMetadata[]> {
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 @@ -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 @@ -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 @@ -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;
}
}

76
src/lib/services/version-manager.ts

@ -3,10 +3,18 @@ @@ -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<string> {
@ -16,8 +24,9 @@ export async function getAppVersion(): Promise<string> { @@ -16,8 +24,9 @@ export async function getAppVersion(): Promise<string> {
// 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<string> { @@ -29,6 +38,26 @@ export async function getAppVersion(): Promise<string> {
return cachedVersion;
}
/**
* Get the current build timestamp
*/
export async function getBuildTimestamp(): Promise<number | null> {
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 { @@ -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<boolean> {
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<void> {
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());
}
}
/**

13
src/routes/+layout.svelte

@ -69,10 +69,11 @@ @@ -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 @@ @@ -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 @@ @@ -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);
}

20
src/routes/about/+page.svelte

@ -69,6 +69,16 @@ @@ -69,6 +69,16 @@
</div>
</section>
<!-- Version Information -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Version Information</h2>
<div class="section-content">
<p class="version-info">
<strong>Current Version:</strong> <span class="version-badge">{appVersion}</span>
</p>
</div>
</section>
<!-- Features -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Key Features</h2>
@ -96,16 +106,6 @@ @@ -96,16 +106,6 @@
</div>
</section>
<!-- Version Information -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Version Information</h2>
<div class="section-content">
<p class="version-info">
<strong>Current Version:</strong> <span class="version-badge">{appVersion}</span>
</p>
</div>
</section>
<!-- Changelog -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">What's New in Version {appVersion}</h2>

59
src/routes/healthz.json/+server.ts

@ -0,0 +1,59 @@ @@ -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'
}
});
}
}
};

19
src/routes/repos/+page.svelte

@ -284,9 +284,22 @@ @@ -284,9 +284,22 @@
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{#if event.kind === KIND.REPO_ANNOUNCEMENT}
{@const naddr = getNaddr(event)}
{#if naddr}
<a href="/repos/{naddr}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{:else}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/if}
{:else}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/if}
{/each}
</div>
</div>

3
src/routes/rss/+page.svelte

@ -442,9 +442,6 @@ @@ -442,9 +442,6 @@
<div class="rss-items-info mb-4">
<p class="text-fog-text dark:text-fog-dark-text text-sm">
Showing {paginatedItems.length} of {rssItems.length} items
{#if totalPages > 1}
(Page {currentPage} of {totalPages})
{/if}
</p>
</div>

8
src/routes/uploads/[...path]/+server.ts

@ -0,0 +1,8 @@ @@ -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');
};

1
static/icons/download.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>

After

Width:  |  Height:  |  Size: 316 B

Loading…
Cancel
Save