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')} -

-

- {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 title')} +

+

+ {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')} - + + ) : null} + {showNostrThread && syntheticRoot ? (
- )} - + ) : null} + {showNostrThread ? : null}
- {syntheticRoot && ( + {showNostrThread && syntheticRoot ? ( - )} + ) : null}
@@ -261,6 +322,20 @@ const RssArticlePage = forwardRef( >
+ {rssFeedReadOnly && !threadUnlocked ? ( +
+

{t('RSS read-only thread hint')}

+ +
+ ) : null} {sourceOptions.length > 1 ? (
- {syntheticRoot && ( + {showNostrThread && syntheticRoot ? (
- )} - + ) : null} + {showNostrThread ? : null}
- {syntheticRoot && ( + {showNostrThread && syntheticRoot ? ( - )} + ) : null}
diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index d8d02cd7..6c36d362 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -8,12 +8,13 @@ import { } from '@/lib/live-activities' import logger from '@/lib/logger' import client from '@/services/client.service' +import storage from '@/services/local-storage.service' import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFollowListOptional } from './FollowListProvider' import { useNostr } from './NostrProvider' -import { useUserPreferences } from './UserPreferencesProvider' +import { useUserPreferencesOptional } from './UserPreferencesProvider' type TLiveActivitiesContext = { items: TLiveActivityItem[] @@ -39,7 +40,9 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followListCtx = useFollowListOptional() const followings = followListCtx?.followings ?? [] - const { showLiveActivitiesBanner } = useUserPreferences() + const userPrefs = useUserPreferencesOptional() + const showLiveActivitiesBanner = + userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner() const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) diff --git a/src/providers/UserPreferencesProvider.tsx b/src/providers/UserPreferencesProvider.tsx index 83522257..d0538672 100644 --- a/src/providers/UserPreferencesProvider.tsx +++ b/src/providers/UserPreferencesProvider.tsx @@ -23,6 +23,11 @@ export const useUserPreferences = () => { return context } +/** When context is missing (e.g. HMR or misplaced tree), returns `undefined` instead of throwing. */ +export function useUserPreferencesOptional(): TUserPreferencesContext | undefined { + return useContext(UserPreferencesContext) +} + export function UserPreferencesProvider({ children }: { children: React.ReactNode }) { const [notificationListStyle, setNotificationListStyle] = useState( storage.getNotificationListStyle()