Browse Source

rss comments and highlights

imwald
Silberengel 1 month ago
parent
commit
56bfe643c9
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 99
      src/PageManager.tsx
  4. 31
      src/components/Content/index.tsx
  5. 6
      src/components/ContentPreview/NormalContentPreview.tsx
  6. 96
      src/components/Note/EventViewer.tsx
  7. 14
      src/components/Note/IValue.tsx
  8. 75
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  9. 11
      src/components/Note/UnknownNote.tsx
  10. 55
      src/components/Note/index.tsx
  11. 8
      src/components/NoteInteractions/index.tsx
  12. 23
      src/components/NoteStats/index.tsx
  13. 12
      src/components/PostEditor/PostContent.tsx
  14. 8
      src/components/PostEditor/PostTextarea/Preview.tsx
  15. 36
      src/components/ReplyNoteList/index.tsx
  16. 126
      src/components/RssFeedItem/index.tsx
  17. 88
      src/components/RssFeedList/index.tsx
  18. 2
      src/constants.ts
  19. 11
      src/i18n/locales/de.ts
  20. 11
      src/i18n/locales/en.ts
  21. 80
      src/lib/draft-event.ts
  22. 11
      src/lib/event.ts
  23. 82
      src/lib/rss-article.ts
  24. 14
      src/lib/url.ts
  25. 169
      src/pages/secondary/RssArticlePage/index.tsx
  26. 6
      src/providers/ReplyProvider.tsx
  27. 9
      src/routes.tsx
  28. 21
      src/services/rss-feed.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "jumble-imwald",
"version": "19.0.0",
"version": "19.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
"version": "19.0.0",
"version": "19.1.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "19.0.0",
"version": "19.1.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",

99
src/PageManager.tsx

@ -40,6 +40,7 @@ import { useTranslation } from 'react-i18next' @@ -40,6 +40,7 @@ import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
import { normalizeUrl } from './lib/url'
import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
import { routes } from './routes'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { SecondaryPageContext, useSecondaryPage } from '@/contexts/secondary-page-context'
@ -250,6 +251,27 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str @@ -250,6 +251,27 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
return `/notes/${noteId}`
}
function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName | null): string {
const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'explore']
if (currentPage && contextualPages.includes(currentPage)) {
return `/${currentPage}/rss-item/${key}`
}
return `/rss-item/${key}`
}
/** Open an RSS article in the secondary panel (same routing pattern as contextual note URLs). */
export function useSmartRssArticleNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToRssArticle = (articleUrl: string) => {
pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage))
}
return { navigateToRssArticle }
}
// Helper function to build contextual relay URL
function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string {
const encodedRelayUrl = encodeURIComponent(relayUrl)
@ -881,6 +903,59 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -881,6 +903,59 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
}
// RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key}
const contextualRssMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/([^/?#]+)/
)
const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/)
const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1]
if (rssArticleKey) {
let decodedArticleUrl = ''
try {
decodedArticleUrl = decodeRssArticlePathSegment(rssArticleKey)
} catch {
decodedArticleUrl = ''
}
if (decodedArticleUrl) {
const resolvedRss = contextualRssMatch
? noteContextToPrimaryEntry(contextualRssMatch[1])
: null
const rssPrimaryEntry: { name: TPrimaryPageName; props?: object } = resolvedRss ?? {
name: 'rss'
}
const applyRssPrimary = () => {
setCurrentPrimaryPage(rssPrimaryEntry.name)
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, rssPrimaryEntry))
setSavedPrimaryPage(rssPrimaryEntry.name)
}
if (isSmallScreen || panelMode === 'single') {
setTimeout(applyRssPrimary, 0)
} else {
applyRssPrimary()
}
const contextualRssUrl = buildRssArticleUrl(decodedArticleUrl, rssPrimaryEntry.name)
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, contextualRssUrl)) return prevStack
const { newStack, newItem } = pushNewPageToStack(
prevStack,
contextualRssUrl,
maxStackSize,
window.history.state?.index
)
if (newItem) {
window.history.replaceState({ index: newItem.index, url: contextualRssUrl }, '', contextualRssUrl)
}
return newStack
})
return
}
}
// Check if this is a primary page URL - don't push primary pages to secondary stack
const pathnameOnly = pathname.split('?')[0].split('#')[0]
const segments = pathnameOnly.split('/').filter(Boolean)
@ -1042,6 +1117,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1042,6 +1117,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setCurrentPrimaryPage('spells')
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'spells', props: spellProps }))
}
// Contextual RSS article: align primary pane when using browser history
let rssPathSync = window.location.pathname.split('?')[0].split('#')[0]
try {
if (urlToCheck.startsWith('http://') || urlToCheck.startsWith('https://')) {
rssPathSync = new URL(urlToCheck).pathname
}
} catch {
/* keep pathname */
}
const ctxRssPop = rssPathSync.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/([^/?#]+)/
)
if (ctxRssPop) {
const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1])
if (resolvedPop) {
setCurrentPrimaryPage(resolvedPop.name)
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, resolvedPop))
setSavedPrimaryPage(resolvedPop.name)
}
} else if (/^\/rss-item\/[^/?#]+/.test(rssPathSync)) {
setCurrentPrimaryPage('rss')
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'rss' }))
setSavedPrimaryPage('rss')
}
}
// If not a note URL and drawer is open - close the drawer immediately

31
src/components/Content/index.tsx

