Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
2eb72e94e1
  1. 2
      nip66-cron/index.mjs
  2. 2
      src/components/Explore/ExploreRelayReviews.tsx
  3. 101
      src/components/NoteList/index.tsx
  4. 18
      src/components/PostEditor/PostRelaySelector.tsx
  5. 57
      src/components/UserAvatar/UserAvatar.test.tsx
  6. 83
      src/components/UserAvatar/index.tsx
  7. 49
      src/constants.ts
  8. 4
      src/hooks/useProfileTimeline.tsx
  9. 4
      src/lib/account-list-relay-urls.ts
  10. 6
      src/lib/event.ts
  11. 43
      src/lib/favorites-feed-relays.ts
  12. 44
      src/lib/relay-url-priority.ts
  13. 7
      src/pages/primary/SpellsPage/index.tsx
  14. 4
      src/pages/secondary/NoteListPage/index.tsx
  15. 2
      src/providers/GroupListProvider.tsx
  16. 2
      src/providers/ReplyProvider.tsx
  17. 3
      src/services/client-events.service.ts
  18. 40
      src/services/client-query.service.ts
  19. 50
      src/services/client.service.ts
  20. 2
      src/services/gif.service.ts
  21. 110
      src/services/relay-operation-log.service.ts
  22. 2
      src/services/spell.service.ts

2
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 monitors kind 10002 unless overridden. * Default URLs to run NIP-11 checks against (30166); always merged with the monitors kind 10002 unless overridden.
* Union of relay presets in src/constants.ts: DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, * 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, * FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS, SEARCHABLE_RELAY_URLS,
* PROFILE_RELAY_URLS, DEFAULT_NOSTRCONNECT_RELAY deduped, sorted. * PROFILE_RELAY_URLS, DEFAULT_NOSTRCONNECT_RELAY deduped, sorted.
*/ */

2
src/components/Explore/ExploreRelayReviews.tsx

@ -46,7 +46,7 @@ export default function ExploreRelayReviews() {
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: relayList?.write ?? [],
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applyKind1BlockedFilter: false applySocialKindBlockedFilter: false
} }
), ),
blockedRelays blockedRelays

101
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 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). */ /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
const ONE_SHOT_MERGED_CAP =100 const ONE_SHOT_MERGED_CAP =100
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 120 /** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */
const FEED_PROFILE_CHUNK = 36 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[] { function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): Event[] {
const byId = new Map<string, Event>() const byId = new Map<string, Event>()
@ -274,11 +276,23 @@ const NoteList = forwardRef(
candidates.add(t.toLowerCase()) 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) { for (const e of events) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e)
} }
for (const e of newEvents) { for (const e of newEvents) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e)
} }
setFeedProfileBatch((prev) => { setFeedProfileBatch((prev) => {
@ -501,14 +515,26 @@ const NoteList = forwardRef(
const candidates = new Set<string>() const candidates = new Set<string>()
const addPk = (p: string | undefined) => { const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) { 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) { for (const e of events) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e)
} }
for (const e of newEvents) { for (const e of newEvents) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e)
} }
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
@ -530,41 +556,44 @@ const NoteList = forwardRef(
}) })
void (async () => { void (async () => {
if (gen !== feedProfileBatchGenRef.current) return
const chunks: string[][] = []
for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) { for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) {
if (gen !== feedProfileBatchGenRef.current) return chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK))
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 }
})
}
} }
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) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)

18
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 { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -7,7 +7,6 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { getRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent } from '@/lib/event-metadata'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants'
import { Check, ChevronDown, Server } from 'lucide-react' import { Check, ChevronDown, Server } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react' import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react'
@ -94,13 +93,16 @@ export default function PostRelaySelector({
// Memoize arrays to prevent unnecessary re-renders // Memoize arrays to prevent unnecessary re-renders
const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays]) const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays])
const memoizedBlockedRelays = useMemo(() => { const memoizedBlockedRelays = useMemo(() => {
// For kind 1 replies and top-level posts, also block 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 isKind1Publish = const isSocialPublish =
!isPublicMessage && (typeof _parentEvent?.kind === 'undefined' || _parentEvent?.kind === 1) !isPublicMessage &&
return isKind1Publish (_parentEvent == null ||
? [...blockedRelays, ...KIND_1_BLOCKED_RELAY_URLS] isDiscussionReply ||
isSocialKindBlockedKind(_parentEvent.kind))
return isSocialPublish
? [...blockedRelays, ...SOCIAL_KIND_BLOCKED_RELAY_URLS]
: blockedRelays : blockedRelays
}, [blockedRelays, isPublicMessage, _parentEvent?.kind]) }, [blockedRelays, isPublicMessage, _parentEvent, isDiscussionReply])
const memoizedRelaySets = useMemo(() => relaySets, [relaySets]) const memoizedRelaySets = useMemo(() => relaySets, [relaySets])
const memoizedOpenFrom = useMemo(() => openFrom, [openFrom]) const memoizedOpenFrom = useMemo(() => openFrom, [openFrom])

