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 @@ -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.
* 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.
*/

2
src/components/Explore/ExploreRelayReviews.tsx

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

101
src/components/NoteList/index.tsx

@ -54,8 +54,10 @@ const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds @@ -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<string, Event>()
@ -274,11 +276,23 @@ const NoteList = forwardRef( @@ -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( @@ -501,14 +515,26 @@ const NoteList = forwardRef(
const candidates = new Set<string>()
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( @@ -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)

18
src/components/PostEditor/PostRelaySelector.tsx

@ -1,4 +1,4 @@ @@ -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' @@ -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({ @@ -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])

57
src/components/UserAvatar/UserAvatar.test.tsx

@ -1,8 +1,10 @@ @@ -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', () => ({ @@ -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(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" fill="gray"/></svg>`)}`
}))
vi.mock('@/lib/link', () => ({
@ -24,6 +30,45 @@ 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', () => { @@ -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!)

83
src/components/UserAvatar/index.tsx

@ -4,7 +4,62 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' @@ -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<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 = {
large: 'w-24 h-24',
@ -40,9 +95,9 @@ export default function UserAvatar({ @@ -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<HTMLDivElement>(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({ @@ -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({ @@ -83,6 +136,7 @@ export default function UserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability
return (
<div
ref={containerRef}
data-user-avatar
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' }}
@ -94,12 +148,13 @@ export default function UserAvatar({ @@ -94,12 +148,13 @@ export default function UserAvatar({
{!imgError && currentSrc ? (
<img
src={currentSrc}
alt={displayPubkey}
alt=""
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 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
decoding="async"
/>
) : (
// Show initials or placeholder when image fails
@ -133,8 +188,8 @@ export function SimpleUserAvatar({ @@ -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<HTMLDivElement>(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({ @@ -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({ @@ -175,17 +228,19 @@ export function SimpleUserAvatar({
// Render image directly instead of using Radix UI Avatar for better reliability
return (
<div
ref={containerRef}
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
>
{!imgError && currentSrc ? (
<img
src={currentSrc}
alt={displayPubkey}
alt=""
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 }}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
decoding="async"
/>
) : (
// Show initials or placeholder when image fails

49
src/constants.ts

@ -1,4 +1,4 @@ @@ -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 = @@ -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 = [ @@ -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 = { @@ -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<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). */
export const READ_ALOUD_KINDS: readonly number[] = [
kinds.ShortTextNote,

4
src/hooks/useProfileTimeline.tsx

@ -2,7 +2,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -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({ @@ -182,7 +182,7 @@ export function useProfileTimeline({
favoriteRelays,
blockedRelays,
authorRl,
kinds.includes(1)
kinds.some(isSocialKindBlockedKind)
)
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {

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

@ -21,14 +21,14 @@ export async function buildAccountListRelayUrlsForMerge(options: { @@ -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))]

6
src/lib/event.ts

@ -358,6 +358,12 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { @@ -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)

43
src/lib/favorites-feed-relays.ts

@ -1,7 +1,11 @@ @@ -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 { @@ -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 = { @@ -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 subrequests `urls` and use only the shared prioritized stack (rare).
* Default true.
@ -137,7 +134,7 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( @@ -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( @@ -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( @@ -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( @@ -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( @@ -202,7 +199,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
favoriteRelays: favorites,
blockedRelays,
maxRelays: max,
applyKind1BlockedFilter: applyK1
applySocialKindBlockedFilter: applySocial
})
}
}
@ -224,7 +221,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( @@ -224,7 +221,7 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
return {
...r,
urls: mergeRelayPriorityLayers(layers, blockedRelays, max, {
applyKind1BlockedFilter: applyK1
applySocialKindBlockedFilter: applySocial
})
}
})

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

@ -1,7 +1,7 @@ @@ -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<string> { @@ -38,23 +38,23 @@ function blockedNormSet(blockedRelays: string[] | undefined): Set<string> {
return new Set((blockedRelays ?? []).map((b) => normalizeUrl(b) || b).filter(Boolean))
}
let kind1BlockedNormCache: Set<string> | undefined
function kind1BlockedNormSet(): Set<string> {
if (!kind1BlockedNormCache) {
kind1BlockedNormCache = new Set(
KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
let socialKindBlockedNormCache: Set<string> | undefined
function socialKindBlockedNormSet(): Set<string> {
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( @@ -63,13 +63,15 @@ export function mergeRelayPriorityLayers(
mergeOpts?: MergeRelayPriorityLayersOptions
): string[] {
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 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[] => @@ -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: { @@ -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: { @@ -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: { @@ -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: { @@ -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: { @@ -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
})
}

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

@ -664,10 +664,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -664,10 +664,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
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<TPageRef>(function SpellsPage( @@ -678,7 +677,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
relayList?.read ?? [],
{
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' @@ -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<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -85,7 +85,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ 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) {

2
src/providers/GroupListProvider.tsx

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

2
src/providers/ReplyProvider.tsx

@ -11,6 +11,7 @@ import { @@ -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 }) { @@ -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)

3
src/services/client-events.service.ts

@ -41,7 +41,8 @@ export class EventService { @@ -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<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. */
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). */

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

@ -1,7 +1,8 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)

50
src/services/client.service.ts

@ -3,7 +3,9 @@ import { @@ -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 { @@ -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<string[]> {
try {
const relayList = await this.fetchRelayList(event.pubkey)
@ -442,7 +444,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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, {

2
src/services/gif.service.ts

@ -241,7 +241,7 @@ export async function fetchGifs( @@ -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,

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

@ -35,7 +35,7 @@ export function compactFilterForRelayLog(f: Filter): Record<string, unknown> { @@ -35,7 +35,7 @@ export function compactFilterForRelayLog(f: Filter): Record<string, unknown> {
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 { @@ -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<string, { count: number; relays: string[]; cmdIndices: number[] }> {
const map = new Map<string, { relays: string[]; cmdIndices: number[] }>()
for (const r of rows) {
@ -144,8 +211,11 @@ export class RelaySubscribeOpBatch { @@ -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 { @@ -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<string, unknown> = {
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 { @@ -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) =>

2
src/services/spell.service.ts

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

Loading…
Cancel
Save