clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

398 lines
11 KiB

import type { NostrProfile } from "./search_types";
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchEventWithFallback } from "./nostrUtils";
import { nip19 } from "nostr-tools";
export type NpubMetadata = NostrProfile;
interface CacheEntry {
profile: NpubMetadata;
timestamp: number;
pubkey: string;
relaySource?: string;
}
class UnifiedProfileCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly storageKey = "alexandria_unified_profile_cache";
private readonly maxAge = 2 * 60 * 60 * 1000; // 2 hours in milliseconds - shorter for fresher data
constructor() {
this.loadFromStorage();
}
private loadFromStorage(): void {
try {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const data = JSON.parse(stored) as Record<string, CacheEntry>;
const now = Date.now();
// Filter out expired entries
for (const [key, entry] of Object.entries(data)) {
if (entry.timestamp && (now - entry.timestamp) < this.maxAge) {
this.cache.set(key, entry);
}
}
}
}
} catch (error) {
console.warn("Failed to load unified profile cache from storage:", error);
}
}
private saveToStorage(): void {
try {
if (typeof window !== "undefined") {
const data: Record<string, CacheEntry> = {};
for (const [key, entry] of this.cache.entries()) {
data[key] = entry;
}
localStorage.setItem(this.storageKey, JSON.stringify(data));
}
} catch (error) {
console.warn("Failed to save unified profile cache to storage:", error);
}
}
/**
* Get profile data, fetching fresh data if needed
*/
async getProfile(identifier: string, ndk?: NDK, force = false): Promise<NpubMetadata> {
const cleanId = identifier.replace(/^nostr:/, "");
// Check cache first (unless forced)
if (!force && this.cache.has(cleanId)) {
const entry = this.cache.get(cleanId)!;
const now = Date.now();
// Return cached data if not expired
if ((now - entry.timestamp) < this.maxAge) {
console.log("UnifiedProfileCache: Returning cached profile:", cleanId);
return entry.profile;
}
}
// Fetch fresh data
return this.fetchAndCacheProfile(cleanId, ndk);
}
/**
* Fetch profile from all available relays and cache it
*/
private async fetchAndCacheProfile(identifier: string, ndk?: NDK): Promise<NpubMetadata> {
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
try {
if (!ndk) {
console.warn("UnifiedProfileCache: No NDK instance available");
return fallback;
}
const decoded = nip19.decode(identifier);
if (!decoded) {
console.warn("UnifiedProfileCache: Failed to decode identifier:", identifier);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("UnifiedProfileCache: Unsupported identifier type:", decoded.type);
return fallback;
}
console.log("UnifiedProfileCache: Fetching fresh profile for pubkey:", pubkey);
// Use fetchEventWithFallback to search ALL available relays
const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
if (!profileEvent || !profileEvent.content) {
console.warn("UnifiedProfileCache: No profile event found for:", pubkey);
return fallback;
}
const profile = JSON.parse(profileEvent.content);
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05,
picture: profile?.picture || profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16,
};
// Cache the fresh data
const entry: CacheEntry = {
profile: metadata,
timestamp: Date.now(),
pubkey: pubkey,
relaySource: profileEvent.relay?.url,
};
this.cache.set(identifier, entry);
this.cache.set(pubkey, entry); // Also cache by pubkey for convenience
this.saveToStorage();
console.log("UnifiedProfileCache: Cached fresh profile:", metadata);
return metadata;
} catch (e) {
console.error("UnifiedProfileCache: Error fetching profile:", e);
return fallback;
}
}
/**
* Get cached profile without fetching (synchronous)
*/
getCached(identifier: string): NpubMetadata | undefined {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
return entry.profile;
} else {
// Remove expired entry
this.cache.delete(cleanId);
}
}
return undefined;
}
/**
* Set profile data in cache
*/
set(identifier: string, profile: NpubMetadata, pubkey?: string, relaySource?: string): void {
const cleanId = identifier.replace(/^nostr:/, "");
const entry: CacheEntry = {
profile,
timestamp: Date.now(),
pubkey: pubkey || cleanId,
relaySource,
};
this.cache.set(cleanId, entry);
if (pubkey && pubkey !== cleanId) {
this.cache.set(pubkey, entry);
}
this.saveToStorage();
}
/**
* Check if profile is cached and valid
*/
has(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) {
return true;
} else {
// Remove expired entry
this.cache.delete(cleanId);
}
}
return false;
}
/**
* Remove profile from cache
*/
delete(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId);
if (entry) {
this.cache.delete(cleanId);
if (entry.pubkey && entry.pubkey !== cleanId) {
this.cache.delete(entry.pubkey);
}
this.saveToStorage();
return true;
}
return false;
}
/**
* Clear all cached profiles
*/
clear(): void {
this.cache.clear();
this.saveToStorage();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
/**
* Get all cached profiles
*/
getAll(): Record<string, NpubMetadata> {
const result: Record<string, NpubMetadata> = {};
for (const [key, entry] of this.cache.entries()) {
result[key] = entry.profile;
}
return result;
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if ((now - entry.timestamp) >= this.maxAge) {
expiredKeys.push(key);
}
}
expiredKeys.forEach(key => this.cache.delete(key));
if (expiredKeys.length > 0) {
this.saveToStorage();
console.log(`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`);
}
}
}
// Export the unified cache instance
export const unifiedProfileCache = new UnifiedProfileCache();
// Clean up expired entries every 30 minutes
if (typeof window !== "undefined") {
setInterval(() => {
unifiedProfileCache.cleanup();
}, 30 * 60 * 1000);
}
// Legacy compatibility - keep the old npubCache for backward compatibility
// but make it use the unified cache internally
export const npubCache = {
get: (key: string) => unifiedProfileCache.getCached(key),
set: (key: string, value: NpubMetadata) => unifiedProfileCache.set(key, value),
has: (key: string) => unifiedProfileCache.has(key),
delete: (key: string) => unifiedProfileCache.delete(key),
clear: () => unifiedProfileCache.clear(),
size: () => unifiedProfileCache.size(),
getAll: () => unifiedProfileCache.getAll(),
};
// Legacy compatibility for old profileCache functions
export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
return profile.displayName || profile.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export function getDisplayNameSync(pubkey: string): string {
const profile = unifiedProfileCache.getCached(pubkey);
return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
export async function batchFetchProfiles(
pubkeys: string[],
ndk: NDK,
onProgress?: (fetched: number, total: number) => void,
): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = [];
if (onProgress) onProgress(0, pubkeys.length);
// Fetch profiles in parallel using the unified cache
const fetchPromises = pubkeys.map(async (pubkey, index) => {
try {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
if (onProgress) onProgress(index + 1, pubkeys.length);
// Create a mock NDKEvent for compatibility
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = pubkey;
return event;
} catch (e) {
console.error(`Failed to fetch profile for ${pubkey}:`, e);
return null;
}
});
const results = await Promise.allSettled(fetchPromises);
results.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
allProfileEvents.push(result.value);
}
});
return allProfileEvents;
}
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
const pubkeys = new Set<string>();
events.forEach((event) => {
// Add author pubkey
if (event.pubkey) {
pubkeys.add(event.pubkey);
}
// Add pubkeys from p tags
const pTags = event.getMatchingTags("p");
pTags.forEach((tag) => {
if (tag[1]) {
pubkeys.add(tag[1]);
}
});
// Extract pubkeys from content (nostr:npub1... format)
const npubPattern = /nostr:npub1[a-z0-9]{58}/g;
const matches = event.content?.match(npubPattern) || [];
matches.forEach((match) => {
try {
const npub = match.replace("nostr:", "");
const decoded = nip19.decode(npub);
if (decoded.type === "npub") {
pubkeys.add(decoded.data as string);
}
} catch (e) {
// Invalid npub, ignore
}
});
});
return pubkeys;
}
export function clearProfileCache(): void {
unifiedProfileCache.clear();
}
export function replacePubkeysWithDisplayNames(text: string): string {
// Match hex pubkeys (64 characters)
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g;
return text.replace(pubkeyRegex, (match) => {
return getDisplayNameSync(match);
});
}