SkateSpots/src/lib/offlineCache.ts
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS
  guard EventSponsors already uses.
- Official SkateSpots badge only shows when the NIP-05 actually verifies
  (NIP19Page), not on a self-asserted handle.
- Community stats reads are null-guarded on persisted-cache fields
  (monthly/cumulativeSpots/topMappers) — no more crash on an old cache.
- Overpass response no longer assumes .elements (food layer).
- user-listings query key is memoized (was Date.now() → refetch storm).
- Pro grant requires the l=pro label (was accepted with none).
- Verify card RSVP maps accepted/tentative (was going/interested → showed
  'No RSVP' on a valid ticket).
- Expired listings can't be bought via a stale/shared URL.
- Batch spot photo/status hooks include the relay suffix and use
  querySkateContent (relay-policy parity with useSpotReports).
- Offline queue requeues rows stuck in 'publishing' after a crash.
- querySkateContent/queryCanonical dedupe replaceable events by author+kind
  (not id) so a stale profile/relay list can't win.
- Route offline cache key includes the origin (was destination+mode only).
- Internal links use <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
2026-10-02 22:26:46 +07:00

433 lines
11 KiB
TypeScript

import Dexie, { type Table } from 'dexie';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SkateSpot } from '@/lib/types';
// 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<number>([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<CachedSpot, string>;
pendingPosts!: Table<PendingPost, string>;
settings!: Table<OfflineSettings, string>;
stash!: Table<CachedStashEvent, string>;
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<void> {
const db = getDB();
const now = Date.now();
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<void> {
const db = getDB();
const now = Date.now();
await db.spots.bulkPut(
spotsWithEvents.map(({ event, spot }) => ({
id: event.id,
pubkey: spot.pubkey,
identifier: spot.identifier,
event,
spot,
cachedAt: now,
viewedAt: now,
}))
);
}
/**
* Get a cached spot by pubkey and identifier
*/
export async function getCachedSpot(pubkey: string, identifier: string): Promise<SkateSpot | null> {
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<SkateSpot[]> {
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<SkateSpot[]> {
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<number> {
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<void> {
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<string> {
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<PendingPost[]> {
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<string> {
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<number> {
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<void> {
const db = getDB();
const update: Partial<PendingPost> = { 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<void> {
const db = getDB();
await db.pendingPosts.delete(id);
}
/**
* Get pending posts count
*/
export async function getPendingPostsCount(): Promise<number> {
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<void> {
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<void> {
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<NostrEvent | undefined> {
const db = getDB();
try {
const row = await db.stash.get(pubkey);
return row?.event;
} catch {
return undefined;
}
}
export async function clearCachedStash(pubkey: string): Promise<void> {
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<void> {
const db = getDB();
await db.settings.put({ key, value });
}
/**
* Get a setting from the offline cache
*/
export async function getSetting(key: string): Promise<string | null> {
const db = getDB();
const setting = await db.settings.get(key);
return setting?.value ?? null;
}