Browse Source

Refactor for performance

imwald
Silberengel 1 week ago
parent
commit
75960edc3a
  1. 34
      src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx
  2. 106
      src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx
  3. 17
      src/components/ConnectedRelays/active-relays-display.ts
  4. 136
      src/components/Explore/ExploreFavoriteRelays.tsx
  5. 77
      src/components/Explore/ExplorePopularRelays.tsx
  6. 239
      src/components/Explore/ExploreRelayReviews.tsx
  7. 146
      src/components/Explore/index.tsx
  8. 94
      src/components/FollowingFavoriteRelayList/index.tsx
  9. 39
      src/components/NoteList/index.tsx
  10. 110
      src/components/NoteStats/SeenOnButton.tsx
  11. 106
      src/components/NotificationThreadWatchButtons/index.tsx
  12. 211
      src/components/Profile/ProfileTimeline.tsx
  13. 184
      src/components/Tabs/index.tsx
  14. 75
      src/components/ui/ProfileSearchBar.tsx
  15. 19
      src/hooks/useBtcUsdRate.ts
  16. 51
      src/hooks/useRelayConnectionRows.ts
  17. 13
      src/lib/scroll-activity.service.ts
  18. 5
      src/lib/youtube-iframe-api.ts
  19. 21
      src/pages/secondary/WalletPage/QuickZapSwitch.tsx
  20. 60
      src/providers/ContentPolicyProvider.tsx
  21. 111
      src/providers/MuteListProvider.tsx
  22. 1
      src/services/Untitled
  23. 6
      src/services/client-replaceable-events.service.ts
  24. 20
      src/services/indexed-db.service.ts

34
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -1,34 +0,0 @@ @@ -1,34 +0,0 @@
import {
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { useTranslation } from 'react-i18next'
import { ActiveRelaysIconGrid } from './ActiveRelaysIconGrid'
/** Compact active-relay icons in the account (user badge) dropdown. */
export function ActiveRelaysDropdownSection() {
const { t } = useTranslation()
const { rows, connectedCount } = useRelayConnectionRows()
if (rows.length === 0) return null
const countSummary = `${connectedCount}/${rows.length}`
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 text-xs font-normal">
<span>{t('Active relays')}</span>
<span className="tabular-nums text-muted-foreground">{countSummary}</span>
</DropdownMenuLabel>
<div
className="px-2 pb-2"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<ActiveRelaysIconGrid />
</div>
</>
)
}

106
src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx

@ -1,106 +0,0 @@ @@ -1,106 +0,0 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import {
ACTIVE_RELAYS_MAX_ICONS,
activeRelayRowMuted,
activeRelayRowTitle
} from './active-relays-display'
/**
* Compact relay status: icon buttons only (no hostname labels).
*/
export function ActiveRelaysIconGrid({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS)
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}>
</p>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}>
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1 max-w-[16rem]">
{overflowRows.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
}

17
src/components/ConnectedRelays/active-relays-display.ts

@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14
export function activeRelayRowMuted(connected: boolean) {
return !connected
}
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
if (relaySessionStrikes.isSessionStrikeActiveForUrl(url)) {
return `${base}${t('Session relay strikes')}`
}
return base
}

136
src/components/Explore/ExploreFavoriteRelays.tsx

@ -1,136 +0,0 @@ @@ -1,136 +0,0 @@
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo'
import { Button } from '@/components/ui/button'
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay, toRelaySettings } from '@/lib/link'
import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { cn } from '@/lib/utils'
import { Newspaper, Settings } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
function FavoriteRelayCard({ url }: { url: string }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return (
<RelaySimpleInfoSkeleton className="h-full min-h-[5.5rem] rounded-lg border bg-card p-3 shadow-sm" />
)
}
if (!relayInfo) {
return (
<button
type="button"
className={cn(
'clickable flex h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 flex-col justify-center rounded-lg border bg-card p-3 text-left shadow-sm',
'transition-colors hover:bg-accent/40'
)}
onClick={() => navigateToRelay(toRelay(url))}
>
<div className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{url}</div>
</button>
)
}
return (
<RelaySimpleInfo
relayInfo={relayInfo}
className={cn(
'clickable h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 rounded-lg border bg-card p-3 shadow-sm',
'transition-colors hover:bg-accent/40'
)}
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayInfo.url))
}}
/>
)
}
/**
* Horizontal strip of favorite relays (non-blocked), or {@link DEFAULT_FAVORITE_RELAYS} when none.
*/
export default function ExploreFavoriteRelays() {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const blockedSet = useMemo(
() => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)),
[blockedRelays]
)
const { urls, usingDefaults } = useMemo(() => {
const visible = ensureTrendingInFavoriteRelayList(favoriteRelays).filter((r) => {
const k = normalizeUrl(r) || r
return k && !blockedSet.has(k)
})
if (visible.length > 0) {
return { urls: visible, usingDefaults: false }
}
if (!useGlobalRelayBootstrap) {
return { urls: [], usingDefaults: false }
}
const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => {
const k = normalizeUrl(r) || r
return k && !blockedSet.has(k)
})
return {
urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS,
usingDefaults: true
}
}, [favoriteRelays, blockedSet, useGlobalRelayBootstrap])
if (urls.length === 0) return null
return (
<section className="min-w-0 px-2 pb-4 pt-1" aria-label={t('Favorite Relays')}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">{t('Favorite Relays')}</h2>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 font-medium"
onClick={() => navigate('feed')}
>
<Newspaper className="size-4 shrink-0" strokeWidth={2.5} />
<span>{t('Favorite Relays')}</span>
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('Relays and Storage Settings')}
title={t('Relays and Storage Settings')}
onClick={() => push(toRelaySettings('favorite-relays'))}
>
<Settings className="size-4 shrink-0" strokeWidth={2.5} />
</Button>
</div>
{usingDefaults ? (
<span className="text-xs text-muted-foreground">{t('Using app default relays')}</span>
) : null}
</div>
<div className="flex gap-3 overflow-x-auto overflow-y-hidden pb-4 pt-0.5 snap-x snap-mandatory [scrollbar-gutter:stable]">
{urls.map((url) => (
<div key={url} className="snap-start">
<FavoriteRelayCard url={url} />
</div>
))}
</div>
</section>
)
}

