diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index 0dac4c59..80c6d332 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -31,7 +31,7 @@ const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166 /** * Default URLs to run NIP-11 checks against (30166); always merged with the monitor’s kind 10002 unless overridden. * Union of relay presets in src/constants.ts: DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, - * NIP66_DISCOVERY_RELAY_URLS, BOOKSTR_RELAY_URLS, READ_ONLY_RELAY_URLS, KIND_1_BLOCKED_RELAY_URLS, + * NIP66_DISCOVERY_RELAY_URLS, BOOKSTR_RELAY_URLS, READ_ONLY_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS, * FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS, SEARCHABLE_RELAY_URLS, * PROFILE_RELAY_URLS, DEFAULT_NOSTRCONNECT_RELAY — deduped, sorted. */ diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 464a7ab1..61baf618 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -46,7 +46,7 @@ export default function ExploreRelayReviews() { { userWriteRelays: relayList?.write ?? [], maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, - applyKind1BlockedFilter: false + applySocialKindBlockedFilter: false } ), blockedRelays diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index bf3bea58..12d36ae5 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -54,8 +54,10 @@ const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */ const ONE_SHOT_MERGED_CAP =100 -const FEED_PROFILE_BATCH_DEBOUNCE_MS = 120 -const FEED_PROFILE_CHUNK = 36 +/** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */ +const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 +/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ +const FEED_PROFILE_CHUNK = 80 function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): Event[] { const byId = new Map() @@ -274,11 +276,23 @@ const NoteList = forwardRef( candidates.add(t.toLowerCase()) } } + const addPkFromEventTags = (e: Event) => { + let n = 0 + for (const tag of e.tags) { + if (tag[0] === 'p' && tag[1]) { + addPk(tag[1]) + n++ + if (n >= 4) break + } + } + } for (const e of events) { addPk(e.pubkey) + addPkFromEventTags(e) } for (const e of newEvents) { addPk(e.pubkey) + addPkFromEventTags(e) } setFeedProfileBatch((prev) => { @@ -501,14 +515,26 @@ const NoteList = forwardRef( const candidates = new Set() const addPk = (p: string | undefined) => { if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) { - candidates.add(p) + candidates.add(p.toLowerCase()) + } + } + const addPkFromEventTags = (e: Event) => { + let n = 0 + for (const tag of e.tags) { + if (tag[0] === 'p' && tag[1]) { + addPk(tag[1]) + n++ + if (n >= 4) break + } } } for (const e of events) { addPk(e.pubkey) + addPkFromEventTags(e) } for (const e of newEvents) { addPk(e.pubkey) + addPkFromEventTags(e) } const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) @@ -530,41 +556,44 @@ const NoteList = forwardRef( }) void (async () => { + if (gen !== feedProfileBatchGenRef.current) return + const chunks: string[][] = [] for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) { - if (gen !== feedProfileBatchGenRef.current) return - const chunk = need.slice(i, i + FEED_PROFILE_CHUNK) - try { - const profiles = await client.fetchProfilesForPubkeys(chunk) - if (gen !== feedProfileBatchGenRef.current) return - setFeedProfileBatch((prev) => { - const next = new Map(prev.profiles) - const pend = new Set(prev.pending) - for (const p of profiles) { - next.set(p.pubkey, p) - pend.delete(p.pubkey) - } - for (const pk of chunk) { - pend.delete(pk) - if (!next.has(pk)) { - next.set(pk, { - pubkey: pk, - npub: pubkeyToNpub(pk) ?? '', - username: formatPubkey(pk) - }) - } - } - return { profiles: next, pending: pend, version: prev.version + 1 } - }) - } catch { - chunk.forEach((pk) => feedProfileLoadedRef.current.delete(pk)) - if (gen !== feedProfileBatchGenRef.current) return - setFeedProfileBatch((prev) => { - const pend = new Set(prev.pending) - chunk.forEach((pk) => pend.delete(pk)) - return { ...prev, pending: pend, version: prev.version + 1 } - }) - } + chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK)) } + const settled = await Promise.allSettled( + chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk)) + ) + if (gen !== feedProfileBatchGenRef.current) return + + setFeedProfileBatch((prev) => { + const next = new Map(prev.profiles) + const pend = new Set(prev.pending) + settled.forEach((res, idx) => { + const chunk = chunks[idx]! + if (res.status === 'rejected') { + chunk.forEach((pk) => feedProfileLoadedRef.current.delete(pk)) + chunk.forEach((pk) => pend.delete(pk)) + return + } + const profiles = res.value + for (const p of profiles) { + next.set(p.pubkey, p) + pend.delete(p.pubkey) + } + for (const pk of chunk) { + pend.delete(pk) + if (!next.has(pk)) { + next.set(pk, { + pubkey: pk, + npub: pubkeyToNpub(pk) ?? '', + username: formatPubkey(pk) + }) + } + } + }) + return { profiles: next, pending: pend, version: prev.version + 1 } + }) })() }, FEED_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 096d0b84..ebe6a390 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,4 +1,4 @@ -import { KIND_1_BLOCKED_RELAY_URLS } from '@/constants' +import { ExtendedKind, isSocialKindBlockedKind, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' @@ -7,7 +7,6 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { getRelayListFromEvent } from '@/lib/event-metadata' import indexedDb from '@/services/indexed-db.service' -import { ExtendedKind } from '@/constants' import { Check, ChevronDown, Server } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react' @@ -94,13 +93,16 @@ export default function PostRelaySelector({ // Memoize arrays to prevent unnecessary re-renders const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays]) const memoizedBlockedRelays = useMemo(() => { - // For kind 1 replies and top-level posts, also block KIND_1_BLOCKED_RELAY_URLS - const isKind1Publish = - !isPublicMessage && (typeof _parentEvent?.kind === 'undefined' || _parentEvent?.kind === 1) - return isKind1Publish - ? [...blockedRelays, ...KIND_1_BLOCKED_RELAY_URLS] + // Top-level compose or reply under a social thread: also block SOCIAL_KIND_BLOCKED_RELAY_URLS in the picker. + const isSocialPublish = + !isPublicMessage && + (_parentEvent == null || + isDiscussionReply || + isSocialKindBlockedKind(_parentEvent.kind)) + return isSocialPublish + ? [...blockedRelays, ...SOCIAL_KIND_BLOCKED_RELAY_URLS] : blockedRelays - }, [blockedRelays, isPublicMessage, _parentEvent?.kind]) + }, [blockedRelays, isPublicMessage, _parentEvent, isDiscussionReply]) const memoizedRelaySets = useMemo(() => relaySets, [relaySets]) const memoizedOpenFrom = useMemo(() => openFrom, [openFrom]) diff --git a/src/components/UserAvatar/UserAvatar.test.tsx b/src/components/UserAvatar/UserAvatar.test.tsx index ed73ab35..969d990d 100644 --- a/src/components/UserAvatar/UserAvatar.test.tsx +++ b/src/components/UserAvatar/UserAvatar.test.tsx @@ -1,8 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest' import { render, waitFor } from '@testing-library/react' import UserAvatar from './index' import * as useFetchProfileHook from '@/hooks/useFetchProfile' +const originalIO = globalThis.IntersectionObserver + // Mock the hooks and dependencies vi.mock('@/hooks/useFetchProfile', () => ({ useFetchProfile: vi.fn() @@ -11,12 +13,16 @@ vi.mock('@/hooks/useFetchProfile', () => ({ vi.mock('@/PageManager', () => ({ useSmartProfileNavigation: () => ({ navigateToProfile: vi.fn() + }), + useSmartProfileNavigationOptional: () => ({ + navigateToProfile: vi.fn() }) })) vi.mock('@/lib/pubkey', () => ({ - userIdToPubkey: (id: string) => id.startsWith('npub') ? 'decoded_pubkey' : id, - generateImageByPubkey: (pubkey: string) => `https://avatar.example.com/${pubkey}` + userIdToPubkey: (id: string) => (id.startsWith('npub') ? 'decoded_pubkey' : id), + generateImageByPubkey: (_pubkey: string) => + `data:image/svg+xml,${encodeURIComponent(``)}` })) vi.mock('@/lib/link', () => ({ @@ -24,6 +30,45 @@ vi.mock('@/lib/link', () => ({ })) describe('UserAvatar in Embedded Notes', () => { + beforeAll(() => { + globalThis.IntersectionObserver = class IntersectionObserverMock { + constructor( + public cb: IntersectionObserverCallback, + public _opts?: IntersectionObserverInit + ) {} + observe(el: Element) { + queueMicrotask(() => { + this.cb( + [ + { + isIntersecting: true, + target: el, + intersectionRatio: 1, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now() + } + ], + this + ) + }) + } + disconnect() {} + unobserve() {} + takeRecords() { + return [] + } + root = null + rootMargin = '' + thresholds = [] + } as unknown as typeof IntersectionObserver + }) + + afterAll(() => { + globalThis.IntersectionObserver = originalIO + }) + beforeEach(() => { vi.clearAllMocks() }) @@ -55,10 +100,12 @@ describe('UserAvatar in Embedded Notes', () => { const avatarContainer = container.querySelector('[data-user-avatar]') expect(avatarContainer).toBeInTheDocument() - // Find the image + // Find the image — identicon first, then remote profile picture after intersection const img = avatarContainer?.querySelector('img') expect(img).toBeInTheDocument() - expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg') + await waitFor(() => { + expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg') + }) // Check that the image is not hidden or covered const computedStyle = window.getComputedStyle(img!) diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index f62b449e..11799435 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -4,7 +4,62 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' import { useSmartProfileNavigationOptional } from '@/PageManager' -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState, useEffect, useRef, type RefObject } from 'react' + +/** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */ +function isHttpOrHttpsUrl(url: string): boolean { + return /^https?:\/\//i.test(url.trim()) +} + +/** + * Defer loading remote profile pictures until the avatar is near the viewport so handles/text + * can paint first; identicon (data URL) shows until then. + */ +function useDeferRemoteProfileAvatar( + profileAvatar: string | undefined, + fallbackSrc: string, + containerRef: RefObject +): string { + const remoteHttp = useMemo(() => { + const a = profileAvatar?.trim() + if (!a || !isHttpOrHttpsUrl(a)) return '' + return a + }, [profileAvatar]) + + const nonHttpAvatar = useMemo(() => { + const a = profileAvatar?.trim() + if (a && !isHttpOrHttpsUrl(a)) return a + return '' + }, [profileAvatar]) + + const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '') + + useEffect(() => { + setAllowRemote(remoteHttp === '') + }, [remoteHttp]) + + useEffect(() => { + if (!remoteHttp || allowRemote) return + if (typeof IntersectionObserver === 'undefined') { + setAllowRemote(true) + return + } + const el = containerRef.current + if (!el) return + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setAllowRemote(true) + } + }, + { root: null, rootMargin: '200px', threshold: 0.01 } + ) + io.observe(el) + return () => io.disconnect() + }, [remoteHttp, allowRemote, containerRef]) + + return nonHttpAvatar || (remoteHttp && allowRemote ? remoteHttp : '') || fallbackSrc +} const UserAvatarSizeCnMap = { large: 'w-24 h-24', @@ -40,9 +95,9 @@ export default function UserAvatar({ () => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey] ) - - // Use profile avatar if available, otherwise use default avatar - const avatarSrc = profile?.avatar || defaultAvatar || '' + + const containerRef = useRef(null) + const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef) // All hooks must be called before any early returns const [imgError, setImgError] = useState(false) @@ -55,12 +110,10 @@ export default function UserAvatar({ }, [avatarSrc]) const handleImageError = () => { - if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) { - // Try default avatar if profile avatar fails + if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) { setCurrentSrc(defaultAvatar) setImgError(false) } else { - // Both failed setImgError(true) } } @@ -83,6 +136,7 @@ export default function UserAvatar({ // Render image directly instead of using Radix UI Avatar for better reliability return (
) : ( // Show initials or placeholder when image fails @@ -133,8 +188,8 @@ export function SimpleUserAvatar({ [pubkey] ) - // Use profile avatar if available, otherwise use default avatar - const avatarSrc = profile?.avatar || defaultAvatar || '' + const containerRef = useRef(null) + const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef) // All hooks must be called before any early returns const [imgError, setImgError] = useState(false) @@ -147,12 +202,10 @@ export function SimpleUserAvatar({ }, [avatarSrc]) const handleImageError = () => { - if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) { - // Try default avatar if profile avatar fails + if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) { setCurrentSrc(defaultAvatar) setImgError(false) } else { - // Both failed setImgError(true) } } @@ -175,17 +228,19 @@ export function SimpleUserAvatar({ // Render image directly instead of using Radix UI Avatar for better reliability return (
{!imgError && currentSrc ? ( {displayPubkey} ) : ( // Show initials or placeholder when image fails diff --git a/src/constants.ts b/src/constants.ts index 52d28817..4e0704da 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -import { kinds } from 'nostr-tools' +import { kinds, type Filter } from 'nostr-tools' /** API base URL; override with VITE_JUMBLE_API_BASE_URL for forks (e.g. https://api.jumble.imwald.eu). */ export const JUMBLE_API_BASE_URL = @@ -26,7 +26,8 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT = export const DEFAULT_FAVORITE_RELAYS = [ 'wss://theforest.nostr1.com', 'wss://orly-relay.imwald.eu', - 'wss://nostr.land' + 'wss://nostr.land', + 'wss://nostr21.com' ] /** @@ -181,19 +182,31 @@ export const BOOKSTR_RELAY_URLS = [ /** * Block-list order (applied in sequence when building relay lists): * 1. READ_ONLY — never publish - * 2. KIND_1_BLOCKED — skip for kind 1 read/write + * 2. SOCIAL_KIND_BLOCKED — skip for REQ/publish that target {@link SOCIAL_KIND_BLOCKED_KINDS} * 3. E_TAG_FILTER_BLOCKED — skip for reply/quote/stats fetches (#e, #a, #q filters) */ /** Relays that must never be used for publishing (read-only aggregators, etc.). */ export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] -/** Relays that block kind 1 (microblogging); skip for kind 1 read and write. */ -export const KIND_1_BLOCKED_RELAY_URLS = [ +/** + * Relays that reject or poorly serve “social” kinds (short notes, discussions, URL comments). + * Strip these from REQ/publish relay stacks when the filter or event uses {@link SOCIAL_KIND_BLOCKED_KINDS}, + * or when a filter omits `kinds` (broad timeline). + */ +export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', 'wss://hist.nostr.land', 'wss://profiles.nostr1.com', 'wss://purplepag.es', - 'wss://wikifreedia.xyz' + 'wss://relay.nsec.app', + 'wss://bucket.coracle.social', + 'wss://spatia-arcana.com', + 'wss://relay.wikifreedia.xyz', + 'wss://relay.gifbuddy.lol', + 'wss://relay.noswhere.com', + 'wss://aggr.nostr.land', + 'wss://search.nos.today', + 'wss://trending.nostr.wine' ] /** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */ @@ -329,6 +342,30 @@ export const ExtendedKind = { WEB_BOOKMARK: 39701 } +/** + * Kinds aligned with {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}: omit those relays when querying or publishing + * these kinds (or when `kinds` is omitted on a filter — see {@link relayFilterIncludesSocialKindBlockedKind}). + */ +export const SOCIAL_KIND_BLOCKED_KINDS: readonly number[] = [ + kinds.ShortTextNote, + ExtendedKind.DISCUSSION, + ExtendedKind.COMMENT +] + +const SOCIAL_KIND_BLOCKED_KIND_SET = new Set(SOCIAL_KIND_BLOCKED_KINDS) + +export function isSocialKindBlockedKind(kind: number): boolean { + return SOCIAL_KIND_BLOCKED_KIND_SET.has(kind) +} + +/** True when the filter is unrestricted by kind or includes any {@link SOCIAL_KIND_BLOCKED_KINDS}. */ +export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolean { + const k = filter.kinds + if (k === undefined) return true + const arr = Array.isArray(k) ? k : [k] + return arr.some((kind) => SOCIAL_KIND_BLOCKED_KIND_SET.has(kind)) +} + /** Event kinds that show “Read this note aloud” in note options (Web Speech API). */ export const READ_ALOUD_KINDS: readonly number[] = [ kinds.ShortTextNote, diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index e9f3e3ee..b2f619b0 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -2,7 +2,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client from '@/services/client.service' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' -import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' +import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -182,7 +182,7 @@ export function useProfileTimeline({ favoriteRelays, blockedRelays, authorRl, - kinds.includes(1) + kinds.some(isSocialKindBlockedKind) ) const startWave = async (subRequests: ReturnType) => { diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts index da2f037b..e81eea75 100644 --- a/src/lib/account-list-relay-urls.ts +++ b/src/lib/account-list-relay-urls.ts @@ -21,14 +21,14 @@ export async function buildAccountListRelayUrlsForMerge(options: { favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, - applyKind1BlockedFilter: false + applySocialKindBlockedFilter: false }) const write = buildPrioritizedWriteRelayUrls({ userWriteRelays: myRelayList.write ?? [], favoriteRelays: favoritesTier, blockedRelays, maxRelays: 100, - applyKind1BlockedFilter: false + applySocialKindBlockedFilter: false }) const merged = [...read, ...write] return [...new Set(merged.map((u) => normalizeUrl(u) || u).filter(Boolean))] diff --git a/src/lib/event.ts b/src/lib/event.ts index aa487027..486f76a3 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -358,6 +358,12 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { } } + // Discussion roots (kind 11) usually do not reference their own id in tags/content; include the + // row id so feed prefetch + open-note `fetchEvent` hit session cache after the list has loaded. + if (event.kind === ExtendedKind.DISCUSSION) { + addHex(event.id) + } + return { hexIds: Array.from(hexSet), nip19Pointers: Array.from(nip19Set) diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index e520a194..75569d64 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -1,7 +1,11 @@ -import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, READ_ONLY_RELAY_URLS } from '@/constants' +import { + DEFAULT_FAVORITE_RELAYS, + FAST_READ_RELAY_URLS, + READ_ONLY_RELAY_URLS, + relayFilterIncludesSocialKindBlockedKind +} from '@/constants' import type { TFeedSubRequest } from '@/types' import { normalizeUrl } from '@/lib/url' -import type { Filter } from 'nostr-tools' import { buildPrioritizedReadRelayUrls, buildReadRelayPriorityLayers, @@ -11,14 +15,6 @@ import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' -/** True when the filter is unrestricted by kind or explicitly includes kind 1 (short notes). */ -export function relayFilterLikelyIncludesKind1(filter: Filter): boolean { - const k = filter.kinds - if (k === undefined) return true - const arr = Array.isArray(k) ? k : [k] - return arr.includes(1) -} - const blockedSet = (blockedRelays: string[]) => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) @@ -104,10 +100,11 @@ export type ReadRelayPriorityOptions = { authorWriteRelays?: string[] maxRelays?: number /** - * When set, applies to all subrequests. When unset, each subrequest uses {@link relayFilterLikelyIncludesKind1} - * on its filter to decide whether to strip kind-1-blocklisted relays before capping. + * When set, applies to all subrequests. When unset, each subrequest uses + * {@link relayFilterIncludesSocialKindBlockedKind} on its filter to decide whether to strip + * relays in `SOCIAL_KIND_BLOCKED_RELAY_URLS` before capping. */ - applyKind1BlockedFilter?: boolean + applySocialKindBlockedFilter?: boolean /** * When false, ignore each subrequest’s `urls` and use only the shared prioritized stack (rare). * Default true. @@ -137,7 +134,7 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays: favorites, blockedRelays, maxRelays: options?.maxRelays, - applyKind1BlockedFilter: options?.applyKind1BlockedFilter + applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter }) } @@ -153,7 +150,7 @@ export function buildProfilePageReadRelayUrls( favoriteRelays: string[], blockedRelays: string[], authorRelayList: { read: string[]; write: string[] }, - kindsIncludeKind1: boolean + kindsIncludeSocialBlockedKind: boolean ): string[] { return getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, @@ -163,14 +160,14 @@ export function buildProfilePageReadRelayUrls( userWriteRelays: authorRelayList.write ?? [], authorWriteRelays: [], maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, - applyKind1BlockedFilter: kindsIncludeKind1 + applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind } ) } /** * Per subrequest: shared inbox → author/favorites → fast read stack, normalized, user-blocked and (when applicable) - * kind-1-blocked stripped, deduped, capped. Subrequest `urls` are prepended first by default (following shards); + * social-kind-blocked stripped, deduped, capped. Subrequest `urls` are prepended first by default (following shards); * set {@link ReadRelayPriorityOptions.mergeSubrequestRelaysIntoAuthorTier} to fold them into the author tier only * (e.g. curated GIF / spell relay lists). */ @@ -185,10 +182,10 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( return requests.map((r) => { const useSubUrls = options?.mergeSubrequestRelayUrls !== false const foldIntoAuthor = options?.mergeSubrequestRelaysIntoAuthorTier === true - const applyK1 = - options?.applyKind1BlockedFilter !== undefined - ? options.applyKind1BlockedFilter - : relayFilterLikelyIncludesKind1(r.filter) + const applySocial = + options?.applySocialKindBlockedFilter !== undefined + ? options.applySocialKindBlockedFilter + : relayFilterIncludesSocialKindBlockedKind(r.filter) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) @@ -202,7 +199,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( favoriteRelays: favorites, blockedRelays, maxRelays: max, - applyKind1BlockedFilter: applyK1 + applySocialKindBlockedFilter: applySocial }) } } @@ -224,7 +221,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( return { ...r, urls: mergeRelayPriorityLayers(layers, blockedRelays, max, { - applyKind1BlockedFilter: applyK1 + applySocialKindBlockedFilter: applySocial }) } }) diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index 04989bc4..f7cd6eae 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -1,7 +1,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, - KIND_1_BLOCKED_RELAY_URLS, + SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, MAX_REQ_RELAY_URLS } from '@/constants' @@ -38,23 +38,23 @@ function blockedNormSet(blockedRelays: string[] | undefined): Set { return new Set((blockedRelays ?? []).map((b) => normalizeUrl(b) || b).filter(Boolean)) } -let kind1BlockedNormCache: Set | undefined -function kind1BlockedNormSet(): Set { - if (!kind1BlockedNormCache) { - kind1BlockedNormCache = new Set( - KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) +let socialKindBlockedNormCache: Set | undefined +function socialKindBlockedNormSet(): Set { + if (!socialKindBlockedNormCache) { + socialKindBlockedNormCache = new Set( + SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ) } - return kind1BlockedNormCache + return socialKindBlockedNormCache } export type MergeRelayPriorityLayersOptions = { - /** When true, drop {@link KIND_1_BLOCKED_RELAY_URLS} before applying the max cap. */ - applyKind1BlockedFilter?: boolean + /** When true, drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before applying the max cap. */ + applySocialKindBlockedFilter?: boolean } /** - * Merge priority layers in order; first occurrence wins; skip blocked (and optional kind-1 block list); stop at `max`. + * Merge priority layers in order; first occurrence wins; skip blocked (and optional social-kind block list); stop at `max`. */ export function mergeRelayPriorityLayers( layers: string[][], @@ -63,13 +63,15 @@ export function mergeRelayPriorityLayers( mergeOpts?: MergeRelayPriorityLayersOptions ): string[] { const blocked = blockedNormSet(blockedRelays) - const k1 = mergeOpts?.applyKind1BlockedFilter ? kind1BlockedNormSet() : new Set() + const socialBlocked = mergeOpts?.applySocialKindBlockedFilter + ? socialKindBlockedNormSet() + : new Set() const seen = new Set() const out: string[] = [] for (const layer of layers) { for (const u of layer) { const n = normalizeUrl(u) || u - if (!n || blocked.has(n) || k1.has(n) || seen.has(n)) continue + if (!n || blocked.has(n) || socialBlocked.has(n) || seen.has(n)) continue seen.add(n) out.push(n) if (out.length >= max) return out @@ -89,7 +91,7 @@ const normFastWrite = (): string[] => ) /** - * Ordered layers for REQ / read (before merge, dedupe, blocked strip, kind-1 strip, cap). + * Ordered layers for REQ / read (before merge, dedupe, blocked strip, social-kind strip, cap). */ export function buildReadRelayPriorityLayers(opts: { userReadRelays: string[] @@ -109,7 +111,7 @@ export function buildReadRelayPriorityLayers(opts: { /** * REQ / read: user inboxes (locals first) + user local outboxes → author outboxes → favorites → FAST_READ. - * Blocked and (optionally) kind-1-blocked relays are removed before slicing to `maxRelays`. + * Blocked and (optionally) social-kind-blocked relays are removed before slicing to `maxRelays`. */ export function buildPrioritizedReadRelayUrls(opts: { userReadRelays: string[] @@ -118,11 +120,11 @@ export function buildPrioritizedReadRelayUrls(opts: { favoriteRelays: string[] blockedRelays?: string[] maxRelays?: number - /** Default true: strip {@link KIND_1_BLOCKED_RELAY_URLS} (kind-1-heavy timelines). Set false for non–kind-1 queries. */ - applyKind1BlockedFilter?: boolean + /** Default true: strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} for social-kind-heavy timelines. Set false for other queries. */ + applySocialKindBlockedFilter?: boolean }): string[] { const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS - const applyK1 = opts.applyKind1BlockedFilter !== false + const applySocial = opts.applySocialKindBlockedFilter !== false const layers = buildReadRelayPriorityLayers({ userReadRelays: opts.userReadRelays, userWriteRelays: opts.userWriteRelays, @@ -130,7 +132,7 @@ export function buildPrioritizedReadRelayUrls(opts: { favoriteRelays: opts.favoriteRelays }) return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applyKind1BlockedFilter: applyK1 + applySocialKindBlockedFilter: applySocial }) } @@ -162,8 +164,8 @@ export function buildPrioritizedWriteRelayUrls(opts: { extraRelays?: string[] blockedRelays?: string[] maxRelays?: number - /** When true, strip {@link KIND_1_BLOCKED_RELAY_URLS} before capping (kind 1 notes). */ - applyKind1BlockedFilter?: boolean + /** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */ + applySocialKindBlockedFilter?: boolean }): string[] { const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS const layers = buildWriteRelayPriorityLayers({ @@ -173,6 +175,6 @@ export function buildPrioritizedWriteRelayUrls(opts: { extraRelays: opts.extraRelays }) return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applyKind1BlockedFilter: opts.applyKind1BlockedFilter === true + applySocialKindBlockedFilter: opts.applySocialKindBlockedFilter === true }) } diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index b2a3469a..9f41d31d 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -664,10 +664,9 @@ const SpellsPage = forwardRef(function SpellsPage( const syncFauxSubRequests = useMemo(() => { if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] - /** Widen relay pool: these filters are not kind-1-only; skipping strip keeps fast-read mirrors in the stack. */ - const fauxSpellSkipKind1Blocked = + /** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */ + const fauxSpellSkipSocialKindBlocked = selectedFauxSpell === 'calendar' || - selectedFauxSpell === 'discussions' || selectedFauxSpell === 'followPacks' || selectedFauxSpell === 'media' || selectedFauxSpell === 'bookmarks' || @@ -678,7 +677,7 @@ const SpellsPage = forwardRef(function SpellsPage( relayList?.read ?? [], { userWriteRelays: relayList?.write ?? [], - applyKind1BlockedFilter: fauxSpellSkipKind1Blocked ? false : undefined + applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined } ) diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 00a4e04c..b02ba59c 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -3,7 +3,7 @@ import type { TNoteListRef } from '@/components/NoteList' import NormalFeed from '@/components/NormalFeed' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' -import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { isSocialKindBlockedKind, SEARCHABLE_RELAY_URLS } from '@/constants' import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox @@ -85,7 +85,7 @@ const NoteListPage = forwardRef(({ index, hid .filter((k) => !isNaN(k)) const readUrlOpts = { userWriteRelays: relayList?.write ?? [], - applyKind1BlockedFilter: kinds.length === 0 || kinds.includes(1) + applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) } const hashtag = searchParams.get('t') if (hashtag) { diff --git a/src/providers/GroupListProvider.tsx b/src/providers/GroupListProvider.tsx index 6e1beffe..407d9085 100644 --- a/src/providers/GroupListProvider.tsx +++ b/src/providers/GroupListProvider.tsx @@ -40,7 +40,7 @@ export function GroupListProvider({ children }: { children: React.ReactNode }) { userWriteRelays: myRelayList.write ?? [], favoriteRelays: favoritesTier, blockedRelays, - applyKind1BlockedFilter: false + applySocialKindBlockedFilter: false }) }, [accountPubkey, favoriteRelays, blockedRelays]) diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 5b097ebe..769234a4 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -11,6 +11,7 @@ import { getRootETag, isNip25ReactionKind } from '@/lib/event' +import client from '@/services/client.service' import { Event, kinds } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' @@ -41,6 +42,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { if (newReplyIdSet.has(reply.id)) return if (isNip25ReactionKind(reply.kind)) return newReplyIdSet.add(reply.id) + client.addEventToCache(reply) let rootId: string | undefined const rootETag = getRootETag(reply) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 42ddbae6..eb671050 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -41,7 +41,8 @@ export class EventService { * In-memory session cache: events seen this tab session (timelines, queries, fetches). * Larger cap + no TTL so navigation and repeat fetches reuse data until reload. */ - private sessionEventCache = new LRUCache({ max: 15000 }) + /** Large cap: timelines + note-stats (reactions, replies, zaps, reposts per note) share one LRU. */ + private sessionEventCache = new LRUCache({ max: 5_000 }) /** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */ private sessionMetadataByPubkey = new Map() /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 9e641f72..b20c145f 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -1,7 +1,8 @@ import { FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT, FIRST_RELAY_RESULT_GRACE_MS, - KIND_1_BLOCKED_RELAY_URLS, + relayFilterIncludesSocialKindBlockedKind, + SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_SUBS_PER_RELAY, SEARCHABLE_RELAY_URLS @@ -107,7 +108,7 @@ export class QueryService { this.onRelayNoticeStrike = relaySession?.onRelayNoticeStrike } - /** Wire after {@link EventService} exists so all `query()` / `fetchEvents` results populate the session cache. */ + /** Wire after {@link EventService} exists: each `query()` / `fetchEvents` event is ingested from `onevent` (session LRU). */ setQueryResultIngest(handler: ((events: NEvent[]) => void) | undefined): void { this.onQueryResultIngest = handler } @@ -263,7 +264,7 @@ export class QueryService { const resolvedList = replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events - this.onQueryResultIngest?.(resolvedList) + // Session cache already updated per-event in onevent; avoid duplicate ingest + waiter churn. resolve(resolvedList) } @@ -271,10 +272,15 @@ export class QueryService { urls, filter, { - onevent(evt) { + onevent: (evt) => { eventCount++ onevent?.(evt) events.push(evt) + // Session cache: ingest as events arrive (reactions/replies/zaps from note-stats, etc.), + // not only at resolve — otherwise embeds and fetchEvent miss until EOSE. + if (!shouldDropEventOnIngest(evt)) { + this.onQueryResultIngest?.([evt]) + } if (firstResultTime === null) { firstResultTime = Date.now() @@ -374,10 +380,12 @@ export class QueryService { let relays = Array.from(new Set(urls)) const filters = Array.isArray(filter) ? filter : [filter] - const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1)) - if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) + const stripSocialBlockedRelays = + SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && + filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) + if (stripSocialBlockedRelays) { + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) } if (this.shouldSkipRelayForSession) { relays = relays.filter((url) => { @@ -568,9 +576,11 @@ export class QueryService { return { close: () => { - opBatch?.finalize('closed', 'subscribe_close') - allOpened.then(() => { + // Close subs first, then finalize — otherwise finalize runs before any EOSE/onclose and every + // relay is mis-labeled "skipped" in batch_end. + void allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) + setTimeout(() => opBatch?.finalize('closed', 'subscribe_close'), 0) }) } } @@ -592,10 +602,12 @@ export class QueryService { relays = [...FAST_READ_RELAY_URLS] } const filters = Array.isArray(filter) ? filter : [filter] - const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1)) - if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) + const stripSocialBlockedRelays = + SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && + filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) + if (stripSocialBlockedRelays) { + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) } const { onevent, ...queryOpts } = options ?? {} return this.query(relays, filter, onevent, queryOpts) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 65782936..ea9a75a9 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3,7 +3,9 @@ import { ExtendedKind, FAST_WRITE_RELAY_URLS, FIRST_RELAY_RESULT_GRACE_MS, - KIND_1_BLOCKED_RELAY_URLS, + isSocialKindBlockedKind, + relayFilterIncludesSocialKindBlockedKind, + SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, OUTBOX_PUBLISH_RETRY_DELAY_MS, NIP66_DISCOVERY_RELAY_URLS, @@ -320,18 +322,18 @@ class ClientService extends EventTarget { */ private filterPublishingRelays(relays: string[], event: NEvent): string[] { const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) return dedupeNormalizeRelayUrlsOrdered( relays.filter((url) => { const n = normalizeUrl(url) || url if (readOnlySet.has(n)) return false - if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false + if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false return true }) ) } - /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / kind-1 blocks). */ + /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */ private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise { try { const relayList = await this.fetchRelayList(event.pubkey) @@ -442,7 +444,7 @@ class ClientService extends EventTarget { ) const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const t0: string[] = [] const t1: string[] = [] @@ -464,7 +466,7 @@ class ClientService extends EventTarget { .filter((url) => { const n = normalizeUrl(url) || url if (readOnlySet.has(n)) return false - if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false + if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false return true }) .slice(0, MAX_PUBLISH_RELAYS) @@ -492,7 +494,7 @@ class ClientService extends EventTarget { ) { const writeRelayPubOpts = { blockedRelays: blockedRelayUrls, - applyKind1BlockedFilter: event.kind === kinds.ShortTextNote + applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind) } if (event.kind === kinds.RelayList) { logger.info('[DetermineTargetRelays] Determining target relays for relay list event', { @@ -578,7 +580,7 @@ class ClientService extends EventTarget { [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], blockedRelayUrls, MAX_PUBLISH_RELAYS, - { applyKind1BlockedFilter: false } + { applySocialKindBlockedFilter: false } ) pubRelays = this.filterPublishingRelays(pubRelays, event) logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', { @@ -593,7 +595,7 @@ class ClientService extends EventTarget { [relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS])], blockedRelayUrls, MAX_PUBLISH_RELAYS, - { applyKind1BlockedFilter: false } + { applySocialKindBlockedFilter: false } ), event ) @@ -927,11 +929,11 @@ class ClientService extends EventTarget { } const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) let filtered = mergedRelayUrls.filter((url) => { const n = normalizeUrl(url) || url if (readOnlySet.has(n)) return false - if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false + if (isSocialKindBlockedKind(event.kind) && socialKindBlockedSet.has(n)) return false const strikes = this.publishStrikeCount.get(n) ?? 0 if (strikes >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) return false return true @@ -1514,10 +1516,12 @@ class ClientService extends EventTarget { let relays = Array.from(new Set(urls)) const filters = Array.isArray(filter) ? filter : [filter] - const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1)) - if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) + const stripSocialBlockedRelays = + SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && + filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) + if (stripSocialBlockedRelays) { + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) } relays = this.filterSessionStrikedRelays(relays) @@ -1542,7 +1546,7 @@ class ClientService extends EventTarget { return { url, filters: filtersForRelay } }) - // Kind-1 queries drop KIND_1_BLOCKED_RELAY_URLS; if every URL was removed, no subs run and + // Social-kind queries drop SOCIAL_KIND_BLOCKED_RELAY_URLS; if every URL was removed, no subs run and // oneose would never fire — timelines stay loading forever (e.g. favorites feed). if (groupedRequests.length === 0) { logger.debug('[relay-req] batch_skip', { @@ -1783,10 +1787,10 @@ class ClientService extends EventTarget { return { close: () => { - opBatch.finalize('closed', 'subscription_closed') this.removeEventListener('newEvent', handleNewEventFromInternal) - allOpened.then(() => { + void allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) + setTimeout(() => opBatch.finalize('closed', 'subscription_closed'), 0) }) } } @@ -2121,10 +2125,12 @@ class ClientService extends EventTarget { let relays = Array.from(new Set(urls)) if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS] const filters = Array.isArray(filter) ? filter : [filter] - const hasKind1 = filters.some((f) => f.kinds && (Array.isArray(f.kinds) ? f.kinds.includes(1) : f.kinds === 1)) - if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { - const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) + const stripSocialBlockedRelays = + SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && + filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) + if (stripSocialBlockedRelays) { + const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + relays = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) } relays = this.filterSessionStrikedRelays(relays) const events = await this.queryService.query(relays, filter, onevent, { diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index c08c501a..20345bc8 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -241,7 +241,7 @@ export async function fetchGifs( ? dedupedUrls : [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] - // Kind 1063 (incl. thecitadel) + kind 1/1111 on the broad list (thecitadel omitted for kind 1 via KIND_1_BLOCKED). + // Kind 1063 (incl. thecitadel) + kind 1/1111 on the broad list (thecitadel omitted for social kinds via SOCIAL_KIND_BLOCKED_RELAY_URLS). const [events1063, eventsNotes] = await Promise.all([ queryService.fetchEvents( relays1063, diff --git a/src/services/relay-operation-log.service.ts b/src/services/relay-operation-log.service.ts index a23af21c..7129b286 100644 --- a/src/services/relay-operation-log.service.ts +++ b/src/services/relay-operation-log.service.ts @@ -35,7 +35,7 @@ export function compactFilterForRelayLog(f: Filter): Record { return out } -export type RelayOpTerminalOutcome = 'eose' | 'closed' | 'skipped' | 'timeout' +export type RelayOpTerminalOutcome = 'eose' | 'closed' | 'timeout' export interface RelayOpTerminalRow { cmdIndex: number @@ -48,6 +48,73 @@ export interface RelayOpTerminalRow { type GroupedRelayRow = { url: string; filters: Filter[] } +/** Short host label for subscribe REQ logs (same as publish). */ +function relayHostForSubscribeLog(url: string): string { + return relayHostForPublishLog(url) +} + +function humanizeSubscribeTerminalDetail(outcome: RelayOpTerminalOutcome, detail?: string): string { + const d = (detail ?? '').trim() + if (!d) { + if (outcome === 'eose') return 'end of stored events' + return outcome + } + if ( + d === 'subscribe_close' || + d === 'subscription_closed' || + d === 'no_report_before_req_closed' || + d === 'batch_finalize_closed' + ) { + return 'REQ ended before this relay reported EOSE (often normal)' + } + if (d === 'batch_finalize_timeout') return 'batch closed on timeout before relay reported' + return d.length > 100 ? `${d.slice(0, 97)}…` : d +} + +/** + * One block of text for the console (like NIP-65 retry logs), instead of expanding `terminals` / `byOutcome`. + */ +export function buildSubscribeBatchReadableSummary(rows: RelayOpTerminalRow[]): string { + if (rows.length === 0) return '(no relay slots)' + + type Group = { outcome: RelayOpTerminalOutcome; label: string; rows: RelayOpTerminalRow[] } + const groups: Group[] = [] + for (const r of rows) { + const label = humanizeSubscribeTerminalDetail(r.outcome, r.detail) + let g = groups.find((x) => x.outcome === r.outcome && x.label === label) + if (!g) { + g = { outcome: r.outcome, label, rows: [] } + groups.push(g) + } + g.rows.push(r) + } + + groups.sort((a, b) => { + const o = a.outcome.localeCompare(b.outcome) + if (o !== 0) return o + return a.label.localeCompare(b.label) + }) + + const parts: string[] = [] + for (const { outcome, label, rows: list } of groups) { + const hosts = list.map((r) => relayHostForSubscribeLog(r.relayUrl)) + const uniq = [...new Set(hosts)] + const head = + outcome === 'eose' + ? `EOSE (${list.length})` + : outcome === 'timeout' + ? `Timeout (${list.length})` + : `Closed (${list.length})` + parts.push(`${head} — ${label}`) + if (uniq.length <= 12) { + parts.push(...uniq.map((h) => ` • ${h}`)) + } else { + parts.push(` • ${uniq.slice(0, 8).join(', ')} … +${uniq.length - 8} more`) + } + } + return parts.join('\n') +} + function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record { const map = new Map() for (const r of rows) { @@ -144,8 +211,11 @@ export class RelaySubscribeOpBatch { this.terminal.set(i, { cmdIndex: i, relayUrl: this.grouped[i]!.url, - outcome: status === 'timeout' ? 'timeout' : 'skipped', - detail: detail ?? (status === 'timeout' ? 'batch_finalize_timeout' : 'batch_finalize_closed'), + outcome: status === 'timeout' ? 'timeout' : 'closed', + detail: + status === 'timeout' + ? (detail ?? 'batch_finalize_timeout') + : (detail ?? 'no_report_before_req_closed'), msFromBatchStart }) } @@ -160,15 +230,33 @@ export class RelaySubscribeOpBatch { const elapsedMs = Math.round( (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 ) - this.logLine('[RelayOp] batch_end', { + const readableSummary = buildSubscribeBatchReadableSummary(rows) + const nEose = rows.filter((r) => r.outcome === 'eose').length + const nTimeout = rows.filter((r) => r.outcome === 'timeout').length + const nClosed = rows.filter((r) => r.outcome === 'closed').length + const headline = `${rows.length} relay(s), ${elapsedMs}ms — EOSE ${nEose}, closed ${nClosed}, timeout ${nTimeout}` + + const compact: Record = { batchId: this.batchId, source: this.source, status, elapsedMs, terminalCount: rows.length, - byOutcome: groupTerminalsByOutcome(rows), - terminals: rows - }) + eoseCount: nEose, + closedCount: nClosed, + timeoutCount: nTimeout + } + + if (this.logLevel === 'debug') { + this.logLine('[RelayOp] batch_end', { + ...compact, + readableSummary, + byOutcome: groupTerminalsByOutcome(rows), + terminals: rows + }) + } else { + logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact) + } } } @@ -228,9 +316,11 @@ export class RelayPublishOpBatch { const fail = this.results.filter((r) => !r.ok) const sorted = this.results.sort((a, b) => a.cmdIndex - b.cmdIndex) const readableSummary = - fail.length === 0 - ? `All ${ok.length} relay(s) accepted the publish.` - : [ + this.relays.length === 0 + ? 'No relays targeted (empty list or skipped by session rules).' + : fail.length === 0 + ? `All ${ok.length} relay(s) accepted the publish.` + : [ `${fail.length} relay(s) failed:`, ...fail.map( (r) => diff --git a/src/services/spell.service.ts b/src/services/spell.service.ts index 1c87cf9b..d5da4d40 100644 --- a/src/services/spell.service.ts +++ b/src/services/spell.service.ts @@ -91,7 +91,7 @@ export function getRelaysForSpellCatalogSync( ): string[] { return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, { userWriteRelays: options?.userWriteRelays ?? [], - applyKind1BlockedFilter: false + applySocialKindBlockedFilter: false }) }