Browse Source

get rid of follows-latest, profile accordion,

imwald
Silberengel 1 month ago
parent
commit
e1c064f2f2
  1. 46
      src/PageManager.tsx
  2. 48
      src/components/FavoriteRelaysActiveStrip/index.tsx
  3. 575
      src/components/LatestFromFollowsSection/index.tsx
  4. 2
      src/components/NoteOptions/useMenuActions.tsx
  5. 151
      src/components/Profile/ProfileInteractionsAccordion.tsx
  6. 12
      src/components/Profile/index.tsx
  7. 25
      src/components/Sidebar/FollowsLatestButton.tsx
  8. 2
      src/components/Sidebar/index.tsx
  9. 210
      src/hooks/useProfileAccordionData.tsx
  10. 3
      src/lib/document-meta.ts
  11. 58
      src/pages/primary/FollowsLatestPage/index.tsx
  12. 21
      src/pages/primary/NoteListPage/index.tsx
  13. 2
      src/routes.tsx

46
src/PageManager.tsx

@ -84,7 +84,6 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage')) @@ -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 = { @@ -130,7 +129,6 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
'follows-latest': createRef<TPageRef>(),
rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(),
spells: createRef<TPageRef>(),
@ -170,11 +168,6 @@ const getPrimaryPageMap = () => ({ @@ -170,11 +168,6 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense>
),
'follows-latest': (
<Suspense fallback={primaryPageLazyFallback}>
<FollowsLatestPageLazy ref={PRIMARY_PAGE_REF_MAP['follows-latest']} />
</Suspense>
),
rss: (
<Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -279,7 +272,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str @@ -279,7 +272,6 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
'spells',
'rss',
'explore',
'follows-latest',
'calendar'
]
@ -303,7 +295,6 @@ function buildRssArticleUrl( @@ -303,7 +295,6 @@ function buildRssArticleUrl(
'spells',
'rss',
'explore',
'follows-latest',
'calendar'
]
let path =
@ -421,7 +412,7 @@ function extractValidNoteId(raw: string): string | null { @@ -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() { @@ -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() { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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( @@ -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

48
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,11 +1,7 @@ @@ -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 @@ -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?: @@ -89,18 +83,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
<div className="flex min-w-0 shrink items-center gap-2">
<p className="text-xs font-medium leading-tight text-foreground">{t('Relay pulse')}</p>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('follows-latest')}
>
<FileText className="size-3.5" />
</Button>
) : null}
</div>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums">
@ -116,9 +98,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -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 @@ -171,18 +151,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
</p>
<div className="flex shrink-0 items-center gap-0.5">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('follows-latest')}
>
<FileText className="size-3.5" />
</Button>
) : null}
</div>
</div>
{lastFetchedAtMs != null && relativeLabel ? (
@ -192,18 +160,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -192,18 +160,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
) : null}
<div className="mb-1 flex justify-center gap-0.5 xl:hidden">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('follows-latest')}
>
<FileText className="size-4" />
</Button>
) : null}
</div>
</div>
)

575
src/components/LatestFromFollowsSection/index.tsx

