Browse Source

change quote-replies to backlinks

render images in poll bars
imwald
Silberengel 1 month ago
parent
commit
702178c536
  1. 210
      src/PageManager.tsx
  2. 27
      src/components/ContentPreview/PollPreview.tsx
  3. 25
      src/components/Note/Poll.tsx
  4. 44
      src/components/Note/PollOptionContent.tsx
  5. 264
      src/components/ReplyNoteList/ThreadQuoteBacklink.tsx
  6. 567
      src/components/ReplyNoteList/index.tsx
  7. 28
      src/constants.ts
  8. 58
      src/hooks/useQuoteEvents.tsx
  9. 15
      src/i18n/locales/de.ts
  10. 15
      src/i18n/locales/en.ts
  11. 5
      src/lib/event.ts
  12. 4
      src/lib/image-extraction.ts
  13. 15
      src/lib/kind-description.ts
  14. 50
      src/lib/poll-option-display.ts
  15. 15
      src/lib/snippet-sanitize.ts
  16. 31
      src/lib/thread-reply-root-match.ts
  17. 24
      src/lib/thread-response-filter.ts

210
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 // Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop
export function useSmartNoteNavigation() { export function useSmartNoteNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage() const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer, isDrawerOpen } = useNoteDrawer() const { openDrawer } = useNoteDrawer()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
@ -397,17 +397,10 @@ export function useSmartNoteNavigation() {
// Desktop: check panel mode // Desktop: check panel mode
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
// Single-pane: if drawer is already open, push to stack AND update drawer // Always push so the secondary stack matches the drawer; otherwise the first note is not on
// Otherwise, just open drawer // the stack and Back after opening a quote only closes the drawer instead of the parent note.
if (isDrawerOpen) { pushSecondaryPage(contextualUrl)
// Navigating from within drawer - push to stack for back button support openDrawer(noteId, event)
pushSecondaryPage(contextualUrl)
openDrawer(noteId, event)
} else {
// Opening drawer for first time
window.history.pushState(null, '', contextualUrl)
openDrawer(noteId, event)
}
} else { } else {
// Double-pane: use secondary panel // Double-pane: use secondary panel
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
@ -434,7 +427,7 @@ export function useSmartNoteNavigationOptional() {
} }
const { push } = pushSecondaryPage const { push } = pushSecondaryPage
const { openDrawer, isDrawerOpen } = noteDrawer const { openDrawer } = noteDrawer
const { isSmallScreen } = screenSize const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage const { current: currentPrimaryPage } = primaryPage
@ -456,13 +449,8 @@ export function useSmartNoteNavigationOptional() {
} else { } else {
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
if (isDrawerOpen) { push(contextualUrl)
push(contextualUrl) openDrawer(noteId, event)
openDrawer(noteId, event)
} else {
window.history.pushState(null, '', contextualUrl)
openDrawer(noteId, event)
}
} else { } else {
push(contextualUrl) push(contextualUrl)
} }
@ -1046,18 +1034,37 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (noteUrlMatch) { if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) { 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 this is a contextual note URL, set the primary page first
if (contextualNoteMatch) { if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1] const pageContext = contextualNoteMatch[1]
const resolved = noteContextToPrimaryEntry(pageContext) const resolved = noteContextToPrimaryEntry(pageContext)
if (resolved) { if (resolved) {
primaryForNoteUrl = resolved.name
// Open drawer immediately, then load background page asynchronously // Open drawer immediately, then load background page asynchronously
// This prevents the background page loading from blocking the drawer // This prevents the background page loading from blocking the drawer
if (isSmallScreen || panelMode === 'single') { 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) openDrawer(noteId)
// Load background page asynchronously after drawer opens
setTimeout(() => { setTimeout(() => {
setCurrentPrimaryPage(resolved.name) setCurrentPrimaryPage(resolved.name)
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, resolved)) 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, primaryForNoteUrl)
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
// Check pane mode to determine how to open the note
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Single-pane mode or mobile: open in drawer pushNoteUrlOnStack(contextualUrl)
openDrawer(noteId) openDrawer(noteId)
// Update URL to contextual URL if different
if (url !== contextualUrl) {
window.history.replaceState(null, '', contextualUrl)
}
return return
} else { } else {
// Double-pane mode: push to secondary stack with contextual URL pushNoteUrlOnStack(contextualUrl)
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
})
return return
} }
} }
@ -1366,13 +1353,34 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const currentItem = pre[pre.length - 1] as TStackItem | undefined const currentItem = pre[pre.length - 1] as TStackItem | undefined
const currentIndex = currentItem?.index const currentIndex = currentItem?.index
if (!state) { if (!state) {
if (window.location.pathname + window.location.search + window.location.hash !== '/') { const locUrl =
// Just change the URL window.location.pathname + window.location.search + window.location.hash
return pre if (locUrl !== '/' && locUrl !== '') {
} else { const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl)
// Back to root if ((isSmallScreen || panelMode === 'single') && drawerOpen && drawerNoteId && synced.length > 0) {
state = { index: -1, url: '/' } 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 // Go forward
@ -1452,8 +1460,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return [] return []
} }
} else if (!topItem.component) { } else if (!topItem.component) {
// Load the component if it's not cached // Load the component if it's not cached (e.g. LRU cleared an older stack frame)
const { component, ref } = findAndCreateComponent(topItem.url, state.index) const { component, ref } = findAndCreateComponent(topItem.url, topItem.index)
if (component) { if (component) {
topItem.component = component topItem.component = component
topItem.ref = ref topItem.ref = ref
@ -1671,17 +1679,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
} else if (secondaryStack.length > 1) { } else if (secondaryStack.length > 1) {
// Pop from stack directly instead of using history.go(-1) // Must use real history navigation: replaceState + slice desyncs URL from the session stack
// This ensures the stack is updated immediately // (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight).
setSecondaryStack((prevStack) => { // popstate applies {@link onPopState} so stack and URL stay aligned with pushState indices.
const newStack = prevStack.slice(0, -1) window.history.back()
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
})
} else { } else {
// Just go back in history - popstate will handle stack update // Just go back in history - popstate will handle stack update
window.history.go(-1) window.history.go(-1)
@ -1748,15 +1749,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
} else if (secondaryStack.length > 1) { } 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 // Same as double-pane: let popstate shrink the stack so it matches history.
setSecondaryStack((prevStack) => { window.history.back()
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
})
} else { } else {
window.history.go(-1) window.history.go(-1)
} }
@ -2128,6 +2122,72 @@ function cloneSecondaryRouteElement(
return cloneElement(element, props as any) 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) { function findAndCreateComponent(url: string, index: number) {
const path = url.split('?')[0].split('#')[0] const path = url.split('?')[0].split('#')[0]
logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length }) logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length })

