diff --git a/src/PageManager.tsx b/src/PageManager.tsx
index db0158c7..f6d6336a 100644
--- a/src/PageManager.tsx
+++ b/src/PageManager.tsx
@@ -241,7 +241,11 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
return `/notes/${noteId}`
}
-function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName | null): string {
+function buildRssArticleUrl(
+ articleUrl: string,
+ currentPage: TPrimaryPageName | null,
+ options?: { rssFeedReadOnly?: boolean }
+): string {
const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [
'search',
@@ -252,10 +256,14 @@ function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName |
'explore',
'follows-latest'
]
- if (currentPage && contextualPages.includes(currentPage)) {
- return `/${currentPage}/rss-item/${key}`
+ let path =
+ currentPage && contextualPages.includes(currentPage)
+ ? `/${currentPage}/rss-item/${key}`
+ : `/rss-item/${key}`
+ if (options?.rssFeedReadOnly) {
+ path += `${path.includes('?') ? '&' : '?'}rssFeedReadOnly=1`
}
- return `/rss-item/${key}`
+ return path
}
/** True for secondary routes that show an RSS / web article in the panel (contextual or bare). */
@@ -272,8 +280,11 @@ export function useSmartRssArticleNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage()
const { current: currentPrimaryPage } = usePrimaryPage()
- const navigateToRssArticle = (articleUrl: string) => {
- pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage))
+ const navigateToRssArticle = (
+ articleUrl: string,
+ navOptions?: { rssFeedReadOnly?: boolean }
+ ) => {
+ pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage, navOptions))
}
return { navigateToRssArticle }
diff --git a/src/components/LiveActivitiesStrip.tsx b/src/components/LiveActivitiesStrip.tsx
index 47f6d9f1..cca2e41c 100644
--- a/src/components/LiveActivitiesStrip.tsx
+++ b/src/components/LiveActivitiesStrip.tsx
@@ -1,7 +1,8 @@
import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities'
import { cn } from '@/lib/utils'
import { useLiveActivitiesOptional } from '@/providers/LiveActivitiesProvider'
-import { useUserPreferences } from '@/providers/UserPreferencesProvider'
+import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider'
+import storage from '@/services/local-storage.service'
import { ExternalLink } from 'lucide-react'
import { useEffect, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -10,7 +11,9 @@ type TPlacement = 'sidebar' | 'mobile'
export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) {
const { t } = useTranslation()
- const { showLiveActivitiesBanner } = useUserPreferences()
+ const userPrefs = useUserPreferencesOptional()
+ const showLiveActivitiesBanner =
+ userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
const live = useLiveActivitiesOptional()
const items = live?.items ?? []
diff --git a/src/components/RssFeedItem/index.tsx b/src/components/RssFeedItem/index.tsx
index d48e64f7..5588fee4 100644
--- a/src/components/RssFeedItem/index.tsx
+++ b/src/components/RssFeedItem/index.tsx
@@ -19,6 +19,7 @@ import { useSmartRssArticleNavigation } from '@/PageManager'
import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url'
import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext'
import { isClawstrDotComHttpHref } from '@/lib/rss-article'
+import { isHttpArticleUrl, promoteRssArticleForNostrThread } from '@/lib/rss-web-feed'
/**
* Convert HTML to plain text by extracting text content and cleaning up whitespace
@@ -49,7 +50,12 @@ export default function RssFeedItem({
className,
layout = 'detail',
expandBodyFully = false,
- sourceStrip
+ sourceStrip,
+ /** Disables text-selection → Nostr highlight flow (e.g. RSS read-only article panel). */
+ readOnlyHighlights = false,
+ /** RSS-column list rows: read-only navigation + promote button; implies read-only highlights. */
+ rssEntryReadOnlyMode = false,
+ onAfterPromoteRss
}: {
item: TRssFeedItem
className?: string
@@ -59,6 +65,9 @@ export default function RssFeedItem({
expandBodyFully?: boolean
/** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */
sourceStrip?: 'rss' | 'web'
+ readOnlyHighlights?: boolean
+ rssEntryReadOnlyMode?: boolean
+ onAfterPromoteRss?: () => void
}) {
const { t } = useTranslation()
const { suppressClawstrLinks } = useRssFeedDisplayPrefs()
@@ -68,6 +77,8 @@ export default function RssFeedItem({
const isWebFaux = isWebOnlyFauxRssItem(item)
const isListLayout = layout === 'list'
const showFullBody = layout === 'detail'
+ const noHighlights = readOnlyHighlights || rssEntryReadOnlyMode
+ const [promotingRss, setPromotingRss] = useState(false)
const [selectedText, setSelectedText] = useState('')
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
const [showHighlightButton, setShowHighlightButton] = useState(false)
@@ -84,6 +95,14 @@ export default function RssFeedItem({
// Handle text selection
useEffect(() => {
+ if (noHighlights) {
+ setShowHighlightButton(false)
+ setShowHighlightDrawer(false)
+ setSelectedText('')
+ setSelectionPosition(null)
+ return
+ }
+
const handleSelection = (forceShow = false) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
@@ -354,7 +373,7 @@ export default function RssFeedItem({
clearTimeout(selectionStableTimeoutRef.current)
}
}
- }, [showHighlightButton, isSmallScreen])
+ }, [noHighlights, showHighlightButton, isSmallScreen])
const handleCreateHighlight = () => {
const currentSelection = window.getSelection()
@@ -560,11 +579,15 @@ export default function RssFeedItem({
target.closest('a') ||
target.closest('button') ||
target.closest('[role="dialog"]') ||
- target.closest('.highlight-button-container')
+ target.closest('.highlight-button-container') ||
+ target.closest('[data-rss-respond-row]')
) {
return
}
- navigateToRssArticle(item.link)
+ navigateToRssArticle(
+ item.link,
+ rssEntryReadOnlyMode && !isWebFaux ? { rssFeedReadOnly: true } : undefined
+ )
}
: undefined
}
@@ -659,6 +682,35 @@ export default function RssFeedItem({
)}
+ {isListLayout &&
+ rssEntryReadOnlyMode &&
+ !isWebFaux &&
+ item.link?.trim() &&
+ isHttpArticleUrl(item.link.trim()) ? (
+
e.stopPropagation()}>
+
+
+ ) : null}
+
{/* List layout: body lives in the secondary panel */}
{showFullBody ? (
<>
@@ -784,7 +836,11 @@ export default function RssFeedItem({
)}
{/* Highlight Button (Desktop) */}
- {!isSmallScreen && showHighlightButton && selectedText && selectionPosition && (
+ {!noHighlights &&
+ !isSmallScreen &&
+ showHighlightButton &&
+ selectedText &&
+ selectionPosition && (
{
@@ -864,18 +920,20 @@ export default function RssFeedItem({
{/* Post Editor for highlights */}
- {
- setIsPostEditorOpen(open)
- if (!open) {
- setHighlightData(undefined)
- setHighlightText('')
- }
- }}
- defaultContent={highlightText}
- initialHighlightData={highlightData}
- />
+ {!noHighlights ? (
+ {
+ setIsPostEditorOpen(open)
+ if (!open) {
+ setHighlightData(undefined)
+ setHighlightText('')
+ }
+ }}
+ defaultContent={highlightText}
+ initialHighlightData={highlightData}
+ />
+ ) : null}
)
}
diff --git a/src/components/RssFeedList/RssEntriesSection.tsx b/src/components/RssFeedList/RssEntriesSection.tsx
deleted file mode 100644
index adbc522b..00000000
--- a/src/components/RssFeedList/RssEntriesSection.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import RssFeedItem from '@/components/RssFeedItem'
-import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
-import { useTranslation } from 'react-i18next'
-
-/** Classic RSS reader: one row per feed item, chronological. */
-export function RssEntriesSection({ items }: { items: TRssFeedItem[] }) {
- const { t } = useTranslation()
- if (items.length === 0) return null
- return (
-
-
-
-
- {t('RSS timeline subtitle')}
-
-
-
- {items.map((item) => (
-
- ))}
-
-
- )
-}
diff --git a/src/components/RssFeedList/RssUnifiedScopeSection.tsx b/src/components/RssFeedList/RssUnifiedScopeSection.tsx
new file mode 100644
index 00000000..11ae732a
--- /dev/null
+++ b/src/components/RssFeedList/RssUnifiedScopeSection.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+
+/** Section chrome for the RSS column: feed items and article cards backed by subscribed feeds. */
+export function RssUnifiedScopeSection({ children }: { children: ReactNode }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+ {t('RSS feed column subtitle')}
+
+
+ {children}
+
+ )
+}
diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx
index d347bcbb..cb3afd6a 100644
--- a/src/components/RssFeedList/index.tsx
+++ b/src/components/RssFeedList/index.tsx
@@ -7,12 +7,13 @@ import { DEFAULT_RSS_FEEDS } from '@/constants'
import RssFeedItem from '../RssFeedItem'
import RssWebFeedCard from '../RssWebFeedCard'
import { ArticleUrlsSection } from './ArticleUrlsSection'
-import { RssEntriesSection } from './RssEntriesSection'
+import { RssUnifiedScopeSection } from './RssUnifiedScopeSection'
import { canonicalizeRssArticleUrl, isClawstrDotComHttpUrl } from '@/lib/rss-article'
import {
addManualRssWebUrl,
fetchDiscoveredWebUrlsFromRelays,
loadManualRssWebUrls,
+ loadPromotedRssThreadUrls,
loadRssWebFeedScopePreference,
loadRssWebHideUnifiedClutterPreference,
loadRssWebSuppressClawstrPreference,
@@ -20,6 +21,7 @@ import {
isHttpArticleUrl,
isRssWebUnifiedClutterUrl,
mergeDiscoveredRssWebUrls,
+ rssWebRowHasRealFeedItems,
saveRssWebFeedScopePreference,
saveRssWebHideUnifiedClutterPreference,
saveRssWebSuppressClawstrPreference,
@@ -174,10 +176,21 @@ export default function RssFeedList() {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
+ const [promotedThreadUrls, setPromotedThreadUrls] = useState([])
+ const promotedThreadUrlSet = useMemo(() => new Set(promotedThreadUrls), [promotedThreadUrls])
+
+ const refreshPromotedThreadUrls = useCallback(() => {
+ void loadPromotedRssThreadUrls().then(setPromotedThreadUrls)
+ }, [])
+
useEffect(() => {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
+ useEffect(() => {
+ void loadPromotedRssThreadUrls().then(setPromotedThreadUrls)
+ }, [])
+
/** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */
const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0)
@@ -550,28 +563,12 @@ export default function RssFeedList() {
)
}, [])
- /** RSS-only view: flat timeline with full-text search. */
- const rssScopeItems = useMemo(() => {
- const q = searchQuery.trim()
- let list = rssWebItemsRespectingClutterPref
- if (q) {
- list = list.filter((item) => rssItemMatchesSearch(item, q))
- }
- if (suppressClawstrLinks) {
- list = list.filter((item) => !rssFeedItemArticleIsClawstrHost(item))
- }
- return [...list].sort(
- (a, b) => (b.pubDate?.getTime() ?? 0) - (a.pubDate?.getTime() ?? 0)
- )
- }, [rssWebItemsRespectingClutterPref, searchQuery, rssItemMatchesSearch, suppressClawstrLinks])
-
type CombinedFeedRow =
| {
kind: 'web'
canonicalUrl: string
rssItems: TRssFeedItem[]
latestPub: number
- fromNostrOrManual: boolean
}
| { kind: 'rss'; item: TRssFeedItem }
@@ -579,16 +576,19 @@ export default function RssFeedList() {
| { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] }
| { kind: 'rssEntry'; item: TRssFeedItem }
- const [feedScope, setFeedScope] = useState('both')
+ const [feedScope, setFeedScope] = useState('urls')
useEffect(() => {
- const handler = () => setRelayDiscoveryTick((n) => n + 1)
+ const handler = () => {
+ setRelayDiscoveryTick((n) => n + 1)
+ refreshManualWebUrls()
+ refreshPromotedThreadUrls()
+ }
window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
- }, [])
+ }, [refreshManualWebUrls, refreshPromotedThreadUrls])
useEffect(() => {
- if (feedScope === 'rss') return
let cancelled = false
void (async () => {
try {
@@ -609,15 +609,7 @@ export default function RssFeedList() {
return () => {
cancelled = true
}
- }, [
- feedScope,
- pubkey,
- favoriteRelays,
- blockedRelays,
- refreshManualWebUrls,
- relayDiscoveryTick,
- hideUnifiedClutter
- ])
+ }, [pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick, hideUnifiedClutter])
const combinedFeedRows = useMemo((): CombinedFeedRow[] => {
const { webRows, nonHttpItems } = buildArticleUrlFeedRows(
@@ -660,32 +652,41 @@ export default function RssFeedList() {
})
}, [combinedFeedRows, searchQuery, rssItemMatchesSearch])
- /**
- * URLs-only: Nostr/manual article URLs only (`fromNostrOrManual`), not URL cards that exist solely from RSS
- * grouping. RSS-only timeline rows stay on the RSS toggle. Both: every web row plus RSS entries.
- */
- const feedDisplayBase = useMemo(():
- | { view: 'rss'; items: TRssFeedItem[] }
- | { view: 'unified'; rows: UnifiedFeedRow[] } => {
- if (feedScope === 'rss') {
- return { view: 'rss', items: rssScopeItems }
- }
-
- if (feedScope === 'urls') {
- const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch
- .filter(
- (r): r is Extract =>
- r.kind === 'web' && r.fromNostrOrManual
- )
- .map((r) => ({
- kind: 'url' as const,
- canonicalUrl: r.canonicalUrl,
- rssItems: r.rssItems
- }))
- return { view: 'unified', rows }
- }
-
- const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch.map((r) =>
+ const urlScopeRows = useMemo((): UnifiedFeedRow[] => {
+ return combinedFeedRowsForSearch
+ .filter(
+ (r): r is Extract =>
+ r.kind === 'web' &&
+ (!rssWebRowHasRealFeedItems(r.rssItems) || promotedThreadUrlSet.has(r.canonicalUrl))
+ )
+ .sort((a, b) => b.latestPub - a.latestPub)
+ .map((r) => ({
+ kind: 'url' as const,
+ canonicalUrl: r.canonicalUrl,
+ rssItems: r.rssItems
+ }))
+ }, [combinedFeedRowsForSearch, promotedThreadUrlSet])
+
+ const rssScopeRows = useMemo((): UnifiedFeedRow[] => {
+ const picked = combinedFeedRowsForSearch.filter((r) => {
+ if (r.kind === 'rss') {
+ const link = r.item.link?.trim()
+ if (link && isHttpArticleUrl(link)) {
+ if (promotedThreadUrlSet.has(canonicalizeRssArticleUrl(link))) return false
+ }
+ return true
+ }
+ if (r.kind === 'web' && rssWebRowHasRealFeedItems(r.rssItems)) {
+ return !promotedThreadUrlSet.has(r.canonicalUrl)
+ }
+ return false
+ })
+ const sorted = [...picked].sort((a, b) => {
+ const ta = a.kind === 'web' ? a.latestPub : (a.item.pubDate?.getTime() ?? 0)
+ const tb = b.kind === 'web' ? b.latestPub : (b.item.pubDate?.getTime() ?? 0)
+ return tb - ta
+ })
+ return sorted.map((r) =>
r.kind === 'web'
? {
kind: 'url' as const,
@@ -694,8 +695,12 @@ export default function RssFeedList() {
}
: { kind: 'rssEntry' as const, item: r.item }
)
- return { view: 'unified', rows }
- }, [feedScope, rssScopeItems, combinedFeedRowsForSearch])
+ }, [combinedFeedRowsForSearch, promotedThreadUrlSet])
+
+ const feedDisplayBase = useMemo(
+ () => ({ rows: feedScope === 'urls' ? urlScopeRows : rssScopeRows }),
+ [feedScope, urlScopeRows, rssScopeRows]
+ )
const persistSuppressClawstr = useCallback((checked: boolean) => {
rssWebPrefsUserTouchedRef.current = true
@@ -733,33 +738,19 @@ export default function RssFeedList() {
}
}, [])
- const feedTotalCount =
- feedDisplayBase.view === 'rss'
- ? feedDisplayBase.items.length
- : feedDisplayBase.rows.length
+ const feedTotalCount = feedDisplayBase.rows.length
// Reset pagination when filters change
useEffect(() => {
setShowRowCount(20)
}, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks, hideUnifiedClutter])
- const displayedFeed = useMemo(():
- | { view: 'rss'; items: TRssFeedItem[] }
- | { view: 'unified'; rows: UnifiedFeedRow[] } => {
- if (feedDisplayBase.view === 'rss') {
- return {
- view: 'rss' as const,
- items: feedDisplayBase.items.slice(0, showRowCount)
- }
- }
- return {
- view: 'unified' as const,
- rows: feedDisplayBase.rows.slice(0, showRowCount)
- }
- }, [feedDisplayBase, showRowCount])
+ const displayedFeed = useMemo(
+ () => ({ rows: feedDisplayBase.rows.slice(0, showRowCount) }),
+ [feedDisplayBase, showRowCount]
+ )
- const displayedCount =
- displayedFeed.view === 'rss' ? displayedFeed.items.length : displayedFeed.rows.length
+ const displayedCount = displayedFeed.rows.length
// IntersectionObserver for infinite scroll
useEffect(() => {
@@ -843,15 +834,6 @@ export default function RssFeedList() {
>
{t('URLs')}
-