diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 93acdb97..3b42acf2 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -42,6 +42,20 @@ function hexEventIdFromNoteId(noteId: string): string | null { } } +/** For naddr (replaceable events), return coordinate kind:pubkey:identifier for suppression matching. */ +function coordinateFromNoteId(noteId: string): string | null { + try { + const { type, data } = nip19.decode(noteId.trim()) + if (type === 'naddr' && data) { + const id = data.identifier ?? '' + return `${data.kind}:${data.pubkey}:${id}`.toLowerCase() + } + return null + } catch { + return null + } +} + /** True if `fetchEventWithExternalRelays(noteId, …)` can build a REQ filter (hex, note, nevent, naddr). */ function canSearchOnExternalRelays(noteId: string): boolean { if (hexEventIdFromNoteId(noteId)) return true @@ -61,10 +75,13 @@ export function EmbeddedNote({ className?: string containingEvent?: Event }) { - const suppressId = useSuppressEmbeddedNoteId() + const suppress = useSuppressEmbeddedNoteId() const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId]) - if (suppressId && embeddedHexId && embeddedHexId === suppressId.toLowerCase()) { - return null + const embeddedCoordinate = useMemo(() => coordinateFromNoteId(noteId), [noteId]) + if (suppress) { + if (embeddedHexId && embeddedHexId === suppress.hexId.toLowerCase()) return null + if (suppress.coordinate && embeddedCoordinate && embeddedCoordinate === suppress.coordinate.toLowerCase()) + return null } const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId]) if (!validation.valid) { diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index afc4cf2a..096d0b84 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,3 +1,4 @@ +import { KIND_1_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' @@ -92,7 +93,14 @@ export default function PostRelaySelector({ // Memoize arrays to prevent unnecessary re-renders const memoizedFavoriteRelays = useMemo(() => favoriteRelays, [favoriteRelays]) - const memoizedBlockedRelays = useMemo(() => blockedRelays, [blockedRelays]) + 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] + : blockedRelays + }, [blockedRelays, isPublicMessage, _parentEvent?.kind]) const memoizedRelaySets = useMemo(() => relaySets, [relaySets]) const memoizedOpenFrom = useMemo(() => openFrom, [openFrom]) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index ebcba5a3..7c4579d5 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -209,6 +209,8 @@ function ReplyNoteList({ }, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) + /** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ + const quoteIdSet = useMemo(() => new Set(quoteEvents.map((e) => e.id)), [quoteEvents]) const mergedFeed = useMemo(() => { if (!showQuotes) return replies const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) @@ -383,18 +385,16 @@ function ReplyNoteList({ const threadRelayHints = [ ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) ] - let finalRelayUrls = await buildReplyReadRelayList( + const replyBlockedRelays = [ + ...(blockedRelays || []), + ...E_TAG_FILTER_BLOCKED_RELAY_URLS + ] + const finalRelayUrls = await buildReplyReadRelayList( opAuthorPubkey, userPubkey || undefined, - blockedRelays || [], + replyBlockedRelays, threadRelayHints ) - const eTagBlockedSet = new Set( - E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u) - ) - finalRelayUrls = finalRelayUrls.filter( - (u) => !eTagBlockedSet.has(normalizeUrl(u) || u) - ) const filters: Filter[] = [] if (rootInfo.type === 'E') { @@ -585,7 +585,7 @@ function ReplyNoteList({ )}
{mergedFeed.slice(0, showCount).map((item) => { - const isQuote = !replyIdSet.has(item.id) + const isQuote = quoteIdSet.has(item.id) // Don't filter by trust until trust data is loaded - prevents replies from // vanishing when wotSet is still empty (all non-self appear untrusted) if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { @@ -606,9 +606,15 @@ function ReplyNoteList({ : item.kind === kinds.LongFormArticle ? t('cited in article') : t('quoted this note') - const hideQuotedNote = eventReferencesEventId(item, event.id) + const hideQuotedNote = eventReferencesEventId(item, event) return ( - +
(replyRefs.current[item.id] = el)} className="scroll-mt-12 border-l-2 border-muted-foreground/40 pl-3 py-1 my-1 rounded-r" diff --git a/src/constants.ts b/src/constants.ts index 3bbf318b..350153be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -157,6 +157,12 @@ export const BOOKSTR_RELAY_URLS = [ 'wss://orly-relay.imwald.eu' ] +/** + * 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 + * 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'] diff --git a/src/contexts/suppress-embedded-note-context.tsx b/src/contexts/suppress-embedded-note-context.tsx index 8393fe3e..b8972471 100644 --- a/src/contexts/suppress-embedded-note-context.tsx +++ b/src/contexts/suppress-embedded-note-context.tsx @@ -1,8 +1,13 @@ import { createContext, useContext } from 'react' -/** When set, EmbeddedNote should not render notes whose id matches this (avoids redundancy when viewing "quotes of this note"). */ -export const SuppressEmbeddedNoteContext = createContext(undefined) +export type SuppressEmbeddedNoteValue = { + hexId: string + coordinate?: string +} + +/** When set, EmbeddedNote should not render notes whose id/coordinate matches (avoids redundancy when viewing "quotes of this note"). */ +export const SuppressEmbeddedNoteContext = createContext(undefined) -export function useSuppressEmbeddedNoteId(): string | undefined { +export function useSuppressEmbeddedNoteId(): SuppressEmbeddedNoteValue | undefined { return useContext(SuppressEmbeddedNoteContext) } diff --git a/src/lib/event.ts b/src/lib/event.ts index 3a70d16d..18d3b442 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -199,18 +199,38 @@ export function getRootEventHexId(event?: Event) { return tag?.[1] } -/** True if event references targetHexId as root, parent, or quoted (#q) — used to hide redundant preview when showing quotes of current note. */ -export function eventReferencesEventId(event: Event | undefined, targetHexId: string): boolean { - if (!event || !targetHexId) return false - const target = targetHexId.toLowerCase() - const rootId = getRootETag(event)?.[1]?.toLowerCase() - if (rootId === target) return true - const parentId = getParentETag(event)?.[1]?.toLowerCase() - if (parentId === target) return true - const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase() - if (qTag === target) return true - const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') - if (eTags.some((t) => t[1]?.toLowerCase() === target)) return true +/** True if event references target as root, parent, or quoted (#q, #a) — used to hide redundant preview when showing quotes of current note. */ +export function eventReferencesEventId( + event: Event | undefined, + targetHexIdOrEvent: string | Event +): boolean { + if (!event) return false + const targetEvent = typeof targetHexIdOrEvent === 'object' ? targetHexIdOrEvent : undefined + const targetHexId = + typeof targetHexIdOrEvent === 'string' + ? targetHexIdOrEvent.toLowerCase() + : targetHexIdOrEvent.id?.toLowerCase() + const targetCoordinate = + targetEvent && isReplaceableEvent(targetEvent.kind) + ? getReplaceableCoordinateFromEvent(targetEvent) + : undefined + + if (targetHexId) { + const rootId = getRootETag(event)?.[1]?.toLowerCase() + if (rootId === targetHexId) return true + const parentId = getParentETag(event)?.[1]?.toLowerCase() + if (parentId === targetHexId) return true + const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase() + if (qTag === targetHexId) return true + const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') + if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true + } + + if (targetCoordinate) { + const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A') + if (aTags.some((t) => t[1]?.toLowerCase() === targetCoordinate.toLowerCase())) return true + } + return false }