@ -1,575 +0,0 @@ @@ -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<string, NostrEvent[]>,
incoming: NostrEvent[],
batchAuthors: string[]
): Map<string, NostrEvent[]> {
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<string, NostrEvent>()
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<string[]>([])
const [guestListReady, setGuestListReady] = useState(false)
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => 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<string[]>([])
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<string, NostrEvent[]>()
setPostsByPubkey(new Map(working))
const summarizePosts = (m: Map<string, NostrEvent[]>) => {
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 (
<div className={cn('space-y-2', vertical)} role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-4 w-56 max-w-full" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
)
}
if (followPubkeys.length === 0) {
return (
<div
className={cn(
'rounded-lg border border-border/80 bg-muted/20 px-4 py-3 text-sm text-muted-foreground',
vertical
)}
>
{followsLabel === 'recommended'
? t('Could not load recommended follows')
: t('Your follow list is empty')}
</div>
)
}
return (
<div className="min-w-0 space-y-0 rounded-lg border border-border/60 overflow-hidden">
{batchBusy && postsByPubkey.size === 0 ? (
<div className="space-y-2 px-4 py-4" role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-3 w-64 max-w-full" />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : null}
{sortedRowPubkeys.map((pk) => {
const posts = postsByPubkey.get(pk) ?? []
const count = posts.length
const latest = posts[0]?.created_at
return (
<FollowPulseRow
key={pk}
pubkey={pk}
count={count}
latestCreatedAt={latest}
posts={posts}
onOpenProfile={() => push(toProfile(pk))}
/>
)
})}
{batchBusy && postsByPubkey.size > 0 ? (
<div className="px-4 py-2 border-t border-border/50">
<Skeleton className="h-3 w-28" aria-hidden />
</div>
) : null}
</div>
)
}
function FollowRowEmptyPosts() {
const { t } = useTranslation()
return (
<div className="px-4 py-3 text-sm text-muted-foreground">
{t('No recent posts from this user in the current fetch')}
</div>
)
}
function FollowPulseRow({
pubkey,
count,
latestCreatedAt,
posts,
onOpenProfile
}: {
pubkey: string
count: number
latestCreatedAt?: number
posts: NostrEvent[]
onOpenProfile: () => void
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen} className="border-b border-border/60 last:border-b-0">
<div className="flex items-stretch gap-0">
<CollapsibleTrigger
className="flex size-10 shrink-0 items-center justify-center border-r border-border/50 text-muted-foreground hover:bg-muted/40"
aria-label={open ? 'Collapse posts' : 'Expand posts'}
>
<ChevronRight className={cn('size-4 transition-transform', open && 'rotate-90')} />
</CollapsibleTrigger>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/30"
onClick={(e) => {
e.stopPropagation()
onOpenProfile()
}}
>
<UserAvatar userId={pubkey} size="medium" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Username
userId={pubkey}
className="truncate text-sm font-semibold"
skeletonClassName="h-4 w-24"
/>
<Star className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.5} aria-hidden />
</div>
<div className="text-xs text-muted-foreground">
{latestCreatedAt ? (
<FormattedTimestamp timestamp={latestCreatedAt} short />
) : (
'—'
)}
</div>
</div>
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary tabular-nums"
title={String(count)}
>
{count}
</div>
</button>
</div>
<CollapsibleContent className="overflow-hidden border-t border-border/50 bg-muted/10">
{posts.length === 0 ? (
<FollowRowEmptyPosts />
) : (
<div className="pb-2">
{posts.map((ev) => (
<NoteCard key={ev.id} className="w-full" event={ev} />
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
)
}

2
src/components/NoteOptions/useMenuActions.tsx

@ -990,8 +990,6 @@ export function useMenuActions({ @@ -990,8 +990,6 @@ export function useMenuActions({
? `/spells/notes/${noteId}`
: currentPrimaryPage === 'rss'
? `/rss/notes/${noteId}`
: currentPrimaryPage === 'follows-latest'
? `/follows-latest/notes/${noteId}`
: `/notes/${noteId}`
const appShareUrl = `https://jumble.imwald.eu${path}`
navigator.clipboard.writeText(appShareUrl)

151
src/components/Profile/ProfileInteractionsAccordion.tsx

@ -1,151 +0,0 @@ @@ -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 (
<div className="py-2 space-y-3">
{[6, 4, 4, 8, 6, 6].map((count, i) => (
<div key={i} className="min-w-0">
<Skeleton className="h-3 w-16 mb-1.5" />
<div
className={
i === 3
? 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1'
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5'
}
>
{Array.from({ length: count }).map((_, j) => (
<Skeleton
key={j}
className={cn('rounded-lg min-w-0', i === 3 ? 'aspect-square h-24 w-full' : 'h-8')}
/>
))}
</div>
</div>
))}
</div>
)
}
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 (
<Collapsible open={isExpanded} onOpenChange={onExpandedChange} className="min-w-0">
<div className="flex min-w-0 items-stretch gap-1 rounded-lg border border-border/80 bg-muted/15 hover:bg-muted/25">
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center justify-between gap-2 px-3 py-2 text-left">
<span className="text-sm font-medium truncate">
{t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
</span>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<Button
type="button"
variant="ghost"
size="icon"
className="my-1 mr-1 shrink-0 rounded-md"
title={t('Refresh')}
aria-label={t('Refresh')}
disabled={!pubkey}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRefresh()
}}
>
<RefreshCw className={cn('size-4', bundleLoading && 'animate-spin')} />
</Button>
</div>
<CollapsibleContent className="overflow-hidden">
{hasContent ? (
showSkeleton ? (
<div className="pt-2">
<ProfileInteractionsSkeleton />
</div>
) : (
<div className="pt-2">
<ProfileHeaderInteractions
profilePubkey={pubkey}
badgeRelayUrls={relayUrls}
zaps={zaps}
reactions={reactions}
comments={comments}
badges={badges}
followPacks={followPacks}
reports={reports}
loading={bundleLoading}
badgesLoading={bundleLoading}
followPacksLoading={bundleLoading}
reportsLoading={bundleLoading}
reportsEnabled={!!viewerPubkey}
/>
</div>
)
) : null}
</CollapsibleContent>
</Collapsible>
)
}

12
src/components/Profile/index.tsx

@ -1,4 +1,3 @@ @@ -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' @@ -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({ @@ -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({ @@ -598,12 +595,10 @@ export default function Profile({
<PubkeyCopy pubkey={pubkey} showFull />
<NpubQrCode pubkey={pubkey} />
</div>
<Collapsible>
<ProfileAbout
about={about}
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
/>
</Collapsible>
{/* Display websites - show first one prominently, others below */}
{website && (
<div className="flex gap-1 items-center text-primary mt-2 truncate select-text">
@ -704,13 +699,6 @@ export default function Profile({ @@ -704,13 +699,6 @@ export default function Profile({
</div>
{!isSelf && <FollowedBy pubkey={pubkey} />}
</div>
<div className="mt-4 pt-2">
<ProfileInteractionsAccordion
pubkey={pubkey}
isExpanded={profileInteractionsExpanded}
onExpandedChange={setProfileInteractionsExpanded}
/>
</div>
</div>
</div>
</div>

25
src/components/Sidebar/FollowsLatestButton.tsx

@ -1,25 +0,0 @@ @@ -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 (
<SidebarItem
title={t('Follows latest nav label')}
onClick={() => navigate('follows-latest')}
active={current === 'follows-latest' && display && primaryViewType === null}
>
<UsersRound strokeWidth={2.5} />
</SidebarItem>
)
}

2
src/components/Sidebar/index.tsx

@ -9,7 +9,6 @@ import NotificationButton from './NotificationButton' @@ -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() { @@ -42,7 +41,6 @@ export default function PrimaryPageSidebar() {
<DiscussionsButton />
<NotificationButton />
<SearchButton />
<FollowsLatestButton />
<FavoritesButton />
<SpellsButton />
<RssButton />

210
src/hooks/useProfileAccordionData.tsx

@ -1,210 +0,0 @@ @@ -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<ProfileAccordionBundle>(EMPTY)
const [loading, setLoading] = useState(false)
const reqId = useRef(0)
const lastSuccessfulRelayUrlsRef = useRef<string[]>([])
// 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
}
}

3
src/lib/document-meta.ts

@ -73,7 +73,6 @@ const PRIMARY_PAGE_LABEL: Record<string, string> = { @@ -73,7 +73,6 @@ const PRIMARY_PAGE_LABEL: Record<string, string> = {
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 { @@ -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
)
)

58
src/pages/primary/FollowsLatestPage/index.tsx

@ -1,58 +0,0 @@ @@ -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<TPageRef>(function FollowsLatestPage(_, ref) {
const { t } = useTranslation()
const [refreshKey, setRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const bumpRefresh = useCallback(() => {
setRefreshKey((k) => k + 1)
}, [])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior),
refresh: bumpRefresh
}),
[bumpRefresh]
)
return (
<PrimaryPageLayout
ref={layoutRef}
pageName="follows-latest"
titlebar={<FollowsLatestPageTitlebar onRefresh={bumpRefresh} />}
displayScrollToTopButton
>
<div className="min-w-0 pt-4 px-4 pb-8">
<p className="mb-4 max-w-prose text-sm text-muted-foreground leading-relaxed">
{t('Follows latest page description')}
</p>
<LatestFromFollowsSection refreshKey={refreshKey} variant="page" />
</div>
</PrimaryPageLayout>
)
})
FollowsLatestPage.displayName = 'FollowsLatestPage'
export default FollowsLatestPage
function FollowsLatestPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<UsersRound className="size-5" />
<div className="app-chrome-title">{t('Follows latest page title')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}

21
src/pages/primary/NoteListPage/index.tsx

@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -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({ @@ -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,23 +209,6 @@ function NoteListPageTitlebar({ @@ -210,23 +209,6 @@ function NoteListPageTitlebar({
<Compass />
</Button>
{pubkey ? (
<>
<Button
variant="ghost"
size="titlebar-icon"
title={t('Follows latest nav label')}
aria-label={t('Follows latest nav label')}
className={`shrink-0 ${followsLatestActive ? 'bg-accent/50' : ''}`}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
}
navigate('follows-latest')
}}
>
<UsersRound />
</Button>
<Button
variant="ghost"
size="titlebar-icon"
@ -243,7 +225,6 @@ function NoteListPageTitlebar({ @@ -243,7 +225,6 @@ function NoteListPageTitlebar({
>
<Flame />
</Button>
</>
) : null}
</div>
<div className="flex min-h-0 min-w-0 items-center justify-center gap-0.5 px-0.5">

2
src/routes.tsx

@ -54,7 +54,6 @@ const ROUTES = [ @@ -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 = [ @@ -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) },

Loading…
Cancel
Save