- 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.
433 lines
11 KiB
TypeScript
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;
|
|
}
|