Browse Source

bug-fix url views

imwald
Silberengel 1 month ago
parent
commit
852598336a
  1. 23
      src/PageManager.tsx
  2. 7
      src/components/LiveActivitiesStrip.tsx
  3. 94
      src/components/RssFeedItem/index.tsx
  4. 35
      src/components/RssFeedList/RssEntriesSection.tsx
  5. 23
      src/components/RssFeedList/RssUnifiedScopeSection.tsx
  6. 179
      src/components/RssFeedList/index.tsx
  7. 98
      src/components/RssUrlThreadEventsPreview/index.tsx
  8. 55
      src/components/RssWebFeedCard/index.tsx
  9. 10
      src/i18n/locales/ar.ts
  10. 10
      src/i18n/locales/de.ts
  11. 10
      src/i18n/locales/en.ts
  12. 10
      src/i18n/locales/es.ts
  13. 10
      src/i18n/locales/fa.ts
  14. 10
      src/i18n/locales/fr.ts
  15. 10
      src/i18n/locales/hi.ts
  16. 10
      src/i18n/locales/it.ts
  17. 10
      src/i18n/locales/ja.ts
  18. 10
      src/i18n/locales/ko.ts
  19. 10
      src/i18n/locales/pl.ts
  20. 10
      src/i18n/locales/pt-BR.ts
  21. 10
      src/i18n/locales/pt-PT.ts
  22. 10
      src/i18n/locales/ru.ts
  23. 10
      src/i18n/locales/th.ts
  24. 10
      src/i18n/locales/zh.ts
  25. 90
      src/lib/rss-web-feed.ts
  26. 98
      src/pages/secondary/RssArticlePage/index.tsx
  27. 7
      src/providers/LiveActivitiesProvider.tsx
  28. 5
      src/providers/UserPreferencesProvider.tsx

23
src/PageManager.tsx

