8 changed files with 3197 additions and 2028 deletions
@ -1,5 +0,0 @@ |
|||||||
export * from './adapters' |
|
||||||
export * from './descriptor' |
|
||||||
export * from './diagnostics' |
|
||||||
export * from './relay-policy' |
|
||||||
export * from './runtime' |
|
||||||
@ -1,75 +0,0 @@ |
|||||||
import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants' |
|
||||||
import { normalizeUrl, isWebsocketUrl } from '@/lib/url' |
|
||||||
import { queryService } from '@/services/client.service' |
|
||||||
import type { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
const BADGE_AWARD_KIND = 8 |
|
||||||
|
|
||||||
function addRelayUrl(out: Set<string>, raw: string | undefined, blocked: Set<string>) { |
|
||||||
if (!raw?.trim()) return |
|
||||||
const n = normalizeUrl(raw.trim()) || raw.trim() |
|
||||||
if (!n || !isWebsocketUrl(n) || blocked.has(n)) return |
|
||||||
out.add(n) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Relay pool for NIP-58 definition + award fetches: profile mirrors, optional `e`-tag hint from kind 30008, |
|
||||||
* then app profile/fast-read fallbacks. Issuer definitions often live off default “fast read” relays only. |
|
||||||
*/ |
|
||||||
export function mergeNip58BadgeRelayPool( |
|
||||||
profileRelayUrls: string[], |
|
||||||
awardRelayHint: string | undefined, |
|
||||||
blockedRelays: string[] |
|
||||||
): string[] { |
|
||||||
const blocked = new Set(blockedRelays.map((u) => normalizeUrl(u) || u).filter(Boolean)) |
|
||||||
const out = new Set<string>() |
|
||||||
for (const u of profileRelayUrls) addRelayUrl(out, u, blocked) |
|
||||||
addRelayUrl(out, awardRelayHint, blocked) |
|
||||||
for (const u of PROFILE_FETCH_RELAY_URLS) addRelayUrl(out, u, blocked) |
|
||||||
for (const u of FAST_READ_RELAY_URLS) addRelayUrl(out, u, blocked) |
|
||||||
return [...out] |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchNip58BadgeDefinition( |
|
||||||
issuerPubkey: string, |
|
||||||
dTag: string, |
|
||||||
relayUrls: string[] |
|
||||||
): Promise<Event | undefined> { |
|
||||||
if (!relayUrls.length) return undefined |
|
||||||
const hexPk = issuerPubkey.toLowerCase() |
|
||||||
const events = await queryService.fetchEvents( |
|
||||||
relayUrls, |
|
||||||
{ |
|
||||||
authors: [hexPk], |
|
||||||
kinds: [ExtendedKind.BADGE_DEFINITION], |
|
||||||
'#d': [dTag] |
|
||||||
}, |
|
||||||
{ |
|
||||||
replaceableRace: true, |
|
||||||
eoseTimeout: 4000, |
|
||||||
globalTimeout: 22_000, |
|
||||||
firstRelayResultGraceMs: false |
|
||||||
} |
|
||||||
) |
|
||||||
const match = events.filter((e) => { |
|
||||||
if (e.pubkey.toLowerCase() !== hexPk) return false |
|
||||||
const d = e.tags.find((t) => t[0] === 'd')?.[1] |
|
||||||
return d === dTag |
|
||||||
}) |
|
||||||
return match.sort((a, b) => b.created_at - a.created_at)[0] |
|
||||||
} |
|
||||||
|
|
||||||
export async function fetchNip58BadgeAward(awardId: string, relayUrls: string[]): Promise<Event | undefined> { |
|
||||||
if (!relayUrls.length || !/^[a-f0-9]{64}$/i.test(awardId)) return undefined |
|
||||||
const events = await queryService.fetchEvents( |
|
||||||
relayUrls, |
|
||||||
{ ids: [awardId.toLowerCase()], kinds: [BADGE_AWARD_KIND] }, |
|
||||||
{ |
|
||||||
immediateReturn: true, |
|
||||||
eoseTimeout: 4000, |
|
||||||
globalTimeout: 18_000, |
|
||||||
firstRelayResultGraceMs: false |
|
||||||
} |
|
||||||
) |
|
||||||
return events.find((e) => e.id.toLowerCase() === awardId.toLowerCase()) |
|
||||||
} |
|
||||||
@ -1,28 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { queryService } from '@/services/client.service' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
function profileBadgesEventReferencesA(ev: Event, badgeATag: string): boolean { |
|
||||||
return ev.tags.some((t) => t[0] === 'a' && t[1] === badgeATag) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Pubkeys whose latest profile badge lists (kind 30008) include this badge definition `a` tag. |
|
||||||
* Uses the same relay set as other profile fetches (typically outbox + profile mirrors). |
|
||||||
*/ |
|
||||||
export async function fetchBadgeRecipientPubkeys( |
|
||||||
relayUrls: string[], |
|
||||||
badgeATag: string |
|
||||||
): Promise<string[]> { |
|
||||||
if (relayUrls.length === 0 || !badgeATag) return [] |
|
||||||
const events = await queryService.fetchEvents( |
|
||||||
relayUrls, |
|
||||||
[{ kinds: [ExtendedKind.PROFILE_BADGES], '#a': [badgeATag], limit: 200 }], |
|
||||||
{ eoseTimeout: 2500, globalTimeout: 18000, firstRelayResultGraceMs: false } |
|
||||||
) |
|
||||||
const authors = new Set<string>() |
|
||||||
for (const ev of events) { |
|
||||||
if (profileBadgesEventReferencesA(ev, badgeATag)) authors.add(ev.pubkey) |
|
||||||
} |
|
||||||
return [...authors] |
|
||||||
} |
|
||||||
@ -1,61 +0,0 @@ |
|||||||
import { |
|
||||||
DEFAULT_FAVORITE_RELAYS, |
|
||||||
FAST_READ_RELAY_URLS, |
|
||||||
READ_ONLY_RELAY_URLS |
|
||||||
} from '@/constants' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' |
|
||||||
import type { TRelayList } from '@/types' |
|
||||||
|
|
||||||
/** First N NIP-65 `write` (outbox) URLs per followed pubkey, follow-list order; locals first per author. */ |
|
||||||
const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2 |
|
||||||
|
|
||||||
/** Plain `ws://` relays are almost always someone else's LAN; the client cannot use them for third-party reads. */ |
|
||||||
function isNonPublicWsRelayUrl(normalizedUrl: string): boolean { |
|
||||||
return normalizedUrl.toLowerCase().startsWith('ws://') |
|
||||||
} |
|
||||||
|
|
||||||
function addLayer( |
|
||||||
out: string[], |
|
||||||
seen: Set<string>, |
|
||||||
blocked: Set<string>, |
|
||||||
urls: readonly string[] |
|
||||||
): void { |
|
||||||
for (const u of urls) { |
|
||||||
const n = normalizeUrl(u) || u |
|
||||||
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue |
|
||||||
seen.add(n) |
|
||||||
out.push(n) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS}, |
|
||||||
* {@link FAST_READ_RELAY_URLS}, and user favorites: normalized, blocked-stripped, |
|
||||||
* deduped (first occurrence wins). |
|
||||||
*/ |
|
||||||
export function buildFollowOutboxAggregateReadUrls( |
|
||||||
relayLists: readonly TRelayList[], |
|
||||||
blockedRelays: readonly string[], |
|
||||||
favoriteRelays: readonly string[] = [] |
|
||||||
): string[] { |
|
||||||
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean)) |
|
||||||
const seen = new Set<string>() |
|
||||||
const out: string[] = [] |
|
||||||
|
|
||||||
for (const rl of relayLists) { |
|
||||||
const writes = relayUrlsLocalsFirst(rl.write ?? []) |
|
||||||
for (const u of writes.slice(0, FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR)) { |
|
||||||
const n = normalizeUrl(u) || u |
|
||||||
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue |
|
||||||
seen.add(n) |
|
||||||
out.push(n) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
addLayer(out, seen, blocked, READ_ONLY_RELAY_URLS) |
|
||||||
addLayer(out, seen, blocked, FAST_READ_RELAY_URLS) |
|
||||||
addLayer(out, seen, blocked, favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS) |
|
||||||
|
|
||||||
return out |
|
||||||
} |
|
||||||
@ -1,92 +0,0 @@ |
|||||||
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