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.
214 lines
7.9 KiB
214 lines
7.9 KiB
/** |
|
* Built-in “faux spells”: same NoteList + filters as kind-777 spells. The Spells page uses live |
|
* `subscribeTimeline` (same as Following) so the first relay results stream in immediately instead of |
|
* waiting for every relay to EOSE on a one-shot query. |
|
* |
|
* **Why faux feeds can feel slow:** each timeline shard opens live REQs over the prioritized relay |
|
* stack (see {@link applyFauxSpellCapsToSubRequests}). Read-only mirrors are **prepended** in |
|
* {@link appendCuratedReadOnlyRelays} so the per-shard relay cap still includes aggregators (otherwise |
|
* inbox+favorites fill the cap and global kinds/media/hashtags never hit aggr). The **interests** spell |
|
* uses **one** shard: all subscribed topics in one `#t` filter (NIP-01 OR semantics). |
|
*/ |
|
import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind, READ_ONLY_RELAY_URLS } from '@/constants' |
|
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' |
|
import { normalizeTopic } from '@/lib/discussion-topics' |
|
import { userIdToPubkey } from '@/lib/pubkey' |
|
import { normalizeUrl } from '@/lib/url' |
|
import type { TFeedSubRequest } from '@/types' |
|
import { type Event, type Filter, kinds } from 'nostr-tools' |
|
|
|
/** Default caps for every faux spell feed (relays per subrequest, events per REQ). */ |
|
export const FAUX_SPELL_MAX_RELAYS = 10 |
|
export const FAUX_SPELL_EVENT_LIMIT = 200 |
|
|
|
/** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */ |
|
export const PROFILE_MEDIA_REQ_LIMIT = 200 |
|
|
|
/** Max relay URLs per Medien REQ (author stack + aggregators; see {@link buildProfileMediaSubRequests}). */ |
|
export const PROFILE_MEDIA_MAX_RELAYS = 16 |
|
|
|
/** |
|
* Trim relay lists and filter limits (and bookmark `ids`) so faux feeds stay cheap to open. |
|
*/ |
|
export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TFeedSubRequest[] { |
|
return requests.map((r) => { |
|
const urls = r.urls.slice(0, FAUX_SPELL_MAX_RELAYS) |
|
const f = { ...r.filter } |
|
const prevLimit = f.limit |
|
f.limit = |
|
typeof prevLimit === 'number' && prevLimit > 0 |
|
? Math.min(prevLimit, FAUX_SPELL_EVENT_LIMIT) |
|
: FAUX_SPELL_EVENT_LIMIT |
|
if (Array.isArray(f.ids) && f.ids.length > FAUX_SPELL_EVENT_LIMIT) { |
|
f.ids = f.ids.slice(0, FAUX_SPELL_EVENT_LIMIT) |
|
} |
|
return { urls, filter: f } |
|
}) |
|
} |
|
|
|
/** |
|
* Mention/notification-shaped kinds only (aligned with global notification-shaped kinds, plus zap receipts). |
|
* Not full {@link PROFILE_FEED_KINDS} — that asked relays for huge multi-kind slices per `#p`. |
|
* |
|
* Live notifications spell: REQ uses `#p` only (no relay `kinds`); {@link NOTIFICATION_SPELL_KINDS} is applied |
|
* in NoteList via `clientSideKindFilter` so the timeline buffer is not filled by other kinds that mention you. |
|
*/ |
|
export const NOTIFICATION_SPELL_KINDS = [ |
|
kinds.ShortTextNote, |
|
kinds.Repost, |
|
kinds.Reaction, |
|
ExtendedKind.EXTERNAL_REACTION, |
|
kinds.Zap, |
|
ExtendedKind.COMMENT, |
|
ExtendedKind.POLL_RESPONSE, |
|
ExtendedKind.VOICE_COMMENT, |
|
ExtendedKind.POLL, |
|
ExtendedKind.PUBLIC_MESSAGE, |
|
ExtendedKind.ZAP_RECEIPT |
|
] as const |
|
|
|
/** Live notifications spell: longer than NoteList’s default 15s before empty state (slow `#p` on some relays). */ |
|
export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000 |
|
|
|
/** |
|
* Max distinct `t` tag values in one filter (very long `#t` arrays can hit relay limits). |
|
*/ |
|
const INTERESTS_MAX_TOPICS = 80 |
|
|
|
/** |
|
* Put {@link READ_ONLY_RELAY_URLS} (e.g. aggr) **first**, then curated relays. Faux spells cap URL count |
|
* ({@link FAUX_SPELL_MAX_RELAYS}); appending read-only at the end dropped mirrors whenever inbox+favorites |
|
* filled the cap. |
|
*/ |
|
export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] { |
|
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) |
|
const seen = new Set<string>() |
|
const out: string[] = [] |
|
for (const u of READ_ONLY_RELAY_URLS) { |
|
const k = normalizeUrl(u) || u |
|
if (!k || blocked.has(k) || seen.has(k)) continue |
|
seen.add(k) |
|
out.push(k) |
|
} |
|
for (const u of curated) { |
|
const k = normalizeUrl(u) || u |
|
if (!k || seen.has(k)) continue |
|
seen.add(k) |
|
out.push(k) |
|
} |
|
return out |
|
} |
|
|
|
/** NIP-style native media kinds only (picture, video, short video, voice). */ |
|
export const MEDIA_SPELL_KINDS = [ |
|
ExtendedKind.PICTURE, |
|
ExtendedKind.VIDEO, |
|
ExtendedKind.SHORT_VIDEO, |
|
ExtendedKind.VOICE |
|
] as const |
|
|
|
/** |
|
* Profile Medien tab: NIP native media only (picture, video, short video, voice) — same as {@link MEDIA_SPELL_KINDS}. |
|
*/ |
|
export const PROFILE_MEDIA_TAB_KINDS = [...MEDIA_SPELL_KINDS] as const |
|
|
|
function normalizeMentionPubkey(pubkey: string): string { |
|
return /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() |
|
} |
|
|
|
/** Notifications faux spell: `#p` = you, narrow kinds — see module docstring. */ |
|
export function buildMentionsSpellFilter(pubkey: string): Filter { |
|
const pk = normalizeMentionPubkey(pubkey) |
|
return { |
|
kinds: [...NOTIFICATION_SPELL_KINDS], |
|
limit: FAUX_SPELL_EVENT_LIMIT, |
|
'#p': [pk] |
|
} |
|
} |
|
|
|
/** Live timeline: one REQ per relay set, any kind with `#p` = you; kinds narrowed in the client. */ |
|
export function buildNotificationsSpellSubRequests(urls: string[], pubkey: string): TFeedSubRequest[] { |
|
const pk = normalizeMentionPubkey(pubkey) |
|
return [{ urls, filter: { limit: FAUX_SPELL_EVENT_LIMIT, '#p': [pk] } }] |
|
} |
|
|
|
export function buildDiscussionFilter(): Filter { |
|
return { |
|
kinds: [ExtendedKind.DISCUSSION], |
|
limit: FAUX_SPELL_EVENT_LIMIT |
|
} |
|
} |
|
|
|
export function buildMediaSpellFilter(): Filter { |
|
return { kinds: [...MEDIA_SPELL_KINDS], limit: FAUX_SPELL_EVENT_LIMIT } |
|
} |
|
|
|
/** Media kinds for a single profile ({@link PROFILE_MEDIA_TAB_KINDS}, scoped by `authors`). */ |
|
export function buildProfileMediaSpellFilter(pubkey: string): Filter { |
|
const decoded = userIdToPubkey(pubkey.trim()) |
|
const pk = /^[0-9a-f]{64}$/i.test(decoded) ? decoded.toLowerCase() : pubkey.trim().toLowerCase() |
|
return { |
|
authors: [pk], |
|
kinds: [...PROFILE_MEDIA_TAB_KINDS], |
|
limit: PROFILE_MEDIA_REQ_LIMIT |
|
} |
|
} |
|
|
|
/** |
|
* Author inboxes/outboxes + read-only + fast read (see {@link buildProfileAugmentedReadRelayUrls}), capped at |
|
* {@link PROFILE_MEDIA_MAX_RELAYS}. |
|
*/ |
|
export function buildProfileMediaSubRequests( |
|
authorRelayUrls: string[], |
|
blockedRelays: string[], |
|
pubkey: string |
|
): TFeedSubRequest[] { |
|
const urls = buildProfileAugmentedReadRelayUrls(authorRelayUrls, blockedRelays, PROFILE_MEDIA_MAX_RELAYS) |
|
if (!urls.length) return [] |
|
return [{ urls, filter: buildProfileMediaSpellFilter(pubkey) }] |
|
} |
|
|
|
export function buildCalendarSpellFilter(): Filter { |
|
return { |
|
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
|
limit: FAUX_SPELL_EVENT_LIMIT |
|
} |
|
} |
|
|
|
/** |
|
* One subrequest for all interests: NIP-01 treats multiple `#t` values as OR (any topic matches). |
|
* Same relay set as before, but a single timeline shard instead of one per hashtag. |
|
*/ |
|
export function buildInterestsSubRequests( |
|
relayUrls: string[], |
|
rawTopics: string[], |
|
kindsList: number[] = DEFAULT_FEED_SHOW_KINDS |
|
): TFeedSubRequest[] { |
|
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] |
|
const topics = Array.from( |
|
new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0)) |
|
).slice(0, INTERESTS_MAX_TOPICS) |
|
if (!topics.length) return [] |
|
return [ |
|
{ |
|
urls: relayUrls, |
|
filter: { |
|
kinds: kindsList, |
|
'#t': topics, |
|
limit: FAUX_SPELL_EVENT_LIMIT |
|
} |
|
} |
|
] |
|
} |
|
|
|
/** Bookmark list e-tags only (hex ids); addressable (a-tag) bookmarks need separate fetches. */ |
|
export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls: string[]): TFeedSubRequest[] { |
|
if (!bookmarkListEvent?.tags?.length || !urls.length) return [] |
|
const ids = bookmarkListEvent.tags |
|
.filter((t) => t[0] === 'e' && t[1] && /^[a-f0-9]{64}$/i.test(t[1])) |
|
.map((t) => t[1] as string) |
|
if (!ids.length) return [] |
|
const cap = FAUX_SPELL_EVENT_LIMIT |
|
const slice = ids.slice(0, cap) |
|
return [{ urls, filter: { ids: slice, limit: slice.length } }] |
|
}
|
|
|