@ -6,6 +6,7 @@ import logger from '@/lib/logger' @@ -6,6 +6,7 @@ import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
@ -74,6 +75,11 @@ export default function Content({ @@ -74,6 +75,11 @@ export default function Content({
mustLoadMedia?: boolean
}) {
const _content = event?.content ?? content
const iArticleUrl = useMemo(() => (event ? getHttpUrlFromITags(event) : undefined), [event])
const iArticleCleaned = useMemo(
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
[iArticleUrl]
)
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content)
@ -109,7 +115,7 @@ export default function Content({ @@ -109,7 +115,7 @@ export default function Content({
const url = node.data
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
if (cleaned && !seenUrls.has(cleaned) && !(iArticleCleaned && cleaned === iArticleCleaned)) {
links.push(cleaned)
seenUrls.add(cleaned)
}
@ -118,7 +124,7 @@ export default function Content({ @@ -118,7 +124,7 @@ export default function Content({
})
return links
}, [nodes])
}, [nodes, iArticleCleaned])
// Extract YouTube URLs from r tags to render as players
const youtubeUrlsFromTags = useMemo(() => {
@ -189,13 +195,16 @@ export default function Content({ @@ -189,13 +195,16 @@ export default function Content({
}
})
// If no nodes but we have media from tags, still render the media
// If no nodes but we have media from tags, still render the media (or i-tag article preview)
if (!nodes || nodes.length === 0) {
// Check if we have any media to display
if (extractedMedia.images.length === 0 && extractedMedia.videos.length === 0 && extractedMedia.audio.length === 0) {
if (
extractedMedia.images.length === 0 &&
extractedMedia.videos.length === 0 &&
extractedMedia.audio.length === 0 &&
!iArticleUrl
) {
return null
}
// If we have media, render it even without content nodes
}
// First pass: find which media appears in content (will be rendered in carousels or inline)
@ -297,6 +306,11 @@ export default function Content({ @@ -297,6 +306,11 @@ export default function Content({
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{iArticleUrl && (
<div className="mb-2 max-w-full">
<WebPreview url={iArticleUrl} className="w-full" />
</div>
)}
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
<ImageGallery
@ -431,7 +445,10 @@ export default function Content({ @@ -431,7 +445,10 @@ export default function Content({
/>
)
}
// Regular URL, not an image or media - show WebPreview
// Regular URL, not an image or media - show WebPreview (skip if same as i-tag article)
if (iArticleCleaned && cleanedUrl === iArticleCleaned) {
return null
}
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {

6
src/components/ContentPreview/NormalContentPreview.tsx

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from './Content'
export default function NormalContentPreview({
@ -10,13 +8,11 @@ export default function NormalContentPreview({ @@ -10,13 +8,11 @@ export default function NormalContentPreview({
event: Event
className?: string
}) {
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
return (
<Content
event={event}
content={event.content}
className={className}
emojiInfos={emojiInfos}
/>
)
}

96
src/components/Note/EventViewer.tsx

@ -8,28 +8,40 @@ import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react' @@ -8,28 +8,40 @@ import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react'
import { toast } from 'sonner'
import logger from '@/lib/logger'
import { cn } from '@/lib/utils'
import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article'
import { isValidPubkey } from '@/lib/pubkey'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
export default function EventViewer({ event, className }: { event: Event; className?: string }) {
function isAllZeroPlaceholderPubkey(pk: string): boolean {
return isValidPubkey(pk) && /^0+$/.test(pk)
}
export default function EventViewer({
event,
className,
/** When true, `event.tags` and nested tag rows render expanded (no collapse). */
expandTagsTree = false
}: {
event: Event
className?: string
expandTagsTree?: boolean
}) {
const { t } = useTranslation()
const [copiedJson, setCopiedJson] = useState(false)
const [copiedNevent, setCopiedNevent] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set(['root']))
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const nevent = useMemo(
() => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }),
[event.id, event.pubkey, event.kind]
)
const toggle = (key: string) => {
const setKeyExpanded = (key: string, open: boolean) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
if (open) next.add(key)
else next.delete(key)
return next
})
}
@ -72,11 +84,36 @@ export default function EventViewer({ event, className }: { event: Event; classN @@ -72,11 +84,36 @@ export default function EventViewer({ event, className }: { event: Event; classN
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span>
}
if (Array.isArray(value)) {
const tagsTreeAlwaysOpen =
expandTagsTree && (key === 'tags' || key.startsWith('tags['))
if (tagsTreeAlwaysOpen) {
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<div className="text-xs text-muted-foreground mb-1">
Array ({value.length})
</div>
<div className="ml-4">
{value.map((item, idx) => (
<div key={idx} className="mb-1">
<span className="text-muted-foreground text-xs">[{idx}]</span>{' '}
{renderValue(item, `${key}[${idx}]`, depth + 1)}
</div>
))}
</div>
</div>
)
}
const isExpanded = expanded.has(key)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}>
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground">
<Collapsible
open={isExpanded}
onOpenChange={(open) => setKeyExpanded(key, open)}
>
<CollapsibleTrigger
type="button"
className="flex items-center gap-1 text-sm hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
@ -102,8 +139,14 @@ export default function EventViewer({ event, className }: { event: Event; classN @@ -102,8 +139,14 @@ export default function EventViewer({ event, className }: { event: Event; classN
const entries = Object.entries(value)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}>
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground">
<Collapsible
open={isExpanded}
onOpenChange={(open) => setKeyExpanded(key, open)}
>
<CollapsibleTrigger
type="button"
className="flex items-center gap-1 text-sm hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
@ -128,6 +171,10 @@ export default function EventViewer({ event, className }: { event: Event; classN @@ -128,6 +171,10 @@ export default function EventViewer({ event, className }: { event: Event; classN
}
const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL')
const pubkey = event.pubkey ?? ''
const hidePubkeyRow = isRssThreadSyntheticParentEvent(event)
const showAuthorBadge =
!hidePubkeyRow && isValidPubkey(pubkey) && !isAllZeroPlaceholderPubkey(pubkey)
return (
<div className={cn('border rounded-lg p-4 bg-muted/30', className)}>
@ -145,13 +192,30 @@ export default function EventViewer({ event, className }: { event: Event; classN @@ -145,13 +192,30 @@ export default function EventViewer({ event, className }: { event: Event; classN
{copiedNevent ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="flex items-center gap-2">
{!hidePubkeyRow && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">pubkey</span>
<div className="flex items-center gap-1.5">
<UserAvatar userId={event.pubkey} size="xSmall" />
<Username userId={event.pubkey} className="font-normal" skeletonClassName="h-4" />
{showAuthorBadge ? (
<div className="flex items-center gap-1.5 min-w-0">
<UserAvatar userId={pubkey} size="xSmall" />
<Username
userId={pubkey}
className="font-normal min-w-0"
skeletonClassName="h-4"
withoutSkeleton
/>
</div>
) : (
<span className="text-muted-foreground text-xs break-all">
{!pubkey
? t('Missing pubkey')
: isAllZeroPlaceholderPubkey(pubkey)
? t('Synthetic event (no author)')
: pubkey}
</span>
)}
</div>
)}
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">kind</span>{' '}
{renderValue(event.kind, 'kind')}

14
src/components/Note/IValue.tsx

@ -14,23 +14,13 @@ export default function IValue({ event, className }: { event: Event; className?: @@ -14,23 +14,13 @@ export default function IValue({ event, className }: { event: Event; className?:
}, [event])
if (!iValue) return null
// HTTP(S) article roots use WebPreview in MarkdownArticle; skip redundant line.
if (iValue.startsWith('http://') || iValue.startsWith('https://')) return null
return (
<div className={cn('truncate text-muted-foreground', className)}>
{t('Comment on') + ' '}
{iValue.startsWith('http') ? (
<a
className="hover:text-foreground underline truncate"
href={iValue}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{iValue}
</a>
) : (
<span>{iValue}</span>
)}
</div>
)
}

75
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -9,7 +9,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' @@ -9,7 +9,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import Emoji from '@/components/Emoji'
import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
@ -428,9 +428,23 @@ function parseMarkdownContent( @@ -428,9 +428,23 @@ function parseMarkdownContent(
emojiInfos?: TEmoji[]
/** When viewing a kind-24 invite, render full calendar card with RSVP instead of EmbeddedNote for this naddr */
fullCalendarInvite?: { naddr: string; event: Event }
/** If set, a standalone markdown link to this cleaned URL renders as inline link (OG shown separately). */
suppressStandaloneWebPreviewForCleanedUrl?: string
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier, emojiInfos = [], fullCalendarInvite } = options
const {
eventPubkey,
imageIndexMap,
openLightbox,
navigateToHashtag,
navigateToRelay,
videoPosterMap,
imageThumbnailMap,
getImageIdentifier,
emojiInfos = [],
fullCalendarInvite,
suppressStandaloneWebPreviewForCleanedUrl
} = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
@ -1817,12 +1831,29 @@ function parseMarkdownContent( @@ -1817,12 +1831,29 @@ function parseMarkdownContent(
}
} else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data
// Standalone links render as WebPreview (OpenGraph card)
const cleanedStandalone = cleanUrl(url) || url
if (
suppressStandaloneWebPreviewForCleanedUrl &&
cleanedStandalone === suppressStandaloneWebPreviewForCleanedUrl
) {
parts.push(
<a
key={`link-${patternIdx}`}
href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{url}
</a>
)
} else {
parts.push(
<div key={`webpreview-${patternIdx}`} className="my-2">
<WebPreview url={url} className="w-full" />
</div>
)
}
} else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data
// Process the link text for inline formatting (bold, italic, etc.)
@ -3198,6 +3229,11 @@ export default function MarkdownArticle({ @@ -3198,6 +3229,11 @@ export default function MarkdownArticle({
const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event])
const iArticleCleaned = useMemo(
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
[iArticleUrl]
)
// Extract all media from event
const extractedMedia = useMediaExtraction(event, event.content)
@ -3470,12 +3506,14 @@ export default function MarkdownArticle({ @@ -3470,12 +3506,14 @@ export default function MarkdownArticle({
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const contentLinksSet = new Set(contentLinks.map((link) => cleanUrl(link)).filter(Boolean))
return tagLinks.filter((link) => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
if (!cleaned) return false
if (iArticleCleaned && cleaned === iArticleCleaned) return false
return !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
}, [tagLinks, contentLinks, iArticleCleaned])
// Preprocess content to convert URLs to markdown syntax
const preprocessedContent = useMemo(() => {
@ -3546,11 +3584,25 @@ export default function MarkdownArticle({ @@ -3546,11 +3584,25 @@ export default function MarkdownArticle({
imageThumbnailMap,
getImageIdentifier,
emojiInfos,
fullCalendarInvite
fullCalendarInvite,
suppressStandaloneWebPreviewForCleanedUrl: iArticleCleaned || undefined
})
// Return nodes and hashtags (footnotes are already included in nodes)
return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent }
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier, emojiInfos, fullCalendarInvite])
}, [
preprocessedContent,
event.pubkey,
imageIndexMap,
openLightbox,
navigateToHashtag,
navigateToRelay,
videoPosterMap,
imageThumbnailMap,
getImageIdentifier,
emojiInfos,
fullCalendarInvite,
iArticleCleaned
])
// Filter metadata tags to only show what's not already in content
const leftoverMetadataTags = useMemo(() => {
@ -3645,6 +3697,11 @@ export default function MarkdownArticle({ @@ -3645,6 +3697,11 @@ export default function MarkdownArticle({
}
`}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{iArticleUrl && (
<div className="not-prose mb-4 max-w-full">
<WebPreview url={iArticleUrl} className="w-full" />
</div>
)}
{/* Metadata */}
{!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideMetadata && metadata.summary && (

11
src/components/Note/UnknownNote.tsx

@ -4,12 +4,21 @@ import { useTranslation } from 'react-i18next' @@ -4,12 +4,21 @@ import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { useMemo } from 'react'
import EventViewer from './EventViewer'
export default function UnknownNote({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const displayEvent = useMemo(() => {
if (event.kind !== ExtendedKind.RSS_THREAD_ROOT) return event
const raw = getArticleUrlFromCommentITags(event)
if (!raw) return event
const c = canonicalizeRssArticleUrl(raw)
if (c === raw) return event
return { ...event, tags: [['i', c], ['I', c]] as Event['tags'] }
}, [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const formatBookName = (book: string) => {
@ -39,7 +48,7 @@ export default function UnknownNote({ event, className }: { event: Event; classN @@ -39,7 +48,7 @@ export default function UnknownNote({ event, className }: { event: Event; classN
)}
<ClientSelect event={event} />
</div>
<EventViewer event={event} />
<EventViewer event={displayEvent} expandTagsTree />
</div>
)
}

55
src/components/Note/index.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { useSmartNoteNavigation } from '@/PageManager'
import { ExtendedKind } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import logger from '@/lib/logger'
import client from '@/services/client.service'
@ -11,9 +11,12 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -11,9 +11,12 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article'
import { CreateHighlightContext } from './CreateHighlightContext'
import SelectionHighlightTrigger from './SelectionHighlightTrigger'
import AudioPlayer from '../AudioPlayer'
import WebPreview from '../WebPreview'
import ClientTag from '../ClientTag'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
@ -66,6 +69,7 @@ export default function Note({ @@ -66,6 +69,7 @@ export default function Note({
/** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */
fullCalendarInvite?: { event: Event; naddr: string }
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { isSmallScreen } = useScreenSize()
const parentEventId = useMemo(
@ -197,8 +201,20 @@ export default function Note({ @@ -197,8 +201,20 @@ export default function Note({
<Poll className="mt-2" event={event} />
</>
)
} else if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) {
} else if (event.kind === ExtendedKind.VOICE) {
content = <AudioPlayer className="mt-2" src={event.content} />
} else if (event.kind === ExtendedKind.VOICE_COMMENT) {
const voiceArticleUrl = getHttpUrlFromITags(event)
content = (
<>
{voiceArticleUrl && (
<div className="mt-2 not-prose max-w-full">
<WebPreview url={voiceArticleUrl} className="w-full" />
</div>
)}
<AudioPlayer className="mt-2" src={event.content} />
</>
)
} else if (event.kind === ExtendedKind.PICTURE) {
content = <PictureNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
@ -220,7 +236,7 @@ export default function Note({ @@ -220,7 +236,7 @@ export default function Note({
content = <Zap className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={event} />
} else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
} else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) {
// Plain text notes use MarkdownArticle for proper markdown rendering
content = <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
} else {
@ -228,6 +244,8 @@ export default function Note({ @@ -228,6 +244,8 @@ export default function Note({
content = <MarkdownArticle className="mt-2" event={event} />
}
const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event)
const wrappedContent = isHighlightableKind ? (
<SelectionHighlightTrigger event={event}>{content}</SelectionHighlightTrigger>
) : (
@ -251,6 +269,35 @@ export default function Note({ @@ -251,6 +269,35 @@ export default function Note({
>
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
{isSyntheticRssParent ? (
<>
<div
className={`shrink-0 rounded-full bg-muted overflow-hidden flex items-center justify-center ${
size === 'small' ? 'w-9 h-9' : 'w-10 h-10'
}`}
>
<img
src="/pwa-192x192.png"
alt=""
className="w-full h-full object-cover"
width={size === 'small' ? 36 : 40}
height={size === 'small' ? 36 : 40}
/>
</div>
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<span
data-username
className={`font-semibold truncate text-foreground ${size === 'small' ? 'text-sm' : ''}`}
>
{t('Jumble Imwald synthetic event')}
</span>
<ClientTag event={event} />
</div>
</div>
</>
) : (
<>
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
@ -270,6 +317,8 @@ export default function Note({ @@ -270,6 +317,8 @@ export default function Note({
/>
</div>
</div>
</>
)}
</div>
<div className="flex items-center gap-1">
{event.kind === ExtendedKind.DISCUSSION && (

8
src/components/NoteInteractions/index.tsx

@ -10,14 +10,18 @@ import ReplySort, { ReplySortOption } from './ReplySort' @@ -10,14 +10,18 @@ import ReplySort, { ReplySortOption } from './ReplySort'
export default function NoteInteractions({
pageIndex,
event
event,
showQuotes: showQuotesProp
}: {
pageIndex?: number
event: Event
/** When set, overrides the default (quotes hidden for discussions only). */
showQuotes?: boolean
}) {
const { t } = useTranslation()
const [replySort, setReplySort] = useState<ReplySortOption>('oldest')
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const showQuotes = showQuotesProp ?? !isDiscussion
// Hide interactions if event is in quiet mode
if (shouldHideInteractions(event)) {
@ -48,7 +52,7 @@ export default function NoteInteractions({ @@ -48,7 +52,7 @@ export default function NoteInteractions({
index={pageIndex}
event={event}
sort={replySort}
showQuotes={!isDiscussion}
showQuotes={showQuotes}
/>
</>
)

23
src/components/NoteStats/index.tsx

@ -45,6 +45,9 @@ export default function NoteStats({ @@ -45,6 +45,9 @@ export default function NoteStats({
// Hide interaction counts if event is in quiet mode
const hideInteractions = shouldHideInteractions(event)
/** Synthetic RSS article root: only reply + reactions (no boost/quote/zap). */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
useMemo(() => {
if (isDiscussion) return // Already a discussion event
@ -73,9 +76,9 @@ export default function NoteStats({ @@ -73,9 +76,9 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
<TopZaps event={event} />
{!isRssArticleRoot && <TopZaps event={event} />}
{/* Kind 11: LikeButton already shows ⬆/⬇; Likes row would duplicate those pills */}
{!isDiscussion && <Likes event={event} />}
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />}
</>
)}
<div
@ -86,9 +89,11 @@ export default function NoteStats({ @@ -86,9 +89,11 @@ export default function NoteStats({
)}
>
<ReplyButton event={event} hideCount={hideInteractions} />
{!isDiscussion && !isReplyToDiscussion && <RepostButton event={event} hideCount={hideInteractions} />}
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButton event={event} hideCount={hideInteractions} />
)}
<LikeButton event={event} hideCount={hideInteractions} />
<ZapButton event={event} hideCount={hideInteractions} />
{!isRssArticleRoot && <ZapButton event={event} hideCount={hideInteractions} />}
<BookmarkButton event={event} />
<SeenOnButton event={event} />
</div>
@ -100,8 +105,8 @@ export default function NoteStats({ @@ -100,8 +105,8 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
<TopZaps event={event} />
{!isDiscussion && <Likes event={event} />}
{!isRssArticleRoot && <TopZaps event={event} />}
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />}
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4">
@ -109,9 +114,11 @@ export default function NoteStats({ @@ -109,9 +114,11 @@ export default function NoteStats({
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
>
<ReplyButton event={event} hideCount={hideInteractions} />
{!isDiscussion && !isReplyToDiscussion && <RepostButton event={event} hideCount={hideInteractions} />}
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButton event={event} hideCount={hideInteractions} />
)}
<LikeButton event={event} hideCount={hideInteractions} />
<ZapButton event={event} hideCount={hideInteractions} />
{!isRssArticleRoot && <ZapButton event={event} hideCount={hideInteractions} />}
</div>
<div className="flex items-center">
<BookmarkButton event={event} />

12
src/components/PostEditor/PostContent.tsx

@ -36,6 +36,7 @@ import { isTouchDevice } from '@/lib/utils' @@ -36,6 +36,7 @@ import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { normalizeUrl, cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
@ -392,6 +393,16 @@ export default function PostContent({ @@ -392,6 +393,16 @@ export default function PostContent({
parentEvent
])
const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => {
if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined
const raw =
parentEvent.tags.find((t) => t[0] === 'I')?.[1] ??
parentEvent.tags.find((t) => t[0] === 'i')?.[1]
if (!raw) return undefined
const c = canonicalizeRssArticleUrl(raw)
return [['i', c], ['I', c]]
}, [parentEvent])
// Shared function to create draft event - used by both preview and posting
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
// Get expiration and quiet settings
@ -1996,6 +2007,7 @@ export default function PostContent({ @@ -1996,6 +2007,7 @@ export default function PostContent({
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}
extraPreviewTags={rssReplyExtraPreviewTags}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
headerActions={

8
src/components/PostEditor/PostTextarea/Preview.tsx

@ -67,7 +67,15 @@ export default function Preview({ @@ -67,7 +67,15 @@ export default function Preview({
if (kind === kinds.Highlights && highlightData) {
// Add source tag
if (highlightData.sourceType === 'url') {
try {
highlightTags.push([
'r',
cleanUrl(highlightData.sourceValue) || highlightData.sourceValue,
'source'
])
} catch {
highlightTags.push(['r', highlightData.sourceValue, 'source'])
}
} else if (highlightData.sourceType === 'nostr') {
// For preview, we'll use a simple e-tag with the source value
// The actual tag building happens in createHighlightDraftEvent

36
src/components/ReplyNoteList/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { ExtendedKind } from '@/constants'
import { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import {
getParentETag,
getReplaceableCoordinateFromEvent,
@ -216,6 +217,14 @@ function ReplyNoteList({ @@ -216,6 +217,14 @@ function ReplyNoteList({
useEffect(() => {
const fetchRootEvent = async () => {
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = event.tags.find((t) => t[0] === 'i' || t[0] === 'I')?.[1]
if (url) {
setRootInfo({ type: 'I', id: url })
}
return
}
let root: TRootInfo
if (isReplaceableEvent(event.kind)) {
@ -251,9 +260,9 @@ function ReplyNoteList({ @@ -251,9 +260,9 @@ function ReplyNoteList({
const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay }
}
const rootITag = event.tags.find(tagNameEquals('I'))
if (rootITag) {
root = { type: 'I', id: rootITag[1] }
const rootArticleUrl = getArticleUrlFromCommentITags(event)
if (rootArticleUrl) {
root = { type: 'I', id: rootArticleUrl }
}
}
setRootInfo(root)
@ -278,8 +287,12 @@ function ReplyNoteList({ @@ -278,8 +287,12 @@ function ReplyNoteList({
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail
const rootId = getRootEventHexId(evt)
if (rootId === rootInfo.id && isReplyNoteEvent(evt)) {
const articleThreadUrl = rootInfo.type === 'I' ? getArticleUrlFromCommentITags(evt) : undefined
const matchesThread =
rootInfo.type === 'I'
? articleThreadUrl === rootInfo.id
: getRootEventHexId(evt) === rootInfo.id
if (matchesThread && isReplyNoteEvent(evt)) {
onNewReply(evt)
}
}
@ -378,6 +391,17 @@ function ReplyNoteList({ @@ -378,6 +391,17 @@ function ReplyNoteList({
if (rootInfo.relay) {
finalRelayUrls.push(rootInfo.relay)
}
} else if (rootInfo.type === 'I') {
filters.push({
'#i': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
// Use fetchEvents instead of subscribeTimeline for one-time fetching
@ -518,7 +542,7 @@ function ReplyNoteList({ @@ -518,7 +542,7 @@ function ReplyNoteList({
const belongsToSameThread = rootInfo && (
(rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
(rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) ||
(rootInfo.type === 'I' && reply.tags.find(tagNameEquals('I'))?.[1] === rootInfo.id)
(rootInfo.type === 'I' && getArticleUrlFromCommentITags(reply) === rootInfo.id)
)
return (

126
src/components/RssFeedItem/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import { FormattedTimestamp } from '../FormattedTimestamp'
import { ExternalLink, Highlighter, ChevronDown, ChevronUp } from 'lucide-react'
import { ExternalLink, Highlighter } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@ -11,6 +11,7 @@ import { cn } from '@/lib/utils' @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'
import MediaPlayer from '@/components/MediaPlayer'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useSmartRssArticleNavigation } from '@/PageManager'
/**
* Convert HTML to plain text by extracting text content and cleaning up whitespace
@ -36,10 +37,22 @@ function htmlToPlainText(html: string): string { @@ -36,10 +37,22 @@ function htmlToPlainText(html: string): string {
return text
}
export default function RssFeedItem({ item, className, compact = false }: { item: TRssFeedItem; className?: string; compact?: boolean }) {
export default function RssFeedItem({
item,
className,
layout = 'detail'
}: {
item: TRssFeedItem
className?: string
/** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */
layout?: 'list' | 'detail'
}) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { isSmallScreen } = useScreenSize()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const isListLayout = layout === 'list'
const showFullBody = layout === 'detail'
const [selectedText, setSelectedText] = useState('')
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
const [showHighlightButton, setShowHighlightButton] = useState(false)
@ -47,7 +60,6 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -47,7 +60,6 @@ export default function RssFeedItem({ item, className, compact = false }: { item
const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null)
const [isPostEditorOpen, setIsPostEditorOpen] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined)
const [isExpanded, setIsExpanded] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const selectionTimeoutRef = useRef<NodeJS.Timeout>()
const isSelectingRef = useRef(false)
@ -426,11 +438,15 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -426,11 +438,15 @@ export default function RssFeedItem({ item, className, compact = false }: { item
// Format publication date
const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null
// Check if content exceeds 400px height
// Check if content exceeds 400px height (detail layout only)
const [needsCollapse, setNeedsCollapse] = useState(false)
const [longBodyExpanded, setLongBodyExpanded] = useState(false)
useEffect(() => {
if (!contentRef.current || !descriptionHtml) return
if (isListLayout || !contentRef.current || !descriptionHtml) {
setNeedsCollapse(false)
return
}
const checkHeight = () => {
const element = contentRef.current
@ -464,8 +480,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -464,8 +480,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item
// Use ResizeObserver to detect when content changes
const resizeObserver = new ResizeObserver(() => {
// Only check if not currently expanded (to avoid unnecessary checks)
if (!isExpanded) {
if (!longBodyExpanded) {
checkHeight()
}
})
@ -479,10 +494,31 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -479,10 +494,31 @@ export default function RssFeedItem({ item, className, compact = false }: { item
clearTimeout(timeoutId2)
resizeObserver.disconnect()
}
}, [descriptionHtml, isExpanded])
}, [descriptionHtml, longBodyExpanded, isListLayout])
return (
<div className={`border rounded-lg bg-background p-4 space-y-3 overflow-hidden ${className || ''}`}>
<div
className={cn(
`border rounded-lg bg-background p-4 space-y-3 overflow-hidden ${className || ''}`,
isListLayout && 'cursor-pointer hover:bg-muted/40 transition-colors'
)}
onClick={
isListLayout
? (e) => {
const target = e.target as HTMLElement
if (
target.closest('a') ||
target.closest('button') ||
target.closest('[role="dialog"]') ||
target.closest('.highlight-button-container')
) {
return
}
navigateToRssArticle(item.link)
}
: undefined
}
>
{/* Feed Header with Metadata */}
<div className="flex items-start gap-3 pb-3 border-b">
{/* Feed Image/Logo */}
@ -520,23 +556,36 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -520,23 +556,36 @@ export default function RssFeedItem({ item, className, compact = false }: { item
{/* Title */}
<div className="min-w-0">
{isListLayout ? (
<div className="text-lg font-medium break-words flex items-start gap-2">
<span className="break-words flex-1 min-w-0">{item.title}</span>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
"text-lg hover:text-primary transition-colors inline-flex items-center gap-2 break-words",
!compact || isExpanded ? "font-semibold" : ""
)}
className="text-primary hover:text-primary/90 shrink-0 mt-0.5"
onClick={(e) => e.stopPropagation()}
title={t('Read full article')}
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
) : (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold hover:text-primary transition-colors inline-flex items-center gap-2 break-words"
onClick={(e) => e.stopPropagation()}
>
<span className="break-words">{item.title}</span>
<ExternalLink className="h-4 w-4 shrink-0" />
</a>
)}
</div>
{/* Compact view: Hide media and description when compact and not expanded */}
{!compact || isExpanded ? (
{/* List layout: body lives in the secondary panel */}
{showFullBody ? (
<>
{/* Media (Images) */}
{item.media && item.media.length > 0 && (
@ -600,7 +649,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -600,7 +649,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item
ref={contentRef}
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere',
needsCollapse && !isExpanded && 'max-h-[400px] overflow-hidden',
needsCollapse && !longBodyExpanded && 'max-h-[400px] overflow-hidden',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
'[&_*]:max-w-full'
)}
@ -618,32 +667,25 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -618,32 +667,25 @@ export default function RssFeedItem({ item, className, compact = false }: { item
/>
{/* Gradient overlay when collapsed */}
{needsCollapse && !isExpanded && (
{needsCollapse && !longBodyExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/60 to-background pointer-events-none" />
)}
{/* Collapse/Expand Button - Only show in full view */}
{!compact && needsCollapse && (
{showFullBody && needsCollapse && (
<div className="flex justify-center mt-2 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
setLongBodyExpanded((prev) => !prev)
}}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
{t('Show less')}
</>
{longBodyExpanded ? (
t('Show less')
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{t('Show more')}
</>
t('Show more')
)}
</Button>
</div>
@ -716,8 +758,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -716,8 +758,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item
</>
) : null}
{/* Link to original article and expand button */}
<div className="flex items-center justify-between gap-2 text-sm min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm min-w-0">
<a
href={item.link}
target="_blank"
@ -728,29 +769,6 @@ export default function RssFeedItem({ item, className, compact = false }: { item @@ -728,29 +769,6 @@ export default function RssFeedItem({ item, className, compact = false }: { item
<span className="truncate">{t('Read full article')}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
{compact && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
{t('Show less')}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{t('Expand')}
</>
)}
</Button>
)}
</div>
{/* Post Editor for highlights */}

88
src/components/RssFeedList/index.tsx

@ -4,7 +4,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -4,7 +4,7 @@ import { useNostr } from '@/providers/NostrProvider'
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import { DEFAULT_RSS_FEEDS } from '@/constants'
import RssFeedItem from '../RssFeedItem'
import { Loader, AlertCircle, Search } from 'lucide-react'
import { Loader, AlertCircle, Search, Plus } from 'lucide-react'
import logger from '@/lib/logger'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
@ -12,8 +12,86 @@ import { Switch } from '@/components/ui/switch' @@ -12,8 +12,86 @@ import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Check, ChevronDown } from 'lucide-react'
import { useSmartRssArticleNavigation } from '@/PageManager'
import { normalizeHttpArticleUrl } from '@/lib/rss-article'
function ManualRssUrlAddRow({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState('')
const submit = () => {
setError('')
const url = normalizeHttpArticleUrl(value)
if (!url) {
setError(t('Enter a valid http(s) URL'))
return
}
setOpen(false)
setValue('')
navigateToRssArticle(url)
}
return (
<>
<Button
type="button"
variant="outline"
className={className ?? 'w-full justify-start gap-2 text-muted-foreground border-dashed'}
onClick={() => setOpen(true)}
>
<Plus className="h-4 w-4 shrink-0" />
{t('+ Add a URL to this list')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('Add a web URL')}</DialogTitle>
<DialogDescription>
{t('Open any https page in the side panel to reply, react, and discuss on Nostr.')}
</DialogDescription>
</DialogHeader>
<Input
placeholder="https://example.com/article"
value={value}
onChange={(e) => {
setValue(e.target.value)
setError('')
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
}}
autoFocus
/>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
{t('Cancel')}
</Button>
<Button type="button" onClick={submit}>
{t('Open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
export default function RssFeedList() {
const { t } = useTranslation()
@ -451,8 +529,9 @@ export default function RssFeedList() { @@ -451,8 +529,9 @@ export default function RssFeedList() {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">{t('No RSS feed items available')}</p>
<div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow />
<p className="text-sm text-muted-foreground text-center">{t('No RSS feed items available')}</p>
</div>
)
}
@ -570,6 +649,7 @@ export default function RssFeedList() { @@ -570,6 +649,7 @@ export default function RssFeedList() {
{/* Content */}
<div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow />
{refreshing && (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-muted-foreground border-b">
<Loader className="h-4 w-4 animate-spin" />
@ -588,7 +668,7 @@ export default function RssFeedList() { @@ -588,7 +668,7 @@ export default function RssFeedList() {
) : (
<>
{displayedItems.map((item) => (
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} compact={isCompactView} />
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} layout={isCompactView ? 'list' : 'detail'} />
))}
{/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && (

2
src/constants.ts

@ -231,6 +231,8 @@ export const ExtendedKind = { @@ -231,6 +231,8 @@ export const ExtendedKind = {
CITATION_HARDCOPY: 32,
CITATION_PROMPT: 33,
RSS_FEED_LIST: 10895,
/** Client-only synthetic "parent" for RSS article threads; never published to relays */
RSS_THREAD_ROOT: 99999,
// NIP-89 Application Handlers
APPLICATION_HANDLER_RECOMMENDATION: 31989,
APPLICATION_HANDLER_INFO: 31990,

11
src/i18n/locales/de.ts

@ -373,6 +373,17 @@ export default { @@ -373,6 +373,17 @@ export default {
Topics: 'Themen',
'Open in a': 'Öffnen in {{a}}',
'Cannot handle event of kind k': 'Ereignis des Typs {{k}} kann nicht verarbeitet werden',
'Jumble Imwald synthetic event': 'Jumble Imwald – synthetisches Ereignis',
'+ Add a URL to this list': 'URL zur Liste hinzufügen',
'Add a web URL': 'Web-URL hinzufügen',
'Open any https page in the side panel to reply, react, and discuss on Nostr.':
'Beliebige https-Seite im Seitenpanel öffnen, um auf Nostr zu antworten, zu reagieren und zu diskutieren.',
'Enter a valid http(s) URL': 'Bitte eine gültige http(s)-URL eingeben',
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.',
'Open in browser': 'Im Browser öffnen',
'Web page': 'Webseite',
Open: 'Öffnen',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',
'This user has been muted': 'Dieser Benutzer wurde stummgeschaltet',
Wallet: 'Wallet',

11
src/i18n/locales/en.ts

@ -366,6 +366,17 @@ export default { @@ -366,6 +366,17 @@ export default {
Topics: 'Topics',
'Open in a': 'Open in {{a}}',
'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}',
'Jumble Imwald synthetic event': 'Jumble Imwald synthetic event',
'+ Add a URL to this list': 'Add a URL to this list',
'Add a web URL': 'Add a web URL',
'Open any https page in the side panel to reply, react, and discuss on Nostr.':
'Open any https page in the side panel to reply, react, and discuss on Nostr.',
'Enter a valid http(s) URL': 'Enter a valid http(s) URL',
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.',
'Open in browser': 'Open in browser',
'Web page': 'Web page',
Open: 'Open',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',
'This user has been muted': 'This user has been muted',
Wallet: 'Wallet',

80
src/lib/draft-event.ts

@ -24,9 +24,16 @@ import { @@ -24,9 +24,16 @@ import {
isProtectedEvent,
isReplaceableEvent
} from './event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { cleanUrl } from '@/lib/url'
import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag'
function canonicalizeHttpUrlForITags(url: string): string {
if (!url.startsWith('http://') && !url.startsWith('https://')) return url
return canonicalizeRssArticleUrl(url)
}
const draftEventCache: Map<string, string> = new Map()
export function deleteDraftEventCache(draftEvent: TDraftEvent) {
@ -232,6 +239,17 @@ export async function createCommentDraftEvent( @@ -232,6 +239,17 @@ export async function createCommentDraftEvent(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
const isRssArticleThreadRoot = parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT
const rssArticleUrl = isRssArticleThreadRoot
? rootUrl || parentEvent.tags.find((t) => t[0] === 'i' || t[0] === 'I')?.[1]
: undefined
if (isRssArticleThreadRoot) {
if (rssArticleUrl) {
const u = canonicalizeHttpUrlForITags(rssArticleUrl)
tags.push(buildITag(u, false), buildITag(u, true))
}
} else {
if (rootCoordinateTag) {
tags.push(rootCoordinateTag)
} else if (rootEventId) {
@ -244,7 +262,8 @@ export async function createCommentDraftEvent( @@ -244,7 +262,8 @@ export async function createCommentDraftEvent(
tags.push(buildKTag(rootKind, true))
}
if (rootUrl) {
tags.push(buildITag(rootUrl, true))
const u = canonicalizeHttpUrlForITags(rootUrl)
tags.push(buildITag(u, false), buildITag(u, true))
}
tags.push(
...[
@ -255,6 +274,7 @@ export async function createCommentDraftEvent( @@ -255,6 +274,7 @@ export async function createCommentDraftEvent(
buildPTag(parentEvent.pubkey)
]
)
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
@ -1054,16 +1074,6 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) { @@ -1054,16 +1074,6 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
async function extractCommentMentions(content: string, parentEvent: Event) {
const quoteEventHexIds: string[] = []
const quoteReplaceableCoordinates: string[] = []
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = isComment
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined
const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item)
@ -1089,6 +1099,32 @@ async function extractCommentMentions(content: string, parentEvent: Event) { @@ -1089,6 +1099,32 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
}
}
if (parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT) {
const url = parentEvent.tags.find((t) => t[0] === 'i' || t[0] === 'I')?.[1]
return {
quoteEventHexIds,
quoteReplaceableCoordinates,
rootEventId: undefined,
rootCoordinateTag: undefined,
rootKind: undefined,
rootPubkey: undefined,
rootUrl: url
}
}
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = isComment
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey
const rootUrl = isComment
? parentEvent.tags.find((t) => t[0] === 'I' || t[0] === 'i')?.[1]
: undefined
return {
quoteEventHexIds,
quoteReplaceableCoordinates,
@ -1096,8 +1132,7 @@ async function extractCommentMentions(content: string, parentEvent: Event) { @@ -1096,8 +1132,7 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
rootCoordinateTag,
rootKind,
rootPubkey,
rootUrl,
parentEvent
rootUrl
}
}
@ -1371,8 +1406,8 @@ export async function createHighlightDraftEvent( @@ -1371,8 +1406,8 @@ export async function createHighlightDraftEvent(
}
}
} else if (sourceType === 'url') {
// Add r-tag with 'source' attribute
tags.push(['r', sourceValue, 'source'])
const trimmed = sourceValue.trim()
tags.push(['r', cleanUrl(trimmed) || trimmed, 'source'])
}
// Add context tag if provided (the full text/quote that the highlight is from)
@ -1513,6 +1548,17 @@ export async function createVoiceCommentDraftEvent( @@ -1513,6 +1548,17 @@ export async function createVoiceCommentDraftEvent(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
const isRssArticleThreadRootVoice = parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT
const rssArticleUrlVoice = isRssArticleThreadRootVoice
? rootUrl || parentEvent.tags.find((t) => t[0] === 'i' || t[0] === 'I')?.[1]
: undefined
if (isRssArticleThreadRootVoice) {
if (rssArticleUrlVoice) {
const u = canonicalizeHttpUrlForITags(rssArticleUrlVoice)
tags.push(buildITag(u, false), buildITag(u, true))
}
} else {
if (rootCoordinateTag) {
tags.push(rootCoordinateTag)
} else if (rootEventId) {
@ -1525,7 +1571,8 @@ export async function createVoiceCommentDraftEvent( @@ -1525,7 +1571,8 @@ export async function createVoiceCommentDraftEvent(
tags.push(buildKTag(rootKind, true))
}
if (rootUrl) {
tags.push(buildITag(rootUrl, true))
const u = canonicalizeHttpUrlForITags(rootUrl)
tags.push(buildITag(u, false), buildITag(u, true))
}
tags.push(
...[
@ -1536,6 +1583,7 @@ export async function createVoiceCommentDraftEvent( @@ -1536,6 +1583,7 @@ export async function createVoiceCommentDraftEvent(
buildPTag(parentEvent.pubkey)
]
)
}
if (options.isNsfw) {
tags.push(buildNsfwTag())

11
src/lib/event.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl } from '@/lib/url'
import client from '@/services/client.service'
import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache'
@ -381,3 +382,13 @@ export function dedupeToLatestPerReplaceableCoordinate(events: Event[]): Event[] @@ -381,3 +382,13 @@ export function dedupeToLatestPerReplaceableCoordinate(events: Event[]): Event[]
}
return [...byKey.values()]
}
/** External article URL from `i` / `I` tags (e.g. kind 1111 comments on web content). */
export function getHttpUrlFromITags(event: Event): string | undefined {
const lower = event.tags.find((t) => t[0] === 'i')?.[1]?.trim()
const upper = event.tags.find((t) => t[0] === 'I')?.[1]?.trim()
const raw = lower ?? upper
if (!raw) return undefined
if (!raw.startsWith('http://') && !raw.startsWith('https://')) return undefined
return cleanUrl(raw) || raw
}

82
src/lib/rss-article.ts

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
import { ExtendedKind } from '@/constants'
import { cleanUrl } from '@/lib/url'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import type { Event } from 'nostr-tools'
/** Encode article URL for a single path segment (UTF-8 → base64url, no padding). */
export function encodeRssArticlePathSegment(articleUrl: string): string {
const bytes = new TextEncoder().encode(articleUrl)
let binary = ''
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)
const b64 = btoa(binary)
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function decodeRssArticlePathSegment(segment: string): string {
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/')
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
const binary = atob(b64 + pad)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return new TextDecoder().decode(out)
}
/** Stable fake event id for caching / stats keys (not a published note id). */
export function rssArticleStableEventId(articleUrl: string): string {
return bytesToHex(sha256(new TextEncoder().encode(`rss-thread-root:${articleUrl}`)))
}
/** Strip tracking params from http(s) article URLs; leave other values unchanged. */
export function canonicalizeRssArticleUrl(url: string): string {
const t = url.trim()
if (!t.startsWith('http://') && !t.startsWith('https://')) return t
return cleanUrl(t) || t
}
/** Normalize user input to an http(s) URL for manual article threads; returns null if invalid. */
export function normalizeHttpArticleUrl(raw: string): string | null {
let s = raw.trim()
if (!s) return null
if (!/^https?:\/\//i.test(s)) {
s = `https://${s}`
}
try {
const u = new URL(s)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
return canonicalizeRssArticleUrl(u.href)
} catch {
return null
}
}
/**
* Synthetic parent event for kind 1111 comments on an RSS article.
* Thread is keyed by the article URL in both `i` and `I` tags (no e/a root).
*/
export function createRssThreadRootEvent(articleUrl: string): Event {
const canonical = canonicalizeRssArticleUrl(articleUrl)
return {
id: rssArticleStableEventId(canonical),
pubkey: '0'.repeat(64),
created_at: 0,
kind: ExtendedKind.RSS_THREAD_ROOT,
tags: [
['i', canonical],
['I', canonical]
],
content: '',
sig: ''
}
}
export function getArticleUrlFromCommentITags(event: Event): string | undefined {
const upper = event.tags.find((t) => t[0] === 'I')?.[1]
if (upper) return upper
return event.tags.find((t) => t[0] === 'i')?.[1]
}
/** Client-only RSS thread parent (non-standard kind); not a real relay event. */
export function isRssThreadSyntheticParentEvent(event: Pick<Event, 'kind'>): boolean {
return event.kind === ExtendedKind.RSS_THREAD_ROOT
}

14
src/lib/url.ts

@ -345,7 +345,12 @@ export function cleanUrl(url: string): string { @@ -345,7 +345,12 @@ export function cleanUrl(url: string): string {
'aff_id', 'affiliate_id', 'aff', 'ref_', 'refer',
// Social media share tracking
'share', 'shared', 'sharesource'
'share', 'shared', 'sharesource',
// Mail Online / Associated Newspapers RSS (e.g. ?ns_mchannel=rss&ito=1490&ns_campaign=1490)
'ns_mchannel',
'ns_campaign',
'ito'
]
// Remove all tracking parameters
@ -353,6 +358,13 @@ export function cleanUrl(url: string): string { @@ -353,6 +358,13 @@ export function cleanUrl(url: string): string {
parsedUrl.searchParams.delete(param)
})
// Other Mail-style campaign params (ns_*)
Array.from(parsedUrl.searchParams.keys()).forEach((key) => {
if (key.startsWith('ns_')) {
parsedUrl.searchParams.delete(key)
}
})
// Remove any parameter that starts with utm_
Array.from(parsedUrl.searchParams.keys()).forEach(key => {
if (key.startsWith('utm_') || key.startsWith('_')) {

169
src/pages/secondary/RssArticlePage/index.tsx

@ -0,0 +1,169 @@ @@ -0,0 +1,169 @@
import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem'
import WebPreview from '@/components/WebPreview'
import { Separator } from '@/components/ui/separator'
import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { decodeRssArticlePathSegment, createRssThreadRootEvent } from '@/lib/rss-article'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
const RssArticlePage = forwardRef(
(
{
articleKey,
index,
hideTitlebar = false,
initialItem
}: {
articleKey: string
index?: number
hideTitlebar?: boolean
initialItem?: TRssFeedItem
},
ref
) => {
const { t } = useTranslation()
const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null)
const [loading, setLoading] = useState(!initialItem)
const articleUrl = useMemo(() => {
try {
return decodeRssArticlePathSegment(articleKey)
} catch {
return ''
}
}, [articleKey])
useEffect(() => {
if (initialItem || !articleUrl) {
setLoading(false)
return
}
let cancelled = false
;(async () => {
try {
const items = await indexedDb.getRssFeedItems()
if (cancelled) return
const found = items.find((i) => i.link === articleUrl) ?? null
setItem(found)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [articleUrl, initialItem])
const syntheticRoot = useMemo(
() => (articleUrl ? createRssThreadRootEvent(articleUrl) : null),
[articleUrl]
)
useEffect(() => {
if (hideTitlebar) {
sessionStorage.setItem('notePageTitle', item ? t('RSS article') : t('Web page'))
}
return () => {
if (hideTitlebar) {
sessionStorage.removeItem('notePageTitle')
}
}
}, [hideTitlebar, t, item])
if (!articleUrl) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}>
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Invalid article link.')}</div>
</SecondaryPageLayout>
)
}
if (loading) {
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}>
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Loading…')}</div>
</SecondaryPageLayout>
)
}
if (!item) {
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Web page')}
displayScrollToTopButton
>
<div className="px-4 pt-3 pb-4 w-full space-y-4">
<p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</p>
<div className="not-prose max-w-full">
<WebPreview url={articleUrl} className="w-full" />
</div>
<Button variant="outline" size="sm" asChild>
<a href={articleUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2">
{t('Open in browser')}
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
{syntheticRoot && (
<div className="px-0 w-full">
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} />
</div>
)}
<Separator />
<div className="w-full">
{syntheticRoot && (
<NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index}
event={syntheticRoot}
showQuotes={false}
/>
)}
</div>
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('RSS article')}
displayScrollToTopButton
>
<div className="px-4 pt-3 w-full">
<RssFeedItem item={item} layout="detail" />
</div>
{syntheticRoot && (
<div className="px-4 w-full">
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} />
</div>
)}
<Separator className="mt-4" />
<div className="px-4 pb-4 w-full">
{syntheticRoot && (
<NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index}
event={syntheticRoot}
showQuotes={false}
/>
)}
</div>
</SecondaryPageLayout>
)
}
)
RssArticlePage.displayName = 'RssArticlePage'
export default RssArticlePage

6
src/providers/ReplyProvider.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { getParentATag, getParentETag, getRootATag, getRootETag } from '@/lib/event'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react'
@ -37,6 +38,11 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { @@ -37,6 +38,11 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const rootATag = getRootATag(reply)
if (rootATag) {
rootId = rootATag[1]
} else {
const articleUrl = getArticleUrlFromCommentITags(reply)
if (articleUrl) {
rootId = articleUrl
}
}
}
if (rootId) {

9
src/routes.tsx

@ -29,6 +29,7 @@ const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage')) @@ -29,6 +29,7 @@ const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage'))
const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect'))
const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage'))
const routeSuspenseFallback = null
@ -50,6 +51,14 @@ const ROUTES = [ @@ -50,6 +51,14 @@ const ROUTES = [
{ path: '/home/notes/:id', element: SR(NotePageLazy) },
{ path: '/feed/notes/:id', element: SR(NotePageLazy) },
{ path: '/spells/notes/:id', element: SR(NotePageLazy) },
{ path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/home/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/users', element: SR(ProfileListPageLazy) },
{ path: '/users/:id', element: SR(ProfilePageLazy) },
{ path: '/users/:id/following', element: SR(FollowingListPageLazy) },

21
src/services/rss-feed.service.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { DEFAULT_RSS_FEEDS } from '@/constants'
import { cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
@ -392,6 +393,10 @@ class RssFeedService { @@ -392,6 +393,10 @@ class RssFeedService {
// If URL parsing fails, keep the original link
}
}
if (itemLink) {
const cleanedLink = cleanUrl(itemLink)
if (cleanedLink) itemLink = cleanedLink
}
// For description, prefer content:encoded (WordPress full content) over description (truncated)
// Check for content:encoded first, then fall back to description
let itemDescription = ''
@ -489,7 +494,11 @@ class RssFeedService { @@ -489,7 +494,11 @@ class RssFeedService {
}
const pubDateText = this.getTextContent(item, 'pubDate')
const itemPubDate = this.parseDate(pubDateText)
const itemGuid = this.getTextContent(item, 'guid') || itemLink || ''
let itemGuid = this.getTextContent(item, 'guid') || itemLink || ''
if (itemGuid && (itemGuid.startsWith('http://') || itemGuid.startsWith('https://'))) {
const cleanedGuid = cleanUrl(itemGuid)
if (cleanedGuid) itemGuid = cleanedGuid
}
// Log item parsing for debugging
if (!itemPubDate && pubDateText) {
@ -722,6 +731,10 @@ class RssFeedService { @@ -722,6 +731,10 @@ class RssFeedService {
// If URL parsing fails, keep the original link
}
}
if (entryLink) {
const cleanedEntryLink = cleanUrl(entryLink)
if (cleanedEntryLink) entryLink = cleanedEntryLink
}
// For content/summary, preserve HTML content
let entryContent = this.getHtmlContent(entry, 'content') || this.getHtmlContent(entry, 'summary') || ''
// Additional cleaning for Atom feeds (getHtmlContent already does basic cleaning)
@ -734,7 +747,11 @@ class RssFeedService { @@ -734,7 +747,11 @@ class RssFeedService {
}
const entryPublished = this.getTextContent(entry, 'published') || this.getTextContent(entry, 'updated')
const entryPubDate = this.parseDate(entryPublished)
const entryId = this.getTextContent(entry, 'id') || entryLink || ''
let entryId = this.getTextContent(entry, 'id') || entryLink || ''
if (entryId && (entryId.startsWith('http://') || entryId.startsWith('https://'))) {
const cleanedId = cleanUrl(entryId)
if (cleanedId) entryId = cleanedId
}
// Extract enclosure/link elements for Atom feeds (Atom uses <link rel="enclosure">)
let enclosure: RssFeedItemEnclosure | undefined

Loading…
Cancel
Save