@ -241,7 +241,11 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
return `/notes/${noteId}` 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 key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [ const contextualPages: TPrimaryPageName[] = [
'search', 'search',
@ -252,10 +256,14 @@ function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName |
'explore', 'explore',
'follows-latest' 'follows-latest'
] ]
if (currentPage && contextualPages.includes(currentPage)) { let path =
return `/${currentPage}/rss-item/${key}` 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). */ /** 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 { push: pushSecondaryPage } = useSecondaryPage()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToRssArticle = (articleUrl: string) => { const navigateToRssArticle = (
pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage)) articleUrl: string,
navOptions?: { rssFeedReadOnly?: boolean }
) => {
pushSecondaryPage(buildRssArticleUrl(articleUrl, currentPrimaryPage, navOptions))
} }
return { navigateToRssArticle } return { navigateToRssArticle }

7
src/components/LiveActivitiesStrip.tsx

@ -1,7 +1,8 @@
import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities' import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useLiveActivitiesOptional } from '@/providers/LiveActivitiesProvider' 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 { ExternalLink } from 'lucide-react'
import { useEffect, useLayoutEffect, useState } from 'react' import { useEffect, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -10,7 +11,9 @@ type TPlacement = 'sidebar' | 'mobile'
export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) { export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) {
const { t } = useTranslation() const { t } = useTranslation()
const { showLiveActivitiesBanner } = useUserPreferences() const userPrefs = useUserPreferencesOptional()
const showLiveActivitiesBanner =
userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
const live = useLiveActivitiesOptional() const live = useLiveActivitiesOptional()
const items = live?.items ?? [] const items = live?.items ?? []

94
src/components/RssFeedItem/index.tsx

@ -19,6 +19,7 @@ import { useSmartRssArticleNavigation } from '@/PageManager'
import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url' import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url'
import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext' import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext'
import { isClawstrDotComHttpHref } from '@/lib/rss-article' 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 * Convert HTML to plain text by extracting text content and cleaning up whitespace
@ -49,7 +50,12 @@ export default function RssFeedItem({
className, className,
layout = 'detail', layout = 'detail',
expandBodyFully = false, 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 item: TRssFeedItem
className?: string className?: string
@ -59,6 +65,9 @@ export default function RssFeedItem({
expandBodyFully?: boolean expandBodyFully?: boolean
/** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */ /** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */
sourceStrip?: 'rss' | 'web' sourceStrip?: 'rss' | 'web'
readOnlyHighlights?: boolean
rssEntryReadOnlyMode?: boolean
onAfterPromoteRss?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { suppressClawstrLinks } = useRssFeedDisplayPrefs() const { suppressClawstrLinks } = useRssFeedDisplayPrefs()
@ -68,6 +77,8 @@ export default function RssFeedItem({
const isWebFaux = isWebOnlyFauxRssItem(item) const isWebFaux = isWebOnlyFauxRssItem(item)
const isListLayout = layout === 'list' const isListLayout = layout === 'list'
const showFullBody = layout === 'detail' const showFullBody = layout === 'detail'
const noHighlights = readOnlyHighlights || rssEntryReadOnlyMode
const [promotingRss, setPromotingRss] = useState(false)
const [selectedText, setSelectedText] = useState('') const [selectedText, setSelectedText] = useState('')
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
const [showHighlightButton, setShowHighlightButton] = useState(false) const [showHighlightButton, setShowHighlightButton] = useState(false)
@ -84,6 +95,14 @@ export default function RssFeedItem({
// Handle text selection // Handle text selection
useEffect(() => { useEffect(() => {
if (noHighlights) {
setShowHighlightButton(false)
setShowHighlightDrawer(false)
setSelectedText('')
setSelectionPosition(null)
return
}
const handleSelection = (forceShow = false) => { const handleSelection = (forceShow = false) => {
const selection = window.getSelection() const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -354,7 +373,7 @@ export default function RssFeedItem({
clearTimeout(selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
} }
} }
}, [showHighlightButton, isSmallScreen]) }, [noHighlights, showHighlightButton, isSmallScreen])
const handleCreateHighlight = () => { const handleCreateHighlight = () => {
const currentSelection = window.getSelection() const currentSelection = window.getSelection()
@ -560,11 +579,15 @@ export default function RssFeedItem({
target.closest('a') || target.closest('a') ||
target.closest('button') || target.closest('button') ||
target.closest('[role="dialog"]') || target.closest('[role="dialog"]') ||
target.closest('.highlight-button-container') target.closest('.highlight-button-container') ||
target.closest('[data-rss-respond-row]')
) { ) {
return return
} }
navigateToRssArticle(item.link) navigateToRssArticle(
item.link,
rssEntryReadOnlyMode && !isWebFaux ? { rssFeedReadOnly: true } : undefined
)
} }
: undefined : undefined
} }
@ -659,6 +682,35 @@ export default function RssFeedItem({
)} )}
</div> </div>
{isListLayout &&
rssEntryReadOnlyMode &&
!isWebFaux &&
item.link?.trim() &&
isHttpArticleUrl(item.link.trim()) ? (
<div className="pt-2" data-rss-respond-row onClick={(e) => e.stopPropagation()}>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full sm:w-auto"
disabled={promotingRss}
onClick={() => {
setPromotingRss(true)
void (async () => {
try {
await promoteRssArticleForNostrThread(item.link!)
} finally {
setPromotingRss(false)
onAfterPromoteRss?.()
}
})()
}}
>
{t('Respond to this RSS entry')}
</Button>
</div>
) : null}
{/* List layout: body lives in the secondary panel */} {/* List layout: body lives in the secondary panel */}
{showFullBody ? ( {showFullBody ? (
<> <>
@ -784,7 +836,11 @@ export default function RssFeedItem({
)} )}
{/* Highlight Button (Desktop) */} {/* Highlight Button (Desktop) */}
{!isSmallScreen && showHighlightButton && selectedText && selectionPosition && ( {!noHighlights &&
!isSmallScreen &&
showHighlightButton &&
selectedText &&
selectionPosition && (
<div <div
className="highlight-button-container fixed z-50" className="highlight-button-container fixed z-50"
style={{ style={{
@ -808,7 +864,7 @@ export default function RssFeedItem({
)} )}
{/* Highlight Drawer (Mobile) */} {/* Highlight Drawer (Mobile) */}
{isSmallScreen && ( {!noHighlights && isSmallScreen && (
<Drawer <Drawer
open={showHighlightDrawer} open={showHighlightDrawer}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -864,18 +920,20 @@ export default function RssFeedItem({
</div> </div>
{/* Post Editor for highlights */} {/* Post Editor for highlights */}
<PostEditor {!noHighlights ? (
open={isPostEditorOpen} <PostEditor
setOpen={(open) => { open={isPostEditorOpen}
setIsPostEditorOpen(open) setOpen={(open) => {
if (!open) { setIsPostEditorOpen(open)
setHighlightData(undefined) if (!open) {
setHighlightText('') setHighlightData(undefined)
} setHighlightText('')
}} }
defaultContent={highlightText} }}
initialHighlightData={highlightData} defaultContent={highlightText}
/> initialHighlightData={highlightData}
/>
) : null}
</div> </div>
) )
} }

35
src/components/RssFeedList/RssEntriesSection.tsx

@ -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 (
<section className="space-y-3" aria-labelledby="jumble-rss-entries-heading">
<div className="space-y-1 px-0.5">
<h2
id="jumble-rss-entries-heading"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{t('RSS timeline')}
</h2>
<p className="text-[11px] leading-snug text-muted-foreground/90">
{t('RSS timeline subtitle')}
</p>
</div>
<div className="space-y-0 divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{items.map((item) => (
<RssFeedItem
key={`${item.feedUrl}-${item.guid}`}
item={item}
layout="list"
sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none"
/>
))}
</div>
</section>
)
}

23
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 (
<section className="space-y-3" aria-labelledby="jumble-rss-unified-heading">
<div className="space-y-1 px-0.5">
<h2
id="jumble-rss-unified-heading"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{t('RSS feed column title')}
</h2>
<p className="text-[11px] leading-snug text-muted-foreground/90">
{t('RSS feed column subtitle')}
</p>
</div>
<div className="space-y-4">{children}</div>
</section>
)
}

179
src/components/RssFeedList/index.tsx

@ -7,12 +7,13 @@ import { DEFAULT_RSS_FEEDS } from '@/constants'
import RssFeedItem from '../RssFeedItem' import RssFeedItem from '../RssFeedItem'
import RssWebFeedCard from '../RssWebFeedCard' import RssWebFeedCard from '../RssWebFeedCard'
import { ArticleUrlsSection } from './ArticleUrlsSection' import { ArticleUrlsSection } from './ArticleUrlsSection'
import { RssEntriesSection } from './RssEntriesSection' import { RssUnifiedScopeSection } from './RssUnifiedScopeSection'
import { canonicalizeRssArticleUrl, isClawstrDotComHttpUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, isClawstrDotComHttpUrl } from '@/lib/rss-article'
import { import {
addManualRssWebUrl, addManualRssWebUrl,
fetchDiscoveredWebUrlsFromRelays, fetchDiscoveredWebUrlsFromRelays,
loadManualRssWebUrls, loadManualRssWebUrls,
loadPromotedRssThreadUrls,
loadRssWebFeedScopePreference, loadRssWebFeedScopePreference,
loadRssWebHideUnifiedClutterPreference, loadRssWebHideUnifiedClutterPreference,
loadRssWebSuppressClawstrPreference, loadRssWebSuppressClawstrPreference,
@ -20,6 +21,7 @@ import {
isHttpArticleUrl, isHttpArticleUrl,
isRssWebUnifiedClutterUrl, isRssWebUnifiedClutterUrl,
mergeDiscoveredRssWebUrls, mergeDiscoveredRssWebUrls,
rssWebRowHasRealFeedItems,
saveRssWebFeedScopePreference, saveRssWebFeedScopePreference,
saveRssWebHideUnifiedClutterPreference, saveRssWebHideUnifiedClutterPreference,
saveRssWebSuppressClawstrPreference, saveRssWebSuppressClawstrPreference,
@ -174,10 +176,21 @@ export default function RssFeedList() {
void loadManualRssWebUrls().then(setManualWebEntries) void loadManualRssWebUrls().then(setManualWebEntries)
}, []) }, [])
const [promotedThreadUrls, setPromotedThreadUrls] = useState<string[]>([])
const promotedThreadUrlSet = useMemo(() => new Set(promotedThreadUrls), [promotedThreadUrls])
const refreshPromotedThreadUrls = useCallback(() => {
void loadPromotedRssThreadUrls().then(setPromotedThreadUrls)
}, [])
useEffect(() => { useEffect(() => {
void loadManualRssWebUrls().then(setManualWebEntries) void loadManualRssWebUrls().then(setManualWebEntries)
}, []) }, [])
useEffect(() => {
void loadPromotedRssThreadUrls().then(setPromotedThreadUrls)
}, [])
/** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ /** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */
const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) 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 = type CombinedFeedRow =
| { | {
kind: 'web' kind: 'web'
canonicalUrl: string canonicalUrl: string
rssItems: TRssFeedItem[] rssItems: TRssFeedItem[]
latestPub: number latestPub: number
fromNostrOrManual: boolean
} }
| { kind: 'rss'; item: TRssFeedItem } | { kind: 'rss'; item: TRssFeedItem }
@ -579,16 +576,19 @@ export default function RssFeedList() {
| { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] } | { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] }
| { kind: 'rssEntry'; item: TRssFeedItem } | { kind: 'rssEntry'; item: TRssFeedItem }
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('both') const [feedScope, setFeedScope] = useState<RssWebFeedScope>('urls')
useEffect(() => { useEffect(() => {
const handler = () => setRelayDiscoveryTick((n) => n + 1) const handler = () => {
setRelayDiscoveryTick((n) => n + 1)
refreshManualWebUrls()
refreshPromotedThreadUrls()
}
window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) window.addEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler) return () => window.removeEventListener(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, handler)
}, []) }, [refreshManualWebUrls, refreshPromotedThreadUrls])
useEffect(() => { useEffect(() => {
if (feedScope === 'rss') return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
try { try {
@ -609,15 +609,7 @@ export default function RssFeedList() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick, hideUnifiedClutter])
feedScope,
pubkey,
favoriteRelays,
blockedRelays,
refreshManualWebUrls,
relayDiscoveryTick,
hideUnifiedClutter
])
const combinedFeedRows = useMemo((): CombinedFeedRow[] => { const combinedFeedRows = useMemo((): CombinedFeedRow[] => {
const { webRows, nonHttpItems } = buildArticleUrlFeedRows( const { webRows, nonHttpItems } = buildArticleUrlFeedRows(
@ -660,32 +652,41 @@ export default function RssFeedList() {
}) })
}, [combinedFeedRows, searchQuery, rssItemMatchesSearch]) }, [combinedFeedRows, searchQuery, rssItemMatchesSearch])
/** const urlScopeRows = useMemo((): UnifiedFeedRow[] => {
* URLs-only: Nostr/manual article URLs only (`fromNostrOrManual`), not URL cards that exist solely from RSS return combinedFeedRowsForSearch
* grouping. RSS-only timeline rows stay on the RSS toggle. Both: every web row plus RSS entries. .filter(
*/ (r): r is Extract<CombinedFeedRow, { kind: 'web' }> =>
const feedDisplayBase = useMemo((): r.kind === 'web' &&
| { view: 'rss'; items: TRssFeedItem[] } (!rssWebRowHasRealFeedItems(r.rssItems) || promotedThreadUrlSet.has(r.canonicalUrl))
| { view: 'unified'; rows: UnifiedFeedRow[] } => { )
if (feedScope === 'rss') { .sort((a, b) => b.latestPub - a.latestPub)
return { view: 'rss', items: rssScopeItems } .map((r) => ({
} kind: 'url' as const,
canonicalUrl: r.canonicalUrl,
if (feedScope === 'urls') { rssItems: r.rssItems
const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch }))
.filter( }, [combinedFeedRowsForSearch, promotedThreadUrlSet])
(r): r is Extract<CombinedFeedRow, { kind: 'web' }> =>
r.kind === 'web' && r.fromNostrOrManual const rssScopeRows = useMemo((): UnifiedFeedRow[] => {
) const picked = combinedFeedRowsForSearch.filter((r) => {
.map((r) => ({ if (r.kind === 'rss') {
kind: 'url' as const, const link = r.item.link?.trim()
canonicalUrl: r.canonicalUrl, if (link && isHttpArticleUrl(link)) {
rssItems: r.rssItems if (promotedThreadUrlSet.has(canonicalizeRssArticleUrl(link))) return false
})) }
return { view: 'unified', rows } return true
} }
if (r.kind === 'web' && rssWebRowHasRealFeedItems(r.rssItems)) {
const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch.map((r) => 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' r.kind === 'web'
? { ? {
kind: 'url' as const, kind: 'url' as const,
@ -694,8 +695,12 @@ export default function RssFeedList() {
} }
: { kind: 'rssEntry' as const, item: r.item } : { kind: 'rssEntry' as const, item: r.item }
) )
return { view: 'unified', rows } }, [combinedFeedRowsForSearch, promotedThreadUrlSet])
}, [feedScope, rssScopeItems, combinedFeedRowsForSearch])
const feedDisplayBase = useMemo(
() => ({ rows: feedScope === 'urls' ? urlScopeRows : rssScopeRows }),
[feedScope, urlScopeRows, rssScopeRows]
)
const persistSuppressClawstr = useCallback((checked: boolean) => { const persistSuppressClawstr = useCallback((checked: boolean) => {
rssWebPrefsUserTouchedRef.current = true rssWebPrefsUserTouchedRef.current = true
@ -733,33 +738,19 @@ export default function RssFeedList() {
} }
}, []) }, [])
const feedTotalCount = const feedTotalCount = feedDisplayBase.rows.length
feedDisplayBase.view === 'rss'
? feedDisplayBase.items.length
: feedDisplayBase.rows.length
// Reset pagination when filters change // Reset pagination when filters change
useEffect(() => { useEffect(() => {
setShowRowCount(20) setShowRowCount(20)
}, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks, hideUnifiedClutter]) }, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks, hideUnifiedClutter])
const displayedFeed = useMemo((): const displayedFeed = useMemo(
| { view: 'rss'; items: TRssFeedItem[] } () => ({ rows: feedDisplayBase.rows.slice(0, showRowCount) }),
| { view: 'unified'; rows: UnifiedFeedRow[] } => { [feedDisplayBase, showRowCount]
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 displayedCount = const displayedCount = displayedFeed.rows.length
displayedFeed.view === 'rss' ? displayedFeed.items.length : displayedFeed.rows.length
// IntersectionObserver for infinite scroll // IntersectionObserver for infinite scroll
useEffect(() => { useEffect(() => {
@ -843,15 +834,6 @@ export default function RssFeedList() {
> >
{t('URLs')} {t('URLs')}
</Button> </Button>
<Button
type="button"
variant={feedScope === 'both' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs"
onClick={() => persistFeedScope('both')}
>
{t('Both')}
</Button>
<Button <Button
type="button" type="button"
variant={feedScope === 'rss' ? 'secondary' : 'ghost'} variant={feedScope === 'rss' ? 'secondary' : 'ghost'}
@ -1005,18 +987,11 @@ export default function RssFeedList() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all' {searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
? t('No items match your filters') ? t('No items match your filters')
: t('No RSS feed items available')} : feedScope === 'urls'
? t('No URL-only items yet')
: t('No RSS feed items available')}
</p> </p>
</div> </div>
) : displayedFeed.view === 'rss' ? (
<>
<RssEntriesSection items={displayedFeed.items} />
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div>
) : null}
</>
) : feedScope === 'urls' ? ( ) : feedScope === 'urls' ? (
<> <>
<ArticleUrlsSection subtitleKey="Article URLs Nostr manual subtitle"> <ArticleUrlsSection subtitleKey="Article URLs Nostr manual subtitle">
@ -1038,13 +1013,14 @@ export default function RssFeedList() {
</> </>
) : ( ) : (
<> <>
<div className="space-y-4"> <RssUnifiedScopeSection>
{displayedFeed.rows.map((row) => {displayedFeed.rows.map((row) =>
row.kind === 'url' ? ( row.kind === 'url' ? (
<RssWebFeedCard <RssWebFeedCard
key={row.canonicalUrl} key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl} canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems} rssItems={row.rssItems}
rssColumnReadOnly
/> />
) : ( ) : (
<div <div
@ -1056,11 +1032,16 @@ export default function RssFeedList() {
layout="list" layout="list"
sourceStrip="rss" sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none" className="rounded-none border-0 bg-transparent shadow-none"
rssEntryReadOnlyMode
onAfterPromoteRss={() => {
refreshManualWebUrls()
refreshPromotedThreadUrls()
}}
/> />
</div> </div>
) )
)} )}
</div> </RssUnifiedScopeSection>
{displayedCount < feedTotalCount ? ( {displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4"> <div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden /> <Skeleton className="h-8 w-8 rounded-md" aria-hidden />

98
src/components/RssUrlThreadEventsPreview/index.tsx

@ -1,98 +0,0 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
import {
buildRssArticleUrlThreadInteractionFilterGroups,
isRssArticleUrlThreadInteraction
} from '@/lib/rss-web-feed'
import { queryService } from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
const PREVIEW_LIMIT = 5
const FETCH_LIMIT = 24
/**
* Compact Nostr thread rows (comments + highlights) for an article URL card in the RSS+Web feed.
*/
export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalUrl: string }) {
const { relayUrls, key: relayKey } = useRssUrlThreadQueryRelays()
const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
const { nonSocial, social } = buildRssArticleUrlThreadInteractionFilterGroups(
canonicalUrl,
FETCH_LIMIT
)
const fetchOpts = {
eoseTimeout: 12_000,
globalTimeout: 26_000,
firstRelayResultGraceMs: false as const
}
if (relayUrls.length === 0) {
return () => {
cancelled = true
}
}
void Promise.all([
nonSocial.length > 0 ? queryService.fetchEvents(relayUrls, nonSocial, fetchOpts) : Promise.resolve([]),
social.length > 0 ? queryService.fetchEvents(relayUrls, social, fetchOpts) : Promise.resolve([])
])
.then(([a, b]) => {
if (cancelled) return
const all = [...a, ...b]
const seen = new Set<string>()
const merged: Event[] = []
for (const e of [...all].sort((x, y) => y.created_at - x.created_at)) {
if (seen.has(e.id)) continue
if (!isRssArticleUrlThreadInteraction(e, canonicalUrl)) continue
seen.add(e.id)
merged.push(e)
}
setEvents(merged.slice(0, PREVIEW_LIMIT))
})
.catch(() => {
if (!cancelled) setEvents([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [canonicalUrl, relayKey, relayUrls])
if (loading) {
return (
<div
className="border-t border-border/50 bg-muted/10 px-3 py-2 pointer-events-auto space-y-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
)
}
if (events.length === 0) return null
return (
<div
className="border-t border-border/50 bg-muted/10 pointer-events-auto max-h-72 overflow-y-auto"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="divide-y divide-border/40">
{events.map((evt) => (
<div key={evt.id} className="px-2 py-1.5">
<NoteCard event={evt} className="border-0 bg-transparent shadow-none" hideParentNotePreview />
</div>
))}
</div>
</div>
)
}

55
src/components/RssWebFeedCard/index.tsx

@ -1,47 +1,42 @@
import RssFeedItem from '@/components/RssFeedItem' import RssFeedItem from '@/components/RssFeedItem'
import RssUrlThreadEventsPreview from '@/components/RssUrlThreadEventsPreview'
import RssUrlThreadStatsBar from '@/components/RssUrlThreadStatsBar' import RssUrlThreadStatsBar from '@/components/RssUrlThreadStatsBar'
import WebPreview from '@/components/WebPreview' import WebPreview from '@/components/WebPreview'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { createRssThreadRootEvent } from '@/lib/rss-article' import { createRssThreadRootEvent } from '@/lib/rss-article'
import { isHttpArticleUrl } from '@/lib/rss-web-feed' import { isHttpArticleUrl } from '@/lib/rss-web-feed'
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import { import { isWebOnlyFauxRssItem } from '@/services/rss-feed.service'
createWebOnlyRssFeedItem,
isWebOnlyFauxRssItem
} from '@/services/rss-feed.service'
import { Globe, Rss } from 'lucide-react' import { Globe, Rss } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSmartRssArticleNavigation } from '@/PageManager' import { useSmartRssArticleNavigation } from '@/PageManager'
/** /**
* Single feed card for an article URL: RSS body and/or faux web item (OpenGraph), plus URL-thread stats. * Single feed card for an article URL: RSS body and/or faux web item (OpenGraph), plus compact thread counts.
* Opens {@link RssArticlePage} in the secondary panel when the card is activated. * Opens RssArticlePage in the secondary panel for full article, comments, and highlights (like a note page).
*/ */
export default function RssWebFeedCard({ export default function RssWebFeedCard({
canonicalUrl, canonicalUrl,
rssItems, rssItems,
className className,
/** When true (RSS column): hide Nostr thread UI; open article read-only until promoted in URL feed. */
rssColumnReadOnly = false
}: { }: {
canonicalUrl: string canonicalUrl: string
rssItems: TRssFeedItem[] rssItems: TRssFeedItem[]
className?: string className?: string
rssColumnReadOnly?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRssArticle } = useSmartRssArticleNavigation() const { navigateToRssArticle } = useSmartRssArticleNavigation()
const syntheticRoot = useMemo(() => createRssThreadRootEvent(canonicalUrl), [canonicalUrl]) const syntheticRoot = useMemo(() => createRssThreadRootEvent(canonicalUrl), [canonicalUrl])
const displayRssItems = useMemo(() => { const hasRealRss = rssItems.some((i) => !isWebOnlyFauxRssItem(i))
if (rssItems.length > 0) return rssItems const showRssRows = rssItems.length > 0
if (isHttpArticleUrl(canonicalUrl)) return [createWebOnlyRssFeedItem(canonicalUrl)] const showWebOgPreview = isHttpArticleUrl(canonicalUrl)
return []
}, [rssItems, canonicalUrl])
const hasRealRss = displayRssItems.some((i) => !isWebOnlyFauxRssItem(i))
const openArticle = () => { const openArticle = () => {
navigateToRssArticle(canonicalUrl) navigateToRssArticle(canonicalUrl, rssColumnReadOnly ? { rssFeedReadOnly: true } : undefined)
} }
return ( return (
@ -74,9 +69,9 @@ export default function RssWebFeedCard({
</div> </div>
<div className="not-prose max-w-full border-b border-border/60 bg-muted/10 pointer-events-none"> <div className="not-prose max-w-full border-b border-border/60 bg-muted/10 pointer-events-none">
{displayRssItems.length > 0 ? ( {showRssRows ? (
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
{displayRssItems.map((item) => ( {rssItems.map((item) => (
<RssFeedItem <RssFeedItem
key={`${item.feedUrl}-${item.guid}`} key={`${item.feedUrl}-${item.guid}`}
item={item} item={item}
@ -85,27 +80,23 @@ export default function RssWebFeedCard({
/> />
))} ))}
</div> </div>
) : ( ) : null}
<WebPreview url={canonicalUrl} className="w-full" /> {showWebOgPreview ? (
)} <div className={cn(showRssRows && 'border-t border-border/60')}>
<WebPreview url={canonicalUrl} className="w-full" />
</div>
) : null}
{!showWebOgPreview && !showRssRows ? (
<p className="px-3 py-2 text-sm text-muted-foreground break-all">{canonicalUrl}</p>
) : null}
</div> </div>
{displayRssItems.length === 0 ? (
<p className="pointer-events-none border-b border-border/60 px-3 py-2 text-sm text-muted-foreground break-all">
{canonicalUrl}
</p>
) : null}
{rssItems.length > 1 ? ( {rssItems.length > 1 ? (
<p className="pointer-events-none border-b border-border/60 px-3 py-1.5 text-xs text-muted-foreground"> <p className="pointer-events-none border-b border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
{t('{{count}} RSS entries for this URL', { count: rssItems.length })} {t('{{count}} RSS entries for this URL', { count: rssItems.length })}
</p> </p>
) : null} ) : null}
{isHttpArticleUrl(canonicalUrl) ? ( {!rssColumnReadOnly ? <RssUrlThreadStatsBar event={syntheticRoot} /> : null}
<RssUrlThreadEventsPreview canonicalUrl={canonicalUrl} />
) : null}
<RssUrlThreadStatsBar event={syntheticRoot} />
</div> </div>
) )
} }

10
src/i18n/locales/ar.ts

@ -1519,13 +1519,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/de.ts

@ -1558,13 +1558,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Nur Links aus Nostr-Relays (Entdeckung) oder von dir hinzugefügte URLs. Einträge, die nur aus abonnierten RSS-Feeds stammen, erscheinen hier nicht — dafür RSS oder Beides.', 'Artikel-URLs ohne Einträge aus abonnierten Feeds — von Nostr-Relays oder von dir hinzugefügt. Sobald ein Feed Einträge liefert, erscheint die Karte unter RSS.',
'RSS feed column title': 'RSS & verknüpfte Artikel',
'RSS feed column subtitle':
'Feed-Einträge und Artikelkarten mit mindestens einem Eintrag aus deinen abonnierten RSS-Feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'Noch keine reinen Artikel-URLs',
'Respond to this RSS entry': 'Auf diesen RSS-Eintrag reagieren',
'RSS read-only thread hint':
'Nostr-Antworten, Zaps und Markierungen sind hier ausgeblendet. Damit fügst du den Artikel der URL-Liste hinzu und reagierst dort.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/en.ts

@ -1532,13 +1532,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/es.ts

@ -1527,13 +1527,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/fa.ts

@ -1523,13 +1523,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/fr.ts

@ -1532,13 +1532,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/hi.ts

@ -1525,13 +1525,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/it.ts

@ -1528,13 +1528,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ja.ts

@ -1523,13 +1523,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ko.ts

@ -1521,13 +1521,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/pl.ts

@ -1526,13 +1526,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/pt-BR.ts

@ -1525,13 +1525,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/pt-PT.ts

@ -1527,13 +1527,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ru.ts

@ -1528,13 +1528,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/th.ts

@ -1518,13 +1518,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

10
src/i18n/locales/zh.ts

@ -1513,13 +1513,19 @@ export default {
'Article URLs subtitle': 'Article URLs subtitle':
'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.', 'One card per link: URLs from Nostr relays (you and people you follow) plus any RSS hit. No RSS row yet → web preview card.',
'Article URLs Nostr manual subtitle': 'Article URLs Nostr manual subtitle':
'Only links from Nostr relay discovery or URLs you added. Items that exist only because they appeared in subscribed RSS feeds are not listed here — use RSS or Both.', 'Article URLs with no subscribed-feed items yet — from Nostr relay discovery or links you added. Anything that already has feed items appears under RSS.',
'RSS feed column title': 'RSS & linked articles',
'RSS feed column subtitle':
'Feed entries and article cards that include at least one item from your subscribed RSS feeds.',
'RSS timeline': 'RSS timeline', 'RSS timeline': 'RSS timeline',
'RSS timeline subtitle': 'RSS timeline subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.', 'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs', URLs: 'URLs',
RSS: 'RSS', RSS: 'RSS',
Both: 'Both', 'No URL-only items yet': 'No URL-only items yet',
'Respond to this RSS entry': 'Respond to this RSS entry',
'RSS read-only thread hint':
'Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.',
'RSS feed item label': 'RSS', 'RSS feed item label': 'RSS',
'Web URL item label': 'Web URL', 'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity', 'URL thread activity': 'URL thread activity',

90
src/lib/rss-web-feed.ts

@ -17,6 +17,7 @@ import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/li
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem } from '@/services/rss-feed.service' import type { RssFeedItem } from '@/services/rss-feed.service'
import { isWebOnlyFauxRssItem } from '@/services/rss-feed.service'
import { kinds, type Event, type Filter } from 'nostr-tools' import { kinds, type Event, type Filter } from 'nostr-tools'
/** IndexedDB: `'1'` (default) = hide clawstr.com (strip preview links + drop URL/RSS rows for that host). */ /** IndexedDB: `'1'` (default) = hide clawstr.com (strip preview links + drop URL/RSS rows for that host). */
@ -25,25 +26,32 @@ export const RSS_WEB_SUPPRESS_CLAWSTR_SETTING = 'rssWebSuppressClawstrLinks'
/** IndexedDB: `'1'` (default) = keep local/media/feed XML links as plain RSS rows, not URL cards. */ /** IndexedDB: `'1'` (default) = keep local/media/feed XML links as plain RSS rows, not URL cards. */
export const RSS_WEB_HIDE_UNIFIED_CLUTTER_SETTING = 'rssWebHideUnifiedClutter' export const RSS_WEB_HIDE_UNIFIED_CLUTTER_SETTING = 'rssWebHideUnifiedClutter'
/** IndexedDB: feed view — article URL cards, flat RSS timeline, or both interleaved. */ /** IndexedDB: feed view — URLs (no feed items) vs RSS (feed-backed rows). */
export const RSS_WEB_FEED_SCOPE_SETTING = 'rssWebFeedScope' export const RSS_WEB_FEED_SCOPE_SETTING = 'rssWebFeedScope'
/** IndexedDB: JSON array of `{ url, addedAt }` for URLs added from “Add URL” (no RSS row yet). */ /** IndexedDB: JSON array of `{ url, addedAt }` for URLs added from “Add URL” (no RSS row yet). */
export const RSS_WEB_MANUAL_URLS_SETTING = 'rssWebManualUrls' export const RSS_WEB_MANUAL_URLS_SETTING = 'rssWebManualUrls'
/** /**
* `urls` = article URL cards that come from manual URLs or Nostr discovery only (not RSS-grouped links). * `urls` = article URL cards with no real subscribed-feed items (Nostr/manual / web preview only).
* `rss` = classic chronological RSS list only. `both` = all URL cards (RSS-enriched + Nostr/manual) plus RSS-only rows. * `rss` = feed items, non-HTTP entries, and URL cards that include at least one real RSS item.
*/ */
export type RssWebFeedScope = 'urls' | 'rss' | 'both' export type RssWebFeedScope = 'urls' | 'rss'
/** Normalize stored scope (legacy `webOnly` / `rssOnly` / `webAndRss` included). */ /** True if the row includes at least one item from a subscribed RSS feed (not the synthetic web-only row). */
export function rssWebRowHasRealFeedItems(
items: Pick<RssFeedItem, 'feedUrl' | 'guid'>[]
): boolean {
return items.some((i) => !isWebOnlyFauxRssItem(i))
}
/** Normalize stored scope (legacy `both` / `webAndRss` → `rss`). */
export function parseRssWebFeedScope(raw: string | null | undefined): RssWebFeedScope { export function parseRssWebFeedScope(raw: string | null | undefined): RssWebFeedScope {
if (raw === 'urls' || raw === 'rss' || raw === 'both') return raw if (raw === 'urls' || raw === 'rss') return raw
if (raw === 'both' || raw === 'webAndRss' || raw === 'all') return 'rss'
if (raw === 'webOnly') return 'urls' if (raw === 'webOnly') return 'urls'
if (raw === 'rssOnly') return 'rss' if (raw === 'rssOnly') return 'rss'
if (raw === 'webAndRss' || raw === 'all') return 'both' return 'urls'
return 'both'
} }
export type ManualRssWebUrlEntry = { url: string; addedAt: number } export type ManualRssWebUrlEntry = { url: string; addedAt: number }
@ -129,6 +137,51 @@ export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry
/** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */ /** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */
export const WEB_EXTERNAL_REACTION_PUBLISHED_EVENT = 'jumble:webExternalReactionPublished' export const WEB_EXTERNAL_REACTION_PUBLISHED_EVENT = 'jumble:webExternalReactionPublished'
/** IndexedDB: JSON array of canonical article URLs promoted from RSS read-only into the URL feed (Nostr thread). */
export const RSS_WEB_PROMOTED_THREAD_URLS_SETTING = 'rssWebPromotedThreadUrls'
const MAX_PROMOTED_THREAD_URLS = 300
export async function loadPromotedRssThreadUrls(): Promise<string[]> {
const raw = await indexedDb.getSetting(RSS_WEB_PROMOTED_THREAD_URLS_SETTING)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: string[] = []
for (const x of parsed) {
if (typeof x !== 'string') continue
const c = canonicalizeRssArticleUrl(x.trim())
if (isHttpArticleUrl(c)) out.push(c)
}
return [...new Set(out)].slice(0, MAX_PROMOTED_THREAD_URLS)
} catch {
return []
}
}
export async function addPromotedRssThreadUrl(rawUrl: string): Promise<string> {
const canonical = canonicalizeRssArticleUrl(rawUrl.trim())
if (!isHttpArticleUrl(canonical)) return canonical
const existing = await loadPromotedRssThreadUrls()
const filtered = existing.filter((u) => u !== canonical)
const next = [canonical, ...filtered].slice(0, MAX_PROMOTED_THREAD_URLS)
await indexedDb.setSetting(RSS_WEB_PROMOTED_THREAD_URLS_SETTING, JSON.stringify(next))
return canonical
}
/**
* Adds the URL to the manual web list, marks it for the URL feed with full Nostr thread UI,
* and dispatches {@link WEB_EXTERNAL_REACTION_PUBLISHED_EVENT}.
*/
export async function promoteRssArticleForNostrThread(rawUrl: string): Promise<string> {
const canonical = await addManualRssWebUrl(rawUrl)
if (!isHttpArticleUrl(canonical)) return canonical
await addPromotedRssThreadUrl(canonical)
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT))
return canonical
}
export type RssUrlGroup = { export type RssUrlGroup = {
canonicalUrl: string canonicalUrl: string
items: RssFeedItem[] items: RssFeedItem[]
@ -301,11 +354,6 @@ export type ArticleUrlFeedWebRow = {
canonicalUrl: string canonicalUrl: string
rssItems: RssFeedItem[] rssItems: RssFeedItem[]
latestPub: number latestPub: number
/**
* True when this URL came from the manual list or Nostr relay discovery. False when the row exists only
* because RSS items were grouped by link (RSS-only article cards).
*/
fromNostrOrManual: boolean
} }
export function buildArticleUrlFeedRows( export function buildArticleUrlFeedRows(
@ -316,16 +364,12 @@ export function buildArticleUrlFeedRows(
): { webRows: ArticleUrlFeedWebRow[]; nonHttpItems: RssFeedItem[] } { ): { webRows: ArticleUrlFeedWebRow[]; nonHttpItems: RssFeedItem[] } {
const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems, options) const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems, options)
const excludeClutter = options?.excludeClutterLinks !== false const excludeClutter = options?.excludeClutterLinks !== false
const webByUrl = new Map< const webByUrl = new Map<string, { rssItems: RssFeedItem[]; latestPub: number }>()
string,
{ rssItems: RssFeedItem[]; latestPub: number; fromNostrOrManual: boolean }
>()
for (const g of groups) { for (const g of groups) {
webByUrl.set(g.canonicalUrl, { webByUrl.set(g.canonicalUrl, {
rssItems: g.items, rssItems: g.items,
latestPub: g.latestPub, latestPub: g.latestPub
fromNostrOrManual: false
}) })
} }
@ -334,11 +378,10 @@ export function buildArticleUrlFeedRows(
if (cur) { if (cur) {
webByUrl.set(url, { webByUrl.set(url, {
...cur, ...cur,
latestPub: Math.max(cur.latestPub, ts), latestPub: Math.max(cur.latestPub, ts)
fromNostrOrManual: true
}) })
} else { } else {
webByUrl.set(url, { rssItems: [], latestPub: ts, fromNostrOrManual: true }) webByUrl.set(url, { rssItems: [], latestPub: ts })
} }
} }
@ -358,8 +401,7 @@ export function buildArticleUrlFeedRows(
kind: 'web' as const, kind: 'web' as const,
canonicalUrl, canonicalUrl,
rssItems: v.rssItems, rssItems: v.rssItems,
latestPub: v.latestPub, latestPub: v.latestPub
fromNostrOrManual: v.fromNostrOrManual
}) })
) )
webRows.sort((a, b) => b.latestPub - a.latestPub) webRows.sort((a, b) => b.latestPub - a.latestPub)

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

@ -2,6 +2,7 @@ import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem' import RssFeedItem from '@/components/RssFeedItem'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
Select, Select,
@ -17,7 +18,7 @@ import {
createWebOnlyRssFeedItem, createWebOnlyRssFeedItem,
isWebOnlyFauxRssItem isWebOnlyFauxRssItem
} from '@/services/rss-feed.service' } from '@/services/rss-feed.service'
import { isHttpArticleUrl } from '@/lib/rss-web-feed' import { isHttpArticleUrl, promoteRssArticleForNostrThread } from '@/lib/rss-web-feed'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -45,6 +46,16 @@ const RssArticlePage = forwardRef(
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const [rssFeedReadOnly, setRssFeedReadOnly] = useState(() => {
try {
return new URLSearchParams(window.location.search).get('rssFeedReadOnly') === '1'
} catch {
return false
}
})
const [threadUnlocked, setThreadUnlocked] = useState(false)
const [promotingThread, setPromotingThread] = useState(false)
const showNostrThread = !rssFeedReadOnly || threadUnlocked
const { rssFeedListEvent } = useNostr() const { rssFeedListEvent } = useNostr()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
@ -60,6 +71,31 @@ const RssArticlePage = forwardRef(
} }
}, [articleKey]) }, [articleKey])
useEffect(() => {
setThreadUnlocked(false)
try {
setRssFeedReadOnly(
new URLSearchParams(window.location.search).get('rssFeedReadOnly') === '1'
)
} catch {
setRssFeedReadOnly(false)
}
}, [articleKey])
useEffect(() => {
const sync = () => {
try {
setRssFeedReadOnly(
new URLSearchParams(window.location.search).get('rssFeedReadOnly') === '1'
)
} catch {
setRssFeedReadOnly(false)
}
}
window.addEventListener('popstate', sync)
return () => window.removeEventListener('popstate', sync)
}, [])
const subscribedFeedUrls = useMemo(() => { const subscribedFeedUrls = useMemo(() => {
if (!rssFeedListEvent?.tags?.length) return new Set<string>() if (!rssFeedListEvent?.tags?.length) return new Set<string>()
const s = new Set<string>() const s = new Set<string>()
@ -174,6 +210,17 @@ const RssArticlePage = forwardRef(
} }
}, [articleUrl]) }, [articleUrl])
const onPromoteForNostrThread = useCallback(async () => {
if (!articleUrl || !isHttpArticleUrl(articleUrl)) return
setPromotingThread(true)
try {
await promoteRssArticleForNostrThread(articleUrl)
setThreadUnlocked(true)
} finally {
setPromotingThread(false)
}
}, [articleUrl])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
@ -230,21 +277,35 @@ const RssArticlePage = forwardRef(
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')} {t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</p> </p>
{syntheticRoot && ( {rssFeedReadOnly && !threadUnlocked ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('RSS read-only thread hint')}</p>
<Button
type="button"
variant="secondary"
size="sm"
disabled={promotingThread}
onClick={() => void onPromoteForNostrThread()}
>
{t('Respond to this RSS entry')}
</Button>
</div>
) : null}
{showNostrThread && syntheticRoot ? (
<div className="px-0 w-full"> <div className="px-0 w-full">
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes /> <NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes />
</div> </div>
)} ) : null}
<Separator /> {showNostrThread ? <Separator /> : null}
<div className="w-full"> <div className="w-full">
{syntheticRoot && ( {showNostrThread && syntheticRoot ? (
<NoteInteractions <NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`} key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index} pageIndex={index}
event={syntheticRoot} event={syntheticRoot}
showQuotes={false} showQuotes={false}
/> />
)} ) : null}
</div> </div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
@ -261,6 +322,20 @@ const RssArticlePage = forwardRef(
> >
<div key={contentKey} className="min-w-0"> <div key={contentKey} className="min-w-0">
<div className="px-4 pt-3 w-full space-y-3"> <div className="px-4 pt-3 w-full space-y-3">
{rssFeedReadOnly && !threadUnlocked ? (
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">{t('RSS read-only thread hint')}</p>
<Button
type="button"
variant="secondary"
size="sm"
disabled={promotingThread || !isHttpArticleUrl(articleUrl)}
onClick={() => void onPromoteForNostrThread()}
>
{t('Respond to this RSS entry')}
</Button>
</div>
) : null}
{sourceOptions.length > 1 ? ( {sourceOptions.length > 1 ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="rss-thread-feed-source" className="text-xs text-muted-foreground"> <Label htmlFor="rss-thread-feed-source" className="text-xs text-muted-foreground">
@ -295,25 +370,26 @@ const RssArticlePage = forwardRef(
item={it} item={it}
layout="detail" layout="detail"
className={itemsToRender.length > 1 ? 'rounded-none border-0' : ''} className={itemsToRender.length > 1 ? 'rounded-none border-0' : ''}
readOnlyHighlights={rssFeedReadOnly && !threadUnlocked}
/> />
))} ))}
</div> </div>
</div> </div>
{syntheticRoot && ( {showNostrThread && syntheticRoot ? (
<div className="px-4 w-full"> <div className="px-4 w-full">
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes /> <NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes />
</div> </div>
)} ) : null}
<Separator className="mt-4" /> {showNostrThread ? <Separator className="mt-4" /> : null}
<div className="px-4 pb-4 w-full"> <div className="px-4 pb-4 w-full">
{syntheticRoot && ( {showNostrThread && syntheticRoot ? (
<NoteInteractions <NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`} key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index} pageIndex={index}
event={syntheticRoot} event={syntheticRoot}
showQuotes={false} showQuotes={false}
/> />
)} ) : null}
</div> </div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

