Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ src/
│ ├── session.ts # NIP-07 session & private key handling
│ ├── navigation.ts # Client-side routing helpers
│ ├── overlays.ts # Image gallery overlay
│ ├── event-cache.ts # In-memory event deduplication
│ ├── event-cache.ts # Compatibility wrapper over the main event cache
│ ├── timeline-cache.ts # Profile cache for timeline rendering
│ ├── deletion-targets.ts # Deleted event tracking
│ ├── meta.ts # Dynamic OG meta tags
Expand Down Expand Up @@ -165,6 +165,14 @@ Default relays are defined in `src/features/relays/relays.ts`.

Pruning limits: 10,000 events max; 14-day TTL general, 30-day TTL home timeline.

### Cache Source Of Truth

- Use `nostr_cache_v2` as the single IndexedDB source of truth for cached app data.
- Read from cache first. Only fetch from relays when the required cache entry is missing.
- After fetching from relays, write the result back to the main cache and render from that cached shape.
- Do not introduce parallel caches for the same entity type. Compatibility wrappers are acceptable only if they delegate to `nostr_cache_v2`.
- When cached data and freshly fetched data both exist, treat the cached value as the authoritative render source for that code path unless the task explicitly changes cache invalidation behavior.

### URL Routing

Client-side routing via History API (`pushState` / `popstate`):
Expand Down Expand Up @@ -219,6 +227,7 @@ import { renderEvent } from '../common/event-render.js';
### Coding Practices

- When making changes, always ensure they are corrected to avoid any side effects (e.g. update all call sites when renaming a function)
- Keep data flow consistent with the cache model: cache-first, remote-on-miss, then cache the remote result.

## Nostr Protocol Reference

Expand Down
3 changes: 2 additions & 1 deletion src/common/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type {

// Database configuration
export const DB_NAME = 'nostr_cache_v2' as const;
export const DB_VERSION = 1 as const;
// Bump when store/index definitions change so existing browsers run migrations.
export const DB_VERSION = 2 as const;

// Store names
export const STORE_NAMES = {
Expand Down
169 changes: 19 additions & 150 deletions src/common/event-cache.ts
Original file line number Diff line number Diff line change
@@ -1,174 +1,43 @@
import type { NostrEvent } from '../../types/nostr';
import { isTimelineCacheEnabled } from './cache-settings.js';
import {
clearEvents,
getCacheStats,
getEvent,
LIMITS,
storeEvent,
} from './db/index.js';

const DB_NAME: string = 'nostr_event_cache_v1';
const DB_VERSION: number = 1;
const STORE_NAME: string = 'events';
export const EVENT_CACHE_LIMIT: number = 1000;
const TTL_MS: number = 7 * 24 * 60 * 60 * 1000;

interface CachedEventRecord {
id: string;
event: NostrEvent;
storedAt: number;
}

function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('indexedDB not available'));
return;
}
const request: IDBOpenDBRequest = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (): void => {
const db: IDBDatabase = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store: IDBObjectStore = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
});
store.createIndex('storedAt', 'storedAt');
}
};
request.onsuccess = (): void => {
resolve(request.result);
};
request.onerror = (): void => {
reject(request.error || new Error('Failed to open indexedDB'));
};
});
}

function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void =>
reject(request.error || new Error('IndexedDB request failed'));
});
}

async function pruneStore(db: IDBDatabase): Promise<void> {
const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite');
const store: IDBObjectStore = tx.objectStore(STORE_NAME);
const count: number = await requestToPromise<number>(store.count());
if (count <= EVENT_CACHE_LIMIT) {
return;
}

const index: IDBIndex = store.index('storedAt');
let remainingToDelete: number = count - EVENT_CACHE_LIMIT;

await new Promise<void>((resolve, reject) => {
const cursorRequest: IDBRequest<IDBCursorWithValue | null> =
index.openCursor();
cursorRequest.onsuccess = (): void => {
const cursor: IDBCursorWithValue | null = cursorRequest.result;
if (!cursor || remainingToDelete <= 0) {
resolve();
return;
}
cursor.delete();
remainingToDelete -= 1;
cursor.continue();
};
cursorRequest.onerror = (): void => reject(cursorRequest.error);
});
}
// Compatibility wrapper: event lookups now use the main IndexedDB cache.
export const EVENT_CACHE_LIMIT: number = LIMITS.EVENTS_HARD;

