|
|
|
@ -1,28 +1,28 @@ |
|
|
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react' |
|
|
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react' |
|
|
|
import { useTranslation } from 'react-i18next' |
|
|
|
import { useTranslation } from 'react-i18next' |
|
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
|
|
|
|
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
|
|
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
|
|
|
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
|
|
|
import { DEFAULT_RSS_FEEDS } from '@/constants' |
|
|
|
import { DEFAULT_RSS_FEEDS } from '@/constants' |
|
|
|
|
|
|
|
import RssFeedItem from '../RssFeedItem' |
|
|
|
import RssWebFeedCard from '../RssWebFeedCard' |
|
|
|
import RssWebFeedCard from '../RssWebFeedCard' |
|
|
|
|
|
|
|
import { ArticleUrlsSection } from './ArticleUrlsSection' |
|
|
|
|
|
|
|
import { RssEntriesSection } from './RssEntriesSection' |
|
|
|
import { |
|
|
|
import { |
|
|
|
addManualRssWebUrl, |
|
|
|
addManualRssWebUrl, |
|
|
|
fetchDiscoveredWebUrlsFromAuthorPubkeys, |
|
|
|
fetchDiscoveredWebUrlsFromRelays, |
|
|
|
fetchNostrWebActivityForUrls, |
|
|
|
|
|
|
|
fetchPubkeyWebExternalReactionUrls, |
|
|
|
|
|
|
|
isHttpArticleUrl, |
|
|
|
|
|
|
|
loadManualRssWebUrls, |
|
|
|
loadManualRssWebUrls, |
|
|
|
loadRssWebFeedScopePreference, |
|
|
|
loadRssWebFeedScopePreference, |
|
|
|
loadRssWebOnlyMyEventsPreference, |
|
|
|
loadRssWebSuppressClawstrPreference, |
|
|
|
|
|
|
|
buildArticleUrlFeedRows, |
|
|
|
mergeDiscoveredRssWebUrls, |
|
|
|
mergeDiscoveredRssWebUrls, |
|
|
|
partitionRssItemsForWebFeed, |
|
|
|
|
|
|
|
saveRssWebFeedScopePreference, |
|
|
|
saveRssWebFeedScopePreference, |
|
|
|
saveRssWebOnlyMyEventsPreference, |
|
|
|
saveRssWebSuppressClawstrPreference, |
|
|
|
WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, |
|
|
|
WEB_EXTERNAL_REACTION_PUBLISHED_EVENT, |
|
|
|
type ManualRssWebUrlEntry, |
|
|
|
type ManualRssWebUrlEntry, |
|
|
|
type NostrWebActivityByUrl, |
|
|
|
|
|
|
|
type RssWebFeedScope |
|
|
|
type RssWebFeedScope |
|
|
|
} from '@/lib/rss-web-feed' |
|
|
|
} from '@/lib/rss-web-feed' |
|
|
|
import { getPubkeysFromPTags } from '@/lib/tag' |
|
|
|
import { RssFeedDisplayPrefsProvider } from './RssFeedDisplayPrefsContext' |
|
|
|
import { Checkbox } from '@/components/ui/checkbox' |
|
|
|
import { Checkbox } from '@/components/ui/checkbox' |
|
|
|
import { Skeleton } from '@/components/ui/skeleton' |
|
|
|
import { Skeleton } from '@/components/ui/skeleton' |
|
|
|
import { AlertCircle, Search, Plus } from 'lucide-react' |
|
|
|
import { AlertCircle, Search, Plus } from 'lucide-react' |
|
|
|
@ -133,7 +133,8 @@ function ManualRssUrlAddRow({ |
|
|
|
|
|
|
|
|
|
|
|
export default function RssFeedList() { |
|
|
|
export default function RssFeedList() { |
|
|
|
const { t } = useTranslation() |
|
|
|
const { t } = useTranslation() |
|
|
|
const { pubkey, rssFeedListEvent, followListEvent } = useNostr() |
|
|
|
const { pubkey, rssFeedListEvent } = useNostr() |
|
|
|
|
|
|
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
|
|
const { isSmallScreen } = useScreenSize() |
|
|
|
const { isSmallScreen } = useScreenSize() |
|
|
|
const [items, setItems] = useState<TRssFeedItem[]>([]) |
|
|
|
const [items, setItems] = useState<TRssFeedItem[]>([]) |
|
|
|
const [loading, setLoading] = useState(true) |
|
|
|
const [loading, setLoading] = useState(true) |
|
|
|
@ -153,6 +154,8 @@ export default function RssFeedList() { |
|
|
|
/** True after user changes RSS+Web scope or “only my web events”; blocks async prefs from overwriting. */ |
|
|
|
/** True after user changes RSS+Web scope or “only my web events”; blocks async prefs from overwriting. */ |
|
|
|
const rssWebPrefsUserTouchedRef = useRef(false) |
|
|
|
const rssWebPrefsUserTouchedRef = useRef(false) |
|
|
|
const [manualWebEntries, setManualWebEntries] = useState<ManualRssWebUrlEntry[]>([]) |
|
|
|
const [manualWebEntries, setManualWebEntries] = useState<ManualRssWebUrlEntry[]>([]) |
|
|
|
|
|
|
|
/** Latest relay discovery (in-memory); URLs appear as faux cards even before IndexedDB merge. */ |
|
|
|
|
|
|
|
const [relayDiscoveredUrls, setRelayDiscoveredUrls] = useState<ManualRssWebUrlEntry[]>([]) |
|
|
|
|
|
|
|
|
|
|
|
const refreshManualWebUrls = useCallback(() => { |
|
|
|
const refreshManualWebUrls = useCallback(() => { |
|
|
|
void loadManualRssWebUrls().then(setManualWebEntries) |
|
|
|
void loadManualRssWebUrls().then(setManualWebEntries) |
|
|
|
@ -162,16 +165,8 @@ export default function RssFeedList() { |
|
|
|
void loadManualRssWebUrls().then(setManualWebEntries) |
|
|
|
void loadManualRssWebUrls().then(setManualWebEntries) |
|
|
|
}, []) |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
const webDiscoveryAuthorPubkeys = useMemo(() => { |
|
|
|
/** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ |
|
|
|
if (!pubkey) return [] |
|
|
|
const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) |
|
|
|
const set = new Set<string>([pubkey]) |
|
|
|
|
|
|
|
if (followListEvent) { |
|
|
|
|
|
|
|
for (const pk of getPubkeysFromPTags(followListEvent.tags)) { |
|
|
|
|
|
|
|
set.add(pk) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return [...set] |
|
|
|
|
|
|
|
}, [pubkey, followListEvent]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Listen for filter toggle events
|
|
|
|
// Listen for filter toggle events
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
@ -481,23 +476,21 @@ export default function RssFeedList() { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Filter items based on selected filters
|
|
|
|
/** Feed + time only (search is applied after merge so URL rows and links match too). */ |
|
|
|
const filteredItems = useMemo(() => { |
|
|
|
const baseFilteredItems = useMemo(() => { |
|
|
|
let filtered = items |
|
|
|
let filtered = items |
|
|
|
|
|
|
|
|
|
|
|
// Filter by feed
|
|
|
|
|
|
|
|
if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { |
|
|
|
if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { |
|
|
|
const normalizedSelectedFeeds = selectedFeeds.map(f => normalizeFeedUrl(f)) |
|
|
|
const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f)) |
|
|
|
filtered = filtered.filter(item =>
|
|
|
|
filtered = filtered.filter((item) => |
|
|
|
normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) |
|
|
|
normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl)) |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Filter by time
|
|
|
|
|
|
|
|
if (timeFilter !== 'all') { |
|
|
|
if (timeFilter !== 'all') { |
|
|
|
const now = Date.now() |
|
|
|
const now = Date.now() |
|
|
|
let cutoffTime = 0 |
|
|
|
let cutoffTime = 0 |
|
|
|
|
|
|
|
|
|
|
|
switch (timeFilter) { |
|
|
|
switch (timeFilter) { |
|
|
|
case 'hour': |
|
|
|
case 'hour': |
|
|
|
cutoffTime = now - 60 * 60 * 1000 |
|
|
|
cutoffTime = now - 60 * 60 * 1000 |
|
|
|
@ -512,147 +505,159 @@ export default function RssFeedList() { |
|
|
|
cutoffTime = now - 30 * 24 * 60 * 60 * 1000 |
|
|
|
cutoffTime = now - 30 * 24 * 60 * 60 * 1000 |
|
|
|
break |
|
|
|
break |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
filtered = filtered.filter(item => { |
|
|
|
filtered = filtered.filter((item) => { |
|
|
|
if (!item.pubDate) return false |
|
|
|
if (!item.pubDate) return false |
|
|
|
return item.pubDate.getTime() >= cutoffTime |
|
|
|
return item.pubDate.getTime() >= cutoffTime |
|
|
|
}) |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Filter by search query
|
|
|
|
|
|
|
|
if (searchQuery.trim()) { |
|
|
|
|
|
|
|
const query = searchQuery.toLowerCase().trim() |
|
|
|
|
|
|
|
filtered = filtered.filter(item => { |
|
|
|
|
|
|
|
const titleMatch = item.title.toLowerCase().includes(query) |
|
|
|
|
|
|
|
const descMatch = item.description.toLowerCase().includes(query) |
|
|
|
|
|
|
|
const feedMatch = (item.feedTitle || '').toLowerCase().includes(query) |
|
|
|
|
|
|
|
return titleMatch || descMatch || feedMatch |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return filtered |
|
|
|
return filtered |
|
|
|
}, [items, selectedFeeds, timeFilter, searchQuery]) |
|
|
|
}, [items, selectedFeeds, timeFilter]) |
|
|
|
|
|
|
|
|
|
|
|
type FeedRow = |
|
|
|
const rssItemMatchesSearch = useCallback((item: TRssFeedItem, q: string) => { |
|
|
|
|
|
|
|
const query = q.toLowerCase().trim() |
|
|
|
|
|
|
|
if (!query) return true |
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
|
|
item.title.toLowerCase().includes(query) || |
|
|
|
|
|
|
|
item.description.toLowerCase().includes(query) || |
|
|
|
|
|
|
|
(item.feedTitle || '').toLowerCase().includes(query) || |
|
|
|
|
|
|
|
(item.link || '').toLowerCase().includes(query) || |
|
|
|
|
|
|
|
(item.guid || '').toLowerCase().includes(query) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** RSS-only view: flat timeline with full-text search. */ |
|
|
|
|
|
|
|
const rssScopeItems = useMemo(() => { |
|
|
|
|
|
|
|
const q = searchQuery.trim() |
|
|
|
|
|
|
|
let list = baseFilteredItems |
|
|
|
|
|
|
|
if (q) { |
|
|
|
|
|
|
|
list = list.filter((item) => rssItemMatchesSearch(item, q)) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return [...list].sort( |
|
|
|
|
|
|
|
(a, b) => (b.pubDate?.getTime() ?? 0) - (a.pubDate?.getTime() ?? 0) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
}, [baseFilteredItems, searchQuery, rssItemMatchesSearch]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type CombinedFeedRow = |
|
|
|
| { kind: 'web'; canonicalUrl: string; rssItems: TRssFeedItem[]; latestPub: number } |
|
|
|
| { kind: 'web'; canonicalUrl: string; rssItems: TRssFeedItem[]; latestPub: number } |
|
|
|
| { kind: 'rss'; item: TRssFeedItem } |
|
|
|
| { kind: 'rss'; item: TRssFeedItem } |
|
|
|
|
|
|
|
|
|
|
|
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('webAndRss') |
|
|
|
type UnifiedFeedRow = |
|
|
|
const [myWebReactionTs, setMyWebReactionTs] = useState<Map<string, number>>(() => new Map()) |
|
|
|
| { kind: 'url'; canonicalUrl: string; rssItems: TRssFeedItem[] } |
|
|
|
|
|
|
|
| { kind: 'rssEntry'; item: TRssFeedItem } |
|
|
|
const loadMyWebReactions = useCallback(() => { |
|
|
|
|
|
|
|
if (!pubkey || feedScope === 'rssOnly') { |
|
|
|
|
|
|
|
setMyWebReactionTs(new Map()) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
void fetchPubkeyWebExternalReactionUrls(pubkey).then(setMyWebReactionTs) |
|
|
|
|
|
|
|
}, [pubkey, feedScope]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const [feedScope, setFeedScope] = useState<RssWebFeedScope>('both') |
|
|
|
loadMyWebReactions() |
|
|
|
|
|
|
|
}, [loadMyWebReactions]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
const handler = () => loadMyWebReactions() |
|
|
|
const handler = () => setRelayDiscoveryTick((n) => n + 1) |
|
|
|
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) |
|
|
|
}, [loadMyWebReactions]) |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
if (feedScope === 'rssOnly' || !pubkey || webDiscoveryAuthorPubkeys.length === 0) return |
|
|
|
if (feedScope === 'rss') return |
|
|
|
let cancelled = false |
|
|
|
let cancelled = false |
|
|
|
void (async () => { |
|
|
|
void (async () => { |
|
|
|
try { |
|
|
|
try { |
|
|
|
const discovered = await fetchDiscoveredWebUrlsFromAuthorPubkeys(webDiscoveryAuthorPubkeys) |
|
|
|
const discovered = await fetchDiscoveredWebUrlsFromRelays({ |
|
|
|
|
|
|
|
accountPubkey: pubkey, |
|
|
|
|
|
|
|
favoriteRelays: favoriteRelays ?? [], |
|
|
|
|
|
|
|
blockedRelays: blockedRelays ?? [] |
|
|
|
|
|
|
|
}) |
|
|
|
if (cancelled) return |
|
|
|
if (cancelled) return |
|
|
|
|
|
|
|
setRelayDiscoveredUrls(discovered) |
|
|
|
const didMerge = await mergeDiscoveredRssWebUrls(discovered) |
|
|
|
const didMerge = await mergeDiscoveredRssWebUrls(discovered) |
|
|
|
if (didMerge && !cancelled) refreshManualWebUrls() |
|
|
|
if (didMerge && !cancelled) refreshManualWebUrls() |
|
|
|
} catch { |
|
|
|
} catch { |
|
|
|
/* ignore */ |
|
|
|
if (!cancelled) setRelayDiscoveredUrls([]) |
|
|
|
} |
|
|
|
} |
|
|
|
})() |
|
|
|
})() |
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
cancelled = true |
|
|
|
cancelled = true |
|
|
|
} |
|
|
|
} |
|
|
|
}, [feedScope, pubkey, webDiscoveryAuthorPubkeys, refreshManualWebUrls]) |
|
|
|
}, [feedScope, pubkey, favoriteRelays, blockedRelays, refreshManualWebUrls, relayDiscoveryTick]) |
|
|
|
|
|
|
|
|
|
|
|
const mergedRows = useMemo(() => { |
|
|
|
const combinedFeedRows = useMemo((): CombinedFeedRow[] => { |
|
|
|
const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems) |
|
|
|
const { webRows, nonHttpItems } = buildArticleUrlFeedRows( |
|
|
|
const webByUrl = new Map<string, { rssItems: TRssFeedItem[]; latestPub: number }>() |
|
|
|
baseFilteredItems, |
|
|
|
for (const g of groups) { |
|
|
|
manualWebEntries, |
|
|
|
webByUrl.set(g.canonicalUrl, { rssItems: g.items, latestPub: g.latestPub }) |
|
|
|
relayDiscoveredUrls |
|
|
|
} |
|
|
|
|
|
|
|
for (const [canonicalUrl, ts] of myWebReactionTs) { |
|
|
|
|
|
|
|
const cur = webByUrl.get(canonicalUrl) |
|
|
|
|
|
|
|
if (cur) { |
|
|
|
|
|
|
|
webByUrl.set(canonicalUrl, { |
|
|
|
|
|
|
|
...cur, |
|
|
|
|
|
|
|
latestPub: Math.max(cur.latestPub, ts) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
webByUrl.set(canonicalUrl, { rssItems: [], latestPub: ts }) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for (const { url, addedAt } of manualWebEntries) { |
|
|
|
|
|
|
|
if (!isHttpArticleUrl(url)) continue |
|
|
|
|
|
|
|
const cur = webByUrl.get(url) |
|
|
|
|
|
|
|
if (cur) { |
|
|
|
|
|
|
|
webByUrl.set(url, { |
|
|
|
|
|
|
|
...cur, |
|
|
|
|
|
|
|
latestPub: Math.max(cur.latestPub, addedAt) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
webByUrl.set(url, { rssItems: [], latestPub: addedAt }) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
const web: Extract<FeedRow, { kind: 'web' }>[] = Array.from(webByUrl.entries()).map( |
|
|
|
|
|
|
|
([canonicalUrl, v]) => ({ |
|
|
|
|
|
|
|
kind: 'web' as const, |
|
|
|
|
|
|
|
canonicalUrl, |
|
|
|
|
|
|
|
rssItems: v.rssItems, |
|
|
|
|
|
|
|
latestPub: v.latestPub |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
const rest: CombinedFeedRow[] = nonHttpItems.map((item) => ({ |
|
|
|
const rest: FeedRow[] = nonHttpItems.map((item) => ({ |
|
|
|
|
|
|
|
kind: 'rss' as const, |
|
|
|
kind: 'rss' as const, |
|
|
|
item |
|
|
|
item |
|
|
|
})) |
|
|
|
})) |
|
|
|
const combined: FeedRow[] = [...web, ...rest].sort((a, b) => { |
|
|
|
return [...webRows, ...rest].sort((a, b) => { |
|
|
|
const ta = a.kind === 'web' ? a.latestPub : (a.item.pubDate?.getTime() ?? 0) |
|
|
|
const ta = a.kind === 'web' ? a.latestPub : (a.item.pubDate?.getTime() ?? 0) |
|
|
|
const tb = b.kind === 'web' ? b.latestPub : (b.item.pubDate?.getTime() ?? 0) |
|
|
|
const tb = b.kind === 'web' ? b.latestPub : (b.item.pubDate?.getTime() ?? 0) |
|
|
|
return tb - ta |
|
|
|
return tb - ta |
|
|
|
}) |
|
|
|
}) |
|
|
|
|
|
|
|
}, [baseFilteredItems, manualWebEntries, relayDiscoveredUrls]) |
|
|
|
// One merged list: Web+RSS shows all; Only Web hides rss rows; Only RSS hides web rows.
|
|
|
|
|
|
|
|
if (feedScope === 'webOnly') { |
|
|
|
const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => { |
|
|
|
return combined.filter((r): r is Extract<FeedRow, { kind: 'web' }> => r.kind === 'web') |
|
|
|
const q = searchQuery.trim() |
|
|
|
|
|
|
|
if (!q) return combinedFeedRows |
|
|
|
|
|
|
|
return combinedFeedRows.filter((row) => { |
|
|
|
|
|
|
|
if (row.kind === 'rss') { |
|
|
|
|
|
|
|
return rssItemMatchesSearch(row.item, q) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (row.canonicalUrl.toLowerCase().includes(q.toLowerCase())) return true |
|
|
|
|
|
|
|
return row.rssItems.some((it) => rssItemMatchesSearch(it, q)) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}, [combinedFeedRows, searchQuery, rssItemMatchesSearch]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Canonical URLs we know from Nostr (relay discovery or user-added), not RSS-only grouping. */ |
|
|
|
|
|
|
|
const urlKeysWithNostrFootprint = useMemo(() => { |
|
|
|
|
|
|
|
const s = new Set<string>() |
|
|
|
|
|
|
|
for (const e of manualWebEntries) s.add(e.url) |
|
|
|
|
|
|
|
for (const e of relayDiscoveredUrls) s.add(e.url) |
|
|
|
|
|
|
|
return s |
|
|
|
|
|
|
|
}, [manualWebEntries, relayDiscoveredUrls]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** What to show before “only my web events” (used for Nostr URL list). */ |
|
|
|
|
|
|
|
const feedDisplayBase = useMemo((): |
|
|
|
|
|
|
|
| { view: 'rss'; items: TRssFeedItem[] } |
|
|
|
|
|
|
|
| { view: 'unified'; rows: UnifiedFeedRow[] } => { |
|
|
|
|
|
|
|
if (feedScope === 'rss') { |
|
|
|
|
|
|
|
return { view: 'rss', items: rssScopeItems } |
|
|
|
} |
|
|
|
} |
|
|
|
if (feedScope === 'rssOnly') { |
|
|
|
|
|
|
|
return combined.filter((r): r is Extract<FeedRow, { kind: 'rss' }> => r.kind === 'rss') |
|
|
|
if (feedScope === 'urls') { |
|
|
|
|
|
|
|
const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch |
|
|
|
|
|
|
|
.filter((r): r is Extract<CombinedFeedRow, { kind: 'web' }> => r.kind === 'web') |
|
|
|
|
|
|
|
.filter((r) => { |
|
|
|
|
|
|
|
const hasRss = r.rssItems.length > 0 |
|
|
|
|
|
|
|
const hasNostr = urlKeysWithNostrFootprint.has(r.canonicalUrl) |
|
|
|
|
|
|
|
if (hasRss && !hasNostr) return false |
|
|
|
|
|
|
|
return true |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.map((r) => ({ |
|
|
|
|
|
|
|
kind: 'url' as const, |
|
|
|
|
|
|
|
canonicalUrl: r.canonicalUrl, |
|
|
|
|
|
|
|
rssItems: [] |
|
|
|
|
|
|
|
})) |
|
|
|
|
|
|
|
return { view: 'unified', rows } |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return combined |
|
|
|
const rows: UnifiedFeedRow[] = combinedFeedRowsForSearch.map((r) => |
|
|
|
}, [filteredItems, feedScope, myWebReactionTs, manualWebEntries]) |
|
|
|
r.kind === 'web' |
|
|
|
|
|
|
|
? { |
|
|
|
const webUrlsKey = useMemo( |
|
|
|
kind: 'url' as const, |
|
|
|
() => |
|
|
|
canonicalUrl: r.canonicalUrl, |
|
|
|
mergedRows |
|
|
|
rssItems: r.rssItems |
|
|
|
.filter((r): r is Extract<FeedRow, { kind: 'web' }> => r.kind === 'web') |
|
|
|
} |
|
|
|
.map((r) => r.canonicalUrl) |
|
|
|
: { kind: 'rssEntry' as const, item: r.item } |
|
|
|
.sort() |
|
|
|
) |
|
|
|
.join('\n'), |
|
|
|
return { view: 'unified', rows } |
|
|
|
[mergedRows] |
|
|
|
}, [feedScope, rssScopeItems, combinedFeedRowsForSearch, urlKeysWithNostrFootprint]) |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const [onlyMyWebEvents, setOnlyMyWebEvents] = useState(false) |
|
|
|
const [suppressClawstrLinks, setSuppressClawstrLinks] = useState(true) |
|
|
|
const [nostrActivity, setNostrActivity] = useState<NostrWebActivityByUrl>(new Map()) |
|
|
|
|
|
|
|
const [nostrLoading, setNostrLoading] = useState(false) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const persistOnlyMine = useCallback((checked: boolean) => { |
|
|
|
const persistSuppressClawstr = useCallback((checked: boolean) => { |
|
|
|
rssWebPrefsUserTouchedRef.current = true |
|
|
|
rssWebPrefsUserTouchedRef.current = true |
|
|
|
setOnlyMyWebEvents(checked) |
|
|
|
setSuppressClawstrLinks(checked) |
|
|
|
void saveRssWebOnlyMyEventsPreference(checked) |
|
|
|
void saveRssWebSuppressClawstrPreference(checked) |
|
|
|
}, []) |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
const persistFeedScope = useCallback((scope: RssWebFeedScope) => { |
|
|
|
const persistFeedScope = useCallback((scope: RssWebFeedScope) => { |
|
|
|
@ -662,72 +667,57 @@ export default function RssFeedList() { |
|
|
|
}, []) |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
let cancelled = false |
|
|
|
void (async () => { |
|
|
|
void (async () => { |
|
|
|
const [onlyMine, scope] = await Promise.all([ |
|
|
|
const [suppressClawstr, scope] = await Promise.all([ |
|
|
|
loadRssWebOnlyMyEventsPreference(), |
|
|
|
loadRssWebSuppressClawstrPreference(), |
|
|
|
loadRssWebFeedScopePreference() |
|
|
|
loadRssWebFeedScopePreference() |
|
|
|
]) |
|
|
|
]) |
|
|
|
if (!rssWebPrefsUserTouchedRef.current) { |
|
|
|
if (cancelled || rssWebPrefsUserTouchedRef.current) return |
|
|
|
setOnlyMyWebEvents(onlyMine) |
|
|
|
setSuppressClawstrLinks(suppressClawstr) |
|
|
|
setFeedScope(scope) |
|
|
|
setFeedScope(scope) |
|
|
|
} |
|
|
|
|
|
|
|
})() |
|
|
|
})() |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
|
|
if (!webUrlsKey) { |
|
|
|
|
|
|
|
setNostrActivity(new Map()) |
|
|
|
|
|
|
|
setNostrLoading(false) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
const urls = webUrlsKey.split('\n').filter(Boolean) |
|
|
|
|
|
|
|
let cancelled = false |
|
|
|
|
|
|
|
setNostrLoading(true) |
|
|
|
|
|
|
|
void fetchNostrWebActivityForUrls(urls) |
|
|
|
|
|
|
|
.then((m) => { |
|
|
|
|
|
|
|
if (!cancelled) setNostrActivity(m) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.catch(() => { |
|
|
|
|
|
|
|
if (!cancelled) setNostrActivity(new Map()) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.finally(() => { |
|
|
|
|
|
|
|
if (!cancelled) setNostrLoading(false) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
cancelled = true |
|
|
|
cancelled = true |
|
|
|
} |
|
|
|
} |
|
|
|
}, [webUrlsKey]) |
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
const mergedRowsForFeed = useMemo(() => { |
|
|
|
const feedTotalCount = |
|
|
|
if (!onlyMyWebEvents || !pubkey) return mergedRows |
|
|
|
feedDisplayBase.view === 'rss' |
|
|
|
return mergedRows.filter((row) => { |
|
|
|
? feedDisplayBase.items.length |
|
|
|
if (row.kind !== 'web') return true |
|
|
|
: feedDisplayBase.rows.length |
|
|
|
const act = nostrActivity.get(row.canonicalUrl) |
|
|
|
|
|
|
|
const myComments = act?.comments.filter((e) => e.pubkey === pubkey).length ?? 0 |
|
|
|
|
|
|
|
const myHighlights = act?.highlights.filter((e) => e.pubkey === pubkey).length ?? 0 |
|
|
|
|
|
|
|
const myReactions = |
|
|
|
|
|
|
|
act?.externalReactions?.filter((e) => e.pubkey === pubkey).length ?? 0 |
|
|
|
|
|
|
|
return myComments > 0 || myHighlights > 0 || myReactions > 0 |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}, [mergedRows, onlyMyWebEvents, pubkey, nostrActivity]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Reset pagination when filters change
|
|
|
|
// Reset pagination when filters change
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
setShowRowCount(20) |
|
|
|
setShowRowCount(20) |
|
|
|
}, [selectedFeeds, timeFilter, searchQuery, feedScope, onlyMyWebEvents]) |
|
|
|
}, [selectedFeeds, timeFilter, searchQuery, feedScope, suppressClawstrLinks]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 displayedRows = useMemo(() => { |
|
|
|
const displayedCount = |
|
|
|
return mergedRowsForFeed.slice(0, showRowCount) |
|
|
|
displayedFeed.view === 'rss' ? displayedFeed.items.length : displayedFeed.rows.length |
|
|
|
}, [mergedRowsForFeed, showRowCount]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// IntersectionObserver for infinite scroll
|
|
|
|
// IntersectionObserver for infinite scroll
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
if (!bottomRef.current || displayedRows.length >= mergedRowsForFeed.length) return |
|
|
|
if (!bottomRef.current || displayedCount >= feedTotalCount) return |
|
|
|
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver( |
|
|
|
const observer = new IntersectionObserver( |
|
|
|
(entries) => { |
|
|
|
(entries) => { |
|
|
|
if (entries[0].isIntersecting && displayedRows.length < mergedRowsForFeed.length) { |
|
|
|
if (entries[0].isIntersecting && displayedCount < feedTotalCount) { |
|
|
|
setShowRowCount((prev) => Math.min(prev + 20, mergedRowsForFeed.length)) |
|
|
|
setShowRowCount((prev) => Math.min(prev + 20, feedTotalCount)) |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ root: null, rootMargin: '100px', threshold: 0.1 } |
|
|
|
{ root: null, rootMargin: '100px', threshold: 0.1 } |
|
|
|
@ -738,7 +728,7 @@ export default function RssFeedList() { |
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
observer.disconnect() |
|
|
|
observer.disconnect() |
|
|
|
} |
|
|
|
} |
|
|
|
}, [displayedRows.length, mergedRowsForFeed.length]) |
|
|
|
}, [displayedCount, feedTotalCount]) |
|
|
|
|
|
|
|
|
|
|
|
// Get display text for feed selector
|
|
|
|
// Get display text for feed selector
|
|
|
|
const feedSelectorText = useMemo(() => { |
|
|
|
const feedSelectorText = useMemo(() => { |
|
|
|
@ -782,69 +772,77 @@ export default function RssFeedList() { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
return ( |
|
|
|
|
|
|
|
<RssFeedDisplayPrefsProvider value={{ suppressClawstrLinks }}> |
|
|
|
<div className="space-y-3"> |
|
|
|
<div className="space-y-3"> |
|
|
|
{/* Feed header — Nostr filter, counts */} |
|
|
|
{/* Feed header — view mode, display prefs, counts */} |
|
|
|
<div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2"> |
|
|
|
<div className="sticky top-0 z-10 space-y-1.5 border-b bg-background px-4 py-2"> |
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between"> |
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between"> |
|
|
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> |
|
|
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> |
|
|
|
<div |
|
|
|
<div |
|
|
|
className="inline-flex max-w-full flex-wrap rounded-md border border-border bg-muted/30 p-0.5 sm:flex-nowrap" |
|
|
|
className="inline-flex max-w-full flex-wrap rounded-md border border-border bg-muted/30 p-0.5 sm:flex-nowrap" |
|
|
|
role="group" |
|
|
|
role="group" |
|
|
|
aria-label={t('RSS + Web feed scope')} |
|
|
|
aria-label={t('RSS feed view mode')} |
|
|
|
> |
|
|
|
> |
|
|
|
<Button |
|
|
|
<Button |
|
|
|
type="button" |
|
|
|
type="button" |
|
|
|
variant={feedScope === 'webOnly' ? 'secondary' : 'ghost'} |
|
|
|
variant={feedScope === 'urls' ? 'secondary' : 'ghost'} |
|
|
|
size="sm" |
|
|
|
size="sm" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
onClick={() => persistFeedScope('webOnly')} |
|
|
|
onClick={() => persistFeedScope('urls')} |
|
|
|
> |
|
|
|
> |
|
|
|
{t('Only Web')} |
|
|
|
{t('URLs')} |
|
|
|
</Button> |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
<Button |
|
|
|
type="button" |
|
|
|
type="button" |
|
|
|
variant={feedScope === 'webAndRss' ? 'secondary' : 'ghost'} |
|
|
|
variant={feedScope === 'both' ? 'secondary' : 'ghost'} |
|
|
|
size="sm" |
|
|
|
size="sm" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
onClick={() => persistFeedScope('webAndRss')} |
|
|
|
onClick={() => persistFeedScope('both')} |
|
|
|
> |
|
|
|
> |
|
|
|
{t('Web + RSS')} |
|
|
|
{t('Both')} |
|
|
|
</Button> |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
<Button |
|
|
|
type="button" |
|
|
|
type="button" |
|
|
|
variant={feedScope === 'rssOnly' ? 'secondary' : 'ghost'} |
|
|
|
variant={feedScope === 'rss' ? 'secondary' : 'ghost'} |
|
|
|
size="sm" |
|
|
|
size="sm" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
className="h-7 rounded-sm px-2 text-[11px] font-normal shadow-none sm:px-2.5 sm:text-xs" |
|
|
|
onClick={() => persistFeedScope('rssOnly')} |
|
|
|
onClick={() => persistFeedScope('rss')} |
|
|
|
> |
|
|
|
> |
|
|
|
{t('Only RSS')} |
|
|
|
{t('RSS')} |
|
|
|
</Button> |
|
|
|
</Button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
<Checkbox |
|
|
|
<Checkbox |
|
|
|
id="only-my-web-events" |
|
|
|
id="suppress-clawstr-links" |
|
|
|
checked={onlyMyWebEvents} |
|
|
|
checked={suppressClawstrLinks} |
|
|
|
disabled={!pubkey || feedScope === 'rssOnly'} |
|
|
|
onCheckedChange={(c) => persistSuppressClawstr(c === true)} |
|
|
|
onCheckedChange={(c) => persistOnlyMine(c === true)} |
|
|
|
|
|
|
|
/> |
|
|
|
/> |
|
|
|
<Label |
|
|
|
<Label |
|
|
|
htmlFor="only-my-web-events" |
|
|
|
htmlFor="suppress-clawstr-links" |
|
|
|
className={`cursor-pointer text-xs text-muted-foreground ${!pubkey || feedScope === 'rssOnly' ? 'opacity-50' : ''}`} |
|
|
|
className="cursor-pointer text-xs text-muted-foreground" |
|
|
|
> |
|
|
|
> |
|
|
|
{t('Only my web events')} |
|
|
|
{t('Suppress Clawstr links in RSS previews')} |
|
|
|
</Label> |
|
|
|
</Label> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<p className="text-xs text-muted-foreground sm:text-right"> |
|
|
|
<p className="text-xs text-muted-foreground sm:text-right"> |
|
|
|
{t('Showing {{filtered}} of {{total}} entries', { |
|
|
|
{t('Showing {{filtered}} of {{total}} entries', { |
|
|
|
filtered: displayedRows.length, |
|
|
|
filtered: displayedCount, |
|
|
|
total: mergedRowsForFeed.length |
|
|
|
total: feedTotalCount |
|
|
|
})} |
|
|
|
})} |
|
|
|
</p> |
|
|
|
</p> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
{nostrLoading ? ( |
|
|
|
<div className="relative w-full max-w-xl"> |
|
|
|
<p className="text-xs text-muted-foreground">{t('Fetching web activity from Nostr…')}</p> |
|
|
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground sm:h-4 sm:w-4" /> |
|
|
|
) : null} |
|
|
|
<Input |
|
|
|
|
|
|
|
type="search" |
|
|
|
|
|
|
|
placeholder={t('Search...')} |
|
|
|
|
|
|
|
value={searchQuery} |
|
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)} |
|
|
|
|
|
|
|
className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm" |
|
|
|
|
|
|
|
aria-label={t('Search...')} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
{/* Filter Bar - Collapsible */} |
|
|
|
{/* Filter Bar - Collapsible */} |
|
|
|
@ -922,18 +920,6 @@ export default function RssFeedList() { |
|
|
|
<SelectItem value="month">{t('Last month')}</SelectItem> |
|
|
|
<SelectItem value="month">{t('Last month')}</SelectItem> |
|
|
|
</SelectContent> |
|
|
|
</SelectContent> |
|
|
|
</Select> |
|
|
|
</Select> |
|
|
|
|
|
|
|
|
|
|
|
{/* Search Box */} |
|
|
|
|
|
|
|
<div className="relative flex-1 min-w-0"> |
|
|
|
|
|
|
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" /> |
|
|
|
|
|
|
|
<Input |
|
|
|
|
|
|
|
type="text" |
|
|
|
|
|
|
|
placeholder={t('Search...')} |
|
|
|
|
|
|
|
value={searchQuery} |
|
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)} |
|
|
|
|
|
|
|
className="h-8 md:h-9 pl-7 md:pl-8 text-xs md:text-sm w-full" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
)} |
|
|
|
@ -948,7 +934,7 @@ export default function RssFeedList() { |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{displayedRows.length === 0 ? ( |
|
|
|
{feedTotalCount === 0 ? ( |
|
|
|
<div className="flex flex-col items-center justify-center py-12"> |
|
|
|
<div className="flex flex-col items-center justify-center py-12"> |
|
|
|
<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' |
|
|
|
@ -956,24 +942,60 @@ export default function RssFeedList() { |
|
|
|
: t('No RSS feed items available')} |
|
|
|
: 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' ? ( |
|
|
|
|
|
|
|
<> |
|
|
|
|
|
|
|
<ArticleUrlsSection> |
|
|
|
|
|
|
|
{displayedFeed.rows |
|
|
|
|
|
|
|
.filter((r): r is Extract<UnifiedFeedRow, { kind: 'url' }> => r.kind === 'url') |
|
|
|
|
|
|
|
.map((row) => ( |
|
|
|
|
|
|
|
<RssWebFeedCard |
|
|
|
|
|
|
|
key={row.canonicalUrl} |
|
|
|
|
|
|
|
canonicalUrl={row.canonicalUrl} |
|
|
|
|
|
|
|
rssItems={row.rssItems} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
))} |
|
|
|
|
|
|
|
</ArticleUrlsSection> |
|
|
|
|
|
|
|
{displayedCount < feedTotalCount ? ( |
|
|
|
|
|
|
|
<div ref={bottomRef} className="flex justify-center py-4"> |
|
|
|
|
|
|
|
<Skeleton className="h-8 w-8 rounded-md" aria-hidden /> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
) : null} |
|
|
|
|
|
|
|
</> |
|
|
|
) : ( |
|
|
|
) : ( |
|
|
|
<> |
|
|
|
<> |
|
|
|
{displayedRows.map((row) => |
|
|
|
<div className="space-y-4"> |
|
|
|
row.kind === 'web' ? ( |
|
|
|
{displayedFeed.rows.map((row) => |
|
|
|
<RssWebFeedCard |
|
|
|
row.kind === 'url' ? ( |
|
|
|
key={row.canonicalUrl} |
|
|
|
<RssWebFeedCard |
|
|
|
canonicalUrl={row.canonicalUrl} |
|
|
|
key={row.canonicalUrl} |
|
|
|
rssItems={row.rssItems} |
|
|
|
canonicalUrl={row.canonicalUrl} |
|
|
|
/> |
|
|
|
rssItems={row.rssItems} |
|
|
|
) : ( |
|
|
|
/> |
|
|
|
<RssWebFeedCard |
|
|
|
) : ( |
|
|
|
key={`${row.item.feedUrl}-${row.item.guid}`} |
|
|
|
<div |
|
|
|
canonicalUrl={row.item.link} |
|
|
|
key={`${row.item.feedUrl}-${row.item.guid}`} |
|
|
|
rssItems={[row.item]} |
|
|
|
className="overflow-hidden rounded-xl border border-border bg-card" |
|
|
|
/> |
|
|
|
> |
|
|
|
) |
|
|
|
<RssFeedItem |
|
|
|
)} |
|
|
|
item={row.item} |
|
|
|
{displayedRows.length < mergedRowsForFeed.length ? ( |
|
|
|
layout="list" |
|
|
|
|
|
|
|
sourceStrip="rss" |
|
|
|
|
|
|
|
className="rounded-none border-0 bg-transparent shadow-none" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
)} |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
{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 /> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -982,6 +1004,7 @@ export default function RssFeedList() { |
|
|
|
)} |
|
|
|
)} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
</RssFeedDisplayPrefsProvider> |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|