6 changed files with 240 additions and 34 deletions
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
import logger from '@/lib/logger' |
||||
import { bytesToHex } from '@noble/hashes/utils' |
||||
import { sha256 } from '@noble/hashes/sha256' |
||||
import type { NostrEvent } from 'nostr-tools' |
||||
|
||||
const STORAGE_KEY = 'jumble.searchFollowsFeed.v1' |
||||
/** Stay under typical 5MB localStorage budgets */ |
||||
const MAX_JSON_CHARS = 4_000_000 |
||||
|
||||
export type SearchFollowsFeedCachePayloadV1 = { |
||||
v: 1 |
||||
scopeKey: string |
||||
/** Hex pubkey → recent posts (same shape as in-memory map) */ |
||||
posts: Record<string, NostrEvent[]> |
||||
savedAtMs: number |
||||
} |
||||
|
||||
export function fingerprintSortedPubkeys(pubkeys: string[]): string { |
||||
if (pubkeys.length === 0) return '0' |
||||
const sorted = [...pubkeys].sort() |
||||
return bytesToHex(sha256(new TextEncoder().encode(sorted.join('\n')))) |
||||
} |
||||
|
||||
export function fingerprintRelaySet(urls: string[]): string { |
||||
if (urls.length === 0) return '0' |
||||
return bytesToHex(sha256(new TextEncoder().encode(urls.join('\n')))) |
||||
} |
||||
|
||||
export function buildSearchFollowsFeedScopeKey(input: { |
||||
mode: 'self' | 'recommended' |
||||
viewerPubkey: string | null |
||||
followListFingerprint: string |
||||
aggregateRelayFingerprint: string |
||||
}): string { |
||||
const v = input.viewerPubkey?.toLowerCase() ?? '' |
||||
return `${input.mode}|${v}|${input.followListFingerprint}|${input.aggregateRelayFingerprint}` |
||||
} |
||||
|
||||
export function readSearchFollowsFeedCache( |
||||
scopeKey: string |
||||
): SearchFollowsFeedCachePayloadV1 | null { |
||||
try { |
||||
const raw = localStorage.getItem(STORAGE_KEY) |
||||
if (!raw || raw.length > MAX_JSON_CHARS) return null |
||||
const data = JSON.parse(raw) as unknown |
||||
if (!data || typeof data !== 'object') return null |
||||
const o = data as Record<string, unknown> |
||||
if (o.v !== 1 || o.scopeKey !== scopeKey) return null |
||||
if (typeof o.savedAtMs !== 'number' || typeof o.posts !== 'object' || o.posts === null) return null |
||||
const posts = o.posts as Record<string, unknown> |
||||
const out: Record<string, NostrEvent[]> = {} |
||||
for (const [pk, arr] of Object.entries(posts)) { |
||||
if (!Array.isArray(arr)) continue |
||||
const evs = arr.filter((x): x is NostrEvent => x && typeof x === 'object' && typeof (x as NostrEvent).id === 'string') |
||||
if (evs.length) out[pk] = evs |
||||
} |
||||
return { v: 1, scopeKey, posts: out, savedAtMs: o.savedAtMs } |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
export function writeSearchFollowsFeedCache(payload: SearchFollowsFeedCachePayloadV1): void { |
||||
try { |
||||
const json = JSON.stringify(payload) |
||||
if (json.length > MAX_JSON_CHARS) { |
||||
logger.debug('[SearchFollowsFeedCache] skip write (payload too large)', { |
||||
chars: json.length |
||||
}) |
||||
return |
||||
} |
||||
localStorage.setItem(STORAGE_KEY, json) |
||||
} catch (e) { |
||||
logger.debug('[SearchFollowsFeedCache] write failed', { error: e }) |
||||
} |
||||
} |
||||
|
||||
export function postsMapToRecord(m: Map<string, NostrEvent[]>): Record<string, NostrEvent[]> { |
||||
const o: Record<string, NostrEvent[]> = {} |
||||
for (const [k, v] of m) { |
||||
if (v.length) o[k] = v |
||||
} |
||||
return o |
||||
} |
||||
|
||||
export function postsRecordToMap(r: Record<string, NostrEvent[]>): Map<string, NostrEvent[]> { |
||||
const m = new Map<string, NostrEvent[]>() |
||||
for (const [k, v] of Object.entries(r)) { |
||||
if (Array.isArray(v) && v.length) m.set(k, v) |
||||
} |
||||
return m |
||||
} |
||||
Loading…
Reference in new issue