export async function getEventCacheStats(): Promise<{
count: number;
bytes: number;
}> {
if (typeof indexedDB === 'undefined') {
return { count: 0, bytes: 0 };
}
try {
const db: IDBDatabase = await openDb();
const tx: IDBTransaction = db.transaction(STORE_NAME, 'readonly');
const store: IDBObjectStore = tx.objectStore(STORE_NAME);
const encoder: TextEncoder | null =
typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
let count: number = 0;
let bytes: number = 0;
await new Promise<void>((resolve, reject) => {
const cursorRequest: IDBRequest<IDBCursorWithValue | null> =
store.openCursor();
cursorRequest.onsuccess = (): void => {
const cursor: IDBCursorWithValue | null = cursorRequest.result;
if (!cursor) {
resolve();
return;
}
const record: CachedEventRecord = cursor.value as CachedEventRecord;
const json: string = JSON.stringify(record.event);
bytes += encoder ? encoder.encode(json).length : json.length;
count += 1;
cursor.continue();
};
cursorRequest.onerror = (): void => reject(cursorRequest.error);
});
return { count, bytes };
} catch {
return { count: 0, bytes: 0 };
}
const stats = await getCacheStats();
return {
count: stats.events.count,
bytes: stats.events.bytes,
};
}

export async function clearEventCache(): Promise<void> {
if (typeof indexedDB === 'undefined') {
return;
}
try {
const db: IDBDatabase = await openDb();
const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite');
const store: IDBObjectStore = tx.objectStore(STORE_NAME);
store.clear();
} catch {
// ignore cache errors
}
await clearEvents();
}

