diff --git a/package-lock.json b/package-lock.json
index e972c742..ae038a0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 90fc9573..b3b74ff1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/PageManager.tsx b/src/PageManager.tsx
index 8f8e03a3..88147aa0 100644
--- a/src/PageManager.tsx
+++ b/src/PageManager.tsx
@@ -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
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)
@@ -880,7 +902,60 @@ 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 }) {
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
diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx
index 4d0ae100..d115b0ee 100644
--- a/src/components/Content/index.tsx
+++ b/src/components/Content/index.tsx
@@ -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,7 +75,12 @@ 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({
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({
})
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({
}
})
- // 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({
return (
+ {iArticleUrl && (
+
+
+
+ )}
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
)
}
- // 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
}
if (node.type === 'invoice') {
diff --git a/src/components/ContentPreview/NormalContentPreview.tsx b/src/components/ContentPreview/NormalContentPreview.tsx
index 6ce25eea..508e39d7 100644
--- a/src/components/ContentPreview/NormalContentPreview.tsx
+++ b/src/components/ContentPreview/NormalContentPreview.tsx
@@ -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({
event: Event
className?: string
}) {
- const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
-
return (
)
}
diff --git a/src/components/Note/EventViewer.tsx b/src/components/Note/EventViewer.tsx
index 060f6681..43c6c95b 100644
--- a/src/components/Note/EventViewer.tsx
+++ b/src/components/Note/EventViewer.tsx
@@ -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
>(new Set(['root']))
+ const [expanded, setExpanded] = useState>(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
return {String(value)}
}
if (Array.isArray(value)) {
+ const tagsTreeAlwaysOpen =
+ expandTagsTree && (key === 'tags' || key.startsWith('tags['))
+ if (tagsTreeAlwaysOpen) {
+ return (
+ 0 && 'border-l border-border/50 pl-2')}>
+
+ Array ({value.length})
+
+
+ {value.map((item, idx) => (
+
+ [{idx}]{' '}
+ {renderValue(item, `${key}[${idx}]`, depth + 1)}
+
+ ))}
+
+
+ )
+ }
const isExpanded = expanded.has(key)
return (
0 && 'border-l border-border/50 pl-2')}>
-
toggle(key)}>
-
+ setKeyExpanded(key, open)}
+ >
+
{isExpanded ? (
) : (
@@ -102,8 +139,14 @@ export default function EventViewer({ event, className }: { event: Event; classN
const entries = Object.entries(value)
return (
0 && 'border-l border-border/50 pl-2')}>
-
toggle(key)}>
-
+ setKeyExpanded(key, open)}
+ >
+
{isExpanded ? (
) : (
@@ -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 (
@@ -145,13 +192,30 @@ export default function EventViewer({ event, className }: { event: Event; classN
{copiedNevent ? : }
-
-
pubkey
-
-
-
+ {!hidePubkeyRow && (
+
+
pubkey
+ {showAuthorBadge ? (
+
+
+
+
+ ) : (
+
+ {!pubkey
+ ? t('Missing pubkey')
+ : isAllZeroPlaceholderPubkey(pubkey)
+ ? t('Synthetic event (no author)')
+ : pubkey}
+
+ )}
-
+ )}
kind{' '}
{renderValue(event.kind, 'kind')}
diff --git a/src/components/Note/IValue.tsx b/src/components/Note/IValue.tsx
index 6df2afab..76ca309e 100644
--- a/src/components/Note/IValue.tsx
+++ b/src/components/Note/IValue.tsx
@@ -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 (
)
}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
index 486650d2..28d24e55 100644
--- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -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(
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
; footnotes: Map; 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()
const footnotes = new Map()
@@ -1817,12 +1831,29 @@ function parseMarkdownContent(
}
} else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data
- // Standalone links render as WebPreview (OpenGraph card)
- parts.push(
-
-
-
- )
+ const cleanedStandalone = cleanUrl(url) || url
+ if (
+ suppressStandaloneWebPreviewForCleanedUrl &&
+ cleanedStandalone === suppressStandaloneWebPreviewForCleanedUrl
+ ) {
+ parts.push(
+
+ {url}
+
+ )
+ } else {
+ parts.push(
+
+
+
+ )
+ }
} else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data
// Process the link text for inline formatting (bold, italic, etc.)
@@ -3198,7 +3229,12 @@ 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({
// 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({
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({
}
`}
+ {iArticleUrl && (
+
+
+
+ )}
{/* Metadata */}
{!hideMetadata && metadata.title &&
{metadata.title}
}
{!hideMetadata && metadata.summary && (
diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx
index 4baaf4c2..0936331f 100644
--- a/src/components/Note/UnknownNote.tsx
+++ b/src/components/Note/UnknownNote.tsx
@@ -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
)}
-
+
)
}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index c9c6696b..9994f12e 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -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'
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({
/** 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({
>
)
- } else if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) {
+ } else if (event.kind === ExtendedKind.VOICE) {
content =
+ } else if (event.kind === ExtendedKind.VOICE_COMMENT) {
+ const voiceArticleUrl = getHttpUrlFromITags(event)
+ content = (
+ <>
+ {voiceArticleUrl && (
+
+
+
+ )}
+
+ >
+ )
} else if (event.kind === ExtendedKind.PICTURE) {
content =
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
@@ -220,7 +236,7 @@ export default function Note({
content =
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content =
- } 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 =
} else {
@@ -228,6 +244,8 @@ export default function Note({
content =
}
+ const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event)
+
const wrappedContent = isHighlightableKind ? (
{content}
) : (
@@ -251,25 +269,56 @@ export default function Note({
>
-
-
+ {isSyntheticRssParent ? (
+ <>
+
+

+
+
+
+
+ {t('Jumble Imwald synthetic event')}
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
{event.kind === ExtendedKind.DISCUSSION && (
diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx
index ffe28910..9af791bc 100644
--- a/src/components/NoteInteractions/index.tsx
+++ b/src/components/NoteInteractions/index.tsx
@@ -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
('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({
index={pageIndex}
event={event}
sort={replySort}
- showQuotes={!isDiscussion}
+ showQuotes={showQuotes}
/>
>
)
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index 47db1990..4d6f4544 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -44,6 +44,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({
e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
-
+ {!isRssArticleRoot &&
}
{/* Kind 11: LikeButton already shows ⬆️/⬇️; Likes row would duplicate those pills */}
- {!isDiscussion &&
}
+ {!isDiscussion && !isRssArticleRoot &&
}
>
)}
- {!isDiscussion && !isReplyToDiscussion && }
+ {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
+
+ )}
-
+ {!isRssArticleRoot && }
@@ -100,8 +105,8 @@ export default function NoteStats({
e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
-
- {!isDiscussion &&
}
+ {!isRssArticleRoot &&
}
+ {!isDiscussion && !isRssArticleRoot &&
}
>
)}
@@ -109,9 +114,11 @@ export default function NoteStats({
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
>
- {!isDiscussion && !isReplyToDiscussion && }
+ {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
+
+ )}
-
+ {!isRssArticleRoot && }
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index dce0bf3d..a598b260 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -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({
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
=> {
// Get expiration and quiet settings
@@ -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={
diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx
index d2c712ff..ccd04966 100644
--- a/src/components/PostEditor/PostTextarea/Preview.tsx
+++ b/src/components/PostEditor/PostTextarea/Preview.tsx
@@ -67,7 +67,15 @@ export default function Preview({
if (kind === kinds.Highlights && highlightData) {
// Add source tag
if (highlightData.sourceType === 'url') {
- highlightTags.push(['r', highlightData.sourceValue, 'source'])
+ 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
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index 959b5230..d1e02205 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -1,4 +1,5 @@
import { ExtendedKind } from '@/constants'
+import { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import {
getParentETag,
getReplaceableCoordinateFromEvent,
@@ -216,8 +217,16 @@ 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)) {
root = {
type: 'A',
@@ -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({
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent
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({
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({
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 (
diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx
index 34f8dcff..9dd680f7 100644
--- a/src/components/RssFeedItem/index.tsx
+++ b/src/components/RssFeedItem/index.tsx
@@ -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'
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 {
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
const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null)
const [isPostEditorOpen, setIsPostEditorOpen] = useState(false)
const [highlightData, setHighlightData] = useState(undefined)
- const [isExpanded, setIsExpanded] = useState(false)
const contentRef = useRef(null)
const selectionTimeoutRef = useRef()
const isSelectingRef = useRef(false)
@@ -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
// 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
clearTimeout(timeoutId2)
resizeObserver.disconnect()
}
- }, [descriptionHtml, isExpanded])
+ }, [descriptionHtml, longBodyExpanded, isListLayout])
return (
-
+
{
+ 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 */}
{/* Feed Image/Logo */}
@@ -520,23 +556,36 @@ export default function RssFeedItem({ item, className, compact = false }: { item
{/* Title */}
- {/* 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
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
/>
{/* Gradient overlay when collapsed */}
- {needsCollapse && !isExpanded && (
+ {needsCollapse && !longBodyExpanded && (
)}
- {/* Collapse/Expand Button - Only show in full view */}
- {!compact && needsCollapse && (
+ {showFullBody && needsCollapse && (
@@ -716,8 +758,7 @@ export default function RssFeedItem({ item, className, compact = false }: { item
>
) : null}
- {/* Link to original article and expand button */}
-
+
{t('Read full article')}
- {compact && (
-
- )}
{/* Post Editor for highlights */}
diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx
index 840aaefd..8385937f 100644
--- a/src/components/RssFeedList/index.tsx
+++ b/src/components/RssFeedList/index.tsx
@@ -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'
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 (
+ <>
+
+
+ >
+ )
+}
export default function RssFeedList() {
const { t } = useTranslation()
@@ -451,8 +529,9 @@ export default function RssFeedList() {
if (items.length === 0) {
return (
-
-
{t('No RSS feed items available')}
+
+
+
{t('No RSS feed items available')}
)
}
@@ -570,6 +649,7 @@ export default function RssFeedList() {
{/* Content */}
+
{refreshing && (
@@ -588,7 +668,7 @@ export default function RssFeedList() {
) : (
<>
{displayedItems.map((item) => (
-
+
))}
{/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && (
diff --git a/src/constants.ts b/src/constants.ts
index 29d0c164..25a17281 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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,
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 311a7feb..a0887fa4 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -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',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 38243534..5fbdda80 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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',
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index aabc26cf..b2362d4c 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -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
= new Map()
export function deleteDraftEventCache(draftEvent: TDraftEvent) {
@@ -232,29 +239,42 @@ export async function createCommentDraftEvent(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
- if (rootCoordinateTag) {
- tags.push(rootCoordinateTag)
- } else if (rootEventId) {
- tags.push(buildETag(rootEventId, rootPubkey, '', true))
- }
- if (rootPubkey) {
- tags.push(buildPTag(rootPubkey, true))
- }
- if (rootKind) {
- tags.push(buildKTag(rootKind, true))
- }
- if (rootUrl) {
- tags.push(buildITag(rootUrl, true))
+ 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) {
+ tags.push(buildETag(rootEventId, rootPubkey, '', true))
+ }
+ if (rootPubkey) {
+ tags.push(buildPTag(rootPubkey, true))
+ }
+ if (rootKind) {
+ tags.push(buildKTag(rootKind, true))
+ }
+ if (rootUrl) {
+ const u = canonicalizeHttpUrlForITags(rootUrl)
+ tags.push(buildITag(u, false), buildITag(u, true))
+ }
+ tags.push(
+ ...[
+ isReplaceableEvent(parentEvent.kind)
+ ? buildATag(parentEvent)
+ : buildETag(parentEvent.id, parentEvent.pubkey),
+ buildKTag(parentEvent.kind),
+ buildPTag(parentEvent.pubkey)
+ ]
+ )
}
- tags.push(
- ...[
- isReplaceableEvent(parentEvent.kind)
- ? buildATag(parentEvent)
- : buildETag(parentEvent.id, parentEvent.pubkey),
- buildKTag(parentEvent.kind),
- buildPTag(parentEvent.pubkey)
- ]
- )
if (options.isNsfw) {
tags.push(buildNsfwTag())
@@ -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) {
}
}
+ 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) {
rootCoordinateTag,
rootKind,
rootPubkey,
- rootUrl,
- parentEvent
+ rootUrl
}
}
@@ -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)
@@ -1512,30 +1547,43 @@ export async function createVoiceCommentDraftEvent(
tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
-
- if (rootCoordinateTag) {
- tags.push(rootCoordinateTag)
- } else if (rootEventId) {
- tags.push(buildETag(rootEventId, rootPubkey, '', true))
- }
- if (rootPubkey) {
- tags.push(buildPTag(rootPubkey, true))
- }
- if (rootKind) {
- tags.push(buildKTag(rootKind, true))
- }
- if (rootUrl) {
- tags.push(buildITag(rootUrl, true))
+
+ 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) {
+ tags.push(buildETag(rootEventId, rootPubkey, '', true))
+ }
+ if (rootPubkey) {
+ tags.push(buildPTag(rootPubkey, true))
+ }
+ if (rootKind) {
+ tags.push(buildKTag(rootKind, true))
+ }
+ if (rootUrl) {
+ const u = canonicalizeHttpUrlForITags(rootUrl)
+ tags.push(buildITag(u, false), buildITag(u, true))
+ }
+ tags.push(
+ ...[
+ isReplaceableEvent(parentEvent.kind)
+ ? buildATag(parentEvent)
+ : buildETag(parentEvent.id, parentEvent.pubkey),
+ buildKTag(parentEvent.kind),
+ buildPTag(parentEvent.pubkey)
+ ]
+ )
}
- tags.push(
- ...[
- isReplaceableEvent(parentEvent.kind)
- ? buildATag(parentEvent)
- : buildETag(parentEvent.id, parentEvent.pubkey),
- buildKTag(parentEvent.kind),
- buildPTag(parentEvent.pubkey)
- ]
- )
if (options.isNsfw) {
tags.push(buildNsfwTag())
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 1de5d43e..ae03fa02 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -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[]
}
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
+}
diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts
new file mode 100644
index 00000000..b2418b39
--- /dev/null
+++ b/src/lib/rss-article.ts
@@ -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): boolean {
+ return event.kind === ExtendedKind.RSS_THREAD_ROOT
+}
diff --git a/src/lib/url.ts b/src/lib/url.ts
index 5895da60..57a51133 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -345,13 +345,25 @@ 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
trackingParams.forEach(param => {
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 => {
diff --git a/src/pages/secondary/RssArticlePage/index.tsx b/src/pages/secondary/RssArticlePage/index.tsx
new file mode 100644
index 00000000..ca33a7cb
--- /dev/null
+++ b/src/pages/secondary/RssArticlePage/index.tsx
@@ -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(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 (
+
+ {t('Invalid article link.')}
+
+ )
+ }
+
+ if (loading) {
+ return (
+
+ {t('Loading…')}
+
+ )
+ }
+
+ if (!item) {
+ return (
+
+
+
+ {t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
+
+
+
+
+
+ {syntheticRoot && (
+
+
+
+ )}
+
+
+ {syntheticRoot && (
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {syntheticRoot && (
+
+
+
+ )}
+
+
+ {syntheticRoot && (
+
+ )}
+
+
+ )
+ }
+)
+
+RssArticlePage.displayName = 'RssArticlePage'
+export default RssArticlePage
diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx
index 10b85869..7b0f52d6 100644
--- a/src/providers/ReplyProvider.tsx
+++ b/src/providers/ReplyProvider.tsx
@@ -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 }) {
const rootATag = getRootATag(reply)
if (rootATag) {
rootId = rootATag[1]
+ } else {
+ const articleUrl = getArticleUrlFromCommentITags(reply)
+ if (articleUrl) {
+ rootId = articleUrl
+ }
}
}
if (rootId) {
diff --git a/src/routes.tsx b/src/routes.tsx
index 6ce89458..7cdc24a0 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -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 = [
{ 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) },
diff --git a/src/services/rss-feed.service.ts b/src/services/rss-feed.service.ts
index 2f65c1f1..549c243c 100644
--- a/src/services/rss-feed.service.ts
+++ b/src/services/rss-feed.service.ts
@@ -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 {
// 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 {
}
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 {
// 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 {
}
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 )
let enclosure: RssFeedItemEnclosure | undefined