27
src/components/ContentPreview/PollPreview.tsx

@ -1,10 +1,12 @@
import { POLL_TYPE } from '@/constants' import { POLL_TYPE } from '@/constants'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { parsePollOptionVisualParts } from '@/lib/poll-option-display'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PollOptionContent from '@/components/Note/PollOptionContent'
import Content from './Content' import Content from './Content'
export default function PollPreview({ event, className }: { event: Event; className?: string }) { export default function PollPreview({ event, className }: { event: Event; className?: string }) {
@ -32,18 +34,29 @@ export default function PollPreview({ event, className }: { event: Event; classN
) : null} ) : null}
{poll && poll.options.length > 0 ? ( {poll && poll.options.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
{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 (
<div <div
key={option.id} key={option.id}
className="relative w-full px-4 py-3 rounded-lg border border-border bg-background flex items-center gap-2 overflow-hidden" className={cn(
'relative w-full px-4 py-3 rounded-lg border border-border bg-background flex gap-2 overflow-hidden',
hasImg ? 'items-start' : 'items-center'
)}
> >
<div className="flex items-center gap-2 flex-1 w-0 z-10"> <div
<div className="line-clamp-2 text-left text-sm"> className={cn(
{option.label || t('Option')} 'flex min-h-0 gap-2 flex-1 w-0 z-10',
</div> hasImg ? 'items-start pt-0.5' : 'items-center'
)}
>
<PollOptionContent label={optLabel} visualParts={visual} textClassName="text-sm" />
</div> </div>
</div> </div>
))} )
})}
</div> </div>
) : poll ? ( ) : poll ? (
<div className="text-sm text-muted-foreground italic"> <div className="text-sm text-muted-foreground italic">

25
src/components/Note/Poll.tsx

@ -3,6 +3,7 @@ import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants'
import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { useFetchPollResults } from '@/hooks/useFetchPollResults'
import { createPollResponseDraftEvent } from '@/lib/draft-event' import { createPollResponseDraftEvent } from '@/lib/draft-event'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { parsePollOptionVisualParts } from '@/lib/poll-option-display'
import { buildPollResultsReadRelayUrls } from '@/lib/relay-list-builder' import { buildPollResultsReadRelayUrls } from '@/lib/relay-list-builder'
import { cn, isPartiallyInViewport } from '@/lib/utils' import { cn, isPartiallyInViewport } from '@/lib/utils'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -17,6 +18,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import PollOptionContent from './PollOptionContent'
/** /**
* Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle). * 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 pollResults && pollResults.totalVotes > 0 && showResults
? Object.values(pollResults.results).every((res) => res.size <= votes) ? Object.values(pollResults.results).every((res) => res.size <= votes)
: false : false
const optionVisual = parsePollOptionVisualParts(option.label)
const optionHasImages = optionVisual.images.length > 0
const rowClass = cn( 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 && 'transition-all',
canVote ? 'cursor-pointer' : 'cursor-default', canVote ? 'cursor-pointer' : 'cursor-default',
canVote && canVote &&
@ -237,10 +242,17 @@ export default function Poll({ event, className }: { event: Event; className?: s
const inner = ( const inner = (
<> <>
<div className="flex items-center gap-2 flex-1 w-0 z-10"> <div
<div className={cn('line-clamp-2 text-left', isMax ? 'font-semibold' : '')}> className={cn(
{option.label} 'flex min-h-0 gap-2 flex-1 w-0 z-10',
</div> optionHasImages ? 'items-start pt-0.5' : 'items-center'
)}
>
<PollOptionContent
label={option.label}
visualParts={optionVisual}
textClassName={isMax ? 'font-semibold' : undefined}
/>
{votedOptionIds.includes(option.id) && ( {votedOptionIds.includes(option.id) && (
<CheckCircle2 className="size-4 shrink-0" /> <CheckCircle2 className="size-4 shrink-0" />
)} )}
@ -249,7 +261,8 @@ export default function Poll({ event, className }: { event: Event; className?: s
<div <div
className={cn( className={cn(
'text-muted-foreground shrink-0 z-10 tabular-nums text-right', 'text-muted-foreground shrink-0 z-10 tabular-nums text-right',
isMax ? 'font-semibold text-foreground' : '' isMax ? 'font-semibold text-foreground' : '',
optionHasImages && 'self-center'
)} )}
> >
{isExpired {isExpired

44
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 (
<div className={cn('line-clamp-2 text-left', textClassName, className)}>
{label}
</div>
)
}
return (
<div className={cn('flex min-w-0 flex-col gap-2 text-left', className)}>
{images.map(({ url, alt }, i) => (
<img
key={`${url}-${i}`}
src={url}
alt={alt}
loading="lazy"
decoding="async"
className="max-w-full w-auto object-contain rounded-md"
style={{ maxHeight: POLL_OPTION_IMAGE_MAX_HEIGHT_PX }}
/>
))}
{text ? <div className={cn('line-clamp-2', textClassName)}>{text}</div> : null}
</div>
)
}

264
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<string>()
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 (
<div className="mb-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{sectionLabel}
</h3>
<div className="mt-2 flex flex-wrap gap-2" role="list">
{unique.map((e) => {
const tip = getTitle ? getTitle(e) : (relationLabelForTitle ?? '')
return (
<button
key={e.id}
type="button"
role="listitem"
className={cn(
'rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
)}
onClick={() => navigateToNote(toNote(e))}
title={tip}
aria-label={tip}
>
<UserAvatar
userId={e.pubkey}
size="medium"
className="ring-1 ring-border/40"
/>
</button>
)
})}
</div>
</div>
)
}
export function ThreadQuoteBacklinkSkeleton() {
return (
<div className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
<Skeleton className="size-9 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-1.5 pt-0.5">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-full max-w-md" />
</div>
</div>
)
}
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 (
<button
type="button"
className={cn(
'group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
isWarning
? cn(
'border border-amber-600/45 bg-amber-500/[0.07] hover:border-amber-600/60 hover:bg-amber-500/[0.11]',
'dark:border-amber-500/40 dark:bg-amber-500/[0.08] dark:hover:border-amber-400/50 dark:hover:bg-amber-500/[0.12]',
'focus-visible:ring-amber-600/50 dark:focus-visible:ring-amber-400/40'
)
: cn(
'border border-transparent hover:border-border/80 hover:bg-muted/35',
'focus-visible:ring-ring'
)
)}
onClick={() => navigateToNote(toNote(event))}
title={t('View full note and thread')}
aria-label={`${quoteKindLabel}: ${secondary}`}
>
<UserAvatar
userId={event.pubkey}
size="medium"
className={cn(
'mt-0.5 ring-1',
isWarning ? 'ring-amber-600/35 dark:ring-amber-400/35' : 'ring-border/40'
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<Username
userId={event.pubkey}
className={cn(
'text-sm font-medium',
isWarning
? 'text-amber-950 group-hover:text-amber-900 dark:text-amber-50 dark:group-hover:text-amber-100'
: 'text-foreground group-hover:text-primary'
)}
/>
<span
className={cn(
'text-[11px] tabular-nums',
isWarning ? 'text-amber-900/75 dark:text-amber-100/70' : 'text-muted-foreground'
)}
>
<FormattedTimestamp timestamp={event.created_at} short />
</span>
</div>
<p
className={cn(
'mt-0.5 flex items-center gap-1.5 text-[11px] font-medium',
isWarning ? 'text-amber-950/95 dark:text-amber-100/95' : 'text-muted-foreground/85'
)}
>
{isWarning ? (
<AlertTriangle className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
) : null}
{quoteKindLabel}
</p>
{secondary ? (
<p
className={cn(
'mt-1 line-clamp-2 text-sm leading-snug',
isWarning
? 'text-amber-950/90 group-hover:text-amber-950 dark:text-amber-50/95 dark:group-hover:text-amber-50'
: 'text-muted-foreground group-hover:text-foreground/90'
)}
>
{secondary}
</p>
) : null}
</div>
<ChevronRight
className={cn(
'mt-2 size-4 shrink-0 transition-transform group-hover:translate-x-0.5',
isWarning
? 'text-amber-700/70 group-hover:text-amber-800 dark:text-amber-300/70 dark:group-hover:text-amber-200'
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
aria-hidden
/>
</button>
)
}

567
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 { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
@ -6,22 +6,21 @@ import {
getHighlightSourceHttpUrl getHighlightSourceHttpUrl
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { import {
eventReferencesEventId,
getParentATag, getParentATag,
getParentETag, getParentETag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
getRootETag, getRootETag,
getRootEventHexId, getRootEventHexId,
isMentioningMutedUsers,
isNip25ReactionKind, isNip25ReactionKind,
isNip56ReportEvent,
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent,
kind1QuotesThreadRoot kind1QuotesThreadRoot
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' 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 noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service' import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' 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 { import {
buildRssArticleUrlThreadInteractionFilters, buildRssArticleUrlThreadInteractionFilters,
isRssArticleUrlThreadInteraction isRssArticleUrlThreadInteraction
@ -44,12 +43,15 @@ import {
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuoteEvents } from '@/hooks' import { useQuoteEvents } from '@/hooks'
import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-context'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ThreadQuoteBacklink, {
BacklinkAvatarStrip,
ThreadQuoteBacklinkSkeleton
} from './ThreadQuoteBacklink'
type TRootInfo = type TRootInfo =
| { type: 'E'; id: string; pubkey: string } | { type: 'E'; id: string; pubkey: string }
@ -83,14 +85,109 @@ function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) {
return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies] return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies]
} }
/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only). */ type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report'
const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>([
kinds.Highlights, function sortWithinBacklinkGroup(events: NEvent[]): NEvent[] {
kinds.LongFormArticle, return [...events].sort((a, b) => b.created_at - a.created_at)
ExtendedKind.WIKI_ARTICLE, }
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.PUBLICATION_CONTENT 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<string>
): 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<number>(THREAD_BACKLINK_STREAM_KINDS)
/** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */ /** 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<number>([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION]) const WEB_THREAD_EXTRA_TAIL_KINDS = new Set<number>([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) 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 { function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean {
if (root.type === 'I') return false if (root.type === 'I') return false
if (evt.kind !== kinds.ShortTextNote) return false if (evt.kind !== kinds.ShortTextNote) return false
@ -137,6 +256,18 @@ function ReplyNoteList({
event, event,
showQuotes ?? false showQuotes ?? false
) )
const filteredQuoteEvents = useMemo(
() =>
quoteEvents.filter(
(e) =>
!shouldHideThreadResponseEvent(
e,
mutePubkeySet,
hideContentMentioningMutedUsers
)
),
[quoteEvents, mutePubkeySet, hideContentMentioningMutedUsers]
)
const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION
@ -228,12 +359,16 @@ function ReplyNoteList({
events.forEach((evt) => { events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return if (replyIdSet.has(evt.id)) return
if (isNip25ReactionKind(evt.kind)) return if (isNip25ReactionKind(evt.kind)) return
if (mutePubkeySet.has(evt.pubkey)) { if (
return shouldHideThreadResponseEvent(
} evt,
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) { mutePubkeySet,
hideContentMentioningMutedUsers
)
) {
return return
} }
if (rootInfo && !replyBelongsToNoteThread(evt, event, rootInfo)) return
replyIdSet.add(evt.id) replyIdSet.add(evt.id)
replyEvents.push(evt) replyEvents.push(evt)
@ -312,8 +447,8 @@ function ReplyNoteList({
) )
} }
}, [ }, [
event.id, event,
event.kind, rootInfo,
repliesMap, repliesMap,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
@ -323,7 +458,7 @@ function ReplyNoteList({
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) 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). */ /** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */
const quoteUiIdSet = useMemo(() => { 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') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
for (const r of replies) { for (const r of replies) {
if (isKind1QuoteOnlyOfEaRoot(r, rootInfo)) s.add(r.id) if (isKind1QuoteOnlyOfEaRoot(r, rootInfo)) s.add(r.id)
@ -335,7 +470,7 @@ function ReplyNoteList({
} }
} }
return s return s
}, [quoteEvents, replies, rootInfo]) }, [filteredQuoteEvents, replies, rootInfo])
const mergedFeed = useMemo(() => { const mergedFeed = useMemo(() => {
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
@ -343,12 +478,12 @@ function ReplyNoteList({
const sortedNon = [...nonZaps].sort((a, b) => const sortedNon = [...nonZaps].sort((a, b) =>
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at 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 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) // 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') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
@ -364,8 +499,8 @@ function ReplyNoteList({
} }
for (const e of qOnlyFromReplies) pushTail(e) for (const e of qOnlyFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e) for (const e of quoteOnly) pushTail(e)
tail.sort((a, b) => b.created_at - a.created_at) const tailSorted = partitionAndSortBacklinkTail(tail)
return [...replyFeedZapsFirst(middle, zaps), ...tail] return [...replyFeedZapsFirst(middle, zaps), ...tailSorted]
} }
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A // 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 tailFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e) for (const e of quoteOnly) pushTail(e)
tail.sort((a, b) => b.created_at - a.created_at) const tailSorted = partitionAndSortBacklinkTail(tail)
return [...replyFeedZapsFirst(middle, zaps), ...tail] return [...replyFeedZapsFirst(middle, zaps), ...tailSorted]
} }
const merged = [...replies, ...quoteOnly] const merged = [...replies, ...quoteOnly]
@ -393,11 +528,11 @@ function ReplyNoteList({
const replyIds = new Set(replies.map((r) => r.id)) const replyIds = new Set(replies.map((r) => r.id))
const sortedReplies = [...replies] const sortedReplies = [...replies]
const qo = merged.filter((e) => !replyIds.has(e.id)) 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 [...sortedReplies, ...sortedQuotes]
} }
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, quoteEvents, showQuotes, sort, replyIdSet, rootInfo]) }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
@ -514,7 +649,17 @@ function ReplyNoteList({
rssStatsHydratedReplyIdsRef.current.delete(id) 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 () => { return () => {
@ -527,24 +672,38 @@ function ReplyNoteList({
noteStats?.replies, noteStats?.replies,
noteStats?.updatedAt, noteStats?.updatedAt,
repliesMap, repliesMap,
addReplies addReplies,
mutePubkeySet,
hideContentMentioningMutedUsers
]) ])
const onNewReply = useCallback((evt: NEvent) => { const onNewReply = useCallback(
addReplies([evt]) (evt: NEvent) => {
if (rootInfo) { if (
const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] shouldHideThreadResponseEvent(
const without = cachedReplies.filter((r) => r.id !== evt.id) evt,
discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) mutePubkeySet,
} hideContentMentioningMutedUsers
}, [addReplies, rootInfo]) )
) {
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(() => { useEffect(() => {
if (!rootInfo) return if (!rootInfo) return
const handleEventPublished = (data: Event) => { const handleEventPublished = (data: Event) => {
const ce = data as CustomEvent<NEvent> const ce = data as CustomEvent<NEvent>
const evt = ce.detail const evt = ce.detail
if (!evt || !eventReplyMatchesThreadRoot(evt, rootInfo)) return if (!evt || !replyBelongsToNoteThread(evt, event, rootInfo)) return
onNewReply(evt) onNewReply(evt)
} }
@ -552,7 +711,7 @@ function ReplyNoteList({
return () => { return () => {
client.removeEventListener('newEvent', handleEventPublished) client.removeEventListener('newEvent', handleEventPublished)
} }
}, [rootInfo, onNewReply]) }, [rootInfo, event, onNewReply])
const replyFetchGenRef = useRef(0) const replyFetchGenRef = useRef(0)
@ -678,13 +837,18 @@ function ReplyNoteList({
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
// Filter and add replies (URL threads include kind 9802 highlights of this page) // Filter and add replies (URL threads include kind 9802 highlights of this page)
const regularReplies = allReplies.filter((evt) => const regularReplies = allReplies.filter((evt) => {
rootInfo.type === 'I' const match =
? isRssArticleUrlThreadInteraction(evt, rootInfo.id) rootInfo.type === 'I'
: isReplyNoteEvent(evt) || ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
((rootInfo.type === 'E' || rootInfo.type === 'A') && : replyBelongsToNoteThread(evt, event, rootInfo)
kind1QuotesThreadRoot(evt, rootInfo)) if (!match) return false
) return !shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers
)
})
// Store in cache (this merges with existing cached replies) // Store in cache (this merges with existing cached replies)
// After this call, the cache contains ALL replies we've ever seen for this thread // After this call, the cache contains ALL replies we've ever seen for this thread
@ -729,7 +893,9 @@ function ReplyNoteList({
event.kind, event.kind,
blockedRelays, blockedRelays,
browsingRelayUrls, browsingRelayUrls,
addReplies addReplies,
mutePubkeySet,
hideContentMentioningMutedUsers
]) ])
useEffect(() => { useEffect(() => {
@ -769,19 +935,34 @@ function ReplyNoteList({
setLoading(true) setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter( const olderEvents = events.filter((evt) => {
(evt) => if (!rootInfo) return false
isReplyNoteEvent(evt) || const matchesThread =
((rootInfo?.type === 'E' || rootInfo?.type === 'A') && rootInfo.type === 'I'
rootInfo && ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
kind1QuotesThreadRoot(evt, rootInfo)) : replyBelongsToNoteThread(evt, event, rootInfo)
) if (!matchesThread) return false
return !shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers
)
})
if (olderEvents.length > 0) { if (olderEvents.length > 0) {
addReplies(olderEvents) addReplies(olderEvents)
} }
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false) setLoading(false)
}, [loading, until, timelineKey, rootInfo?.type, rootInfo?.id]) }, [
loading,
until,
timelineKey,
rootInfo,
event,
mutePubkeySet,
hideContentMentioningMutedUsers,
addReplies
])
const highlightReply = useCallback((eventId: string, scrollTo = true) => { const highlightReply = useCallback((eventId: string, scrollTo = true) => {
if (scrollTo) { if (scrollTo) {
@ -799,6 +980,50 @@ function ReplyNoteList({
}, 1500) }, 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 ( return (
<div className="min-h-[80vh] pb-12"> <div className="min-h-[80vh] pb-12">
{loading && <LoadingBar />} {loading && <LoadingBar />}
@ -811,117 +1036,147 @@ function ReplyNoteList({
</div> </div>
)} )}
<div> <div>
{mergedFeed.slice(0, showCount).map((item) => { {displayRows.map((row, ri) => {
const isQuote = quoteUiIdSet.has(item.id) const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
// Don't filter by trust until trust data is loaded - prevents replies from if (row.type === 'reply') {
// vanishing when wotSet is still empty (all non-self appear untrusted) const reply = row.event
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { const parentETag = getParentETag(reply)
if (isQuote) return null const parentEventHexId = parentETag?.[1]
// URL-scoped comments (NIP-22 / kind 1111) are keyed under the article URL in ReplyProvider, const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
// not under each note id — repliesMap.get(item.id) is usually empty. Skipping the "trusted
// children" rule avoids hiding every untrusted URL-thread note. const replyRootId = getRootEventHexId(reply)
if (rootInfo?.type !== 'I') { const replyUrlForIThread =
const repliesForThisReply = repliesMap.get(item.id) rootInfo?.type === 'I'
if ( ? reply.kind === kinds.Highlights
!repliesForThisReply || ? getHighlightSourceHttpUrl(reply)
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey)) : getArticleUrlFromCommentITags(reply)
) { : undefined
return null 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 (
<div
ref={(el) => (replyRefs.current[reply.id] = el)}
key={reply.id}
className="scroll-mt-12"
>
<ReplyNote
event={reply}
parentEventId={event.id !== parentEventHexId ? parentEventId : undefined}
duplicateWebPreviewCleanedUrlHints={replyDuplicateWebPreviewHints}
onClickParent={() => {
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}
/>
</div>
)
} }
if (isQuote) { const { subsection, events: blEvents } = row
const quoteLabel = const wrapClass = backlinkRunSectionClass(subsection, prevRow)
item.kind === kinds.Highlights
? t('highlighted this note') if (subsection === 'bookmark') {
: 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)
return ( return (
<SuppressEmbeddedNoteContext.Provider <div
key={item.id} key={`bl-bookmark-${blEvents[0].id}`}
value={{ className={wrapClass}
hexId: event.id,
coordinate: isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : undefined
}}
> >
<BacklinkAvatarStrip
events={blEvents}
sectionLabel={t('Thread backlinks bookmarks section')}
relationLabelForTitle={t('bookmarked this note')}
/>
</div>
)
}
if (subsection === 'list') {
return (
<div
key={`bl-list-${blEvents[0].id}`}
className={wrapClass}
>
<BacklinkAvatarStrip
events={blEvents}
sectionLabel={t('Thread backlinks lists section')}
getTitle={(e) => threadBacklinkRelationLabel(e, t)}
/>
</div>
)
}
if (subsection === 'report') {
return (
<div key={`bl-report-${blEvents[0].id}`} className={wrapClass}>
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-amber-950/90 dark:text-amber-100/90">
{t('Report events heading')}
</h2>
{blEvents.map((item) => (
<div
key={item.id}
ref={(el) => (replyRefs.current[item.id] = el)}
className="scroll-mt-12 mb-1"
>
<ThreadQuoteBacklink
event={item}
quoteKindLabel={threadBacklinkRelationLabel(item, t)}
variant="warning"
/>
</div>
))}
</div>
)
}
return (
<div key={`bl-primary-${blEvents[0].id}`} className={wrapClass}>
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('Thread backlinks primary section')}
</h2>
{blEvents.map((item) => (
<div <div
key={item.id}
ref={(el) => (replyRefs.current[item.id] = el)} ref={(el) => (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"
> >
<div className="text-xs font-medium text-muted-foreground mb-1"> <ThreadQuoteBacklink
{quoteLabel}
</div>
<NoteCard
event={item} event={item}
className="w-full" quoteKindLabel={threadBacklinkRelationLabel(item, t)}
hideParentNotePreview={hideQuotedNote} variant="default"
/> />
</div> </div>
</SuppressEmbeddedNoteContext.Provider> ))}
)
}
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 (
<div
ref={(el) => (replyRefs.current[reply.id] = el)}
key={reply.id}
className="scroll-mt-12"
>
<ReplyNote
event={reply}
parentEventId={event.id !== parentEventHexId ? parentEventId : undefined}
duplicateWebPreviewCleanedUrlHints={replyDuplicateWebPreviewHints}
onClickParent={() => {
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}
/>
</div> </div>
) )
})} })}
</div> </div>
{quoteLoading && showQuotes && <NoteCardLoadingSkeleton />} {quoteLoading && showQuotes && (
<div className="mt-4 space-y-2">
<ThreadQuoteBacklinkSkeleton />
</div>
)}
{!loading && !quoteLoading && ( {!loading && !quoteLoading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} {mergedFeed.length > 0 ? t('no more replies') : t('no replies')}

28
src/constants.ts

@ -351,6 +351,34 @@ export const ExtendedKind = {
WEB_BOOKMARK: 39701 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 * 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}). * these kinds (or when `kinds` is omitted on a filter see {@link relayFilterIncludesSocialKindBlockedKind}).

58
src/hooks/useQuoteEvents.tsx

@ -1,34 +1,32 @@
import { import {
E_TAG_FILTER_BLOCKED_RELAY_URLS, E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS,
THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT
} from '@/constants' } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
const LIMIT = 100 const LIMIT = 100
const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 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). */ /** Fetches events that quote or reference the given event (#q, #e, #a tags). */
export function useQuoteEvents(event: Event | null, enabled: boolean) { export function useQuoteEvents(event: Event | null, enabled: boolean) {
const { relayList: userRelayList } = useNostr() const { relayList: userRelayList } = useNostr()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const { blockedRelays } = useFavoriteRelays()
const userBlockedRelaysNorm = useMemo(
() => buildNormalizedBlockedRelaySet(blockedRelays),
[blockedRelays]
)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -86,25 +84,51 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
) )
.filter(Boolean) .filter(Boolean)
.filter((u) => !eTagBlockedSet.has(normalizeUrl(u) || u)) .filter((u) => !eTagBlockedSet.has(normalizeUrl(u) || u))
.filter((u) => !userBlockedRelaysNorm.has((normalizeUrl(u) || u).toLowerCase()))
const filterQeId = isReplaceableEvent(ev.kind) const filterQeId = isReplaceableEvent(ev.kind)
? getReplaceableCoordinateFromEvent(ev) ? getReplaceableCoordinateFromEvent(ev)
: ev.id : ev.id
const qeIdForTagFilter =
/^[0-9a-f]{64}$/i.test(filterQeId) ? filterQeId.toLowerCase() : filterQeId
const eventCoordinate = isReplaceableEvent(ev.kind) const eventCoordinate = isReplaceableEvent(ev.kind)
? getReplaceableCoordinateFromEvent(ev) ? getReplaceableCoordinateFromEvent(ev)
: `${ev.kind}:${ev.pubkey}:${ev.id}` : `${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( const { closer, timelineKey } = await client.subscribeTimeline(
[ [
{ {
urls: finalRelayUrls, 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, urls: finalRelayUrls,
filter: { filter: {
'#e': [filterQeId], '#a': [eventCoordinate],
kinds: [...QUOTE_STREAM_REFERENCE_KINDS], kinds: [...highlightKinds],
limit: LIMIT limit: LIMIT
} }
}, },
@ -112,7 +136,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#a': [eventCoordinate], '#a': [eventCoordinate],
kinds: [...QUOTE_STREAM_REFERENCE_KINDS], kinds: otherBacklinkKinds,
limit: LIMIT limit: LIMIT
} }
} }
@ -164,7 +188,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
if (loadTimeoutId) clearTimeout(loadTimeoutId) if (loadTimeoutId) clearTimeout(loadTimeoutId)
promise.then((closer) => closer?.()) promise.then((closer) => closer?.())
} }
}, [event, enabled, browsingRelayUrls, userRelayList?.read]) }, [event, enabled, browsingRelayUrls, userRelayList?.read, userBlockedRelaysNorm])
const loadMore = async () => { const loadMore = async () => {
if (!timelineKey || loading || !hasMore) return if (!timelineKey || loading || !hasMore) return

15
src/i18n/locales/de.ts

@ -743,6 +743,21 @@ export default {
'quoted this note': 'Hat diese Notiz zitiert', 'quoted this note': 'Hat diese Notiz zitiert',
'highlighted this note': 'Hat diese Notiz hervorgehoben', 'highlighted this note': 'Hat diese Notiz hervorgehoben',
'cited in article': 'In Artikel zitiert', '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', 'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
'reacted to your note': 'hat auf Ihre Notiz reagiert', 'reacted to your note': 'hat auf Ihre Notiz reagiert',
'boosted your note': 'hat Ihre Notiz geboostet', 'boosted your note': 'hat Ihre Notiz geboostet',

15
src/i18n/locales/en.ts

@ -771,6 +771,21 @@ export default {
'quoted this note': 'Quoted this note', 'quoted this note': 'Quoted this note',
'highlighted this note': 'Highlighted this note', 'highlighted this note': 'Highlighted this note',
'cited in article': 'Cited in article', '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', 'voted in your poll': 'voted in your poll',
'reacted to your note': 'reacted to your note', 'reacted to your note': 'reacted to your note',
'boosted your note': 'boosted your note', 'boosted your note': 'boosted your note',

5
src/lib/event.ts

@ -25,6 +25,11 @@ export function isNip18RepostKind(kind: number): boolean {
return kind === kinds.Repost || kind === ExtendedKind.GENERIC_REPOST 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<Event, 'kind'>): boolean {
return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT
}
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 }) const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 }) const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })

4
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 imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico)(\?.*)?$/i
const imageDomains = [ const imageDomains = [
'i.nostr.build', 'i.nostr.build',

15
src/lib/kind-description.ts

@ -106,8 +106,23 @@ export function getKindDescription(
return { number: 99999, description: 'Web article thread' } return { number: 99999, description: 'Web article thread' }
case ExtendedKind.FILE_METADATA: case ExtendedKind.FILE_METADATA:
return { number: 1063, description: 'File metadata' } return { number: 1063, description: 'File metadata' }
case kinds.Report:
case ExtendedKind.REPORT: case ExtendedKind.REPORT:
return { number: 1984, description: '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: case ExtendedKind.WEB_BOOKMARK:
return { number: 39701, description: 'Web bookmark' } return { number: 39701, description: 'Web bookmark' }
default: default:

50
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<string>()
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 }
}

15
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()
}

31
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 { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags, getArticleUrlFromCommentITags,
@ -34,3 +40,26 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (getRootEventHexId(evt) === root.id) return true if (getRootEventHexId(evt) === root.id) return true
return kind1QuotesThreadRoot(evt, root) 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
}

24
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<string> {
const s = new Set<string>()
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<string>,
hideContentMentioningMutedUsers: boolean | undefined
): boolean {
if (mutePubkeySet.has(evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false
}
Loading…
Cancel
Save