export async function getCachedEvent(
eventId: string,
): Promise<NostrEvent | null> {
if (typeof indexedDB === 'undefined' || !isTimelineCacheEnabled()) {
return null;
}
try {
const db: IDBDatabase = await openDb();
const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite');
const store: IDBObjectStore = tx.objectStore(STORE_NAME);
const record: CachedEventRecord | undefined = await requestToPromise<
CachedEventRecord | undefined
>(store.get(eventId));
if (!record) {
return null;
}
const now: number = Date.now();
if (now - record.storedAt > TTL_MS) {
store.delete(eventId);
return null;
}
return record.event;
} catch {
if (!isTimelineCacheEnabled()) {
return null;
}
return await getEvent(eventId);
}

export async function setCachedEvent(event: NostrEvent): Promise<void> {
if (typeof indexedDB === 'undefined' || !isTimelineCacheEnabled()) {
if (!isTimelineCacheEnabled()) {
return;
}
try {
const db: IDBDatabase = await openDb();
const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite');
const store: IDBObjectStore = tx.objectStore(STORE_NAME);
const record: CachedEventRecord = {
id: event.id,
event,
storedAt: Date.now(),
};
store.put(record);
await pruneStore(db);
} catch {
// ignore cache errors
}
await storeEvent(event);
}
70 changes: 58 additions & 12 deletions src/common/event-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type {
OGPResponse,
PubkeyHex,
} from '../../types/nostr';
import { fetchProfile } from '../features/profile/profile.js';
import {
fetchProfile,
getAuthoritativeProfile,
} from '../features/profile/profile.js';
import { getRelays, normalizeRelayUrl } from '../features/relays/relays.js';
import {
fetchOGP,
Expand Down Expand Up @@ -99,6 +102,27 @@ function normalizeHttpUrl(url: string): string | null {
}
}

function normalizeAvatarUrl(url: string): string | null {
try {
const parsed: URL = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString();
}
if (parsed.protocol === 'blob:') {
return url;
}
if (
parsed.protocol === 'data:' &&
url.trim().toLowerCase().startsWith('data:image/')
) {
return url;
}
return null;
} catch {
return null;
}
}

function hasTextSelectionWithin(container: HTMLElement): boolean {
const selection: Selection | null = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
Expand Down Expand Up @@ -781,10 +805,14 @@ async function loadReactionDetails(
} catch (error: unknown) {
console.warn('Failed to load profile for reaction:', error);
}
const renderProfile: NostrProfile | null = getAuthoritativeProfile(
event.pubkey as PubkeyHex,
profile,
);

const npub: Npub = nip19.npubEncode(event.pubkey);
const name: string = getDisplayName(npub, profile);
const avatar: string = getAvatarURL(event.pubkey, profile);
const name: string = getDisplayName(npub, renderProfile);
const avatar: string = getAvatarURL(event.pubkey, renderProfile);

const row: HTMLAnchorElement = document.createElement('a');
row.className =
Expand Down Expand Up @@ -981,7 +1009,11 @@ function renderReplyBadge(
(profile: NostrProfile | null): void => {
const nameEl = container.querySelector('.reply-badge-username');
if (nameEl) {
nameEl.textContent = `@${getDisplayName(parentNpub as Npub, profile)}`;
const renderProfile: NostrProfile | null = getAuthoritativeProfile(
parentAuthorPubkey,
profile,
);
nameEl.textContent = `@${getDisplayName(parentNpub as Npub, renderProfile)}`;
}
},
);
Expand All @@ -1002,12 +1034,16 @@ export function renderEvent(
pubkey: PubkeyHex,
output: HTMLElement,
): void {
const renderProfile: NostrProfile | null = getAuthoritativeProfile(
pubkey,
profile,
);
const isRepost: boolean = event.kind === 6 || event.kind === 16;
const repostEventId: string | null = isRepost
? resolveRepostEventId(event)
: null;
const avatar: string = getAvatarURL(pubkey, profile);
const name: string = getDisplayName(npub, profile);
const avatar: string = getAvatarURL(pubkey, renderProfile);
const name: string = getDisplayName(npub, renderProfile);
const safeName: string = escapeHtmlAttribute(name);
const safeNpub: string = escapeHtmlAttribute(npub);
const createdAt: string = new Date(event.created_at * 1000).toLocaleString();
Expand All @@ -1024,7 +1060,9 @@ export function renderEvent(
storedPubkey && storedPubkey === event.pubkey,
);
const isLoggedIn: boolean = Boolean(storedPubkey);
const canZapTarget: boolean = Boolean(profile?.lud16 || profile?.lud06);
const canZapTarget: boolean = Boolean(
renderProfile?.lud16 || renderProfile?.lud06,
);
const actionBtnBase: string =
'event-action-btn inline-flex items-center justify-center p-1 rounded transition-colors';
const actionBtnDisabled: string = 'opacity-60 cursor-not-allowed';
Expand Down Expand Up @@ -1266,7 +1304,7 @@ export function renderEvent(
}
// Avatar display based on energy saving mode
const safeAvatar: string =
normalizeHttpUrl(avatar) || 'https://placekitten.com/100/100';
normalizeAvatarUrl(avatar) || 'https://placekitten.com/100/100';
const avatarHtml: string = isEnergySavingMode
? `<div class="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 text-xl">👤</div>`
: `<img src="${escapeHtmlAttribute(safeAvatar)}" alt="Avatar" class="event-avatar w-12 h-12 rounded-full object-cover cursor-pointer"
Expand Down Expand Up @@ -1662,10 +1700,14 @@ async function enrichMentionDisplayNames(
mentionedPubkey,
relays,
);
const renderProfile: NostrProfile | null = getAuthoritativeProfile(
mentionedPubkey,
mentionedProfile,
);
const mentionedNpub: Npub = nip19.npubEncode(mentionedPubkey);
const displayName: string = getDisplayName(
mentionedNpub,
mentionedProfile,
renderProfile,
);

// Handle both npub and nprofile mentions
Expand Down Expand Up @@ -1841,14 +1883,18 @@ async function renderReferencedEventCards(
referencedEvent.pubkey,
relaysToUse,
);
const renderProfile: NostrProfile | null = getAuthoritativeProfile(
referencedEvent.pubkey as PubkeyHex,
referencedProfile,
);
const referencedNpub: Npub = nip19.npubEncode(referencedEvent.pubkey);
const referencedName: string = getDisplayName(
referencedNpub,
referencedProfile,
renderProfile,
);
const referencedAvatar: string = getAvatarURL(
referencedEvent.pubkey,
referencedProfile,
renderProfile,
);
const referencedContentWithUnicodeEmoji: string = replaceEmojiShortcodes(
escapeHtmlAttribute(referencedEvent.content),
Expand All @@ -1870,7 +1916,7 @@ async function renderReferencedEventCards(
const isEnergySavingMode: boolean =
localStorage.getItem('energy_saving_mode') === 'true';
const safeReferencedAvatar: string =
normalizeHttpUrl(referencedAvatar) || 'https://placekitten.com/80/80';
normalizeAvatarUrl(referencedAvatar) || 'https://placekitten.com/80/80';
const referencedAvatarHtml: string = isEnergySavingMode
? `<div class="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 text-sm flex-shrink-0">👤</div>`
: `<img
Expand Down
Loading