const evt = ce.detail
- if (!evt || !eventReplyMatchesThreadRoot(evt, rootInfo)) return
+ if (!evt || !replyBelongsToNoteThread(evt, event, rootInfo)) return
onNewReply(evt)
}
@@ -552,7 +711,7 @@ function ReplyNoteList({
return () => {
client.removeEventListener('newEvent', handleEventPublished)
}
- }, [rootInfo, onNewReply])
+ }, [rootInfo, event, onNewReply])
const replyFetchGenRef = useRef(0)
@@ -678,13 +837,18 @@ function ReplyNoteList({
if (fetchGeneration !== replyFetchGenRef.current) return
// Filter and add replies (URL threads include kind 9802 highlights of this page)
- const regularReplies = allReplies.filter((evt) =>
- rootInfo.type === 'I'
- ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
- : isReplyNoteEvent(evt) ||
- ((rootInfo.type === 'E' || rootInfo.type === 'A') &&
- kind1QuotesThreadRoot(evt, rootInfo))
- )
+ const regularReplies = allReplies.filter((evt) => {
+ const match =
+ rootInfo.type === 'I'
+ ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
+ : replyBelongsToNoteThread(evt, event, rootInfo)
+ if (!match) return false
+ return !shouldHideThreadResponseEvent(
+ evt,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers
+ )
+ })
// Store in cache (this merges with existing cached replies)
// After this call, the cache contains ALL replies we've ever seen for this thread
@@ -729,7 +893,9 @@ function ReplyNoteList({
event.kind,
blockedRelays,
browsingRelayUrls,
- addReplies
+ addReplies,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers
])
useEffect(() => {
@@ -769,19 +935,34 @@ function ReplyNoteList({
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
- const olderEvents = events.filter(
- (evt) =>
- isReplyNoteEvent(evt) ||
- ((rootInfo?.type === 'E' || rootInfo?.type === 'A') &&
- rootInfo &&
- kind1QuotesThreadRoot(evt, rootInfo))
- )
+ const olderEvents = events.filter((evt) => {
+ if (!rootInfo) return false
+ const matchesThread =
+ rootInfo.type === 'I'
+ ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
+ : replyBelongsToNoteThread(evt, event, rootInfo)
+ if (!matchesThread) return false
+ return !shouldHideThreadResponseEvent(
+ evt,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers
+ )
+ })
if (olderEvents.length > 0) {
addReplies(olderEvents)
}
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
- }, [loading, until, timelineKey, rootInfo?.type, rootInfo?.id])
+ }, [
+ loading,
+ until,
+ timelineKey,
+ rootInfo,
+ event,
+ mutePubkeySet,
+ hideContentMentioningMutedUsers,
+ addReplies
+ ])
const highlightReply = useCallback((eventId: string, scrollTo = true) => {
if (scrollTo) {
@@ -799,6 +980,50 @@ function ReplyNoteList({
}, 1500)
}, [])
+ const visibleFeed = mergedFeed.slice(0, showCount)
+
+ const shouldShowFeedItem = useCallback(
+ (item: NEvent) => {
+ if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
+ return false
+ }
+ const isQuote = quoteUiIdSet.has(item.id)
+ if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {
+ if (isQuote) return false
+ if (rootInfo?.type !== 'I') {
+ const repliesForThisReply = repliesMap.get(item.id)
+ if (
+ !repliesForThisReply ||
+ repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
+ ) {
+ return false
+ }
+ }
+ }
+ return true
+ },
+ [
+ mutePubkeySet,
+ hideContentMentioningMutedUsers,
+ quoteUiIdSet,
+ isTrustLoaded,
+ hideUntrustedInteractions,
+ isUserTrusted,
+ rootInfo?.type,
+ repliesMap
+ ]
+ )
+
+ const visibleForRender = useMemo(
+ () => visibleFeed.filter(shouldShowFeedItem),
+ [visibleFeed, shouldShowFeedItem]
+ )
+
+ const displayRows = useMemo(
+ () => buildVisibleBacklinkRows(visibleForRender, quoteUiIdSet),
+ [visibleForRender, quoteUiIdSet]
+ )
+
return (
{loading && }
@@ -811,117 +1036,147 @@ function ReplyNoteList({
)}
- {mergedFeed.slice(0, showCount).map((item) => {
- const isQuote = quoteUiIdSet.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)) {
- if (isQuote) return null
- // URL-scoped comments (NIP-22 / kind 1111) are keyed under the article URL in ReplyProvider,
- // not under each note id — repliesMap.get(item.id) is usually empty. Skipping the "trusted
- // children" rule avoids hiding every untrusted URL-thread note.
- if (rootInfo?.type !== 'I') {
- const repliesForThisReply = repliesMap.get(item.id)
- if (
- !repliesForThisReply ||
- repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
- ) {
- return null
- }
- }
+ {displayRows.map((row, ri) => {
+ const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
+ if (row.type === 'reply') {
+ const reply = row.event
+ const parentETag = getParentETag(reply)
+ const parentEventHexId = parentETag?.[1]
+ const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
+
+ const replyRootId = getRootEventHexId(reply)
+ const replyUrlForIThread =
+ rootInfo?.type === 'I'
+ ? reply.kind === kinds.Highlights
+ ? getHighlightSourceHttpUrl(reply)
+ : getArticleUrlFromCommentITags(reply)
+ : undefined
+ const belongsToSameThread = rootInfo && (
+ (rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
+ (rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) ||
+ (rootInfo.type === 'I' &&
+ !!replyUrlForIThread &&
+ canonicalizeRssArticleUrl(replyUrlForIThread) === canonicalizeRssArticleUrl(rootInfo.id))
+ )
+
+ return (
+
(replyRefs.current[reply.id] = el)}
+ key={reply.id}
+ className="scroll-mt-12"
+ >
+ {
+ if (!parentEventHexId) return
+ if (replies.every((r) => r.id !== parentEventHexId)) {
+ navigateToNote(toNote(parentEventId ?? parentEventHexId))
+ return
+ }
+ highlightReply(parentEventHexId)
+ }}
+ onClickReply={belongsToSameThread ? (replyEvent) => {
+ const replyNoteUrl = toNote(replyEvent.id)
+ window.history.pushState(null, '', replyNoteUrl)
+ const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id)
+ if (replyIndex >= 0 && replyIndex >= showCount) {
+ setShowCount(replyIndex + 1)
+ }
+ setTimeout(() => {
+ highlightReply(replyEvent.id, true)
+ }, 50)
+ } : undefined}
+ highlight={highlightReplyId === reply.id}
+ />
+
+ )
}
- if (isQuote) {
- const quoteLabel =
- item.kind === kinds.Highlights
- ? t('highlighted this note')
- : item.kind === kinds.ShortTextNote
- ? t('quoted this note')
- : EA_THREAD_TAIL_REFERENCE_KINDS.has(item.kind)
- ? t('cited in article')
- : t('quoted this note')
- const hideQuotedNote = eventReferencesEventId(item, event)
+ const { subsection, events: blEvents } = row
+ const wrapClass = backlinkRunSectionClass(subsection, prevRow)
+
+ if (subsection === 'bookmark') {
return (
-
+
+
+ )
+ }
+
+ if (subsection === 'list') {
+ return (
+
+ threadBacklinkRelationLabel(e, t)}
+ />
+
+ )
+ }
+
+ if (subsection === 'report') {
+ return (
+
+
+ {t('Report events heading')}
+
+ {blEvents.map((item) => (
+
(replyRefs.current[item.id] = el)}
+ className="scroll-mt-12 mb-1"
+ >
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+ {t('Thread backlinks primary section')}
+
+ {blEvents.map((item) => (
(replyRefs.current[item.id] = el)}
- className="scroll-mt-12 border-l-2 border-muted-foreground/40 pl-3 py-1 my-1 rounded-r"
+ className="scroll-mt-12 mb-1"
>
-
- {quoteLabel}
-
-
-
- )
- }
-
- const reply = item
- const parentETag = getParentETag(reply)
- const parentEventHexId = parentETag?.[1]
- const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
-
- const replyRootId = getRootEventHexId(reply)
- const replyUrlForIThread =
- rootInfo?.type === 'I'
- ? reply.kind === kinds.Highlights
- ? getHighlightSourceHttpUrl(reply)
- : getArticleUrlFromCommentITags(reply)
- : undefined
- const belongsToSameThread = rootInfo && (
- (rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
- (rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) ||
- (rootInfo.type === 'I' &&
- !!replyUrlForIThread &&
- canonicalizeRssArticleUrl(replyUrlForIThread) === canonicalizeRssArticleUrl(rootInfo.id))
- )
-
- return (
-
(replyRefs.current[reply.id] = el)}
- key={reply.id}
- className="scroll-mt-12"
- >
- {
- if (!parentEventHexId) return
- if (replies.every((r) => r.id !== parentEventHexId)) {
- navigateToNote(toNote(parentEventId ?? parentEventHexId))
- return
- }
- highlightReply(parentEventHexId)
- }}
- onClickReply={belongsToSameThread ? (replyEvent) => {
- const replyNoteUrl = toNote(replyEvent.id)
- window.history.pushState(null, '', replyNoteUrl)
- const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id)
- if (replyIndex >= 0 && replyIndex >= showCount) {
- setShowCount(replyIndex + 1)
- }
- setTimeout(() => {
- highlightReply(replyEvent.id, true)
- }, 50)
- } : undefined}
- highlight={highlightReplyId === reply.id}
- />
+ ))}
)
})}
- {quoteLoading && showQuotes && }
+ {quoteLoading && showQuotes && (
+
+
+
+ )}
{!loading && !quoteLoading && (
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
diff --git a/src/constants.ts b/src/constants.ts
index f1cc60e7..7c6e21d1 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -351,6 +351,34 @@ export const ExtendedKind = {
WEB_BOOKMARK: 39701
}
+/**
+ * Kinds subscribed on `#e` / `#a` for the OP in {@link useQuoteEvents} (thread “backlinks” shard),
+ * alongside kind-1 `#q` quotes. Covers highlights, long-form, NIP-32 labels, NIP-56 reports,
+ * NIP-51 lists (bookmarks, pins, generic/bookmark/curation sets), and NIP-58 badge awards.
+ */
+export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [
+ kinds.Highlights,
+ kinds.LongFormArticle,
+ ExtendedKind.WIKI_ARTICLE,
+ ExtendedKind.WIKI_ARTICLE_MARKDOWN,
+ ExtendedKind.PUBLICATION_CONTENT,
+ kinds.Label,
+ kinds.Report,
+ kinds.BookmarkList,
+ kinds.Pinlist,
+ kinds.Genericlists,
+ kinds.Bookmarksets,
+ kinds.Curationsets,
+ kinds.BadgeAward
+]
+
+/**
+ * {@link THREAD_BACKLINK_STREAM_KINDS} without kind 9802. Highlights use separate low-`kinds` REQs so
+ * relays that reject large `kinds` arrays still return NIP-84 backlinks.
+ */
+export const THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT: readonly number[] =
+ THREAD_BACKLINK_STREAM_KINDS.filter((k) => k !== kinds.Highlights)
+
/**
* 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}).
diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx
index 5bb65e5a..d5fa0de2 100644
--- a/src/hooks/useQuoteEvents.tsx
+++ b/src/hooks/useQuoteEvents.tsx
@@ -1,34 +1,32 @@
import {
E_TAG_FILTER_BLOCKED_RELAY_URLS,
- ExtendedKind,
FAST_READ_RELAY_URLS,
- SEARCHABLE_RELAY_URLS
+ SEARCHABLE_RELAY_URLS,
+ THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT
} from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
+import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter'
import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
const LIMIT = 100
const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000
-/** Kinds that reference the OP via #e / #a in the quote shard (with highlights). */
-const QUOTE_STREAM_REFERENCE_KINDS: number[] = [
- kinds.Highlights,
- kinds.LongFormArticle,
- ExtendedKind.WIKI_ARTICLE,
- ExtendedKind.WIKI_ARTICLE_MARKDOWN,
- ExtendedKind.PUBLICATION_CONTENT
-]
-
/** Fetches events that quote or reference the given event (#q, #e, #a tags). */
export function useQuoteEvents(event: Event | null, enabled: boolean) {
const { relayList: userRelayList } = useNostr()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
+ const { blockedRelays } = useFavoriteRelays()
+ const userBlockedRelaysNorm = useMemo(
+ () => buildNormalizedBlockedRelaySet(blockedRelays),
+ [blockedRelays]
+ )
const [timelineKey, setTimelineKey] = useState(undefined)
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(true)
@@ -86,25 +84,51 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
)
.filter(Boolean)
.filter((u) => !eTagBlockedSet.has(normalizeUrl(u) || u))
+ .filter((u) => !userBlockedRelaysNorm.has((normalizeUrl(u) || u).toLowerCase()))
const filterQeId = isReplaceableEvent(ev.kind)
? getReplaceableCoordinateFromEvent(ev)
: ev.id
+ const qeIdForTagFilter =
+ /^[0-9a-f]{64}$/i.test(filterQeId) ? filterQeId.toLowerCase() : filterQeId
const eventCoordinate = isReplaceableEvent(ev.kind)
? getReplaceableCoordinateFromEvent(ev)
: `${ev.kind}:${ev.pubkey}:${ev.id}`
+ const highlightKinds = [kinds.Highlights] as const
+ const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT]
+
const { closer, timelineKey } = await client.subscribeTimeline(
[
{
urls: finalRelayUrls,
- filter: { '#q': [filterQeId], kinds: [kinds.ShortTextNote], limit: LIMIT }
+ filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT }
+ },
+ {
+ urls: finalRelayUrls,
+ filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT }
+ },
+ {
+ urls: finalRelayUrls,
+ filter: {
+ '#e': [qeIdForTagFilter],
+ kinds: [...highlightKinds],
+ limit: LIMIT
+ }
+ },
+ {
+ urls: finalRelayUrls,
+ filter: {
+ '#e': [qeIdForTagFilter],
+ kinds: otherBacklinkKinds,
+ limit: LIMIT
+ }
},
{
urls: finalRelayUrls,
filter: {
- '#e': [filterQeId],
- kinds: [...QUOTE_STREAM_REFERENCE_KINDS],
+ '#a': [eventCoordinate],
+ kinds: [...highlightKinds],
limit: LIMIT
}
},
@@ -112,7 +136,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls,
filter: {
'#a': [eventCoordinate],
- kinds: [...QUOTE_STREAM_REFERENCE_KINDS],
+ kinds: otherBacklinkKinds,
limit: LIMIT
}
}
@@ -164,7 +188,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
promise.then((closer) => closer?.())
}
- }, [event, enabled, browsingRelayUrls, userRelayList?.read])
+ }, [event, enabled, browsingRelayUrls, userRelayList?.read, userBlockedRelaysNorm])
const loadMore = async () => {
if (!timelineKey || loading || !hasMore) return
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index ecec732c..a93b7959 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -743,6 +743,21 @@ export default {
'quoted this note': 'Hat diese Notiz zitiert',
'highlighted this note': 'Hat diese Notiz hervorgehoben',
'cited in article': 'In Artikel zitiert',
+ 'Thread backlinks heading': 'Verweise auf diese Notiz',
+ 'Thread backlinks primary section': 'Zitate, Markierungen & Verweise',
+ 'Thread backlinks bookmarks section': 'Lesezeichen',
+ 'Thread backlinks lists section': 'Listen & Sammlungen',
+ 'View full note and thread': 'Vollständige Notiz und Thread anzeigen',
+ 'labeled this note': 'Hat diese Notiz etikettiert',
+ 'reported this note': 'Hat diese Notiz gemeldet',
+ 'bookmarked this note': 'Lesezeichen für diese Notiz',
+ 'pinned this note': 'Diese Notiz angepinnt',
+ 'listed this note': 'In einer Liste gespeichert',
+ 'bookmark set reference': 'In einem Lesezeichen-Set',
+ 'curated this note': 'Kuratierung dieser Notiz',
+ 'badge award for this note': 'Abzeichen für diese Notiz',
+ 'referenced this note': 'Verweist auf diese Notiz',
+ 'Report events heading': 'Meldungen (Moderation)',
'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
'reacted to your note': 'hat auf Ihre Notiz reagiert',
'boosted your note': 'hat Ihre Notiz geboostet',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 09a6127a..70ec210f 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -771,6 +771,21 @@ export default {
'quoted this note': 'Quoted this note',
'highlighted this note': 'Highlighted this note',
'cited in article': 'Cited in article',
+ 'Thread backlinks heading': 'Also quoting this note',
+ 'Thread backlinks primary section': 'Quotes, highlights & citations',
+ 'Thread backlinks bookmarks section': 'Bookmarks',
+ 'Thread backlinks lists section': 'Lists & collections',
+ 'View full note and thread': 'View full note and thread',
+ 'labeled this note': 'Labeled this note',
+ 'reported this note': 'Reported this note',
+ 'bookmarked this note': 'Bookmarked this note',
+ 'pinned this note': 'Pinned this note',
+ 'listed this note': 'Listed this note',
+ 'bookmark set reference': 'Bookmark set includes this note',
+ 'curated this note': 'Curated this note',
+ 'badge award for this note': 'Badge award for this note',
+ 'referenced this note': 'Referenced this note',
+ 'Report events heading': 'Moderation reports',
'voted in your poll': 'voted in your poll',
'reacted to your note': 'reacted to your note',
'boosted your note': 'boosted your note',
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 6c689539..5bd3c776 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -25,6 +25,11 @@ export function isNip18RepostKind(kind: number): boolean {
return kind === kinds.Repost || kind === ExtendedKind.GENERIC_REPOST
}
+/** NIP-56: kind 1984 report / flag (`kinds.Report` and {@link ExtendedKind.REPORT} are the same kind). */
+export function isNip56ReportEvent(event: Pick): boolean {
+ return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT
+}
+
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 })
diff --git a/src/lib/image-extraction.ts b/src/lib/image-extraction.ts
index 6ee5de26..fdb7e3aa 100644
--- a/src/lib/image-extraction.ts
+++ b/src/lib/image-extraction.ts
@@ -116,9 +116,9 @@ function normalizeImageUrl(url: string): string | null {
}
/**
- * Check if URL is likely an image
+ * Check if URL is likely an image (extension or known image host).
*/
-function isImageUrl(url: string): boolean {
+export function isImageUrl(url: string): boolean {
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico)(\?.*)?$/i
const imageDomains = [
'i.nostr.build',
diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts
index 8f450b07..6d61fb90 100644
--- a/src/lib/kind-description.ts
+++ b/src/lib/kind-description.ts
@@ -106,8 +106,23 @@ export function getKindDescription(
return { number: 99999, description: 'Web article thread' }
case ExtendedKind.FILE_METADATA:
return { number: 1063, description: 'File metadata' }
+ case kinds.Report:
case ExtendedKind.REPORT:
return { number: 1984, description: 'Report' }
+ case kinds.Label:
+ return { number: 1985, description: 'Label' }
+ case kinds.BookmarkList:
+ return { number: 10003, description: 'Bookmark list' }
+ case kinds.Pinlist:
+ return { number: 10001, description: 'Pin list' }
+ case kinds.Genericlists:
+ return { number: 30001, description: 'List' }
+ case kinds.Bookmarksets:
+ return { number: 30003, description: 'Bookmark set' }
+ case kinds.Curationsets:
+ return { number: 30004, description: 'Curation set' }
+ case kinds.BadgeAward:
+ return { number: 8, description: 'Badge award' }
case ExtendedKind.WEB_BOOKMARK:
return { number: 39701, description: 'Web bookmark' }
default:
diff --git a/src/lib/poll-option-display.ts b/src/lib/poll-option-display.ts
new file mode 100644
index 00000000..1cd721ce
--- /dev/null
+++ b/src/lib/poll-option-display.ts
@@ -0,0 +1,50 @@
+import { isImageUrl } from '@/lib/image-extraction'
+
+export const POLL_OPTION_IMAGE_MAX_HEIGHT_PX = 200
+
+export type TPollOptionImagePart = { url: string; alt: string }
+
+/**
+ * Split a poll `option` tag label into plain text and image URLs (markdown `` or bare https image links).
+ */
+export type TPollOptionVisualParts = {
+ text: string
+ images: TPollOptionImagePart[]
+}
+
+export function parsePollOptionVisualParts(label: string): TPollOptionVisualParts {
+ const images: TPollOptionImagePart[] = []
+ const seen = new Set()
+
+ const push = (url: string, alt: string) => {
+ const u = url.trim()
+ if (!u || seen.has(u)) return
+ seen.add(u)
+ images.push({ url: u, alt: alt.trim() })
+ }
+
+ let rest = label
+ const mdRe = /!\[([^\]]*)\]\(([^)]+)\)/g
+ let m: RegExpExecArray | null
+ while ((m = mdRe.exec(label)) !== null) {
+ push(m[2] ?? '', m[1] ?? '')
+ }
+ rest = rest.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' ').replace(/\s+/g, ' ').trim()
+
+ if (images.length === 0 && rest) {
+ const single = rest.trim()
+ if (!/\s/.test(single) && /^https?:\/\//i.test(single) && isImageUrl(single)) {
+ return { text: '', images: [{ url: single, alt: '' }] }
+ }
+ }
+
+ const tokens = rest.match(/https?:\/\/[^\s]+/gi) || []
+ for (const t of tokens) {
+ if (seen.has(t) || !isImageUrl(t)) continue
+ push(t, '')
+ rest = rest.split(t).join(' ')
+ }
+
+ rest = rest.replace(/\s+/g, ' ').trim()
+ return { text: rest, images }
+}
diff --git a/src/lib/snippet-sanitize.ts b/src/lib/snippet-sanitize.ts
new file mode 100644
index 00000000..d8aee624
--- /dev/null
+++ b/src/lib/snippet-sanitize.ts
@@ -0,0 +1,15 @@
+import { NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
+
+/** Bare NIP-19 entities (no `nostr:` prefix) often pasted in note text */
+const BARE_BECH32 = /\b(npub|nprofile|note|nevent|naddr|nrelay)1[a-z0-9]+\b/gi
+
+/**
+ * Remove `nostr:` NIP-21 URIs, bare bech32 ids, and 64-char hex event ids so one-line UI snippets
+ * (e.g. thread backlinks) do not show raw addresses when the quoted note is mostly references.
+ */
+export function stripNostrIdsFromPlainTextSnippet(text: string): string {
+ let s = text.replace(NOSTR_URI_INLINE_REGEX, ' ')
+ s = s.replace(BARE_BECH32, ' ')
+ s = s.replace(/\b[0-9a-f]{64}\b/gi, ' ')
+ return s.replace(/\s+/g, ' ').trim()
+}
diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts
index ea667cb1..c9a32064 100644
--- a/src/lib/thread-reply-root-match.ts
+++ b/src/lib/thread-reply-root-match.ts
@@ -1,4 +1,10 @@
-import { getRootATag, getRootEventHexId, kind1QuotesThreadRoot } from '@/lib/event'
+import {
+ getParentEventHexId,
+ getQuotedEventHexIdFromQTags,
+ getRootATag,
+ getRootEventHexId,
+ kind1QuotesThreadRoot
+} from '@/lib/event'
import {
canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags,
@@ -34,3 +40,26 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (getRootEventHexId(evt) === root.id) return true
return kind1QuotesThreadRoot(evt, root)
}
+
+/**
+ * Whether `evt` should appear in the reply list for note `opEvent` with thread root `root`.
+ * Stricter than treating any kind-1 with an `e` tag as a reply: requires thread root / #q to match (so notes that only
+ * tag the quoted inner note as `e`+`root` do not show under the quoter's thread).
+ * For quote posts, also drops kind-1 replies whose **parent** is the embedded quoted id but not the OP.
+ */
+export function replyBelongsToNoteThread(evt: Event, opEvent: Event, root: TThreadRootRef): boolean {
+ if (root.type === 'I') {
+ return eventReplyMatchesThreadRoot(evt, root)
+ }
+ if (!eventReplyMatchesThreadRoot(evt, root)) return false
+ if (root.type === 'A') return true
+
+ if (opEvent.kind !== kinds.ShortTextNote) return true
+ const quotedHex = getQuotedEventHexIdFromQTags(opEvent)?.toLowerCase()
+ if (!quotedHex) return true
+ const parentHex = getParentEventHexId(evt)?.toLowerCase()
+ if (!parentHex) return true
+ const rootId = root.id.trim().toLowerCase()
+ if (parentHex === quotedHex && parentHex !== rootId) return false
+ return true
+}
diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts
new file mode 100644
index 00000000..5eb6430e
--- /dev/null
+++ b/src/lib/thread-response-filter.ts
@@ -0,0 +1,24 @@
+import { isMentioningMutedUsers } from '@/lib/event'
+import { normalizeUrl } from '@/lib/url'
+import type { Event } from 'nostr-tools'
+
+/** Lowercase normalized URLs for comparing user-blocked relays (e.g. before REQ). */
+export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] | undefined): Set {
+ const s = new Set()
+ for (const u of blockedRelays ?? []) {
+ const n = (normalizeUrl(u) || u).toLowerCase()
+ if (n) s.add(n)
+ }
+ return s
+}
+
+/** Hide thread replies / backlinks: muted author or (when enabled) mentions of mutes. */
+export function shouldHideThreadResponseEvent(
+ evt: Event,
+ mutePubkeySet: Set,
+ hideContentMentioningMutedUsers: boolean | undefined
+): boolean {
+ if (mutePubkeySet.has(evt.pubkey)) return true
+ if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
+ return false
+}