57
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 { render, waitFor } from '@testing-library/react'
import UserAvatar from './index' import UserAvatar from './index'
import * as useFetchProfileHook from '@/hooks/useFetchProfile' import * as useFetchProfileHook from '@/hooks/useFetchProfile'
const originalIO = globalThis.IntersectionObserver
// Mock the hooks and dependencies // Mock the hooks and dependencies
vi.mock('@/hooks/useFetchProfile', () => ({ vi.mock('@/hooks/useFetchProfile', () => ({
useFetchProfile: vi.fn() useFetchProfile: vi.fn()
@ -11,12 +13,16 @@ vi.mock('@/hooks/useFetchProfile', () => ({
vi.mock('@/PageManager', () => ({ vi.mock('@/PageManager', () => ({
useSmartProfileNavigation: () => ({ useSmartProfileNavigation: () => ({
navigateToProfile: vi.fn() navigateToProfile: vi.fn()
}),
useSmartProfileNavigationOptional: () => ({
navigateToProfile: vi.fn()
}) })
})) }))
vi.mock('@/lib/pubkey', () => ({ vi.mock('@/lib/pubkey', () => ({
userIdToPubkey: (id: string) => id.startsWith('npub') ? 'decoded_pubkey' : id, userIdToPubkey: (id: string) => (id.startsWith('npub') ? 'decoded_pubkey' : id),
generateImageByPubkey: (pubkey: string) => `https://avatar.example.com/${pubkey}` generateImageByPubkey: (_pubkey: string) =>
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" fill="gray"/></svg>`)}`
})) }))
vi.mock('@/lib/link', () => ({ vi.mock('@/lib/link', () => ({
@ -24,6 +30,45 @@ vi.mock('@/lib/link', () => ({
})) }))
describe('UserAvatar in Embedded Notes', () => { 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(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
@ -55,10 +100,12 @@ describe('UserAvatar in Embedded Notes', () => {
const avatarContainer = container.querySelector('[data-user-avatar]') const avatarContainer = container.querySelector('[data-user-avatar]')
expect(avatarContainer).toBeInTheDocument() expect(avatarContainer).toBeInTheDocument()
// Find the image // Find the image — identicon first, then remote profile picture after intersection
const img = avatarContainer?.querySelector('img') const img = avatarContainer?.querySelector('img')
expect(img).toBeInTheDocument() 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 // Check that the image is not hidden or covered
const computedStyle = window.getComputedStyle(img!) const computedStyle = window.getComputedStyle(img!)

83
src/components/UserAvatar/index.tsx

@ -4,7 +4,62 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager' 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<HTMLDivElement | null>
): 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 = { const UserAvatarSizeCnMap = {
large: 'w-24 h-24', large: 'w-24 h-24',
@ -40,9 +95,9 @@ export default function UserAvatar({
() => (pubkey ? generateImageByPubkey(pubkey) : ''), () => (pubkey ? generateImageByPubkey(pubkey) : ''),
[pubkey] [pubkey]
) )
// Use profile avatar if available, otherwise use default avatar const containerRef = useRef<HTMLDivElement>(null)
const avatarSrc = profile?.avatar || defaultAvatar || '' const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef)
// All hooks must be called before any early returns // All hooks must be called before any early returns
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
@ -55,12 +110,10 @@ export default function UserAvatar({
}, [avatarSrc]) }, [avatarSrc])
const handleImageError = () => { const handleImageError = () => {
if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) { if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) {
// Try default avatar if profile avatar fails
setCurrentSrc(defaultAvatar) setCurrentSrc(defaultAvatar)
setImgError(false) setImgError(false)
} else { } else {
// Both failed
setImgError(true) setImgError(true)
} }
} }
@ -83,6 +136,7 @@ export default function UserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability // Render image directly instead of using Radix UI Avatar for better reliability
return ( return (
<div <div
ref={containerRef}
data-user-avatar data-user-avatar
className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }} style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }}
@ -94,12 +148,13 @@ export default function UserAvatar({
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
<img <img
src={currentSrc} src={currentSrc}
alt={displayPubkey} alt=""
className="block w-full h-full object-cover object-center" className="block w-full h-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }} style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError} onError={handleImageError}
onLoad={handleImageLoad} onLoad={handleImageLoad}
loading="lazy" loading="lazy"
decoding="async"
/> />
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
@ -133,8 +188,8 @@ export function SimpleUserAvatar({
[pubkey] [pubkey]
) )
// Use profile avatar if available, otherwise use default avatar const containerRef = useRef<HTMLDivElement>(null)
const avatarSrc = profile?.avatar || defaultAvatar || '' const avatarSrc = useDeferRemoteProfileAvatar(profile?.avatar, defaultAvatar, containerRef)
// All hooks must be called before any early returns // All hooks must be called before any early returns
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
@ -147,12 +202,10 @@ export function SimpleUserAvatar({
}, [avatarSrc]) }, [avatarSrc])
const handleImageError = () => { const handleImageError = () => {
if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) { if (profile?.avatar && defaultAvatar && currentSrc !== defaultAvatar) {
// Try default avatar if profile avatar fails
setCurrentSrc(defaultAvatar) setCurrentSrc(defaultAvatar)
setImgError(false) setImgError(false)
} else { } else {
// Both failed
setImgError(true) setImgError(true)
} }
} }
@ -175,17 +228,19 @@ export function SimpleUserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability // Render image directly instead of using Radix UI Avatar for better reliability
return ( return (
<div <div
ref={containerRef}
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
<img <img
src={currentSrc} src={currentSrc}
alt={displayPubkey} alt=""
className="block w-full h-full object-cover object-center" className="block w-full h-full object-cover object-center"
style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }} style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
onError={handleImageError} onError={handleImageError}
onLoad={handleImageLoad} onLoad={handleImageLoad}
loading="lazy" loading="lazy"
decoding="async"
/> />
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails

49
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). */ /** 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 = export const JUMBLE_API_BASE_URL =
@ -26,7 +26,8 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT =
export const DEFAULT_FAVORITE_RELAYS = [ export const DEFAULT_FAVORITE_RELAYS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://orly-relay.imwald.eu', '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): * Block-list order (applied in sequence when building relay lists):
* 1. READ_ONLY never publish * 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) * 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.). */ /** Relays that must never be used for publishing (read-only aggregators, etc.). */
export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] 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://thecitadel.nostr1.com',
'wss://hist.nostr.land', 'wss://hist.nostr.land',
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es', '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. */ /** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */
@ -329,6 +342,30 @@ export const ExtendedKind = {
WEB_BOOKMARK: 39701 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<number>(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). */ /** Event kinds that show “Read this note aloud” in note options (Web Speech API). */
export const READ_ALOUD_KINDS: readonly number[] = [ export const READ_ALOUD_KINDS: readonly number[] = [
kinds.ShortTextNote, kinds.ShortTextNote,

4
src/hooks/useProfileTimeline.tsx

@ -2,7 +2,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools' 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 { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -182,7 +182,7 @@ export function useProfileTimeline({
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
authorRl, authorRl,
kinds.includes(1) kinds.some(isSocialKindBlockedKind)
) )
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => { const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {

4
src/lib/account-list-relay-urls.ts

@ -21,14 +21,14 @@ export async function buildAccountListRelayUrlsForMerge(options: {
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applyKind1BlockedFilter: false applySocialKindBlockedFilter: false
}) })
const write = buildPrioritizedWriteRelayUrls({ const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: myRelayList.write ?? [], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applyKind1BlockedFilter: false applySocialKindBlockedFilter: false
}) })
const merged = [...read, ...write] const merged = [...read, ...write]
return [...new Set(merged.map((u) => normalizeUrl(u) || u).filter(Boolean))] return [...new Set(merged.map((u) => normalizeUrl(u) || u).filter(Boolean))]

6
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 { return {
hexIds: Array.from(hexSet), hexIds: Array.from(hexSet),
nip19Pointers: Array.from(nip19Set) nip19Pointers: Array.from(nip19Set)

43
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 type { TFeedSubRequest } from '@/types'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { Filter } from 'nostr-tools'
import { import {
buildPrioritizedReadRelayUrls, buildPrioritizedReadRelayUrls,
buildReadRelayPriorityLayers, buildReadRelayPriorityLayers,
@ -11,14 +15,6 @@ import {
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } 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[]) => const blockedSet = (blockedRelays: string[]) =>
new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
@ -104,10 +100,11 @@ export type ReadRelayPriorityOptions = {
authorWriteRelays?: string[] authorWriteRelays?: string[]
maxRelays?: number maxRelays?: number
/** /**
* When set, applies to all subrequests. When unset, each subrequest uses {@link relayFilterLikelyIncludesKind1} * When set, applies to all subrequests. When unset, each subrequest uses
* on its filter to decide whether to strip kind-1-blocklisted relays before capping. * {@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 subrequests `urls` and use only the shared prioritized stack (rare). * When false, ignore each subrequests `urls` and use only the shared prioritized stack (rare).
* Default true. * Default true.
@ -137,7 +134,7 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays: favorites, favoriteRelays: favorites,
blockedRelays, blockedRelays,
maxRelays: options?.maxRelays, maxRelays: options?.maxRelays,
applyKind1BlockedFilter: options?.applyKind1BlockedFilter applySocialKindBlockedFilter: options?.applySocialKindBlockedFilter
}) })
} }
@ -153,7 +150,7 @@ export function buildProfilePageReadRelayUrls(
favoriteRelays: string[], favoriteRelays: string[],
blockedRelays: string[], blockedRelays: string[],
authorRelayList: { read: string[]; write: string[] }, authorRelayList: { read: string[]; write: string[] },
kindsIncludeKind1: boolean kindsIncludeSocialBlockedKind: boolean
): string[] { ): string[] {
return getRelayUrlsWithFavoritesFastReadAndInbox( return getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
@ -163,14 +160,14 @@ export function buildProfilePageReadRelayUrls(
userWriteRelays: authorRelayList.write ?? [], userWriteRelays: authorRelayList.write ?? [],
authorWriteRelays: [], authorWriteRelays: [],
maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, 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) * 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 * set {@link ReadRelayPriorityOptions.mergeSubrequestRelaysIntoAuthorTier} to fold them into the author tier only
* (e.g. curated GIF / spell relay lists). * (e.g. curated GIF / spell relay lists).
*/ */
@ -185,10 +182,10 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
return requests.map((r) => { return requests.map((r) => {
const useSubUrls = options?.mergeSubrequestRelayUrls !== false const useSubUrls = options?.mergeSubrequestRelayUrls !== false
const foldIntoAuthor = options?.mergeSubrequestRelaysIntoAuthorTier === true const foldIntoAuthor = options?.mergeSubrequestRelaysIntoAuthorTier === true
const applyK1 = const applySocial =
options?.applyKind1BlockedFilter !== undefined options?.applySocialKindBlockedFilter !== undefined
? options.applyKind1BlockedFilter ? options.applySocialKindBlockedFilter
: relayFilterLikelyIncludesKind1(r.filter) : relayFilterIncludesSocialKindBlockedKind(r.filter)
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
@ -202,7 +199,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
favoriteRelays: favorites, favoriteRelays: favorites,
blockedRelays, blockedRelays,
maxRelays: max, maxRelays: max,
applyKind1BlockedFilter: applyK1 applySocialKindBlockedFilter: applySocial
}) })
} }
} }
@ -224,7 +221,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
return { return {
...r, ...r,
urls: mergeRelayPriorityLayers(layers, blockedRelays, max, { urls: mergeRelayPriorityLayers(layers, blockedRelays, max, {
applyKind1BlockedFilter: applyK1 applySocialKindBlockedFilter: applySocial
}) })
} }
}) })

44
src/lib/relay-url-priority.ts

@ -1,7 +1,7 @@
import { import {
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS, FAST_WRITE_RELAY_URLS,
KIND_1_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
MAX_REQ_RELAY_URLS MAX_REQ_RELAY_URLS
} from '@/constants' } from '@/constants'
@ -38,23 +38,23 @@ function blockedNormSet(blockedRelays: string[] | undefined): Set<string> {
return new Set((blockedRelays ?? []).map((b) => normalizeUrl(b) || b).filter(Boolean)) return new Set((blockedRelays ?? []).map((b) => normalizeUrl(b) || b).filter(Boolean))
} }
let kind1BlockedNormCache: Set<string> | undefined let socialKindBlockedNormCache: Set<string> | undefined
function kind1BlockedNormSet(): Set<string> { function socialKindBlockedNormSet(): Set<string> {
if (!kind1BlockedNormCache) { if (!socialKindBlockedNormCache) {
kind1BlockedNormCache = new Set( socialKindBlockedNormCache = new Set(
KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
) )
} }
return kind1BlockedNormCache return socialKindBlockedNormCache
} }
export type MergeRelayPriorityLayersOptions = { export type MergeRelayPriorityLayersOptions = {
/** When true, drop {@link KIND_1_BLOCKED_RELAY_URLS} before applying the max cap. */ /** When true, drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before applying the max cap. */
applyKind1BlockedFilter?: boolean 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( export function mergeRelayPriorityLayers(
layers: string[][], layers: string[][],
@ -63,13 +63,15 @@ export function mergeRelayPriorityLayers(
mergeOpts?: MergeRelayPriorityLayersOptions mergeOpts?: MergeRelayPriorityLayersOptions
): string[] { ): string[] {
const blocked = blockedNormSet(blockedRelays) const blocked = blockedNormSet(blockedRelays)
const k1 = mergeOpts?.applyKind1BlockedFilter ? kind1BlockedNormSet() : new Set<string>() const socialBlocked = mergeOpts?.applySocialKindBlockedFilter
? socialKindBlockedNormSet()
: new Set<string>()
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const layer of layers) { for (const layer of layers) {
for (const u of layer) { for (const u of layer) {
const n = normalizeUrl(u) || u 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) seen.add(n)
out.push(n) out.push(n)
if (out.length >= max) return out 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: { export function buildReadRelayPriorityLayers(opts: {
userReadRelays: string[] userReadRelays: string[]
@ -109,7 +111,7 @@ export function buildReadRelayPriorityLayers(opts: {
/** /**
* REQ / read: user inboxes (locals first) + user local outboxes author outboxes favorites FAST_READ. * 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: { export function buildPrioritizedReadRelayUrls(opts: {
userReadRelays: string[] userReadRelays: string[]
@ -118,11 +120,11 @@ export function buildPrioritizedReadRelayUrls(opts: {
favoriteRelays: string[] favoriteRelays: string[]
blockedRelays?: string[] blockedRelays?: string[]
maxRelays?: number maxRelays?: number
/** Default true: strip {@link KIND_1_BLOCKED_RELAY_URLS} (kind-1-heavy timelines). Set false for non–kind-1 queries. */ /** Default true: strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} for social-kind-heavy timelines. Set false for other queries. */
applyKind1BlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
}): string[] { }): string[] {
const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS
const applyK1 = opts.applyKind1BlockedFilter !== false const applySocial = opts.applySocialKindBlockedFilter !== false
const layers = buildReadRelayPriorityLayers({ const layers = buildReadRelayPriorityLayers({
userReadRelays: opts.userReadRelays, userReadRelays: opts.userReadRelays,
userWriteRelays: opts.userWriteRelays, userWriteRelays: opts.userWriteRelays,
@ -130,7 +132,7 @@ export function buildPrioritizedReadRelayUrls(opts: {
favoriteRelays: opts.favoriteRelays favoriteRelays: opts.favoriteRelays
}) })
return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, {
applyKind1BlockedFilter: applyK1 applySocialKindBlockedFilter: applySocial
}) })
} }
@ -162,8 +164,8 @@ export function buildPrioritizedWriteRelayUrls(opts: {
extraRelays?: string[] extraRelays?: string[]
blockedRelays?: string[] blockedRelays?: string[]
maxRelays?: number maxRelays?: number
/** When true, strip {@link KIND_1_BLOCKED_RELAY_URLS} before capping (kind 1 notes). */ /** When true, strip {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before capping (social kinds). */
applyKind1BlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
}): string[] { }): string[] {
const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS const max = opts.maxRelays ?? MAX_PUBLISH_RELAYS
const layers = buildWriteRelayPriorityLayers({ const layers = buildWriteRelayPriorityLayers({
@ -173,6 +175,6 @@ export function buildPrioritizedWriteRelayUrls(opts: {
extraRelays: opts.extraRelays extraRelays: opts.extraRelays
}) })
return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, {
applyKind1BlockedFilter: opts.applyKind1BlockedFilter === true applySocialKindBlockedFilter: opts.applySocialKindBlockedFilter === true
}) })
} }

7
src/pages/primary/SpellsPage/index.tsx

@ -664,10 +664,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] if (!selectedFauxSpell || selectedFauxSpell === 'following') return []
/** Widen relay pool: these filters are not kind-1-only; skipping strip keeps fast-read mirrors in the stack. */ /** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */
const fauxSpellSkipKind1Blocked = const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' || selectedFauxSpell === 'calendar' ||
selectedFauxSpell === 'discussions' ||
selectedFauxSpell === 'followPacks' || selectedFauxSpell === 'followPacks' ||
selectedFauxSpell === 'media' || selectedFauxSpell === 'media' ||
selectedFauxSpell === 'bookmarks' || selectedFauxSpell === 'bookmarks' ||
@ -678,7 +677,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
relayList?.read ?? [], relayList?.read ?? [],
{ {
userWriteRelays: relayList?.write ?? [], userWriteRelays: relayList?.write ?? [],
applyKind1BlockedFilter: fauxSpellSkipKind1Blocked ? false : undefined applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined
} }
) )

4
src/pages/secondary/NoteListPage/index.tsx

@ -3,7 +3,7 @@ import type { TNoteListRef } from '@/components/NoteList'
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SEARCHABLE_RELAY_URLS } from '@/constants' import { isSocialKindBlockedKind, SEARCHABLE_RELAY_URLS } from '@/constants'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox getRelayUrlsWithFavoritesFastReadAndInbox
@ -85,7 +85,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
.filter((k) => !isNaN(k)) .filter((k) => !isNaN(k))
const readUrlOpts = { const readUrlOpts = {
userWriteRelays: relayList?.write ?? [], userWriteRelays: relayList?.write ?? [],
applyKind1BlockedFilter: kinds.length === 0 || kinds.includes(1) applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind)
} }
const hashtag = searchParams.get('t') const hashtag = searchParams.get('t')
if (hashtag) { if (hashtag) {

2
src/providers/GroupListProvider.tsx

@ -40,7 +40,7 @@ export function GroupListProvider({ children }: { children: React.ReactNode }) {
userWriteRelays: myRelayList.write ?? [], userWriteRelays: myRelayList.write ?? [],
favoriteRelays: favoritesTier, favoriteRelays: favoritesTier,
blockedRelays, blockedRelays,
applyKind1BlockedFilter: false applySocialKindBlockedFilter: false
}) })
}, [accountPubkey, favoriteRelays, blockedRelays]) }, [accountPubkey, favoriteRelays, blockedRelays])