7
src/providers/LiveActivitiesProvider.tsx

@ -8,12 +8,13 @@ import {
} from '@/lib/live-activities' } from '@/lib/live-activities'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useFollowListOptional } from './FollowListProvider' import { useFollowListOptional } from './FollowListProvider'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useUserPreferences } from './UserPreferencesProvider' import { useUserPreferencesOptional } from './UserPreferencesProvider'
type TLiveActivitiesContext = { type TLiveActivitiesContext = {
items: TLiveActivityItem[] items: TLiveActivityItem[]
@ -39,7 +40,9 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followListCtx = useFollowListOptional() const followListCtx = useFollowListOptional()
const followings = followListCtx?.followings ?? [] const followings = followListCtx?.followings ?? []
const { showLiveActivitiesBanner } = useUserPreferences() const userPrefs = useUserPreferencesOptional()
const showLiveActivitiesBanner =
userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
const [items, setItems] = useState<TLiveActivityItem[]>([]) const [items, setItems] = useState<TLiveActivityItem[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)

5
src/providers/UserPreferencesProvider.tsx

@ -23,6 +23,11 @@ export const useUserPreferences = () => {
return context 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 }) { export function UserPreferencesProvider({ children }: { children: React.ReactNode }) {
const [notificationListStyle, setNotificationListStyle] = useState( const [notificationListStyle, setNotificationListStyle] = useState(
storage.getNotificationListStyle() storage.getNotificationListStyle()

Loading…
Cancel
Save