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 @@ -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 | @@ -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() { @@ -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 }

7
src/components/LiveActivitiesStrip.tsx

@ -1,7 +1,8 @@ @@ -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' @@ -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 ?? []

94
src/components/RssFeedItem/index.tsx

@ -19,6 +19,7 @@ import { useSmartRssArticleNavigation } from '@/PageManager' @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -659,6 +682,35 @@ export default function RssFeedItem({
)}
</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 */}
{showFullBody ? (
<>
@ -784,7 +836,11 @@ export default function RssFeedItem({ @@ -784,7 +836,11 @@ export default function RssFeedItem({
)}
{/* Highlight Button (Desktop) */}
{!isSmallScreen && showHighlightButton && selectedText && selectionPosition && (
{!noHighlights &&
!isSmallScreen &&
showHighlightButton &&
selectedText &&
selectionPosition && (
<div
className="highlight-button-container fixed z-50"
style={{
@ -808,7 +864,7 @@ export default function RssFeedItem({ @@ -808,7 +864,7 @@ export default function RssFeedItem({
)}
{/* Highlight Drawer (Mobile) */}
{isSmallScreen && (
{!noHighlights && isSmallScreen && (
<Drawer
open={showHighlightDrawer}
onOpenChange={(open) => {
@ -864,18 +920,20 @@ export default function RssFeedItem({ @@ -864,18 +920,20 @@ export default function RssFeedItem({
</div>
{/* Post Editor for highlights */}
<PostEditor
open={isPostEditorOpen}
setOpen={(open) => {
setIsPostEditorOpen(open)
if (!open) {
setHighlightData(undefined)
setHighlightText('')
}
}}
defaultContent={highlightText}
initialHighlightData={highlightData}
/>
{!noHighlights ? (
<PostEditor
open={isPostEditorOpen}
setOpen={(open) => {
setIsPostEditorOpen(open)
if (!open) {
setHighlightData(undefined)
setHighlightText('')
}
}}
defaultContent={highlightText}
initialHighlightData={highlightData}
/>
) : null}
</div>
)
}

35
src/components/RssFeedList/RssEntriesSection.tsx

@ -1,35 +0,0 @@ @@ -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 @@ @@ -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' @@ -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 { @@ -20,6 +21,7 @@ import {
isHttpArticleUrl,
isRssWebUnifiedClutterUrl,
mergeDiscoveredRssWebUrls,
rssWebRowHasRealFeedItems,
saveRssWebFeedScopePreference,
saveRssWebHideUnifiedClutterPreference,
saveRssWebSuppressClawstrPreference,
@ -174,10 +176,21 @@ export default function RssFeedList() { @@ -174,10 +176,21 @@ export default function RssFeedList() {
void loadManualRssWebUrls().then(setManualWebEntries)
}, [])
const [promotedThreadUrls, setPromotedThreadUrls] = useState<string[]>([])
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() { @@ -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() { @@ -579,16 +576,19 @@ export default function RssFeedList() {
| { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] }
| { kind: 'rssEntry'; item: TRssFeedItem }
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('both')
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('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() { @@ -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() { @@ -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<CombinedFeedRow, { kind: 'web' }> =>
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<CombinedFeedRow, { kind: 'web' }> =>
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() { @@ -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() { @@ -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() { @@ -843,15 +834,6 @@ export default function RssFeedList() {
>
{t('URLs')}
</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
type="button"
variant={feedScope === 'rss' ? 'secondary' : 'ghost'}
@ -1005,18 +987,11 @@ export default function RssFeedList() { @@ -1005,18 +987,11 @@ export default function RssFeedList() {
<p className="text-sm text-muted-foreground">
{searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
? 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>
</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' ? (
<>
<ArticleUrlsSection subtitleKey="Article URLs Nostr manual subtitle">
@ -1038,13 +1013,14 @@ export default function RssFeedList() { @@ -1038,13 +1013,14 @@ export default function RssFeedList() {
</>
) : (
<>
<div className="space-y-4">
<RssUnifiedScopeSection>
{displayedFeed.rows.map((row) =>
row.kind === 'url' ? (
<RssWebFeedCard
key={row.canonicalUrl}
canonicalUrl={row.canonicalUrl}
rssItems={row.rssItems}
rssColumnReadOnly
/>
) : (
<div
@ -1056,11 +1032,16 @@ export default function RssFeedList() { @@ -1056,11 +1032,16 @@ export default function RssFeedList() {
layout="list"
sourceStrip="rss"
className="rounded-none border-0 bg-transparent shadow-none"
rssEntryReadOnlyMode
onAfterPromoteRss={() => {
refreshManualWebUrls()
refreshPromotedThreadUrls()
}}
/>
</div>
)
)}
</div>
</RssUnifiedScopeSection>
{displayedCount < feedTotalCount ? (
<div ref={bottomRef} className="flex justify-center py-4">
<Skeleton className="h-8 w-8 rounded-md" aria-hidden />

98
src/components/RssUrlThreadEventsPreview/index.tsx

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

10
src/i18n/locales/ar.ts

@ -1519,13 +1519,19 @@ export default { @@ -1519,13 +1519,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/de.ts

@ -1558,13 +1558,19 @@ export default { @@ -1558,13 +1558,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/en.ts

@ -1532,13 +1532,19 @@ export default { @@ -1532,13 +1532,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/es.ts

@ -1527,13 +1527,19 @@ export default { @@ -1527,13 +1527,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/fa.ts

@ -1523,13 +1523,19 @@ export default { @@ -1523,13 +1523,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/fr.ts

@ -1532,13 +1532,19 @@ export default { @@ -1532,13 +1532,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/hi.ts

@ -1525,13 +1525,19 @@ export default { @@ -1525,13 +1525,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/it.ts

@ -1528,13 +1528,19 @@ export default { @@ -1528,13 +1528,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ja.ts

@ -1523,13 +1523,19 @@ export default { @@ -1523,13 +1523,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ko.ts

@ -1521,13 +1521,19 @@ export default { @@ -1521,13 +1521,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/pl.ts

@ -1526,13 +1526,19 @@ export default { @@ -1526,13 +1526,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

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

@ -1525,13 +1525,19 @@ export default { @@ -1525,13 +1525,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

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

@ -1527,13 +1527,19 @@ export default { @@ -1527,13 +1527,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/ru.ts

@ -1528,13 +1528,19 @@ export default { @@ -1528,13 +1528,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/th.ts

@ -1518,13 +1518,19 @@ export default { @@ -1518,13 +1518,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'URL thread activity': 'URL thread activity',

10
src/i18n/locales/zh.ts

@ -1513,13 +1513,19 @@ export default { @@ -1513,13 +1513,19 @@ export default {
'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.',
'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 subtitle':
'Every item from your subscribed feeds, newest first — classic RSS reader.',
URLs: 'URLs',
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',
'Web URL item label': 'Web URL',
'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 @@ -17,6 +17,7 @@ import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/li
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.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'
/** 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' @@ -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. */
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'
/** IndexedDB: JSON array of `{ url, addedAt }` for URLs added from “Add URL” (no RSS row yet). */
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).
* `rss` = classic chronological RSS list only. `both` = all URL cards (RSS-enriched + Nostr/manual) plus RSS-only rows.
* `urls` = article URL cards with no real subscribed-feed items (Nostr/manual / web preview only).
* `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 {
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 === 'rssOnly') return 'rss'
if (raw === 'webAndRss' || raw === 'all') return 'both'
return 'both'
return 'urls'
}
export type ManualRssWebUrlEntry = { url: string; addedAt: number }
@ -129,6 +137,51 @@ export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry @@ -129,6 +137,51 @@ export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry
/** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */
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 = {
canonicalUrl: string
items: RssFeedItem[]
@ -301,11 +354,6 @@ export type ArticleUrlFeedWebRow = { @@ -301,11 +354,6 @@ export type ArticleUrlFeedWebRow = {
canonicalUrl: string
rssItems: RssFeedItem[]
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(
@ -316,16 +364,12 @@ export function buildArticleUrlFeedRows( @@ -316,16 +364,12 @@ export function buildArticleUrlFeedRows(
): { webRows: ArticleUrlFeedWebRow[]; nonHttpItems: RssFeedItem[] } {
const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems, options)
const excludeClutter = options?.excludeClutterLinks !== false
const webByUrl = new Map<
string,
{ rssItems: RssFeedItem[]; latestPub: number; fromNostrOrManual: boolean }
>()
const webByUrl = new Map<string, { rssItems: RssFeedItem[]; latestPub: number }>()
for (const g of groups) {
webByUrl.set(g.canonicalUrl, {
rssItems: g.items,
latestPub: g.latestPub,
fromNostrOrManual: false
latestPub: g.latestPub
})
}
@ -334,11 +378,10 @@ export function buildArticleUrlFeedRows( @@ -334,11 +378,10 @@ export function buildArticleUrlFeedRows(
if (cur) {
webByUrl.set(url, {
...cur,
latestPub: Math.max(cur.latestPub, ts),
fromNostrOrManual: true
latestPub: Math.max(cur.latestPub, ts)
})
} else {
webByUrl.set(url, { rssItems: [], latestPub: ts, fromNostrOrManual: true })
webByUrl.set(url, { rssItems: [], latestPub: ts })
}
}
@ -358,8 +401,7 @@ export function buildArticleUrlFeedRows( @@ -358,8 +401,7 @@ export function buildArticleUrlFeedRows(
kind: 'web' as const,
canonicalUrl,
rssItems: v.rssItems,
latestPub: v.latestPub,
fromNostrOrManual: v.fromNostrOrManual
latestPub: v.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' @@ -2,6 +2,7 @@ import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
@ -17,7 +18,7 @@ import { @@ -17,7 +18,7 @@ import {
createWebOnlyRssFeedItem,
isWebOnlyFauxRssItem
} 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 { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider'
@ -45,6 +46,16 @@ const RssArticlePage = forwardRef( @@ -45,6 +46,16 @@ const RssArticlePage = forwardRef(
ref
) => {
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 { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
@ -60,6 +71,31 @@ const RssArticlePage = forwardRef( @@ -60,6 +71,31 @@ const RssArticlePage = forwardRef(
}
}, [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(() => {
if (!rssFeedListEvent?.tags?.length) return new Set<string>()
const s = new Set<string>()
@ -174,6 +210,17 @@ const RssArticlePage = forwardRef( @@ -174,6 +210,17 @@ const RssArticlePage = forwardRef(
}
}, [articleUrl])
const onPromoteForNostrThread = useCallback(async () => {
if (!articleUrl || !isHttpArticleUrl(articleUrl)) return
setPromotingThread(true)
try {
await promoteRssArticleForNostrThread(articleUrl)
setThreadUnlocked(true)
} finally {
setPromotingThread(false)
}
}, [articleUrl])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
@ -230,21 +277,35 @@ const RssArticlePage = forwardRef( @@ -230,21 +277,35 @@ const RssArticlePage = forwardRef(
<p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</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">
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes />
</div>
)}
<Separator />
) : null}
{showNostrThread ? <Separator /> : null}
<div className="w-full">
{syntheticRoot && (
{showNostrThread && syntheticRoot ? (
<NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index}
event={syntheticRoot}
showQuotes={false}
/>
)}
) : null}
</div>
</div>
</SecondaryPageLayout>
@ -261,6 +322,20 @@ const RssArticlePage = forwardRef( @@ -261,6 +322,20 @@ const RssArticlePage = forwardRef(
>
<div key={contentKey} className="min-w-0">
<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 ? (
<div className="space-y-1.5">
<Label htmlFor="rss-thread-feed-source" className="text-xs text-muted-foreground">
@ -295,25 +370,26 @@ const RssArticlePage = forwardRef( @@ -295,25 +370,26 @@ const RssArticlePage = forwardRef(
item={it}
layout="detail"
className={itemsToRender.length > 1 ? 'rounded-none border-0' : ''}
readOnlyHighlights={rssFeedReadOnly && !threadUnlocked}
/>
))}
</div>
</div>
{syntheticRoot && (
{showNostrThread && syntheticRoot ? (
<div className="px-4 w-full">
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes />
</div>
)}
<Separator className="mt-4" />
) : null}
{showNostrThread ? <Separator className="mt-4" /> : null}
<div className="px-4 pb-4 w-full">
{syntheticRoot && (
{showNostrThread && syntheticRoot ? (
<NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index}
event={syntheticRoot}
showQuotes={false}
/>
)}
) : null}
</div>
</div>
</SecondaryPageLayout>

7
src/providers/LiveActivitiesProvider.tsx

@ -8,12 +8,13 @@ import { @@ -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 @@ -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<TLiveActivityItem[]>([])
const [loading, setLoading] = useState(false)

5
src/providers/UserPreferencesProvider.tsx

@ -23,6 +23,11 @@ export const useUserPreferences = () => { @@ -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()

Loading…
Cancel
Save