From 56bfe643c9f4f11e9f3bb7b7056652ceeeaaea21 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Mar 2026 22:24:29 +0100 Subject: [PATCH] rss comments and highlights --- package-lock.json | 4 +- package.json | 2 +- src/PageManager.tsx | 101 ++++++++++- src/components/Content/index.tsx | 33 +++- .../ContentPreview/NormalContentPreview.tsx | 6 +- src/components/Note/EventViewer.tsx | 100 +++++++++-- src/components/Note/IValue.tsx | 16 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 87 +++++++-- src/components/Note/UnknownNote.tsx | 11 +- src/components/Note/index.tsx | 93 +++++++--- src/components/NoteInteractions/index.tsx | 8 +- src/components/NoteStats/index.tsx | 23 ++- src/components/PostEditor/PostContent.tsx | 12 ++ .../PostEditor/PostTextarea/Preview.tsx | 10 +- src/components/ReplyNoteList/index.tsx | 38 +++- src/components/RssFeedItem/index.tsx | 144 ++++++++------- src/components/RssFeedList/index.tsx | 88 ++++++++- src/constants.ts | 2 + src/i18n/locales/de.ts | 11 ++ src/i18n/locales/en.ts | 11 ++ src/lib/draft-event.ts | 166 +++++++++++------ src/lib/event.ts | 11 ++ src/lib/rss-article.ts | 82 +++++++++ src/lib/url.ts | 14 +- src/pages/secondary/RssArticlePage/index.tsx | 169 ++++++++++++++++++ src/providers/ReplyProvider.tsx | 6 + src/routes.tsx | 9 + src/services/rss-feed.service.ts | 21 ++- 28 files changed, 1045 insertions(+), 233 deletions(-) create mode 100644 src/lib/rss-article.ts create mode 100644 src/pages/secondary/RssArticlePage/index.tsx 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 (
{t('Comment on') + ' '} - {iValue.startsWith('http') ? ( - e.stopPropagation()} - > - {iValue} - - ) : ( - {iValue} - )} + {iValue}
) } 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 ( + <> + + + + + {t('Add a web URL')} + + {t('Open any https page in the side panel to reply, react, and discuss on Nostr.')} + + + { + setValue(e.target.value) + setError('') + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } + }} + autoFocus + /> + {error ?

{error}

: null} + + + + +
+
+ + ) +} 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