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
}