diff --git a/src/PageManager.tsx b/src/PageManager.tsx index d7836073..ee8091d9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -84,7 +84,6 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage')) const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage')) const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage')) const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) -const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage')) @@ -130,7 +129,6 @@ const PRIMARY_PAGE_REF_MAP = { profile: createRef(), relay: createRef(), search: createRef(), - 'follows-latest': createRef(), rss: createRef(), settings: createRef(), spells: createRef(), @@ -170,11 +168,6 @@ const getPrimaryPageMap = () => ({ ), - 'follows-latest': ( - - - - ), rss: ( @@ -279,7 +272,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str 'spells', 'rss', 'explore', - 'follows-latest', 'calendar' ] @@ -303,7 +295,6 @@ function buildRssArticleUrl( 'spells', 'rss', 'explore', - 'follows-latest', 'calendar' ] let path = @@ -421,7 +412,7 @@ function extractValidNoteId(raw: string): string | null { function parseNoteUrl(url: string): { noteId: string; context?: string } | null { // Match patterns like /discussions/notes/{noteId} or /notes/{noteId} const contextualMatch = url.match( - /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/ + /\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ ) if (contextualMatch) { const noteId = extractValidNoteId(contextualMatch[2]) @@ -560,7 +551,7 @@ export function useSmartRelayNavigation() { // Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url}) const relayUrlMatch = url.match( - /\/(discussions|search|profile|home|feed|spells|explore|follows-latest)\/relays\/(.+)$/ + /\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/ ) || url.match(/\/relays\/(.+)$/) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) @@ -600,7 +591,7 @@ export function useSmartRelayNavigationOptional() { const navigateToRelay = (url: string) => { const relayUrlMatch = url.match( - /\/(discussions|search|profile|home|feed|spells|explore|follows-latest)\/relays\/(.+)$/ + /\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/ ) || url.match(/\/relays\/(.+)$/) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) @@ -1247,6 +1238,19 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { '', '/notes' + window.location.pathname + window.location.search + window.location.hash ) + } else if ( + window.location.pathname === '/follows-latest' || + window.location.pathname.startsWith('/follows-latest/') + ) { + /** `/follows-latest` primary page removed — rewrite to `/feed` (same suffix e.g. `/notes/…`). */ + window.history.replaceState( + null, + '', + '/feed' + + window.location.pathname.slice('/follows-latest'.length) + + window.location.search + + window.location.hash + ) } // OG HTML proxy (`VITE_PROXY_SERVER`, e.g. https://host/proxy) must be reverse-proxied to the // fetch service. If /proxy is routed to this SPA, normalize to / so we don't push an unknown URL. @@ -1262,7 +1266,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const pathname = window.location.pathname // Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id} - const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) + const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const noteUrlMatch = contextualNoteMatch || standardNoteMatch @@ -1325,7 +1329,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key} const contextualRssMatch = pathname.match( - /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/ + /^\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/rss-item\/([^/?#]+)/ ) const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/) const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1] @@ -1450,7 +1454,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Check if pathname matches a primary page name // First, check if it's a contextual note URL (e.g., /discussions/notes/...) const contextualNoteMatch = pathname.match( - /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\// + /^\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\// ) if (contextualNoteMatch) { const pageContext = contextualNoteMatch[1] @@ -1520,7 +1524,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const urlToCheck = state?.url || window.location.pathname // Check if it's a note URL (we'll update drawer after stack is synced) - const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) || + const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) || urlToCheck.match(/\/notes\/(.+)$/) const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null @@ -1542,7 +1546,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { /* keep pathname */ } const ctxRssPop = rssPathSync.match( - /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/ + /^\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/rss-item\/([^/?#]+)/ ) if (ctxRssPop) { const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1]) @@ -1577,7 +1581,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (topItemUrl) { const topNoteUrlMatch = topItemUrl.match( - /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/ + /\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ ) || topItemUrl.match(/\/notes\/(.+)$/) if (topNoteUrlMatch) { const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1] @@ -1673,7 +1677,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id}) - const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) || + const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) || state.url.match(/\/notes\/(.+)$/) if (noteUrlMatch) { const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -1730,7 +1734,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Extract noteId from top item's URL or from state.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url if (topItemUrl) { - const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) || + const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) || topItemUrl.match(/\/notes\/(.+)$/) if (topNoteUrlMatch) { const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] @@ -2414,7 +2418,7 @@ function cloneSecondaryRouteElement( /** Hex id segment from /notes/{id} or /{context}/notes/{id} (query/hash stripped). */ function noteHexIdFromSecondaryNoteUrl(url: string): string | null { const contextual = url.match( - /\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/ + /\/(?:discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ ) const standard = url.match(/\/notes\/(.+)$/) const m = contextual || standard diff --git a/src/components/FavoriteRelaysActiveStrip/index.tsx b/src/components/FavoriteRelaysActiveStrip/index.tsx index 352ec536..93188778 100644 --- a/src/components/FavoriteRelaysActiveStrip/index.tsx +++ b/src/components/FavoriteRelaysActiveStrip/index.tsx @@ -1,11 +1,7 @@ -import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { useNostr } from '@/providers/NostrProvider' -import { usePrimaryPage } from '@/contexts/primary-page-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' import type { TFunction } from 'i18next' -import { FileText } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,9 +32,7 @@ function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string /** Home feed / mobile: full label above the page title */ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { const { t } = useTranslation() - const { navigate } = usePrimaryPage() - const { pubkey } = useNostr() - const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() + const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) @@ -89,18 +83,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:

{t('Relay pulse')}

- {pubkey && followCount > 0 ? ( - - ) : null}
{lastFetchedAtMs != null && relativeLabel ? (

@@ -116,9 +98,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: /** Desktop sidebar: compact row under nav */ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { const { t } = useTranslation() - const { navigate } = usePrimaryPage() - const { pubkey } = useNostr() - const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() + const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) @@ -171,18 +151,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st

- {pubkey && followCount > 0 ? ( - - ) : null}
{lastFetchedAtMs != null && relativeLabel ? ( @@ -192,18 +160,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st ) : null}
- {pubkey && followCount > 0 ? ( - - ) : null}
) diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx deleted file mode 100644 index f920d1f3..00000000 --- a/src/components/LatestFromFollowsSection/index.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import NoteCard from '@/components/NoteCard' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Skeleton } from '@/components/ui/skeleton' -import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' -import { buildFollowOutboxAggregateReadUrls } from '@/lib/follow-outbox-aggregate-relays' -import { - buildSearchFollowsFeedScopeKey, - fingerprintRelaySet, - fingerprintSortedPubkeys, - postsMapToRecord, - postsRecordToMap, - readSearchFollowsFeedCache, - writeSearchFollowsFeedCache -} from '@/lib/search-follows-feed-cache' -import { shouldFilterEvent } from '@/lib/event-filtering' -import { toProfile } from '@/lib/link' -import { getPubkeysFromPTags } from '@/lib/tag' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' -import { useDeletedEvent } from '@/providers/DeletedEventProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useMuteList } from '@/contexts/mute-list-context' -import { muteSetHas } from '@/lib/mute-set' -import { useNostr } from '@/providers/NostrProvider' -import { useUserTrust } from '@/contexts/user-trust-context' -import { queryService, replaceableEventService } from '@/services/client.service' -import type { TRelayList } from '@/types' -import logger from '@/lib/logger' -import { ChevronRight, Star } from 'lucide-react' -import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { FormattedTimestamp } from '../FormattedTimestamp' -import UserAvatar from '../UserAvatar' -import Username from '../Username' - -/** Curated follow list for guests (hex from npub). */ -const RECOMMENDED_FOLLOW_CURATOR_NPUB = - 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const - -const MAX_FOLLOWS = 1000 -const AUTHORS_PER_BATCH = 20 -const MAX_POSTS_PER_AUTHOR = 5 -/** Enough headroom to often fill 5 notes per author in a batch. */ -const BATCH_EVENT_LIMIT = 200 -/** Chunk size for batched NIP-65 list load while building the aggregate REQ set. */ -const RELAY_LIST_PRELOAD_CHUNK = 100 - -const FEED_KINDS = [ - kinds.ShortTextNote, - ExtendedKind.DISCUSSION, - kinds.LongFormArticle, - kinds.Highlights, - ExtendedKind.PICTURE, - ...NIP71_VIDEO_KINDS, - ExtendedKind.COMMENT, - kinds.Repost, - ExtendedKind.GENERIC_REPOST -] as number[] - -const feedKindSet = new Set(FEED_KINDS) - -const LOG = '[LatestFromFollows]' - -function mergeBatchPosts( - prev: Map, - incoming: NostrEvent[], - batchAuthors: string[] -): Map { - const next = new Map(prev) - /** Follow list pubkeys are lowercased in `getPubkeysFromPTags`; relay `pubkey` may be mixed-case hex. */ - const authorSet = new Set(batchAuthors.map((a) => a.toLowerCase())) - const filtered = incoming.filter((e) => authorSet.has(e.pubkey.toLowerCase())) - for (const pk of batchAuthors) { - const pkNorm = pk.toLowerCase() - const prevList = next.get(pk) ?? [] - const newForPk = filtered.filter((e) => e.pubkey.toLowerCase() === pkNorm) - const byId = new Map() - for (const e of prevList) byId.set(e.id, e) - for (const e of newForPk) { - const ex = byId.get(e.id) - if (!ex || e.created_at >= ex.created_at) byId.set(e.id, e) - } - const sorted = [...byId.values()] - .sort((a, b) => b.created_at - a.created_at) - .slice(0, MAX_POSTS_PER_AUTHOR) - next.set(pk, sorted) - } - return next -} - -function recommendedCuratorHexPubkey(): string | null { - try { - const dec = nip19.decode(RECOMMENDED_FOLLOW_CURATOR_NPUB) - if (dec.type !== 'npub') return null - return dec.data - } catch { - return null - } -} - -export default function LatestFromFollowsSection({ - refreshKey = 0, - variant = 'embedded' -}: { - /** Bump to re-run batched relay fetches (e.g. titlebar / page refresh). */ - refreshKey?: number - /** `page`: full-width list on the follows-latest primary page; `embedded`: tighter vertical spacing. */ - variant?: 'page' | 'embedded' -} = {}) { - const { t } = useTranslation() - const { push } = useSecondaryPage() - const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const { mutePubkeySet } = useMuteList() - const { isEventDeleted } = useDeletedEvent() - const { hideUntrustedNotes, isUserTrusted } = useUserTrust() - - const loggedInFollowPubkeys = useMemo(() => { - if (!pubkey || !isInitialized) return null - return getPubkeysFromPTags(followListEvent?.tags ?? []).slice(0, MAX_FOLLOWS) - }, [pubkey, isInitialized, followListEvent]) - - const [guestFollowPubkeys, setGuestFollowPubkeys] = useState([]) - const [guestListReady, setGuestListReady] = useState(false) - - const [postsByPubkey, setPostsByPubkey] = useState>(() => new Map()) - const [batchBusy, setBatchBusy] = useState(false) - const abortedRef = useRef(false) - - const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys - const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' - const [followListGraceExpired, setFollowListGraceExpired] = useState(false) - useEffect(() => { - if (!pubkey || followListEvent) { - setFollowListGraceExpired(false) - return - } - const t = setTimeout(() => setFollowListGraceExpired(true), 4000) - return () => clearTimeout(t) - }, [pubkey, followListEvent]) - - const loadingFollowList = - (!pubkey && isInitialized && !guestListReady) || - (!!pubkey && !followListEvent && (isAccountSessionHydrating || !followListGraceExpired)) - - const [aggregateRelayUrls, setAggregateRelayUrls] = useState([]) - const [aggregateRelaysReady, setAggregateRelaysReady] = useState(false) - - const followListFingerprint = useMemo( - () => fingerprintSortedPubkeys(followPubkeys), - [followPubkeys] - ) - const aggregateRelayFingerprint = useMemo( - () => fingerprintRelaySet(aggregateRelayUrls), - [aggregateRelayUrls] - ) - const followsFeedScopeKey = useMemo( - () => - buildSearchFollowsFeedScopeKey({ - mode: followsLabel, - viewerPubkey: pubkey?.toLowerCase() ?? null, - followListFingerprint, - aggregateRelayFingerprint - }), - [followsLabel, pubkey, followListFingerprint, aggregateRelayFingerprint] - ) - - const acceptEvent = useCallback( - (e: Event) => { - if (!feedKindSet.has(e.kind)) return false - if (isEventDeleted(e)) return false - if (shouldFilterEvent(e)) return false - if (muteSetHas(mutePubkeySet, e.pubkey)) return false - if (hideUntrustedNotes && !isUserTrusted(e.pubkey)) return false - return true - }, - [hideUntrustedNotes, isEventDeleted, isUserTrusted, mutePubkeySet] - ) - - // Guest: load curated follow list from npub; logged-in list comes from useMemo above. - useEffect(() => { - if (!isInitialized) return - if (pubkey) { - setGuestFollowPubkeys([]) - setGuestListReady(false) - return - } - - let cancelled = false - setGuestListReady(false) - setGuestFollowPubkeys([]) - - ;(async () => { - logger.info(`${LOG} guest: loading recommended follow list`) - const hex = recommendedCuratorHexPubkey() - if (!hex) { - if (!cancelled) { - setGuestFollowPubkeys([]) - setGuestListReady(true) - logger.info(`${LOG} guest: no curator npub; follow list empty`) - } - return - } - try { - const evt = await replaceableEventService.fetchReplaceableEvent(hex, kinds.Contacts) - if (cancelled) return - const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : [] - setGuestFollowPubkeys(list) - logger.info(`${LOG} guest: follow list loaded`, { count: list.length }) - } catch (err) { - logger.warn('[LatestFromFollows] Failed to load recommended follow list', err) - if (!cancelled) setGuestFollowPubkeys([]) - } finally { - if (!cancelled) setGuestListReady(true) - } - })() - - return () => { - cancelled = true - } - }, [isInitialized, pubkey]) - - // Load each follow's NIP-65 list (IndexedDB + network), then aggregate first outboxes + READ_ONLY relays. - useEffect(() => { - if (!isInitialized || loadingFollowList) { - logger.info(`${LOG} relays: waiting`, { - isInitialized, - loadingFollowList, - variant, - followsLabel - }) - return - } - if (followPubkeys.length === 0) { - logger.info(`${LOG} relays: no follows; skipping aggregate`) - setAggregateRelayUrls([]) - setAggregateRelaysReady(true) - return - } - - let cancelled = false - setAggregateRelaysReady(false) - setAggregateRelayUrls([]) - - ;(async () => { - logger.info(`${LOG} relays: fetch NIP-65 lists start`, { - authorCount: followPubkeys.length, - variant, - followsLabel - }) - try { - // Dynamic import avoids a static cycle: client.service → replaceable-events → client.service - // (would break React context / HMR when this module loads early). - const { default: nostrClient } = await import('@/services/client.service') - const allLists: TRelayList[] = [] - for (let i = 0; i < followPubkeys.length; i += RELAY_LIST_PRELOAD_CHUNK) { - if (cancelled) return - const chunk = followPubkeys.slice(i, i + RELAY_LIST_PRELOAD_CHUNK) - const lists = await nostrClient.fetchRelayLists(chunk) - allLists.push(...lists) - } - if (cancelled) return - const urls = buildFollowOutboxAggregateReadUrls( - allLists, - blockedRelays, - favoriteRelays - ) - setAggregateRelayUrls(urls) - logger.info(`${LOG} relays: aggregate URLs computed → setState`, { - nip65ListsLoaded: allLists.length, - aggregateUrlCount: urls.length, - relaySample: urls.slice(0, 6) - }) - } catch (err) { - logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) - if (!cancelled) { - const fallback = buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) - setAggregateRelayUrls(fallback) - logger.info(`${LOG} relays: using fallback aggregate URLs after error`, { - aggregateUrlCount: fallback.length - }) - } - } finally { - if (!cancelled) { - setAggregateRelaysReady(true) - logger.info(`${LOG} relays: aggregateRelaysReady → true`) - } - } - })() - - return () => { - cancelled = true - } - }, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList, variant, followsLabel]) - - // Batch-fetch posts per slice of authors against the aggregate relay set. - useEffect(() => { - if (!isInitialized || loadingFollowList) { - logger.info(`${LOG} posts: waiting`, { - isInitialized, - loadingFollowList, - aggregateRelaysReady, - followCount: followPubkeys.length, - variant - }) - return - } - if (followPubkeys.length === 0) { - logger.info(`${LOG} posts: no follows; skipping batch fetch`) - return - } - if (!aggregateRelaysReady) { - logger.info(`${LOG} posts: waiting for aggregate relays`) - return - } - - abortedRef.current = false - let cancelled = false - - const run = async () => { - setBatchBusy(true) - const seed = readSearchFollowsFeedCache(followsFeedScopeKey) - let working = seed ? postsRecordToMap(seed.posts) : new Map() - setPostsByPubkey(new Map(working)) - - const summarizePosts = (m: Map) => { - let authorsWithPosts = 0 - let totalNotes = 0 - for (const arr of m.values()) { - if (arr.length > 0) authorsWithPosts++ - totalNotes += arr.length - } - return { authorsWithPosts, totalNotes, mapKeyCount: m.size } - } - - logger.info(`${LOG} posts: batch run start`, { - followCount: followPubkeys.length, - relayUrlCount: aggregateRelayUrls.length, - hideUntrustedNotes, - usedCacheSeed: Boolean(seed), - ...summarizePosts(working) - }) - - const persist = () => { - writeSearchFollowsFeedCache({ - v: 1, - scopeKey: followsFeedScopeKey, - posts: postsMapToRecord(working), - savedAtMs: Date.now() - }) - } - - const batchCount = Math.ceil(followPubkeys.length / AUTHORS_PER_BATCH) - for (let i = 0; i < followPubkeys.length; i += AUTHORS_PER_BATCH) { - if (cancelled || abortedRef.current) break - const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) - const batchIndex = Math.floor(i / AUTHORS_PER_BATCH) + 1 - try { - logger.info(`${LOG} posts: REQ batch ${batchIndex}/${batchCount}`, { - authorBatchSize: batch.length, - kinds: FEED_KINDS.length, - limit: BATCH_EVENT_LIMIT - }) - const t0 = performance.now() - const raw = await queryService.fetchEvents( - aggregateRelayUrls, - { - kinds: [...FEED_KINDS], - authors: batch, - limit: BATCH_EVENT_LIMIT - }, - { eoseTimeout: 2800, globalTimeout: 9000 } - ) - const ms = Math.round(performance.now() - t0) - if (cancelled || abortedRef.current) break - const filtered = raw.filter((e) => acceptEvent(e)) - working = mergeBatchPosts(working, filtered, batch) - setPostsByPubkey(new Map(working)) - persist() - logger.info(`${LOG} posts: batch ${batchIndex}/${batchCount} done + UI setPostsByPubkey`, { - ms, - rawFromRelays: raw.length, - afterAcceptFilter: filtered.length, - droppedByAccept: raw.length - filtered.length, - ...summarizePosts(working) - }) - } catch (err) { - logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length }) - } - } - if (!cancelled) { - persist() - setBatchBusy(false) - logger.info(`${LOG} posts: batch run finished`, { - cancelled: false, - ...summarizePosts(working) - }) - } - } - - void run() - return () => { - cancelled = true - abortedRef.current = true - setBatchBusy(false) - logger.info(`${LOG} posts: batch effect cleanup (cancelled / deps changed)`) - } - }, [ - followPubkeys, - aggregateRelayUrls, - aggregateRelaysReady, - loadingFollowList, - isInitialized, - acceptEvent, - followsFeedScopeKey, - refreshKey - ]) - - const sortedRowPubkeys = useMemo(() => { - const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0) - const withoutPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) === 0) - withPosts.sort((a, b) => { - const ta = postsByPubkey.get(a)?.[0]?.created_at ?? 0 - const tb = postsByPubkey.get(b)?.[0]?.created_at ?? 0 - return tb - ta - }) - return [...withPosts, ...withoutPosts] - }, [followPubkeys, postsByPubkey]) - - const vertical = variant === 'page' ? '' : 'mb-6' - - if (!isInitialized) { - return null - } - - if (loadingFollowList) { - return ( -
- - -
- ) - } - - if (followPubkeys.length === 0) { - return ( -
- {followsLabel === 'recommended' - ? t('Could not load recommended follows') - : t('Your follow list is empty')} -
- ) - } - - return ( -
- {batchBusy && postsByPubkey.size === 0 ? ( -
- - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : null} - {sortedRowPubkeys.map((pk) => { - const posts = postsByPubkey.get(pk) ?? [] - const count = posts.length - const latest = posts[0]?.created_at - return ( - push(toProfile(pk))} - /> - ) - })} - {batchBusy && postsByPubkey.size > 0 ? ( -
- -
- ) : null} -
- ) -} - -function FollowRowEmptyPosts() { - const { t } = useTranslation() - return ( -
- {t('No recent posts from this user in the current fetch')} -
- ) -} - -function FollowPulseRow({ - pubkey, - count, - latestCreatedAt, - posts, - onOpenProfile -}: { - pubkey: string - count: number - latestCreatedAt?: number - posts: NostrEvent[] - onOpenProfile: () => void -}) { - const [open, setOpen] = useState(false) - - return ( - -
- - - - -
- - {posts.length === 0 ? ( - - ) : ( -
- {posts.map((ev) => ( - - ))} -
- )} -
-
- ) -} diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 4a5ff337..8a505a49 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -990,9 +990,7 @@ export function useMenuActions({ ? `/spells/notes/${noteId}` : currentPrimaryPage === 'rss' ? `/rss/notes/${noteId}` - : currentPrimaryPage === 'follows-latest' - ? `/follows-latest/notes/${noteId}` - : `/notes/${noteId}` + : `/notes/${noteId}` const appShareUrl = `https://jumble.imwald.eu${path}` navigator.clipboard.writeText(appShareUrl) closeDrawer() diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx deleted file mode 100644 index 00aeb0c7..00000000 --- a/src/components/Profile/ProfileInteractionsAccordion.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { ChevronDown, RefreshCw } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls' -import { useProfileAccordionData } from '@/hooks/useProfileAccordionData' -import { useNostr } from '@/providers/NostrProvider' -import ProfileHeaderInteractions from './ProfileHeaderInteractions' - -type Props = { - pubkey: string | undefined - isExpanded: boolean - onExpandedChange: (open: boolean) => void -} - -function ProfileInteractionsSkeleton() { - return ( -
- {[6, 4, 4, 8, 6, 6].map((count, i) => ( -
- -
- {Array.from({ length: count }).map((_, j) => ( - - ))} -
-
- ))} -
- ) -} - -export default function ProfileInteractionsAccordion({ - pubkey, - isExpanded, - onExpandedChange -}: Props) { - const { t } = useTranslation() - const { pubkey: viewerPubkey } = useNostr() - const { relayUrls, loading: relayUrlsLoading, refresh: refreshRelayUrls } = useProfileRelayUrls( - pubkey, - isExpanded - ) - const relaysReady = !relayUrlsLoading - const urlsForFetch = relayUrls.length > 0 ? relayUrls : undefined - - const { - zaps, - reactions, - comments, - badges, - followPacks, - reports, - loading: bundleLoading, - refresh: refreshBundle - } = useProfileAccordionData({ - pubkey, - relayUrls: urlsForFetch, - enabled: isExpanded && relaysReady && !!pubkey, - viewerPubkey - }) - - const handleRefresh = () => { - void (async () => { - const urls = await refreshRelayUrls() - refreshBundle(urls.length > 0 ? urls : undefined) - })() - } - - const hasContent = isExpanded && pubkey - const hasAnyBundleData = - zaps.length > 0 || - reactions.length > 0 || - comments.length > 0 || - badges.length > 0 || - followPacks.length > 0 || - reports.length > 0 - const showSkeleton = hasContent && (!relaysReady || (bundleLoading && !hasAnyBundleData)) - - return ( - -
- - - {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')} - - - - -
- - {hasContent ? ( - showSkeleton ? ( -
- -
- ) : ( -
- -
- ) - ) : null} -
-
- ) -} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index bf776b6c..9953bdcc 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -1,4 +1,3 @@ -import Collapsible from '@/components/Collapsible' import FollowButton from '@/components/FollowButton' import Nip05 from '@/components/Nip05' import Nip05List from '@/components/Nip05List' @@ -64,7 +63,6 @@ import ProfileMediaFeed from './ProfileMediaFeed' import ProfilePublicationsFeed from './ProfilePublicationsFeed' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { TNoteListRef } from '@/components/NoteList' -import ProfileInteractionsAccordion from './ProfileInteractionsAccordion' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' @@ -303,7 +301,6 @@ export default function Profile({ [profile] ) const isSelf = accountPubkey === profile?.pubkey - const [profileInteractionsExpanded, setProfileInteractionsExpanded] = useState(false) /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ const allAvailableRelayUrls = useMemo(() => { @@ -598,12 +595,10 @@ export default function Profile({ - - - + {/* Display websites - show first one prominently, others below */} {website && (
@@ -704,13 +699,6 @@ export default function Profile({
{!isSelf && } -
- -
diff --git a/src/components/Sidebar/FollowsLatestButton.tsx b/src/components/Sidebar/FollowsLatestButton.tsx deleted file mode 100644 index 8bbb526a..00000000 --- a/src/components/Sidebar/FollowsLatestButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { usePrimaryPage } from '@/contexts/primary-page-context' -import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { useNostr } from '@/providers/NostrProvider' -import { UsersRound } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import SidebarItem from './SidebarItem' - -export default function FollowsLatestButton() { - const { t } = useTranslation() - const { navigate, current, display } = usePrimaryPage() - const { primaryViewType } = usePrimaryNoteView() - const { pubkey } = useNostr() - - if (!pubkey) return null - - return ( - navigate('follows-latest')} - active={current === 'follows-latest' && display && primaryViewType === null} - > - - - ) -} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 44d3faa9..52329c2d 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -9,7 +9,6 @@ import NotificationButton from './NotificationButton' import PostButton from './PostButton' import RssButton from './RssButton' import SearchButton from './SearchButton' -import FollowsLatestButton from './FollowsLatestButton' import FavoritesButton from './FavoritesButton' import SpellsButton from './SpellsButton' import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip' @@ -42,7 +41,6 @@ export default function PrimaryPageSidebar() { - diff --git a/src/hooks/useProfileAccordionData.tsx b/src/hooks/useProfileAccordionData.tsx deleted file mode 100644 index b06efb76..00000000 --- a/src/hooks/useProfileAccordionData.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { - fetchProfileAccordionBundle, - mergeProfileAccordionBundles, - profileAccordionBundleCacheKey, - type ProfileAccordionBundle -} from '@/lib/profile-accordion-fetch' -import { - profileAccordionGetCachedBadges, - profileAccordionGetCachedFollowPacks, - profileAccordionGetCachedInteractions, - profileAccordionGetCachedReports, - profileAccordionRelayUrlsKey, - profileAccordionSetBadges, - profileAccordionSetFollowPacks, - profileAccordionSetInteractions, - profileAccordionSetReports -} from '@/lib/profile-accordion-session-cache' -import { subtractNormalizedRelayUrls } from '@/lib/url' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' - -const EMPTY: ProfileAccordionBundle = { - zaps: [], - reactions: [], - comments: [], - badges: [], - followPacks: [], - reports: [] -} - -function readFullCache( - pubkey: string, - relayKey: string, - viewerPubkey: string | null | undefined -): ProfileAccordionBundle | null { - const zi = profileAccordionGetCachedInteractions(pubkey, relayKey) - const zb = profileAccordionGetCachedBadges(pubkey, relayKey) - const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey) - const viewer = viewerPubkey?.trim() - const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined - if (!zi || zb === undefined || zf === undefined || !reportsReady) return null - const reports = - viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : [] - return { - zaps: zi.zaps, - reactions: zi.reactions, - comments: zi.comments, - badges: zb, - followPacks: zf, - reports - } -} - -/** - * Loads profile accordion data only when `enabled` (accordion open); hydrates from session cache first. - * Use {@link refresh} for manual network refresh. - */ -export function useProfileAccordionData(opts: { - pubkey: string | undefined - relayUrls: string[] | undefined - enabled: boolean - viewerPubkey: string | null | undefined -}) { - const { pubkey, relayUrls, enabled, viewerPubkey } = opts - const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const [data, setData] = useState(EMPTY) - const [loading, setLoading] = useState(false) - const reqId = useRef(0) - const lastSuccessfulRelayUrlsRef = useRef([]) - - // Keep refs so callbacks don't get recreated when these arrays change reference. - // Including live array references as useCallback deps causes the useLayoutEffect - // to re-fire and increment reqId, cancelling every in-flight fetch before it - // can commit its result — the accordion never shows data. - const relayUrlsRef = useRef(relayUrls) - relayUrlsRef.current = relayUrls - const favoriteRelaysRef = useRef(favoriteRelays) - favoriteRelaysRef.current = favoriteRelays - const blockedRelaysRef = useRef(blockedRelays) - blockedRelaysRef.current = blockedRelays - - const relayKey = useMemo( - () => profileAccordionBundleCacheKey(relayUrls ?? []), - [relayUrls] - ) - - useEffect(() => { - lastSuccessfulRelayUrlsRef.current = [] - }, [pubkey]) - - const runFetch = useCallback( - async (force: boolean, overrideUrls?: string[]) => { - const urls = (overrideUrls?.length ? overrideUrls : relayUrlsRef.current) ?? [] - if (!pubkey?.trim() || !urls.length) return - const id = ++reqId.current - setLoading(true) - try { - const bundle = await fetchProfileAccordionBundle({ - pubkey: pubkey.trim(), - urls, - viewerPubkey, - favoriteRelays: favoriteRelaysRef.current ?? [], - blockedRelays: blockedRelaysRef.current, - force, - onPartial: (partial) => { - if (id !== reqId.current) return - setData(partial) - } - }) - if (id !== reqId.current) return - setData(bundle) - lastSuccessfulRelayUrlsRef.current = urls - } finally { - if (id === reqId.current) setLoading(false) - } - }, - // relayUrls, favoriteRelays, and blockedRelays are read via refs — intentionally - // excluded from deps to prevent callback churn that cancels in-flight requests. - // eslint-disable-next-line react-hooks/exhaustive-deps - [pubkey, viewerPubkey] - ) - - const runMergeFetch = useCallback( - async (fullRelayUrls: string[], deltaUrls: string[], base: ProfileAccordionBundle) => { - const pk = pubkey?.trim() - if (!pk || !deltaUrls.length) return - const id = ++reqId.current - setLoading(true) - try { - const deltaB = await fetchProfileAccordionBundle({ - pubkey: pk, - urls: deltaUrls, - viewerPubkey, - favoriteRelays: favoriteRelaysRef.current ?? [], - blockedRelays: blockedRelaysRef.current, - force: true, - onPartial: (partial) => { - if (id !== reqId.current) return - setData(mergeProfileAccordionBundles(base, partial)) - } - }) - if (id !== reqId.current) return - const merged = mergeProfileAccordionBundles(base, deltaB) - setData(merged) - const fullKey = profileAccordionBundleCacheKey(fullRelayUrls) - profileAccordionSetInteractions(pk, fullKey, { - zaps: merged.zaps, - reactions: merged.reactions, - comments: merged.comments - }) - profileAccordionSetBadges(pk, fullKey, merged.badges) - profileAccordionSetFollowPacks(pk, fullKey, merged.followPacks) - const viewer = viewerPubkey?.trim() - if (viewer) profileAccordionSetReports(pk, viewer, merged.reports) - lastSuccessfulRelayUrlsRef.current = fullRelayUrls - } finally { - if (id === reqId.current) setLoading(false) - } - }, - // favoriteRelays and blockedRelays are read via refs — see runFetch comment. - // eslint-disable-next-line react-hooks/exhaustive-deps - [pubkey, viewerPubkey] - ) - - const refresh = useCallback( - (overrideUrls?: string[]) => { - void runFetch(true, overrideUrls) - }, - [runFetch] - ) - - useLayoutEffect(() => { - if (!enabled || !pubkey?.trim() || !relayUrls?.length) { - return - } - const pk = pubkey.trim() - const cached = readFullCache(pk, relayKey, viewerPubkey) - if (cached) { - setData(cached) - setLoading(false) - lastSuccessfulRelayUrlsRef.current = relayUrls - return - } - - const prevSucc = lastSuccessfulRelayUrlsRef.current - if ( - prevSucc.length > 0 && - profileAccordionRelayUrlsKey(prevSucc) !== profileAccordionRelayUrlsKey(relayUrls) - ) { - const delta = subtractNormalizedRelayUrls(relayUrls, prevSucc) - if (delta.length > 0) { - const prevKey = profileAccordionBundleCacheKey(prevSucc) - const base = readFullCache(pk, prevKey, viewerPubkey) - if (base) { - void runMergeFetch(relayUrls, delta, base) - return - } - } - } - - setLoading(true) - void runFetch(false) - }, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch, runMergeFetch]) - - return { - ...data, - loading, - refresh - } -} diff --git a/src/lib/document-meta.ts b/src/lib/document-meta.ts index cace9f68..e9bcdb5f 100644 --- a/src/lib/document-meta.ts +++ b/src/lib/document-meta.ts @@ -73,7 +73,6 @@ const PRIMARY_PAGE_LABEL: Record = { profile: 'Profile', relay: 'Relay', search: 'Search', - 'follows-latest': 'Latest follows', rss: 'RSS', settings: 'Settings', spells: 'Spells', @@ -105,7 +104,7 @@ export function isNoteDetailPathname(pathname: string): boolean { const path = pathname.split('?')[0].split('#')[0] return ( /\/notes\/[^/?#]+/.test(path) || - /\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/[^/?#]+/.test( + /\/(?:discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/[^/?#]+/.test( path ) ) diff --git a/src/pages/primary/FollowsLatestPage/index.tsx b/src/pages/primary/FollowsLatestPage/index.tsx deleted file mode 100644 index 0edccc08..00000000 --- a/src/pages/primary/FollowsLatestPage/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' -import { RefreshButton } from '@/components/RefreshButton' -import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' -import { TPageRef } from '@/types' -import { UsersRound } from 'lucide-react' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' - -const FollowsLatestPage = forwardRef(function FollowsLatestPage(_, ref) { - const { t } = useTranslation() - const [refreshKey, setRefreshKey] = useState(0) - const layoutRef = useRef(null) - - const bumpRefresh = useCallback(() => { - setRefreshKey((k) => k + 1) - }, []) - - useImperativeHandle( - ref, - () => ({ - scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior), - refresh: bumpRefresh - }), - [bumpRefresh] - ) - - return ( - } - displayScrollToTopButton - > -
-

- {t('Follows latest page description')} -

- -
-
- ) -}) - -FollowsLatestPage.displayName = 'FollowsLatestPage' -export default FollowsLatestPage - -function FollowsLatestPageTitlebar({ onRefresh }: { onRefresh: () => void }) { - const { t } = useTranslation() - return ( -
-
- -
{t('Follows latest page title')}
-
- -
- ) -} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 3e4e6a86..d06b3ba2 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { TPageRef } from '@/types' -import { Calendar, Compass, Flame, UsersRound } from 'lucide-react' +import { Calendar, Compass, Flame } from 'lucide-react' import React, { forwardRef, useCallback, @@ -172,7 +172,6 @@ function NoteListPageTitlebar({ const { pubkey } = useNostr() const spell = (currentPageProps as { spell?: string } | undefined)?.spell const exploreActive = display && current === 'explore' && primaryViewType === null - const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null const heatMapActive = display && current === 'spells' && spell === 'heatMap' && primaryViewType === null const calendarActive = display && current === 'calendar' && primaryViewType === null @@ -210,40 +209,22 @@ function NoteListPageTitlebar({ {pubkey ? ( - <> - - - + ) : null}
diff --git a/src/routes.tsx b/src/routes.tsx index dd4a510f..6559207f 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -54,7 +54,6 @@ const ROUTES = [ { path: '/notes/:id', element: SR(NotePageLazy) }, { path: '/discussions/notes/:id', element: SR(NotePageLazy) }, { path: '/search/notes/:id', element: SR(NotePageLazy) }, - { path: '/follows-latest/notes/:id', element: SR(NotePageLazy) }, { path: '/profile/notes/:id', element: SR(NotePageLazy) }, { path: '/explore/notes/:id', element: SR(NotePageLazy) }, { path: '/home/notes/:id', element: SR(NotePageLazy) }, @@ -67,7 +66,6 @@ const ROUTES = [ { path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, - { path: '/follows-latest/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) },