77
src/components/Explore/ExplorePopularRelays.tsx

@ -1,77 +0,0 @@ @@ -1,77 +0,0 @@
import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays'
import { toRelay } from '@/lib/link'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import { useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Lightweight Explore relay list: URLs from the viewer's NIP-65 / favorites / defaults and optional
* cached NIP-66 data no GitHub collections fetch and no NIP-11 storm on mount.
*/
export default function ExplorePopularRelays() {
const { t } = useTranslation()
const { relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { navigateToRelay } = useSmartRelayNavigation()
const [nip66Cached, setNip66Cached] = useState<string[]>([])
useEffect(() => {
let cancelled = false
void indexedDb
.getPublicLivelyRelayUrlsCache()
.then((c) => {
if (!cancelled && c?.urls?.length) setNip66Cached(c.urls)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const urls = useMemo(
() =>
buildExplorePopularRelayUrls({
relayList,
favoriteRelays,
blockedRelays,
nip66CachedUrls: nip66Cached
}),
[relayList, favoriteRelays, blockedRelays, nip66Cached]
)
if (urls.length === 0) {
return (
<p className="px-4 py-6 text-sm text-muted-foreground">{t('No relays in your lists yet.')}</p>
)
}
return (
<section className="min-w-0 pb-6" aria-label={t('Popular relays')}>
<h2 className="mb-2 px-4 text-base font-semibold tracking-tight">{t('Popular relays')}</h2>
<p className="mb-3 px-4 text-sm text-muted-foreground">
{t('From your mailbox, favorites, and cached relay lists on this device.')}
</p>
<ul className="grid min-w-0 gap-2 px-2 md:grid-cols-2 md:px-4">
{urls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
return (
<li key={key}>
<button
type="button"
className="flex w-full min-w-0 flex-col rounded-lg border bg-card px-3 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40"
onClick={() => navigateToRelay(toRelay(url))}
>
<span className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</span>
<span className="mt-0.5 truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
)
})}
</ul>
</section>
)
}

239
src/components/Explore/ExploreRelayReviews.tsx

@ -1,239 +0,0 @@ @@ -1,239 +0,0 @@
import RelayIcon from '@/components/RelayIcon'
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useFetchRelayInfo } from '@/hooks'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import {
dedupeRelayReviewsNewestFirst,
loadCachedRelayReviews
} from '@/lib/explore-relay-reviews'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { toRelay } from '@/lib/link'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useSmartRelayNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
function RelayGroupHeader({ url, reviewCount }: { url: string; reviewCount: number }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo } = useFetchRelayInfo(url)
return (
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 px-4 md:px-4 pt-4 pb-2 border-b text-left hover:opacity-75 transition-opacity"
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} skipRelayInfoFetch className="h-8 w-8 shrink-0 rounded-sm" iconSize={16} />
<div className="min-w-0 flex-1">
{relayInfo?.name && (
<div className="truncate font-semibold text-sm leading-tight">{relayInfo.name}</div>
)}
<div className="flex items-center gap-1.5 min-w-0">
<div className="truncate font-mono text-xs text-muted-foreground leading-tight">{url}</div>
<span className="shrink-0 text-xs text-muted-foreground">
· {reviewCount} {reviewCount === 1 ? 'review' : 'reviews'}
</span>
</div>
</div>
</button>
)
}
const REVIEW_QUERY_LIMIT = 100
const SHOW_COUNT = 20
/** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors prepended then capped. */
const EXPLORE_REVIEWS_MAX_RELAYS = 12
/** After all relays EOSE, wait longer than default so slow mirrors can flush events (default query eose is 500ms). */
const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500
function stableRelayInputsKey(
favoriteRelays: string[],
blockedRelays: string[],
relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined,
cacheRelayListEvent: Event | null | undefined
): string {
const normSortJoin = (urls: string[]) =>
[...urls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.join('|')
return [
normSortJoin(favoriteRelays),
normSortJoin(blockedRelays),
normSortJoin(userReadInboxUrls(relayList, cacheRelayListEvent)),
normSortJoin(userWriteOutboxUrls(relayList, cacheRelayListEvent))
].join('::')
}
export default function ExploreRelayReviews() {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { relayList, cacheRelayListEvent } = useNostr()
const relayInputsKey = useMemo(
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList, cacheRelayListEvent),
[favoriteRelays, blockedRelays, relayList, cacheRelayListEvent]
)
const relayUrls = useMemo(() => {
const stacked = appendCuratedReadOnlyRelays(
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
{
userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS,
applySocialKindBlockedFilter: false
}
),
blockedRelays
)
const sliced = stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS)
const normalized = sliced
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter((u): u is string => Boolean(u) && isExploreBrowsableRelayUrl(u))
normalized.sort((a, b) => a.localeCompare(b))
return normalized
// eslint-disable-next-line react-hooks/exhaustive-deps -- relayInputsKey is a content hash of favorites/blocked/NIP-65; relayList identity churn must not re-open REQ sockets.
}, [relayInputsKey])
const relayUrlsKey = relayInputsKey
const [loading, setLoading] = useState(true)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchGenRef = useRef(0)
useEffect(() => {
const gen = ++fetchGenRef.current
let cancelled = false
setLoading(true)
setEvents([])
setShowCount(SHOW_COUNT)
void (async () => {
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT)
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) {
setEvents(cached)
}
try {
const raw = await client.fetchEvents(
relayUrls,
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT },
{
onevent: (e) => {
if (cancelled || fetchGenRef.current !== gen) return
if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) {
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e]))
}
},
firstRelayResultGraceMs: false,
globalTimeout: 12_000,
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS,
cache: true
}
)
if (cancelled || fetchGenRef.current !== gen) return
const withRelay = raw.filter(
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)
)
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay]))
} catch {
if (!cancelled && fetchGenRef.current === gen) setEvents([])
} finally {
if (!cancelled && fetchGenRef.current === gen) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [relayUrlsKey])
useEffect(() => {
const options = { root: null, rootMargin: '120px', threshold: 0 }
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const el = bottomRef.current
if (el) observer.observe(el)
return () => {
if (el) observer.unobserve(el)
}
}, [showCount, events.length])
const visible = events.slice(0, showCount)
const groupedVisible = useMemo(() => {
const groups = new Map<string, Event[]>()
for (const event of visible) {
const url = getRelayUrlFromRelayReviewEvent(event)
if (!url || !isExploreBrowsableRelayUrl(url)) continue
if (!groups.has(url)) groups.set(url, [])
groups.get(url)!.push(event)
}
return Array.from(groups.entries())
}, [visible])
const showInitialSkeleton = loading && events.length === 0
const showEmptyAfterLoad = !loading && events.length === 0
return (
<div className="min-w-0 pt-1 pb-8">
{showInitialSkeleton ? (
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg border md:border" />
))}
</div>
) : showEmptyAfterLoad ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p>
) : (
<>
{groupedVisible.map(([relayUrl, relayEvents]) => (
<div key={relayUrl} className="mb-4">
<RelayGroupHeader url={relayUrl} reviewCount={relayEvents.length} />
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3 mt-2">
{relayEvents.map((event) => (
<RelayReviewCard
key={event.id}
event={event}
showRelayInfo={false}
className="border-b md:border md:border-border"
/>
))}
</div>
</div>
))}
{loading ? (
<div
className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-lg border md:border" />
))}
</div>
) : null}
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}
{!loading && showCount >= events.length ? (
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p>
) : null}
</>
)}
</div>
)
}

