diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 9b2e507e..1463849e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -365,7 +365,7 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } { // Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop export function useSmartNoteNavigation() { const { push: pushSecondaryPage } = useSecondaryPage() - const { openDrawer, isDrawerOpen } = useNoteDrawer() + const { openDrawer } = useNoteDrawer() const { isSmallScreen } = useScreenSize() const { current: currentPrimaryPage } = usePrimaryPage() @@ -397,17 +397,10 @@ export function useSmartNoteNavigation() { // Desktop: check panel mode const currentPanelMode = storage.getPanelMode() if (currentPanelMode === 'single') { - // Single-pane: if drawer is already open, push to stack AND update drawer - // Otherwise, just open drawer - if (isDrawerOpen) { - // Navigating from within drawer - push to stack for back button support - pushSecondaryPage(contextualUrl) - openDrawer(noteId, event) - } else { - // Opening drawer for first time - window.history.pushState(null, '', contextualUrl) - openDrawer(noteId, event) - } + // Always push so the secondary stack matches the drawer; otherwise the first note is not on + // the stack and Back after opening a quote only closes the drawer instead of the parent note. + pushSecondaryPage(contextualUrl) + openDrawer(noteId, event) } else { // Double-pane: use secondary panel pushSecondaryPage(contextualUrl) @@ -434,7 +427,7 @@ export function useSmartNoteNavigationOptional() { } const { push } = pushSecondaryPage - const { openDrawer, isDrawerOpen } = noteDrawer + const { openDrawer } = noteDrawer const { isSmallScreen } = screenSize const { current: currentPrimaryPage } = primaryPage @@ -456,13 +449,8 @@ export function useSmartNoteNavigationOptional() { } else { const currentPanelMode = storage.getPanelMode() if (currentPanelMode === 'single') { - if (isDrawerOpen) { - push(contextualUrl) - openDrawer(noteId, event) - } else { - window.history.pushState(null, '', contextualUrl) - openDrawer(noteId, event) - } + push(contextualUrl) + openDrawer(noteId, event) } else { push(contextualUrl) } @@ -1046,18 +1034,37 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (noteUrlMatch) { const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] if (noteId) { + let primaryForNoteUrl: TPrimaryPageName = currentPrimaryPage + + const pushNoteUrlOnStack = (noteUrl: string) => { + setSecondaryStack((prevStack) => { + if (isCurrentPage(prevStack, noteUrl)) return prevStack + const { newStack, newItem } = pushNewPageToStack( + prevStack, + noteUrl, + maxStackSize, + window.history.state?.index + ) + if (newItem) { + window.history.replaceState({ index: newItem.index, url: noteUrl }, '', noteUrl) + } + return newStack + }) + } + // If this is a contextual note URL, set the primary page first if (contextualNoteMatch) { const pageContext = contextualNoteMatch[1] const resolved = noteContextToPrimaryEntry(pageContext) if (resolved) { + primaryForNoteUrl = resolved.name // Open drawer immediately, then load background page asynchronously // This prevents the background page loading from blocking the drawer if (isSmallScreen || panelMode === 'single') { - // Single-pane mode or mobile: open drawer first + // Seed stack so in-drawer navigation (e.g. quotes → back) can pop to this note + pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name)) openDrawer(noteId) - // Load background page asynchronously after drawer opens setTimeout(() => { setCurrentPrimaryPage(resolved.name) setPrimaryPages((prev) => mergePrimaryPageEntry(prev, resolved)) @@ -1072,35 +1079,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } } } - - // Build contextual URL based on current page (for both single and double-pane) - const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) - - // Check pane mode to determine how to open the note + + const contextualUrl = buildNoteUrl(noteId, primaryForNoteUrl) + if (isSmallScreen || panelMode === 'single') { - // Single-pane mode or mobile: open in drawer + pushNoteUrlOnStack(contextualUrl) openDrawer(noteId) - // Update URL to contextual URL if different - if (url !== contextualUrl) { - window.history.replaceState(null, '', contextualUrl) - } return } else { - // Double-pane mode: push to secondary stack with contextual URL - setSecondaryStack((prevStack) => { - if (isCurrentPage(prevStack, contextualUrl)) return prevStack - - const { newStack, newItem } = pushNewPageToStack( - prevStack, - contextualUrl, - maxStackSize, - window.history.state?.index - ) - if (newItem) { - window.history.replaceState({ index: newItem.index, url: contextualUrl }, '', contextualUrl) - } - return newStack - }) + pushNoteUrlOnStack(contextualUrl) return } } @@ -1366,13 +1353,34 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const currentItem = pre[pre.length - 1] as TStackItem | undefined const currentIndex = currentItem?.index if (!state) { - if (window.location.pathname + window.location.search + window.location.hash !== '/') { - // Just change the URL - return pre - } else { - // Back to root - state = { index: -1, url: '/' } + const locUrl = + window.location.pathname + window.location.search + window.location.hash + if (locUrl !== '/' && locUrl !== '') { + const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl) + if ((isSmallScreen || panelMode === 'single') && drawerOpen && drawerNoteId && synced.length > 0) { + const topItemUrl = synced[synced.length - 1]?.url + if (topItemUrl) { + const topNoteUrlMatch = + topItemUrl.match( + /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/ + ) || topItemUrl.match(/\/notes\/(.+)$/) + if (topNoteUrlMatch) { + const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1] + .split('?')[0] + .split('#')[0] + if (topNoteId && topNoteId !== drawerNoteId) { + setTimeout(() => { + if (drawerOpen) { + openDrawer(topNoteId) + } + }, 0) + } + } + } + } + return synced } + state = { index: -1, url: '/' } } // Go forward @@ -1452,8 +1460,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return [] } } else if (!topItem.component) { - // Load the component if it's not cached - const { component, ref } = findAndCreateComponent(topItem.url, state.index) + // Load the component if it's not cached (e.g. LRU cleared an older stack frame) + const { component, ref } = findAndCreateComponent(topItem.url, topItem.index) if (component) { topItem.component = component topItem.ref = ref @@ -1671,17 +1679,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } } else if (secondaryStack.length > 1) { - // Pop from stack directly instead of using history.go(-1) - // This ensures the stack is updated immediately - setSecondaryStack((prevStack) => { - const newStack = prevStack.slice(0, -1) - const topItem = newStack[newStack.length - 1] - if (topItem) { - // Update URL to match the top item - window.history.replaceState({ index: topItem.index, url: topItem.url }, '', topItem.url) - } - return newStack - }) + // Must use real history navigation: replaceState + slice desyncs URL from the session stack + // (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight). + // popstate applies {@link onPopState} so stack and URL stay aligned with pushState indices. + window.history.back() } else { // Just go back in history - popstate will handle stack update window.history.go(-1) @@ -1748,15 +1749,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) } } else if (secondaryStack.length > 1) { - // Pop to previous page (e.g. from /settings/general back to /settings) so Back/Close return to the list instead of closing the panel - setSecondaryStack((prevStack) => { - const newStack = prevStack.slice(0, -1) - const topItem = newStack[newStack.length - 1] - if (topItem) { - window.history.replaceState({ index: topItem.index, url: topItem.url }, '', topItem.url) - } - return newStack - }) + // Same as double-pane: let popstate shrink the stack so it matches history. + window.history.back() } else { window.history.go(-1) } @@ -2128,6 +2122,72 @@ function cloneSecondaryRouteElement( return cloneElement(element, props as any) } +/** Hex id segment from /notes/{id} or /{context}/notes/{id} (query/hash stripped). */ +function noteHexIdFromSecondaryNoteUrl(url: string): string | null { + const contextual = url.match( + /\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/ + ) + const standard = url.match(/\/notes\/(.+)$/) + const m = contextual || standard + return m ? m[m.length - 1].split('?')[0].split('#')[0] : null +} + +/** Same secondary destination as /notes/x vs /explore/notes/x (different paths, one note). */ +function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean { + if (stackUrl === locationUrl) return true + const idA = noteHexIdFromSecondaryNoteUrl(stackUrl) + const idB = noteHexIdFromSecondaryNoteUrl(locationUrl) + return Boolean(idA && idB && idA === idB) +} + +/** + * When popstate has no history state (e.g. after pushState(null, …) on load), the URL still updates + * but we must realign the secondary stack; otherwise the panel shows a stale page. + */ +function syncSecondaryStackWhenPopStateStateIsNull(pre: TStackItem[], locUrl: string): TStackItem[] { + const pathOnly = locUrl.split('?')[0].split('#')[0] + const segments = pathOnly.split('/').filter(Boolean) + const firstSeg = segments[0] ?? '' + const primaryMap = getPrimaryPageMap() + const isPrimaryOnly = + segments.length === 0 || + (segments.length === 1 && + (firstSeg === 'discussions' || + firstSeg === 'home' || + firstSeg === 'explore' || + firstSeg in primaryMap)) + if (isPrimaryOnly) { + return [] + } + + const top = pre[pre.length - 1] + if (top && secondaryPanelUrlsMatch(top.url, locUrl)) { + return pre + } + + for (let i = pre.length - 1; i >= 0; i--) { + if (secondaryPanelUrlsMatch(pre[i].url, locUrl)) { + const newStack = pre.slice(0, i + 1) + const newTop = newStack[newStack.length - 1] + if (newTop && !newTop.component) { + const { component, ref } = findAndCreateComponent(newTop.url, newTop.index) + if (component) { + newTop.component = component + newTop.ref = ref + } + } + return newStack + } + } + + const nextIdx = pre.length === 0 ? 0 : Math.max(...pre.map((x) => x.index)) + 1 + const { component, ref } = findAndCreateComponent(locUrl, nextIdx) + if (!component) { + return [] + } + return [{ index: nextIdx, url: locUrl, component, ref }] +} + function findAndCreateComponent(url: string, index: number) { const path = url.split('?')[0].split('#')[0] logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length }) diff --git a/src/components/ContentPreview/PollPreview.tsx b/src/components/ContentPreview/PollPreview.tsx index d457183f..919647c9 100644 --- a/src/components/ContentPreview/PollPreview.tsx +++ b/src/components/ContentPreview/PollPreview.tsx @@ -1,10 +1,12 @@ import { POLL_TYPE } from '@/constants' import { getPollMetadataFromEvent } from '@/lib/event-metadata' +import { parsePollOptionVisualParts } from '@/lib/poll-option-display' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import PollOptionContent from '@/components/Note/PollOptionContent' import Content from './Content' export default function PollPreview({ event, className }: { event: Event; className?: string }) { @@ -32,18 +34,29 @@ export default function PollPreview({ event, className }: { event: Event; classN ) : null} {poll && poll.options.length > 0 ? (
- {poll.options.map((option) => ( + {poll.options.map((option) => { + const optLabel = option.label || t('Option') + const visual = parsePollOptionVisualParts(optLabel) + const hasImg = visual.images.length > 0 + return (
-
-
- {option.label || t('Option')} -
+
+
- ))} + ) + })}
) : poll ? (
diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index dfdb6f65..94a0653d 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -3,6 +3,7 @@ import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants' import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { createPollResponseDraftEvent } from '@/lib/draft-event' import { getPollMetadataFromEvent } from '@/lib/event-metadata' +import { parsePollOptionVisualParts } from '@/lib/poll-option-display' import { buildPollResultsReadRelayUrls } from '@/lib/relay-list-builder' import { cn, isPartiallyInViewport } from '@/lib/utils' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -17,6 +18,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import PollOptionContent from './PollOptionContent' /** * Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle). @@ -224,9 +226,12 @@ export default function Poll({ event, className }: { event: Event; className?: s pollResults && pollResults.totalVotes > 0 && showResults ? Object.values(pollResults.results).every((res) => res.size <= votes) : false + const optionVisual = parsePollOptionVisualParts(option.label) + const optionHasImages = optionVisual.images.length > 0 const rowClass = cn( - 'relative w-full px-4 py-3 rounded-lg border flex items-center gap-2 overflow-hidden', + 'relative w-full px-4 py-3 rounded-lg border flex gap-2 overflow-hidden', + optionHasImages ? 'items-start' : 'items-center', canVote && 'transition-all', canVote ? 'cursor-pointer' : 'cursor-default', canVote && @@ -237,10 +242,17 @@ export default function Poll({ event, className }: { event: Event; className?: s const inner = ( <> -
-
- {option.label} -
+
+ {votedOptionIds.includes(option.id) && ( )} @@ -249,7 +261,8 @@ export default function Poll({ event, className }: { event: Event; className?: s
{isExpired diff --git a/src/components/Note/PollOptionContent.tsx b/src/components/Note/PollOptionContent.tsx new file mode 100644 index 00000000..df1347c2 --- /dev/null +++ b/src/components/Note/PollOptionContent.tsx @@ -0,0 +1,44 @@ +import { + POLL_OPTION_IMAGE_MAX_HEIGHT_PX, + parsePollOptionVisualParts, + type TPollOptionVisualParts +} from '@/lib/poll-option-display' +import { cn } from '@/lib/utils' + +export default function PollOptionContent({ + label, + visualParts: visualPartsProp, + className, + textClassName +}: { + label: string + /** When supplied (e.g. from parent), avoids parsing twice. */ + visualParts?: TPollOptionVisualParts + className?: string + textClassName?: string +}) { + const { text, images } = visualPartsProp ?? parsePollOptionVisualParts(label) + if (images.length === 0) { + return ( +
+ {label} +
+ ) + } + return ( +
+ {images.map(({ url, alt }, i) => ( + {alt} + ))} + {text ?
{text}
: null} +
+ ) +} diff --git a/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx new file mode 100644 index 00000000..5c956c55 --- /dev/null +++ b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx @@ -0,0 +1,264 @@ +import { useSmartNoteNavigation } from '@/PageManager' +import { ExtendedKind } from '@/constants' +import { getKindDescription } from '@/lib/kind-description' +import { toNote } from '@/lib/link' +import { stripNostrIdsFromPlainTextSnippet } from '@/lib/snippet-sanitize' +import { cn } from '@/lib/utils' +import { FormattedTimestamp } from '@/components/FormattedTimestamp' +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' +import { Skeleton } from '@/components/ui/skeleton' +import { Event, kinds } from 'nostr-tools' +import { AlertTriangle, ChevronRight } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +function quoteBacklinkSnippet(event: Event, maxLen = 96): string { + const trim = (s: string) => { + const cleaned = stripNostrIdsFromPlainTextSnippet(s) + if (!cleaned) return '' + const x = cleaned.replace(/\s+/g, ' ').trim() + if (x.length <= maxLen) return x + return `${x.slice(0, maxLen - 1).trimEnd()}…` + } + + if ( + event.kind === kinds.ShortTextNote || + event.kind === ExtendedKind.COMMENT || + event.kind === ExtendedKind.VOICE_COMMENT + ) { + const c = event.content.trim() + if (c) { + const out = trim(c) + if (out) return out + } + } + if (event.kind === kinds.Highlights) { + const ctx = event.tags.find((t) => t[0] === 'context')?.[1] + if (ctx?.trim()) { + const out = trim(ctx) + if (out) return out + } + } + if (event.kind === kinds.Label) { + const L = event.tags.find((t) => t[0] === 'l' || t[0] === 'L') + if (L) { + const parts = [L[1], L[2], L[3]].filter(Boolean) + if (parts.length) return trim(parts.join(' · ')) + } + if (event.content.trim()) { + const out = trim(event.content) + if (out) return out + } + } + if (event.kind === kinds.Report || event.kind === ExtendedKind.REPORT) { + const rep = event.tags.find((t) => t[0] === 'report' || t[0] === 'Report')?.[1] + if (rep) return trim(rep) + if (event.content.trim()) { + const out = trim(event.content) + if (out) return out + } + } + if ( + event.kind === kinds.BookmarkList || + event.kind === kinds.Pinlist || + event.kind === kinds.Genericlists || + event.kind === kinds.Bookmarksets || + event.kind === kinds.Curationsets + ) { + if (event.content.trim()) { + const out = trim(event.content) + if (out) return out + } + const dList = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() + if (dList) return trim(dList) + } + if (event.kind === kinds.BadgeAward) { + if (event.content.trim()) { + const out = trim(event.content) + if (out) return out + } + const a = event.tags.find((t) => t[0] === 'a' || t[0] === 'A')?.[1] + if (a) return trim(a) + } + const title = event.tags.find((t) => t[0] === 'title')?.[1]?.trim() + if (title) return trim(title) + const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() + if (d) return trim(d) + return '' +} + +/** One row of avatars for bookmark / list backlinks; dedupes by pubkey (newest event per author kept). */ +export function BacklinkAvatarStrip({ + events, + sectionLabel, + relationLabelForTitle, + getTitle +}: { + events: Event[] + sectionLabel: string + /** Default tooltip when {@link getTitle} is omitted */ + relationLabelForTitle?: string + /** Per-event tooltip (e.g. listed vs pinned) */ + getTitle?: (e: Event) => string +}) { + const { navigateToNote } = useSmartNoteNavigation() + const seen = new Set() + const unique = events.filter((e) => { + if (seen.has(e.pubkey)) return false + seen.add(e.pubkey) + return true + }) + + if (unique.length === 0) return null + + return ( +
+

+ {sectionLabel} +

+
+ {unique.map((e) => { + const tip = getTitle ? getTitle(e) : (relationLabelForTitle ?? '') + return ( + + ) + })} +
+
+ ) +} + +export function ThreadQuoteBacklinkSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +export default function ThreadQuoteBacklink({ + event, + quoteKindLabel, + variant = 'default' +}: { + event: Event + /** Short relation label (e.g. “Quoted this note”) for screen readers. */ + quoteKindLabel: string + /** NIP-56 reports use warning styling at the bottom of the backlinks list. */ + variant?: 'default' | 'warning' +}) { + const { t } = useTranslation() + const { navigateToNote } = useSmartNoteNavigation() + + const snippet = useMemo(() => quoteBacklinkSnippet(event), [event]) + const kindLine = useMemo(() => getKindDescription(event.kind, event).description, [event]) + + const secondary = snippet || kindLine + const isWarning = variant === 'warning' + + return ( + + ) +} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index c5f8eb52..a277263c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,4 +1,4 @@ -import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' +import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind, THREAD_BACKLINK_STREAM_KINDS } from '@/constants' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { canonicalizeRssArticleUrl, @@ -6,22 +6,21 @@ import { getHighlightSourceHttpUrl } from '@/lib/rss-article' import { - eventReferencesEventId, getParentATag, getParentETag, getReplaceableCoordinateFromEvent, getRootATag, getRootETag, getRootEventHexId, - isMentioningMutedUsers, isNip25ReactionKind, + isNip56ReportEvent, isReplaceableEvent, - isReplyNoteEvent, kind1QuotesThreadRoot } from '@/lib/event' import logger from '@/lib/logger' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { normalizeUrl } from '@/lib/url' +import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { toNote } from '@/lib/link' import { generateBech32IdFromETag } from '@/lib/tag' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' @@ -36,7 +35,7 @@ import client, { eventService, queryService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' -import { eventReplyMatchesThreadRoot } from '@/lib/thread-reply-root-match' +import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { buildRssArticleUrlThreadInteractionFilters, isRssArticleUrlThreadInteraction @@ -44,12 +43,15 @@ import { import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' import { useQuoteEvents } from '@/hooks' -import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-context' import { LoadingBar } from '../LoadingBar' -import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' +import ThreadQuoteBacklink, { + BacklinkAvatarStrip, + ThreadQuoteBacklinkSkeleton +} from './ThreadQuoteBacklink' type TRootInfo = | { type: 'E'; id: string; pubkey: string } @@ -83,14 +85,109 @@ function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) { return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies] } -/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only). */ -const EA_THREAD_TAIL_REFERENCE_KINDS = new Set([ - kinds.Highlights, - kinds.LongFormArticle, - ExtendedKind.WIKI_ARTICLE, - ExtendedKind.WIKI_ARTICLE_MARKDOWN, - ExtendedKind.PUBLICATION_CONTENT -]) +type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report' + +function sortWithinBacklinkGroup(events: NEvent[]): NEvent[] { + return [...events].sort((a, b) => b.created_at - a.created_at) +} + +function backlinkTailSubsection(item: NEvent): TBacklinkSubsection { + if (isNip56ReportEvent(item)) return 'report' + if (item.kind === kinds.BookmarkList) return 'bookmark' + if ( + item.kind === kinds.Pinlist || + item.kind === kinds.Genericlists || + item.kind === kinds.Bookmarksets || + item.kind === kinds.Curationsets + ) { + return 'list' + } + return 'primary' +} + +/** Quotes/highlights/citations → bookmarks → lists → reports; newest first within each group. */ +function partitionAndSortBacklinkTail(tail: NEvent[]): NEvent[] { + const primary: NEvent[] = [] + const bookmarks: NEvent[] = [] + const lists: NEvent[] = [] + const reports: NEvent[] = [] + for (const e of tail) { + const sub = backlinkTailSubsection(e) + if (sub === 'report') reports.push(e) + else if (sub === 'bookmark') bookmarks.push(e) + else if (sub === 'list') lists.push(e) + else primary.push(e) + } + return [ + ...sortWithinBacklinkGroup(primary), + ...sortWithinBacklinkGroup(bookmarks), + ...sortWithinBacklinkGroup(lists), + ...sortWithinBacklinkGroup(reports) + ] +} + +type TBacklinkDisplayRow = + | { type: 'reply'; event: NEvent } + | { type: 'backlink-run'; subsection: TBacklinkSubsection; events: NEvent[] } + +function buildVisibleBacklinkRows( + visibleFeed: NEvent[], + quoteUiIdSet: Set +): TBacklinkDisplayRow[] { + const rows: TBacklinkDisplayRow[] = [] + let i = 0 + while (i < visibleFeed.length) { + const item = visibleFeed[i] + if (!quoteUiIdSet.has(item.id)) { + rows.push({ type: 'reply', event: item }) + i++ + continue + } + const sub = backlinkTailSubsection(item) + const run: NEvent[] = [] + while ( + i < visibleFeed.length && + quoteUiIdSet.has(visibleFeed[i].id) && + backlinkTailSubsection(visibleFeed[i]) === sub + ) { + run.push(visibleFeed[i]) + i++ + } + if (run.length > 0) { + rows.push({ type: 'backlink-run', subsection: sub, events: run }) + } + } + return rows +} + +function backlinkRunSectionClass( + subsection: TBacklinkSubsection, + prev: TBacklinkDisplayRow | undefined +): string { + if (!prev) { + return subsection === 'report' + ? 'mb-3 pt-1' + : 'mb-3 pt-1' + } + if (prev.type === 'reply') { + return subsection === 'report' + ? 'mt-8 mb-3 border-t border-amber-500/40 pt-6 dark:border-amber-400/30' + : 'mt-8 mb-3 border-t border-border/60 pt-6' + } + return subsection === 'report' + ? 'mt-6 mb-3 border-t border-amber-500/40 pt-4 dark:border-amber-400/30' + : 'mt-6 mb-3 border-t border-border/60 pt-4' +} + +/** Preserve order except NIP-56 reports move to the end (after all non-reports). */ +function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { + const non = events.filter((e) => !isNip56ReportEvent(e)) + const rep = events.filter((e) => isNip56ReportEvent(e)) + return [...non, ...rep] +} + +/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */ +const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(THREAD_BACKLINK_STREAM_KINDS) /** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */ const WEB_THREAD_EXTRA_TAIL_KINDS = new Set([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION]) @@ -99,6 +196,28 @@ function isWebThreadTailKind(kind: number): boolean { return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind) } +function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { + if (item.kind === kinds.Highlights) return t('highlighted this note') + if (item.kind === kinds.ShortTextNote) return t('quoted this note') + if ( + item.kind === kinds.LongFormArticle || + item.kind === ExtendedKind.WIKI_ARTICLE || + item.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || + item.kind === ExtendedKind.PUBLICATION_CONTENT + ) { + return t('cited in article') + } + if (item.kind === kinds.Label) return t('labeled this note') + if (isNip56ReportEvent(item)) return t('reported this note') + if (item.kind === kinds.BookmarkList) return t('bookmarked this note') + if (item.kind === kinds.Pinlist) return t('pinned this note') + if (item.kind === kinds.Genericlists) return t('listed this note') + if (item.kind === kinds.Bookmarksets) return t('bookmark set reference') + if (item.kind === kinds.Curationsets) return t('curated this note') + if (item.kind === kinds.BadgeAward) return t('badge award for this note') + return t('referenced this note') +} + function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean { if (root.type === 'I') return false if (evt.kind !== kinds.ShortTextNote) return false @@ -137,6 +256,18 @@ function ReplyNoteList({ event, showQuotes ?? false ) + const filteredQuoteEvents = useMemo( + () => + quoteEvents.filter( + (e) => + !shouldHideThreadResponseEvent( + e, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ), + [quoteEvents, mutePubkeySet, hideContentMentioningMutedUsers] + ) const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION @@ -228,12 +359,16 @@ function ReplyNoteList({ events.forEach((evt) => { if (replyIdSet.has(evt.id)) return if (isNip25ReactionKind(evt.kind)) return - if (mutePubkeySet.has(evt.pubkey)) { - return - } - if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) { + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ) { return } + if (rootInfo && !replyBelongsToNoteThread(evt, event, rootInfo)) return replyIdSet.add(evt.id) replyEvents.push(evt) @@ -312,8 +447,8 @@ function ReplyNoteList({ ) } }, [ - event.id, - event.kind, + event, + rootInfo, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, @@ -323,7 +458,7 @@ function ReplyNoteList({ const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) /** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */ const quoteUiIdSet = useMemo(() => { - const s = new Set(quoteEvents.map((e) => e.id)) + const s = new Set(filteredQuoteEvents.map((e) => e.id)) if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { for (const r of replies) { if (isKind1QuoteOnlyOfEaRoot(r, rootInfo)) s.add(r.id) @@ -335,7 +470,7 @@ function ReplyNoteList({ } } return s - }, [quoteEvents, replies, rootInfo]) + }, [filteredQuoteEvents, replies, rootInfo]) const mergedFeed = useMemo(() => { /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { @@ -343,12 +478,12 @@ function ReplyNoteList({ const sortedNon = [...nonZaps].sort((a, b) => direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at ) - return replyFeedZapsFirst(sortedNon, zaps) + return moveReportsToEndPreserveOrder(replyFeedZapsFirst(sortedNon, zaps)) } if (!showQuotes) return replies - const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) + const quoteOnly = filteredQuoteEvents.filter((e) => !replyIdSet.has(e.id)) // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { @@ -364,8 +499,8 @@ function ReplyNoteList({ } for (const e of qOnlyFromReplies) pushTail(e) for (const e of quoteOnly) pushTail(e) - tail.sort((a, b) => b.created_at - a.created_at) - return [...replyFeedZapsFirst(middle, zaps), ...tail] + const tailSorted = partitionAndSortBacklinkTail(tail) + return [...replyFeedZapsFirst(middle, zaps), ...tailSorted] } // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A @@ -382,8 +517,8 @@ function ReplyNoteList({ } for (const e of tailFromReplies) pushTail(e) for (const e of quoteOnly) pushTail(e) - tail.sort((a, b) => b.created_at - a.created_at) - return [...replyFeedZapsFirst(middle, zaps), ...tail] + const tailSorted = partitionAndSortBacklinkTail(tail) + return [...replyFeedZapsFirst(middle, zaps), ...tailSorted] } const merged = [...replies, ...quoteOnly] @@ -393,11 +528,11 @@ function ReplyNoteList({ const replyIds = new Set(replies.map((r) => r.id)) const sortedReplies = [...replies] const qo = merged.filter((e) => !replyIds.has(e.id)) - const sortedQuotes = [...qo].sort((a, b) => b.created_at - a.created_at) + const sortedQuotes = partitionAndSortBacklinkTail([...qo]) return [...sortedReplies, ...sortedQuotes] } return zapsThenTimeSorted(merged, 'desc') - }, [replies, quoteEvents, showQuotes, sort, replyIdSet, rootInfo]) + }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo]) const [timelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) @@ -514,7 +649,17 @@ function ReplyNoteList({ rssStatsHydratedReplyIdsRef.current.delete(id) } } - if (!cancelled && batch.length > 0) addReplies(batch) + if (!cancelled && batch.length > 0) { + const ok = batch.filter( + (e) => + !shouldHideThreadResponseEvent( + e, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ) + if (ok.length > 0) addReplies(ok) + } })() return () => { @@ -527,24 +672,38 @@ function ReplyNoteList({ noteStats?.replies, noteStats?.updatedAt, repliesMap, - addReplies + addReplies, + mutePubkeySet, + hideContentMentioningMutedUsers ]) - const onNewReply = useCallback((evt: NEvent) => { - addReplies([evt]) - if (rootInfo) { - const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] - const without = cachedReplies.filter((r) => r.id !== evt.id) - discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) - } - }, [addReplies, rootInfo]) + const onNewReply = useCallback( + (evt: NEvent) => { + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ) { + return + } + addReplies([evt]) + if (rootInfo) { + const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] + const without = cachedReplies.filter((r) => r.id !== evt.id) + discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) + } + }, + [addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers] + ) useEffect(() => { if (!rootInfo) return const handleEventPublished = (data: Event) => { const ce = data as CustomEvent 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 `![](url)` 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 +}