2
src/providers/ReplyProvider.tsx

@ -11,6 +11,7 @@ import {
getRootETag, getRootETag,
isNip25ReactionKind isNip25ReactionKind
} from '@/lib/event' } from '@/lib/event'
import client from '@/services/client.service'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' 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 (newReplyIdSet.has(reply.id)) return
if (isNip25ReactionKind(reply.kind)) return if (isNip25ReactionKind(reply.kind)) return
newReplyIdSet.add(reply.id) newReplyIdSet.add(reply.id)
client.addEventToCache(reply)
let rootId: string | undefined let rootId: string | undefined
const rootETag = getRootETag(reply) const rootETag = getRootETag(reply)

3
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). * 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. * Larger cap + no TTL so navigation and repeat fetches reuse data until reload.
*/ */
private sessionEventCache = new LRUCache<string, NEvent>({ max: 15000 }) /** Large cap: timelines + note-stats (reactions, replies, zaps, reposts per note) share one LRU. */
private sessionEventCache = new LRUCache<string, NEvent>({ max: 5_000 })
/** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */ /** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */
private sessionMetadataByPubkey = new Map<string, NEvent>() private sessionMetadataByPubkey = new Map<string, NEvent>()
/** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */

40
src/services/client-query.service.ts

@ -1,7 +1,8 @@
import { import {
FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT, FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
KIND_1_BLOCKED_RELAY_URLS, relayFilterIncludesSocialKindBlockedKind,
SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_RELAY_CONNECTIONS,
MAX_CONCURRENT_SUBS_PER_RELAY, MAX_CONCURRENT_SUBS_PER_RELAY,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
@ -107,7 +108,7 @@ export class QueryService {
this.onRelayNoticeStrike = relaySession?.onRelayNoticeStrike 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 { setQueryResultIngest(handler: ((events: NEvent[]) => void) | undefined): void {
this.onQueryResultIngest = handler this.onQueryResultIngest = handler
} }
@ -263,7 +264,7 @@ export class QueryService {
const resolvedList = const resolvedList =
replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events
this.onQueryResultIngest?.(resolvedList) // Session cache already updated per-event in onevent; avoid duplicate ingest + waiter churn.
resolve(resolvedList) resolve(resolvedList)
} }
@ -271,10 +272,15 @@ export class QueryService {
urls, urls,
filter, filter,
{ {
onevent(evt) { onevent: (evt) => {
eventCount++ eventCount++
onevent?.(evt) onevent?.(evt)
events.push(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) { if (firstResultTime === null) {
firstResultTime = Date.now() firstResultTime = Date.now()
@ -374,10 +380,12 @@ export class QueryService {
let relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
const filters = Array.isArray(filter) ? filter : [filter] 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)) const stripSocialBlockedRelays =
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) 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) { if (this.shouldSkipRelayForSession) {
relays = relays.filter((url) => { relays = relays.filter((url) => {
@ -568,9 +576,11 @@ export class QueryService {
return { return {
close: () => { close: () => {
opBatch?.finalize('closed', 'subscribe_close') // Close subs first, then finalize — otherwise finalize runs before any EOSE/onclose and every
allOpened.then(() => { // relay is mis-labeled "skipped" in batch_end.
void allOpened.then(() => {
subs.forEach(({ close: subClose }) => subClose()) subs.forEach(({ close: subClose }) => subClose())
setTimeout(() => opBatch?.finalize('closed', 'subscribe_close'), 0)
}) })
} }
} }
@ -592,10 +602,12 @@ export class QueryService {
relays = [...FAST_READ_RELAY_URLS] relays = [...FAST_READ_RELAY_URLS]
} }
const filters = Array.isArray(filter) ? filter : [filter] 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)) const stripSocialBlockedRelays =
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) 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 ?? {} const { onevent, ...queryOpts } = options ?? {}
return this.query(relays, filter, onevent, queryOpts) return this.query(relays, filter, onevent, queryOpts)

50
src/services/client.service.ts

@ -3,7 +3,9 @@ import {
ExtendedKind, ExtendedKind,
FAST_WRITE_RELAY_URLS, FAST_WRITE_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
KIND_1_BLOCKED_RELAY_URLS, isSocialKindBlockedKind,
relayFilterIncludesSocialKindBlockedKind,
SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
OUTBOX_PUBLISH_RETRY_DELAY_MS, OUTBOX_PUBLISH_RETRY_DELAY_MS,
NIP66_DISCOVERY_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS,
@ -320,18 +322,18 @@ class ClientService extends EventTarget {
*/ */
private filterPublishingRelays(relays: string[], event: NEvent): string[] { private filterPublishingRelays(relays: string[], event: NEvent): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) 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( return dedupeNormalizeRelayUrlsOrdered(
relays.filter((url) => { relays.filter((url) => {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false 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 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<string[]> { private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> {
try { try {
const relayList = await this.fetchRelayList(event.pubkey) 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 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 t0: string[] = []
const t1: string[] = [] const t1: string[] = []
@ -464,7 +466,7 @@ class ClientService extends EventTarget {
.filter((url) => { .filter((url) => {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false 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 return true
}) })
.slice(0, MAX_PUBLISH_RELAYS) .slice(0, MAX_PUBLISH_RELAYS)
@ -492,7 +494,7 @@ class ClientService extends EventTarget {
) { ) {
const writeRelayPubOpts = { const writeRelayPubOpts = {
blockedRelays: blockedRelayUrls, blockedRelays: blockedRelayUrls,
applyKind1BlockedFilter: event.kind === kinds.ShortTextNote applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind)
} }
if (event.kind === kinds.RelayList) { if (event.kind === kinds.RelayList) {
logger.info('[DetermineTargetRelays] Determining target relays for relay list event', { logger.info('[DetermineTargetRelays] Determining target relays for relay list event', {
@ -578,7 +580,7 @@ class ClientService extends EventTarget {
[relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)],
blockedRelayUrls, blockedRelayUrls,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
{ applyKind1BlockedFilter: false } { applySocialKindBlockedFilter: false }
) )
pubRelays = this.filterPublishingRelays(pubRelays, event) pubRelays = this.filterPublishingRelays(pubRelays, event)
logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', { 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])], [relayUrlsLocalsFirst([...FAST_WRITE_RELAY_URLS])],
blockedRelayUrls, blockedRelayUrls,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
{ applyKind1BlockedFilter: false } { applySocialKindBlockedFilter: false }
), ),
event event
) )
@ -927,11 +929,11 @@ class ClientService extends EventTarget {
} }
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) 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) => { let filtered = mergedRelayUrls.filter((url) => {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (readOnlySet.has(n)) return false 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 const strikes = this.publishStrikeCount.get(n) ?? 0
if (strikes >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) return false if (strikes >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) return false
return true return true
@ -1514,10 +1516,12 @@ class ClientService extends EventTarget {
let relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
const filters = Array.isArray(filter) ? filter : [filter] 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)) const stripSocialBlockedRelays =
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) 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) relays = this.filterSessionStrikedRelays(relays)
@ -1542,7 +1546,7 @@ class ClientService extends EventTarget {
return { url, filters: filtersForRelay } 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). // oneose would never fire — timelines stay loading forever (e.g. favorites feed).
if (groupedRequests.length === 0) { if (groupedRequests.length === 0) {
logger.debug('[relay-req] batch_skip', { logger.debug('[relay-req] batch_skip', {
@ -1783,10 +1787,10 @@ class ClientService extends EventTarget {
return { return {
close: () => { close: () => {
opBatch.finalize('closed', 'subscription_closed')
this.removeEventListener('newEvent', handleNewEventFromInternal) this.removeEventListener('newEvent', handleNewEventFromInternal)
allOpened.then(() => { void allOpened.then(() => {
subs.forEach(({ close: subClose }) => subClose()) 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)) let relays = Array.from(new Set(urls))
if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS] if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS]
const filters = Array.isArray(filter) ? filter : [filter] 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)) const stripSocialBlockedRelays =
if (hasKind1 && KIND_1_BLOCKED_RELAY_URLS.length > 0) { SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) 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) relays = this.filterSessionStrikedRelays(relays)
const events = await this.queryService.query(relays, filter, onevent, { const events = await this.queryService.query(relays, filter, onevent, {

2
src/services/gif.service.ts

@ -241,7 +241,7 @@ export async function fetchGifs(
? dedupedUrls ? dedupedUrls
: [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] : [...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([ const [events1063, eventsNotes] = await Promise.all([
queryService.fetchEvents( queryService.fetchEvents(
relays1063, relays1063,

110
src/services/relay-operation-log.service.ts

@ -35,7 +35,7 @@ export function compactFilterForRelayLog(f: Filter): Record<string, unknown> {
return out return out
} }
export type RelayOpTerminalOutcome = 'eose' | 'closed' | 'skipped' | 'timeout' export type RelayOpTerminalOutcome = 'eose' | 'closed' | 'timeout'
export interface RelayOpTerminalRow { export interface RelayOpTerminalRow {
cmdIndex: number cmdIndex: number
@ -48,6 +48,73 @@ export interface RelayOpTerminalRow {
type GroupedRelayRow = { url: string; filters: Filter[] } 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<string, { count: number; relays: string[]; cmdIndices: number[] }> { function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record<string, { count: number; relays: string[]; cmdIndices: number[] }> {
const map = new Map<string, { relays: string[]; cmdIndices: number[] }>() const map = new Map<string, { relays: string[]; cmdIndices: number[] }>()
for (const r of rows) { for (const r of rows) {
@ -144,8 +211,11 @@ export class RelaySubscribeOpBatch {
this.terminal.set(i, { this.terminal.set(i, {
cmdIndex: i, cmdIndex: i,
relayUrl: this.grouped[i]!.url, relayUrl: this.grouped[i]!.url,
outcome: status === 'timeout' ? 'timeout' : 'skipped', outcome: status === 'timeout' ? 'timeout' : 'closed',
detail: detail ?? (status === 'timeout' ? 'batch_finalize_timeout' : 'batch_finalize_closed'), detail:
status === 'timeout'
? (detail ?? 'batch_finalize_timeout')
: (detail ?? 'no_report_before_req_closed'),
msFromBatchStart msFromBatchStart
}) })
} }
@ -160,15 +230,33 @@ export class RelaySubscribeOpBatch {
const elapsedMs = Math.round( const elapsedMs = Math.round(
(typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 (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<string, unknown> = {
batchId: this.batchId, batchId: this.batchId,
source: this.source, source: this.source,
status, status,
elapsedMs, elapsedMs,
terminalCount: rows.length, terminalCount: rows.length,
byOutcome: groupTerminalsByOutcome(rows), eoseCount: nEose,
terminals: rows 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 fail = this.results.filter((r) => !r.ok)
const sorted = this.results.sort((a, b) => a.cmdIndex - b.cmdIndex) const sorted = this.results.sort((a, b) => a.cmdIndex - b.cmdIndex)
const readableSummary = const readableSummary =
fail.length === 0 this.relays.length === 0
? `All ${ok.length} relay(s) accepted the publish.` ? '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.length} relay(s) failed:`,
...fail.map( ...fail.map(
(r) => (r) =>

2
src/services/spell.service.ts

@ -91,7 +91,7 @@ export function getRelaysForSpellCatalogSync(
): string[] { ): string[] {
return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, { return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, {
userWriteRelays: options?.userWriteRelays ?? [], userWriteRelays: options?.userWriteRelays ?? [],
applyKind1BlockedFilter: false applySocialKindBlockedFilter: false
}) })
} }

Loading…
Cancel
Save