146
src/components/Explore/index.tsx

@ -1,146 +0,0 @@ @@ -1,146 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link'
import { useSmartRelayNavigation } from '@/PageManager'
import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useState } from 'react'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
import logger from '@/lib/logger'
export default function Explore() {
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
// Add timeout to prevent hanging forever
timeoutId = setTimeout(() => {
if (!cancelled) {
logger.warn('[Explore] Timeout loading relay collections after 10 seconds')
setError('Timeout loading relay collections')
setCollections([]) // Set empty array to stop showing skeletons
}
}, 10000) // 10 second timeout
logger.debug('[Explore] Fetching awesome relay collections')
relayInfoService.getAwesomeRelayCollections()
.then((data) => {
if (!cancelled) {
if (timeoutId) clearTimeout(timeoutId)
logger.debug('[Explore] Loaded collections', { count: data?.length || 0 })
setCollections(data || [])
}
})
.catch((err) => {
if (!cancelled) {
if (timeoutId) clearTimeout(timeoutId)
logger.error('[Explore] Error loading collections', { error: err })
setError(err instanceof Error ? err.message : 'Failed to load relay collections')
setCollections([]) // Set empty array to stop showing skeletons
}
})
return () => {
cancelled = true
if (timeoutId) clearTimeout(timeoutId)
}
}, [])
if (collections === null) {
return (
<div>
<div className="p-4 max-md:border-b">
<Skeleton className="h-6 w-20" />
</div>
<div className="grid md:px-4 md:grid-cols-2 md:gap-2">
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" />
</div>
</div>
)
}
if (error) {
return (
<div className="p-4">
<div className="text-red-500 mb-2">Error: {error}</div>
<button
onClick={() => {
setCollections(null)
setError(null)
// Trigger reload
relayInfoService.getAwesomeRelayCollections()
.then(setCollections)
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load')
setCollections([])
})
}}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Retry
</button>
</div>
)
}
if (collections.length === 0) {
return (
<div className="p-4 text-center text-muted-foreground">
No relay collections available
</div>
)
}
return (
<div className="min-w-0 w-full overflow-x-hidden space-y-6 pb-8">
{collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div>
)
}
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) {
return (
<div className="min-w-0">
<div className="px-4 pt-3 pb-3.5 text-2xl font-semibold max-md:border-b min-w-0 break-words">
{collection.name}
</div>
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3">
{collection.relays.map((url) => (
<RelayItem key={url} url={url} />
))}
</div>
</div>
)
}
function RelayItem({ url }: { url: string }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
if (isFetching) {
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" />
}
if (!relayInfo) {
return null
}
return (
<div className="min-w-0">
<RelaySimpleInfo
key={relayInfo.url}
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border min-w-0"
relayInfo={relayInfo}
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(relayInfo.url))
}}
/>
</div>
)
}

94
src/components/FollowingFavoriteRelayList/index.tsx

@ -1,94 +0,0 @@ @@ -1,94 +0,0 @@
import { useFetchRelayInfo } from '@/hooks'
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays'
import { toRelay } from '@/lib/link'
import { useSmartRelayNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
const SHOW_COUNT = 10
export default function FollowingFavoriteRelayList() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [loading, setLoading] = useState(true)
const [relays, setRelays] = useState<[string, string[]][]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setLoading(true)
const init = async () => {
if (!pubkey) return
const relays = ((await client.fetchFollowingFavoriteRelays(pubkey)) ?? []).filter(([url]) =>
isExploreBrowsableRelayUrl(url)
)
setRelays(relays)
}
init().finally(() => {
setLoading(false)
})
}, [pubkey])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && showCount < relays.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, relays])
return (
<div className="pb-8">
{relays.slice(0, showCount).map(([url, users]) => (
<RelayItem key={url} url={url} users={users} />
))}
{showCount < relays.length && <div ref={bottomRef} />}
{loading && <RelaySimpleInfoSkeleton className="p-4" />}
{!loading && (
<div className="text-center text-muted-foreground text-sm mt-2">
{relays.length === 0 ? t('no relays found') : t('no more relays')}
</div>
)}
</div>
)
}
function RelayItem({ url, users }: { url: string; users: string[] }) {
const { navigateToRelay } = useSmartRelayNavigation()
const { relayInfo } = useFetchRelayInfo(url)
return (
<RelaySimpleInfo
key={url}
relayInfo={relayInfo}
users={users}
className="clickable p-4 border-b"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(url))
}}
/>
)
}

