import Dexie, { type Table } from 'dexie'; import type { NostrEvent } from '@nostrify/nostrify'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; import { getLocalDeletions } from '@/lib/localDeletions'; // Define the database schema interface CachedSpot { id: string; // event id pubkey: string; identifier: string; event: NostrEvent; spot: SkateSpot; cachedAt: number; viewedAt: number; } interface PendingPost { id: string; // local uuid kind: number; content: string; tags: string[][]; createdAt: number; status: 'pending' | 'publishing' | 'failed'; retries: number; /** * The ALREADY-SIGNED event, when we have it. Signing happens at the moment of * the action (so an interactive signer like Amber is NOT re-prompted at sync * time — the signature is just delivered later). */ signedEvent?: NostrEvent; lastError?: string; } /** * Kinds that are only true "now" — a queued one is worse than none (e.g. a * spot-status "I'm here" delivered 3 hours later). Dropped once stale. */ const EPHEMERAL_KINDS = new Set([2259, 24899]); const EPHEMERAL_TTL_MS = 15 * 60 * 1000; /** Hard cap so a long offline stretch can't grow the queue without bound. */ const MAX_PENDING = 100; interface OfflineSettings { key: string; value: string; } /** * The encrypted stash event, kept on device so the stash is still readable * offline (and during a relay outage). We deliberately cache the CIPHERTEXT — * exactly what relays already hold — never the decrypted list. */ interface CachedStashEvent { pubkey: string; event: NostrEvent; cachedAt: number; } // Create the Dexie database class SkateSpotsCacheDB extends Dexie { spots!: Table; pendingPosts!: Table; settings!: Table; stash!: Table; constructor() { super('SkateSpotsCacheDB'); this.version(1).stores({ spots: 'id, pubkey, identifier, [pubkey+identifier], cachedAt, viewedAt', pendingPosts: 'id, kind, status, createdAt', settings: 'key', }); // v2 adds the encrypted-stash cache (see CachedStashEvent). this.version(2).stores({ stash: 'pubkey, cachedAt', }); } } // Singleton database instance let db: SkateSpotsCacheDB | null = null; function getDB(): SkateSpotsCacheDB { if (!db) { db = new SkateSpotsCacheDB(); } return db; } // ============ Spot Caching ============ /** * Cache a spot for offline viewing */ export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise { const db = getDB(); const now = Date.now(); // Never re-cache a locally-deleted spot, and evict a stale cached copy. const deleted = getLocalDeletions(); if ( deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) || deleted.has(`e:${event.id}`) ) { await db.spots.delete(event.id); return; } await db.spots.put({ id: event.id, pubkey: spot.pubkey, identifier: spot.identifier, event, spot, cachedAt: now, viewedAt: now, }); } /** * Cache multiple spots at once */ export async function cacheSpots(spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>): Promise { const db = getDB(); const now = Date.now(); const deleted = getLocalDeletions(); const isTombstoned = (event: NostrEvent, spot: SkateSpot) => deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) || deleted.has(`e:${event.id}`); const keep = deleted.size > 0 ? spotsWithEvents.filter(({ event, spot }) => !isTombstoned(event, spot)) : spotsWithEvents; if (keep.length > 0) { await db.spots.bulkPut( keep.map(({ event, spot }) => ({ id: event.id, pubkey: spot.pubkey, identifier: spot.identifier, event, spot, cachedAt: now, viewedAt: now, })) ); } // Evict previously-cached rows that now carry a tombstone (deleted after they // were cached), so the offline map can never resurrect a deleted spot. if (deleted.size > 0) { const stale = (await db.spots.toArray()) .filter( (c) => deleted.has(`${SKATE_SPOT_KIND}:${c.pubkey}:${c.identifier}`) || deleted.has(`e:${c.id}`), ) .map((c) => c.id); if (stale.length > 0) await db.spots.bulkDelete(stale); } } /** * Get a cached spot by pubkey and identifier */ export async function getCachedSpot(pubkey: string, identifier: string): Promise { const db = getDB(); const cached = await db.spots .where('[pubkey+identifier]') .equals([pubkey, identifier]) .first(); if (cached) { // Update viewedAt await db.spots.update(cached.id, { viewedAt: Date.now() }); return cached.spot; } return null; } /** * Get all cached spots */ export async function getAllCachedSpots(): Promise { const db = getDB(); const cached = await db.spots.toArray(); return cached.map(c => c.spot); } /** * Get recently viewed spots (for offline browsing) */ export async function getRecentlyViewedSpots(limit: number = 50): Promise { const db = getDB(); const cached = await db.spots .orderBy('viewedAt') .reverse() .limit(limit) .toArray(); return cached.map(c => c.spot); } /** * Get cache statistics */ export async function getCacheStats(): Promise<{ spotCount: number; oldestCachedAt: number | null; newestCachedAt: number | null; totalSizeEstimate: number; }> { const db = getDB(); const spots = await db.spots.toArray(); if (spots.length === 0) { return { spotCount: 0, oldestCachedAt: null, newestCachedAt: null, totalSizeEstimate: 0, }; } const cachedTimes = spots.map(s => s.cachedAt); const totalSize = spots.reduce((acc, s) => acc + JSON.stringify(s).length, 0); return { spotCount: spots.length, oldestCachedAt: Math.min(...cachedTimes), newestCachedAt: Math.max(...cachedTimes), totalSizeEstimate: totalSize, }; } /** * Clear old cached spots (older than maxAge in milliseconds) */ export async function clearOldCache(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): Promise { const db = getDB(); const cutoff = Date.now() - maxAgeMs; const oldSpots = await db.spots .where('cachedAt') .below(cutoff) .primaryKeys(); await db.spots.bulkDelete(oldSpots); return oldSpots.length; } /** * Clear all cached spots */ export async function clearAllCache(): Promise { const db = getDB(); await db.spots.clear(); } // ============ Pending Posts Queue ============ /** * Add a post to the pending queue (for posting when back online) */ export async function queuePost(kind: number, content: string, tags: string[][]): Promise { const db = getDB(); const id = crypto.randomUUID(); await db.pendingPosts.add({ id, kind, content, tags, createdAt: Date.now(), status: 'pending', retries: 0, }); return id; } /** * Get all pending posts */ export async function getPendingPosts(): Promise { const db = getDB(); // Anything not currently mid-publish: 'pending' AND retryable 'failed' items // (a single failed attempt must not strand an action forever). const all = await db.pendingPosts.toArray(); return all.filter((p) => p.status !== 'publishing').sort((a, b) => a.createdAt - b.createdAt); } /** * Queue an ALREADY-SIGNED event (the offline path for `useNostrPublish`). * * Before adding we: * • drop queued events this one SUPERSEDES — for a replaceable (kind 0/3/10000+) * or addressable (30000+) event, only the newest version is worth delivering; * • drop stale ephemeral events; * • cap the queue. * so a long offline stretch leaves the newest, still-meaningful actions. */ export async function queueSignedEvent(event: NostrEvent): Promise { const db = getDB(); const replaceable = event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000); const addressable = event.kind >= 30000 && event.kind < 40000; if (replaceable || addressable) { const d = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; const existing = await db.pendingPosts.toArray(); const superseded = existing .filter((p) => { if (p.kind !== event.kind) return false; if (p.signedEvent && p.signedEvent.pubkey !== event.pubkey) return false; if (!addressable) return true; return (p.tags.find(([n]) => n === 'd')?.[1] ?? '') === d; }) .map((p) => p.id); if (superseded.length > 0) await db.pendingPosts.bulkDelete(superseded); } const id = crypto.randomUUID(); await db.pendingPosts.add({ id, kind: event.kind, content: event.content, tags: event.tags, createdAt: Date.now(), status: 'pending', retries: 0, signedEvent: event, }); // Keep only the newest MAX_PENDING. const count = await db.pendingPosts.count(); if (count > MAX_PENDING) { const oldest = await db.pendingPosts.orderBy('createdAt').limit(count - MAX_PENDING).toArray(); await db.pendingPosts.bulkDelete(oldest.map((p) => p.id)); } return id; } /** Drop queued ephemeral events that are past their useful life. */ export async function prunePendingPosts(): Promise { const db = getDB(); const now = Date.now(); const stale = (await db.pendingPosts.toArray()).filter( (p) => EPHEMERAL_KINDS.has(p.kind) && now - p.createdAt > EPHEMERAL_TTL_MS, ); if (stale.length > 0) await db.pendingPosts.bulkDelete(stale.map((p) => p.id)); return stale.length; } /** * Update a pending post's status */ export async function updatePendingPostStatus( id: string, status: 'pending' | 'publishing' | 'failed', retries?: number ): Promise { const db = getDB(); const update: Partial = { status }; if (retries !== undefined) { update.retries = retries; } await db.pendingPosts.update(id, update); } /** * Remove a pending post (after successful publish) */ export async function removePendingPost(id: string): Promise { const db = getDB(); await db.pendingPosts.delete(id); } /** * Get pending posts count */ export async function getPendingPostsCount(): Promise { const db = getDB(); return db.pendingPosts.count(); } /** * Requeue posts left in `publishing` by a crash/reload. Without this they are * never retried (`getPendingPosts` skips `publishing`) yet still counted, so the * action silently disappears while the badge claims it is still pending. */ export async function reconcileStalePendingPosts(): Promise { const db = getDB(); const all = await db.pendingPosts.toArray(); const stuck = all.filter((p) => p.status === 'publishing'); await Promise.all(stuck.map((p) => db.pendingPosts.update(p.id, { status: 'pending' }))); } // ============ Encrypted Stash Cache (offline readability) ============ /** * Remember the encrypted stash event so it can be decrypted offline. Stores the * CIPHERTEXT only — never the decrypted list. */ export async function cacheStashEvent(pubkey: string, event: NostrEvent): Promise { const db = getDB(); await db.stash.put({ pubkey, event, cachedAt: Date.now() }); } /** The last encrypted stash event we saw for this pubkey, if any. */ export async function getCachedStashEvent(pubkey: string): Promise { const db = getDB(); try { const row = await db.stash.get(pubkey); return row?.event; } catch { return undefined; } } export async function clearCachedStash(pubkey: string): Promise { const db = getDB(); await db.stash.delete(pubkey); } // ============ Offline Detection ============ /** * Check if the app is currently offline */ export function isOffline(): boolean { return !navigator.onLine; } /** * Listen for online/offline status changes */ export function onConnectionChange(callback: (isOnline: boolean) => void): () => void { const handleOnline = () => callback(true); const handleOffline = () => callback(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; } // ============ Settings ============ /** * Store a setting in the offline cache */ export async function setSetting(key: string, value: string): Promise { const db = getDB(); await db.settings.put({ key, value }); } /** * Get a setting from the offline cache */ export async function getSetting(key: string): Promise { const db = getDB(); const setting = await db.settings.get(key); return setting?.value ?? null; }