39
src/components/NoteList/index.tsx

@ -38,6 +38,7 @@ import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch' @@ -38,6 +38,7 @@ import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds'
import { shouldIncludePaymentInFeed } from '@/lib/superchat'
import { scrollActivity } from '@/lib/scroll-activity.service'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider'
@ -1390,20 +1391,23 @@ const NoteList = forwardRef( @@ -1390,20 +1391,23 @@ const NoteList = forwardRef(
[withKindFilter, showAllKinds]
)
const shouldHideEvent = useCallback(
(evt: Event) => {
const pinnedEventHexIdSet = new Set()
pinnedEventIds.forEach((id) => {
try {
const { type, data } = decode(id)
if (type === 'nevent') {
pinnedEventHexIdSet.add(data.id)
}
} catch {
// ignore
const pinnedEventHexIdSet = useMemo(() => {
const set = new Set<string>()
pinnedEventIds.forEach((id) => {
try {
const { type, data } = decode(id)
if (type === 'nevent') {
set.add(data.id)
}
})
} catch {
// ignore
}
})
return set
}, [pinnedEventIds])
const shouldHideEvent = useCallback(
(evt: Event) => {
if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
@ -1454,7 +1458,7 @@ const NoteList = forwardRef( @@ -1454,7 +1458,7 @@ const NoteList = forwardRef(
hideReplies,
hideContentMentioningMutedUsers,
mutePubkeySet,
pinnedEventIds,
pinnedEventHexIdSet,
isEventDeleted,
feedAttestedSuperchatIds,
incomingPaymentRecipientPubkey,
@ -1924,11 +1928,12 @@ const NoteList = forwardRef( @@ -1924,11 +1928,12 @@ const NoteList = forwardRef(
const handle = window.setTimeout(() => {
const candidates = new Set<string>()
const emojiAuthors = new Set<string>()
for (const e of timelineEventsForFilter) {
const profilePrefetchCap = Math.min(120, Math.max(showCount + 64, 64))
for (const e of filteredEvents.slice(0, profilePrefetchCap)) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
}
for (const e of newEvents) {
for (const e of newEvents.slice(0, 32)) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
}
@ -1945,7 +1950,7 @@ const NoteList = forwardRef( @@ -1945,7 +1950,7 @@ const NoteList = forwardRef(
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [
timelineEventsForFilter,
filteredEvents,
newEvents,
clientFilteredEvents,
showCount,
@ -4508,6 +4513,7 @@ const NoteList = forwardRef( @@ -4508,6 +4513,7 @@ const NoteList = forwardRef(
let lastScrollPrefetchInvokeMs = 0
const onScrollFlushNewNotesAtTop = () => {
scrollActivity.markScrolling()
if (oneShotFetchRef.current) return
if (feedFullSearchEventsRef.current !== null) return
const t = scrollPrefetchTarget
@ -4522,6 +4528,7 @@ const NoteList = forwardRef( @@ -4522,6 +4528,7 @@ const NoteList = forwardRef(
}
const onScrollPrefetch = () => {
scrollActivity.markScrolling()
if (scrollPrefetchRafId) return
scrollPrefetchRafId = requestAnimationFrame(() => {
scrollPrefetchRafId = 0

110
src/components/NoteStats/SeenOnButton.tsx

@ -1,110 +0,0 @@ @@ -1,110 +0,0 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
drawerMenuContentClassName,
drawerMenuScrollClassName
} from '@/components/DrawerMenuItem'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Server } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
export default function SeenOnButton({
event,
/** When set (home favorites feed), only list relays from the feed allowlist. */
allowedRelays
}: {
event: Event
allowedRelays?: readonly string[]
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const trigger = (
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
title={t('Seen on')}
disabled={relays.length === 0}
onClick={() => {
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
<Server />
{relays.length > 0 ? <span className="text-sm">{relays.length}</span> : null}
</button>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay className={drawerMenuContentClassName}>
<DrawerHeader className="sr-only">
<DrawerTitle>Seen on</DrawerTitle>
</DrawerHeader>
<div className={drawerMenuScrollClassName}>
{relays.map((relay) => (
<Button
className={drawerMenuButtonClassName}
variant="ghost"
key={relay}
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
navigateToRelay(toRelay(relay))
}, 50)
}}
>
<RelayIcon url={relay} className="size-5 shrink-0" />
<span className="min-w-0 flex-1 text-left">{simplifyUrl(relay)}</span>
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{relays.map((relay) => (
<DropdownMenuItem
key={relay}
onSelect={(e) => e.preventDefault()}
onClick={() => navigateToRelay(toRelay(relay))}
className="min-w-52"
>
<RelayIcon url={relay} />
{simplifyUrl(relay)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

106
src/components/NotificationThreadWatchButtons/index.tsx

@ -1,106 +0,0 @@ @@ -1,106 +0,0 @@
import { cn } from '@/lib/utils'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { Bell, BellOff } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useNostr } from '@/providers/NostrProvider'
export default function NotificationThreadWatchButtons({ event }: { event: Event }) {
const { t } = useTranslation()
const { pubkey, checkLogin, canManageIdentity } = useNostr()
const watch = useNotificationThreadWatchOptional()
const [busy, setBusy] = useState<'follow' | 'mute' | null>(null)
// Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor.
if (!watch || !pubkey || !canManageIdentity) return null
const followed = watch.isFollowedForNotifications(event)
const muted = watch.isMutedForNotifications(event)
const onFollow = (e: React.MouseEvent) => {
e.stopPropagation()
void checkLogin(async () => {
setBusy('follow')
try {
if (followed) {
const ok = await watch.unfollowThreadForNotifications(event)
if (ok) {
toast.success(t('Unfollowed thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await watch.followThreadForNotifications(event)
toast.success(t('Following thread for notifications'))
}
} catch (err) {
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message)
} finally {
setBusy(null)
}
})
}
const onMute = (e: React.MouseEvent) => {
e.stopPropagation()
void checkLogin(async () => {
setBusy('mute')
try {
if (muted) {
const ok = await watch.unmuteThreadForNotifications(event)
if (ok) {
toast.success(t('Unmuted thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await watch.muteThreadForNotifications(event)
toast.success(t('Muted thread for notifications'))
}
} catch (err) {
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message)
} finally {
setBusy(null)
}
})
}
return (
<>
<button
type="button"
className={cn(
'rounded p-1 transition-colors enabled:hover:bg-muted',
followed
? 'bg-primary/15 text-primary ring-1 ring-inset ring-primary/35'
: 'text-muted-foreground'
)}
disabled={busy !== null}
aria-pressed={followed}
title={followed ? t('Unfollow thread notifications') : t('Follow this')}
aria-label={followed ? t('Unfollow thread notifications') : t('Follow this')}
onClick={onFollow}
>
<Bell className={cn('size-4', followed && 'fill-current')} />
</button>
<button
type="button"
className={cn(
'rounded p-1 transition-colors enabled:hover:bg-muted',
muted
? 'bg-destructive/15 text-destructive ring-1 ring-inset ring-destructive/30'
: 'text-muted-foreground'
)}
disabled={busy !== null}
aria-pressed={muted}
title={muted ? t('Unmute thread notifications') : t('Mute this')}
aria-label={muted ? t('Unmute thread notifications') : t('Mute this')}
onClick={onMute}
>
<BellOff className={cn('size-4', muted && 'fill-current')} />
</button>
</>
)
}

211
src/components/Profile/ProfileTimeline.tsx

@ -1,211 +0,0 @@ @@ -1,211 +0,0 @@
import NoteCard from '@/components/NoteCard'
import { CALENDAR_EVENT_KINDS } from '@/constants'
import { RefreshCw } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react'
import { useProfileTimeline, type ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
interface ProfileTimelineProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
kinds: number[]
cacheKey: string
filterPredicate?: (event: Event) => boolean
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
getKindLabel: (kindValue: string) => string
refreshLabel: string
emptyLabel: string
emptySearchLabel: string
}
const ProfileTimeline = forwardRef<
{ refresh: () => void; getEvents?: () => Event[] },
ProfileTimelineProps
>(
(
{
pubkey,
topSpace,
searchQuery = '',
kindFilter = 'all',
onEventsChange,
kinds: timelineKinds,
cacheKey,
filterPredicate,
relayUrlsBuilder,
getKindLabel,
refreshLabel,
emptyLabel,
emptySearchLabel
},
ref
) => {
const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: timelineKinds,
limit: 200,
filterPredicate,
relayUrlsBuilder
})
useEffect(() => {
onEventsChange?.(timelineEvents)
}, [timelineEvents, onEventsChange])
useEffect(() => {
if (!isLoading) {
setIsRefreshing(false)
}
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
},
getEvents: () => timelineEvents
}),
[refresh, timelineEvents]
)
const eventsFilteredByKind = useMemo(() => {
if (kindFilter === 'all') {
return timelineEvents
}
const kindNumber = parseInt(kindFilter, 10)
if (Number.isNaN(kindNumber)) {
return timelineEvents
}
return timelineEvents.filter((event) =>
event.kind === kindNumber ||
(CALENDAR_EVENT_KINDS.includes(kindNumber) && CALENDAR_EVENT_KINDS.includes(event.kind))
)
}, [timelineEvents, kindFilter])
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return eventsFilteredByKind
}
// Pre-compute lowercase query once
const query = searchQuery.toLowerCase().trim()
// Pre-compute lowercase content and tags for each event to avoid repeated conversions
return eventsFilteredByKind.filter((event) => {
const contentLower = event.content.toLowerCase()
if (contentLower.includes(query)) return true
// Only check tags if content doesn't match
return event.tags.some((tag) => {
if (tag.length <= 1) return false
const tagValue = tag[1]
return tagValue && tagValue.toLowerCase().includes(query)
})
})
}, [eventsFilteredByKind, searchQuery])
// Reset showCount when filters change
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery, kindFilter, pubkey])
// Pagination: slice to showCount for display
const displayedEvents = useMemo(() => {
return filteredEvents.slice(0, showCount)
}, [filteredEvents, showCount])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => {
observer.disconnect()
}
}, [displayedEvents.length, filteredEvents.length, isLoading])
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (isLoading && timelineEvents.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim() ? emptySearchLabel : emptyLabel}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{refreshLabel}
</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
{displayedEvents.length < filteredEvents.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">Loading more...</div>
</div>
)}
</div>
)
}
)
ProfileTimeline.displayName = 'ProfileTimeline'
export default ProfileTimeline

184
src/components/Tabs/index.tsx

@ -1,184 +0,0 @@ @@ -1,184 +0,0 @@
import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type TabDefinition = {
value: string
label: string
icon?: ReactNode
}
export default function Tabs({
tabs,
value,
onTabChange,
threshold = 800,
options = null,
/** When true, tabs live in layout chrome (subHeader) — no sticky offset or deep-scroll collapse. */
pinnedToLayout = false
}: {
tabs: TabDefinition[]
value: string
onTabChange?: (tab: string) => void
threshold?: number
options?: ReactNode
pinnedToLayout?: boolean
}) {
const { t } = useTranslation()
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
const containerRef = useRef<HTMLDivElement | null>(null)
const tabsContainerRef = useRef<HTMLDivElement | null>(null)
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 })
const isUpdatingRef = useRef(false)
const lastStyleRef = useRef({ width: 0, left: 0, top: 0 })
const updateIndicatorPosition = useCallback(() => {
// Prevent multiple simultaneous updates
if (isUpdatingRef.current) return
const activeIndex = tabs.findIndex((tab) => tab.value === value)
if (activeIndex >= 0 && tabRefs.current[activeIndex] && tabsContainerRef.current) {
const activeTab = tabRefs.current[activeIndex]
const tabsContainer = tabsContainerRef.current
const { offsetWidth, offsetLeft, offsetHeight } = activeTab
const padding = Math.min(24, Math.max(8, offsetWidth * 0.12))
// Get the container's top position relative to the viewport
const containerTop = tabsContainer.getBoundingClientRect().top
const tabTop = activeTab.getBoundingClientRect().top
// Calculate the indicator's top position relative to the container
// Position it at the bottom of the active tab's row
const relativeTop = tabTop - containerTop + offsetHeight
const newWidth = offsetWidth - padding
const newLeft = offsetLeft + padding / 2
const newTop = relativeTop - 4 // 4px for the indicator height (1px) + spacing
// Only update if values actually changed
if (
lastStyleRef.current.width !== newWidth ||
lastStyleRef.current.left !== newLeft ||
lastStyleRef.current.top !== newTop
) {
isUpdatingRef.current = true
lastStyleRef.current = { width: newWidth, left: newLeft, top: newTop }
setIndicatorStyle({ width: newWidth, left: newLeft, top: newTop })
// Reset flag after state update completes
requestAnimationFrame(() => {
isUpdatingRef.current = false
})
}
}
}, [tabs, value])
useEffect(() => {
const animationId = requestAnimationFrame(() => {
updateIndicatorPosition()
})
return () => {
cancelAnimationFrame(animationId)
}
}, [updateIndicatorPosition])
useEffect(() => {
if (!containerRef.current || !tabsContainerRef.current) return
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
updateIndicatorPosition()
})
})
const intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
requestAnimationFrame(() => {
updateIndicatorPosition()
})
}
})
},
{ threshold: 0 }
)
intersectionObserver.observe(containerRef.current)
tabRefs.current.forEach((tab) => {
if (tab) resizeObserver.observe(tab)
})
if (tabsContainerRef.current) {
resizeObserver.observe(tabsContainerRef.current)
}
return () => {
resizeObserver.disconnect()
intersectionObserver.disconnect()
}
}, [updateIndicatorPosition])
const collapseOnDeepBrowse =
!pinnedToLayout && deepBrowsing && lastScrollTop > threshold
return (
<div
ref={containerRef}
className={cn(
// Single row: flex-nowrap (Firefox grid + w-max/min-w-full wrapped the tool column). Tabs scroll in flex-1.
'flex w-full min-w-0 flex-nowrap items-end gap-0.5 border-b bg-background px-1 sm:gap-1',
pinnedToLayout
? 'z-10'
: 'sticky top-12 z-30 transition-transform',
collapseOnDeepBrowse ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
<div className="min-h-0 min-w-0 flex-1 basis-0 overflow-x-auto overscroll-x-contain scrollbar-hide">
<div
ref={tabsContainerRef}
role="tablist"
className="relative inline-flex w-max max-w-none gap-0.5 sm:gap-1"
>
{tabs.map((tab, index) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={value === tab.value}
ref={(el) => {
tabRefs.current[index] = el
}}
className={cn(
'flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-0 bg-transparent px-1.5 py-1.5 text-center text-xs font-semibold shadow-none transition-colors sm:gap-2 sm:px-3 sm:py-2 sm:text-sm md:px-5 md:text-base',
'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
value === tab.value ? 'text-foreground' : 'text-muted-foreground'
)}
onClick={() => {
onTabChange?.(tab.value)
}}
>
{tab.icon && <span className="shrink-0">{tab.icon}</span>}
{t(tab.label)}
</button>
))}
<div
className="absolute h-1 rounded-full bg-primary transition-all duration-500"
style={{
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`,
top: `${indicatorStyle.top}px`
}}
/>
</div>
</div>
{options ? (
<div className="flex shrink-0 flex-nowrap items-center gap-0 py-1 pl-0.5">{options}</div>
) : null}
</div>
)
}

75
src/components/ui/ProfileSearchBar.tsx

@ -1,75 +0,0 @@ @@ -1,75 +0,0 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { Input } from '@/components/ui/input'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useState, useEffect } from 'react'
interface ProfileSearchBarProps {
onSearch: (query: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
export default function ProfileSearchBar({
onSearch,
placeholder = "Search...",
className,
disabled = false
}: ProfileSearchBarProps) {
const [query, setQuery] = useState('')
const [isFocused, setIsFocused] = useState(false)
// Debounce search to avoid too many calls
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query)
}, SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [query, onSearch])
const handleClear = () => {
setQuery('')
onSearch('')
}
return (
<div className={cn('relative flex items-center', className)}>
<div className="relative flex-1">
<Search
className={cn(
'absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors',
isFocused && 'text-green-500'
)}
/>
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled}
className={cn(
'pl-10 pr-10 h-10',
'border-2 border-muted-foreground/20 focus:border-green-500',
'bg-background text-foreground',
'transition-all duration-200',
'rounded-lg',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>
{query && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
disabled={disabled}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}

19
src/hooks/useBtcUsdRate.ts

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
import { fetchBtcUsdRate } from '@/lib/btc-usd-rate'
import { useEffect, useState } from 'react'
/** BTC/USD spot for zap amount hints (null while loading or if fetch failed). */
export function useBtcUsdRate() {
const [btcUsd, setBtcUsd] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetchBtcUsdRate().then((rate) => {
if (!cancelled) setBtcUsd(rate)
})
return () => {
cancelled = true
}
}, [])
return btcUsd
}

51
src/hooks/useRelayConnectionRows.ts

@ -1,51 +0,0 @@ @@ -1,51 +0,0 @@
import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url'
import client from '@/services/client.service'
import { useEffect, useMemo, useState } from 'react'
const POLL_MS = 1500
function normalizeRelayRowUrl(raw: string): string {
const t = raw.trim()
if (/^https?:\/\//i.test(t)) return normalizeHttpRelayUrl(t) || t
return normalizeAnyRelayUrl(t) || t
}
function rowCanon(url: string): string {
return (canonicalRelaySessionKey(url) || normalizeRelayRowUrl(url)).trim().toLowerCase()
}
export type TRelayConnectionRow = {
url: string
/** WebSocket open in the pool. */
connected: boolean
}
/**
* Relays for active relays UI: only relays with an open WebSocket in the pool right now.
*/
export function useRelayConnectionRows(): {
rows: TRelayConnectionRow[]
connectedCount: number
} {
const [connectedUrls, setConnectedUrls] = useState<string[]>(() => client.getConnectedRelayUrls())
useEffect(() => {
const tick = () => setConnectedUrls(client.getConnectedRelayUrls())
tick()
const id = window.setInterval(tick, POLL_MS)
return () => clearInterval(id)
}, [])
return useMemo(() => {
const seen = new Set<string>()
const rows: TRelayConnectionRow[] = []
for (const raw of connectedUrls) {
const url = normalizeRelayRowUrl(raw)
const k = rowCanon(url)
if (!k || seen.has(k)) continue
seen.add(k)
rows.push({ url, connected: true })
}
return { rows, connectedCount: rows.length }
}, [connectedUrls])
}

13
src/lib/scroll-activity.service.ts

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
/** How long after the last scroll event we treat the user as still scrolling. */
const SCROLL_ACTIVITY_MS = 450
let scrollingUntil = 0
export const scrollActivity = {
markScrolling() {
scrollingUntil = Date.now() + SCROLL_ACTIVITY_MS
},
get isActive() {
return Date.now() < scrollingUntil
}
}

5
src/lib/youtube-iframe-api.ts

@ -34,9 +34,14 @@ export function ensureYouTubeIframeApi(): Promise<void> { @@ -34,9 +34,14 @@ export function ensureYouTubeIframeApi(): Promise<void> {
if (scriptAlreadyPresent()) {
chainReady()
const pollDeadlineMs = Date.now() + 5_000
const poll = () => {
tryResolve()
if (hasYtPlayer()) return
if (Date.now() >= pollDeadlineMs) {
resolve()
return
}
requestAnimationFrame(poll)
}
poll()

21
src/pages/secondary/WalletPage/QuickZapSwitch.tsx

@ -1,21 +0,0 @@ @@ -1,21 +0,0 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useZap } from '@/providers/ZapProvider'
import { useTranslation } from 'react-i18next'
export default function QuickZapSwitch() {
const { t } = useTranslation()
const { quickZap, updateQuickZap } = useZap()
return (
<div className="w-full flex justify-between items-center">
<Label htmlFor="quick-zap-switch">
<div className="text-base font-medium">{t('Quick zap')}</div>
<div className="text-muted-foreground text-sm">
{t('If enabled, you can zap with a single click. Click and hold for custom amounts')}
</div>
</Label>
<Switch id="quick-zap-switch" checked={quickZap} onCheckedChange={updateQuickZap} />
</div>
)
}

60
src/providers/ContentPolicyProvider.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import storage from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy } from '@/types'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
type TContentPolicyContext = {
autoplay: boolean
@ -81,22 +81,22 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -81,22 +81,22 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
return connectionType !== 'cellular'
}, [mediaAutoLoadPolicy, connectionType])
const updateAutoplay = (autoplay: boolean) => {
const updateAutoplay = useCallback((autoplay: boolean) => {
storage.setAutoplay(autoplay)
setAutoplay(autoplay)
}
}, [])
const updateDefaultShowNsfw = (defaultShowNsfw: boolean) => {
const updateDefaultShowNsfw = useCallback((defaultShowNsfw: boolean) => {
storage.setDefaultShowNsfw(defaultShowNsfw)
setDefaultShowNsfw(defaultShowNsfw)
}
}, [])
const updateHideContentMentioningMutedUsers = (hide: boolean) => {
const updateHideContentMentioningMutedUsers = useCallback((hide: boolean) => {
storage.setHideContentMentioningMutedUsers(hide)
setHideContentMentioningMutedUsers(hide)
}
}, [])
const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => {
const updateMediaAutoLoadPolicy = useCallback((policy: TMediaAutoLoadPolicy) => {
storage.setMediaAutoLoadPolicy(policy)
// Defer React state: Radix Select fires onValueChange while its portal is still unmounting.
// An immediate full-tree re-render (feed + body portals) races removeChild and throws.
@ -106,23 +106,37 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -106,23 +106,37 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
} else {
run()
}
}
}, [])
const contextValue = useMemo(
() => ({
autoplay,
setAutoplay: updateAutoplay,
defaultShowNsfw,
setDefaultShowNsfw: updateDefaultShowNsfw,
hideContentMentioningMutedUsers,
setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers,
autoLoadMedia,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy,
isOffline
}),
[
autoplay,
updateAutoplay,
defaultShowNsfw,
updateDefaultShowNsfw,
hideContentMentioningMutedUsers,
updateHideContentMentioningMutedUsers,
autoLoadMedia,
mediaAutoLoadPolicy,
updateMediaAutoLoadPolicy,
isOffline
]
)
return (
<ContentPolicyContext.Provider
value={{
autoplay,
setAutoplay: updateAutoplay,
defaultShowNsfw,
setDefaultShowNsfw: updateDefaultShowNsfw,
hideContentMentioningMutedUsers,
setHideContentMentioningMutedUsers: updateHideContentMentioningMutedUsers,
autoLoadMedia,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy: updateMediaAutoLoadPolicy,
isOffline
}}
>
<ContentPolicyContext.Provider value={contextValue}>
{children}
</ContentPolicyContext.Provider>
)

111
src/providers/MuteListProvider.tsx

@ -163,7 +163,7 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -163,7 +163,7 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
return (await client.fetchMuteListEvent(accountPubkey)) ?? null
}, [accountPubkey, favoriteRelays, blockedRelays])
const publishNewMuteListEvent = async (tags: string[][], content?: string) => {
const publishNewMuteListEvent = useCallback(async (tags: string[][], content?: string) => {
if (dayjs().unix() === muteListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
@ -171,9 +171,9 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -171,9 +171,9 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const event = await publish(newMuteListDraftEvent)
toast.success(t('Successfully updated mute list'))
return event
}
}, [muteListEvent?.created_at, publish, t])
const checkMuteListEvent = (muteListEvent: Event | null | undefined) => {
const checkMuteListEvent = useCallback((muteListEvent: Event | null | undefined) => {
if (!muteListEvent) {
const result = confirm(t('MuteListNotFoundConfirmation'))
@ -181,9 +181,9 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -181,9 +181,9 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
throw new Error('Mute list not found')
}
}
}
}, [t])
const mutePubkeyPublicly = async (pubkey: string) => {
const mutePubkeyPublicly = useCallback(async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
@ -207,9 +207,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -207,9 +207,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} finally {
setChanging(false)
}
}
const mutePubkeyPrivately = async (pubkey: string) => {
}, [
accountPubkey,
changing,
loadLatestMuteListEvent,
publishNewMuteListEvent,
t,
updateMuteListEvent
])
const mutePubkeyPrivately = useCallback(async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
@ -234,9 +241,17 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -234,9 +241,17 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} finally {
setChanging(false)
}
}
const unmutePubkey = async (pubkey: string) => {
}, [
accountPubkey,
changing,
loadLatestMuteListEvent,
nip04Encrypt,
publishNewMuteListEvent,
t,
updateMuteListEvent
])
const unmutePubkey = useCallback(async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
@ -261,9 +276,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -261,9 +276,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} finally {
setChanging(false)
}
}
const switchToPublicMute = async (pubkey: string) => {
}, [
accountPubkey,
changing,
loadLatestMuteListEvent,
nip04Encrypt,
publishNewMuteListEvent,
updateMuteListEvent
])
const switchToPublicMute = useCallback(async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
@ -288,9 +310,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -288,9 +310,16 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} finally {
setChanging(false)
}
}
const switchToPrivateMute = async (pubkey: string) => {
}, [
accountPubkey,
changing,
loadLatestMuteListEvent,
nip04Encrypt,
publishNewMuteListEvent,
updateMuteListEvent
])
const switchToPrivateMute = useCallback(async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
@ -316,22 +345,42 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -316,22 +345,42 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} finally {
setChanging(false)
}
}
}, [
accountPubkey,
changing,
loadLatestMuteListEvent,
nip04Encrypt,
publishNewMuteListEvent,
updateMuteListEvent
])
const contextValue = useMemo(
() => ({
mutePubkeySet,
changing,
getMutePubkeys,
getMuteType,
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
switchToPublicMute,
switchToPrivateMute
}),
[
mutePubkeySet,
changing,
getMutePubkeys,
getMuteType,
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
switchToPublicMute,
switchToPrivateMute
]
)
return (
<MuteListContext.Provider
value={{
mutePubkeySet,
changing,
getMutePubkeys,
getMuteType,
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
switchToPublicMute,
switchToPrivateMute
}}
>
<MuteListContext.Provider value={contextValue}>
{children}
</MuteListContext.Provider>
)

1
src/services/Untitled

@ -1 +0,0 @@ @@ -1 +0,0 @@
I

6
src/services/client-replaceable-events.service.ts

@ -16,6 +16,7 @@ import { @@ -16,6 +16,7 @@ import {
import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
import { scrollActivity } from '@/lib/scroll-activity.service'
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges'
@ -158,8 +159,9 @@ export class ReplaceableEventService { @@ -158,8 +159,9 @@ export class ReplaceableEventService {
>(
this.replaceableEventFromBigRelaysBatchLoadFn.bind(this),
{
batchScheduleFn: (callback) => setTimeout(callback, 100), // Increased from 50ms to 100ms to better batch rapid scrolling
maxBatchSize: 200, // Reduced from 500 to prevent overwhelming the system during rapid scrolling
batchScheduleFn: (callback) =>
setTimeout(callback, scrollActivity.isActive ? 200 : 100),
maxBatchSize: 64,
cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}`
}
)

20
src/services/indexed-db.service.ts

@ -916,7 +916,7 @@ class IndexedDbService { @@ -916,7 +916,7 @@ class IndexedDbService {
}
/**
* Loads all cached kind-0 rows in one synchronous cursor pass (no `await` inside `onsuccess`, which
* Loads cached kind-0 rows in one synchronous cursor pass (no `await` inside `onsuccess`, which
* would risk inactive transactions), then invokes `callback` in chunks with `requestAnimationFrame`
* yields so FlexSearch indexing does not monopolize the main thread.
*/
@ -926,6 +926,9 @@ class IndexedDbService { @@ -926,6 +926,9 @@ class IndexedDbService {
return
}
const MAX_PROFILE_EVENTS_ITERATE = 8_000
let truncated = false
const events = await new Promise<Event[]>((resolve, reject) => {
const out: Event[] = []
const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readonly')
@ -937,8 +940,12 @@ class IndexedDbService { @@ -937,8 +940,12 @@ class IndexedDbService {
resolve(out)
return
}
const value = (cursor.value as TValue<Event>).value
if (value) out.push(value)
if (out.length < MAX_PROFILE_EVENTS_ITERATE) {
const value = (cursor.value as TValue<Event>).value
if (value) out.push(value)
} else {
truncated = true
}
cursor.continue()
}
request.onerror = () => {
@ -946,6 +953,13 @@ class IndexedDbService { @@ -946,6 +953,13 @@ class IndexedDbService {
}
})
if (truncated) {
logger.warn('[indexedDb] iterateProfileEvents capped profile row scan', {
cap: MAX_PROFILE_EVENTS_ITERATE,
loaded: events.length
})
}
const yieldToMain = () =>
new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === 'function') {

Loading…
Cancel
Save