From 75960edc3a73177c3cc43b0133d53d4889e98889 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 07:29:45 +0200 Subject: [PATCH] Refactor for performance --- .../ActiveRelaysDropdownSection.tsx | 34 --- .../ConnectedRelays/ActiveRelaysIconGrid.tsx | 106 -------- .../ConnectedRelays/active-relays-display.ts | 17 -- .../Explore/ExploreFavoriteRelays.tsx | 136 ---------- .../Explore/ExplorePopularRelays.tsx | 77 ------ .../Explore/ExploreRelayReviews.tsx | 239 ------------------ src/components/Explore/index.tsx | 146 ----------- .../FollowingFavoriteRelayList/index.tsx | 94 ------- src/components/NoteList/index.tsx | 39 +-- src/components/NoteStats/SeenOnButton.tsx | 110 -------- .../NotificationThreadWatchButtons/index.tsx | 106 -------- src/components/Profile/ProfileTimeline.tsx | 211 ---------------- src/components/Tabs/index.tsx | 184 -------------- src/components/ui/ProfileSearchBar.tsx | 75 ------ src/hooks/useBtcUsdRate.ts | 19 -- src/hooks/useRelayConnectionRows.ts | 51 ---- src/lib/scroll-activity.service.ts | 13 + src/lib/youtube-iframe-api.ts | 5 + .../secondary/WalletPage/QuickZapSwitch.tsx | 21 -- src/providers/ContentPolicyProvider.tsx | 60 +++-- src/providers/MuteListProvider.tsx | 111 +++++--- src/services/Untitled | 1 - .../client-replaceable-events.service.ts | 6 +- src/services/indexed-db.service.ts | 20 +- 24 files changed, 179 insertions(+), 1702 deletions(-) delete mode 100644 src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx delete mode 100644 src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx delete mode 100644 src/components/ConnectedRelays/active-relays-display.ts delete mode 100644 src/components/Explore/ExploreFavoriteRelays.tsx delete mode 100644 src/components/Explore/ExplorePopularRelays.tsx delete mode 100644 src/components/Explore/ExploreRelayReviews.tsx delete mode 100644 src/components/Explore/index.tsx delete mode 100644 src/components/FollowingFavoriteRelayList/index.tsx delete mode 100644 src/components/NoteStats/SeenOnButton.tsx delete mode 100644 src/components/NotificationThreadWatchButtons/index.tsx delete mode 100644 src/components/Profile/ProfileTimeline.tsx delete mode 100644 src/components/Tabs/index.tsx delete mode 100644 src/components/ui/ProfileSearchBar.tsx delete mode 100644 src/hooks/useBtcUsdRate.ts delete mode 100644 src/hooks/useRelayConnectionRows.ts create mode 100644 src/lib/scroll-activity.service.ts delete mode 100644 src/pages/secondary/WalletPage/QuickZapSwitch.tsx delete mode 100644 src/services/Untitled diff --git a/src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx b/src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx deleted file mode 100644 index 3e7a0ce3..00000000 --- a/src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx +++ /dev/null @@ -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 ( - <> - - - {t('Active relays')} - {countSummary} - -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - -
- - ) -} diff --git a/src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx b/src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx deleted file mode 100644 index 49e3324f..00000000 --- a/src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx +++ /dev/null @@ -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 ( -

- — -

- ) - } - - return ( -
- {shown.map(({ url, connected }) => ( - - ))} - {overflow > 0 ? ( - - - - - - - {t('More relays', { count: overflow })} - - -
- {overflowRows.map(({ url, connected }) => ( - - ))} -
-
-
- ) : null} -
- ) -} diff --git a/src/components/ConnectedRelays/active-relays-display.ts b/src/components/ConnectedRelays/active-relays-display.ts deleted file mode 100644 index e67a0ca3..00000000 --- a/src/components/ConnectedRelays/active-relays-display.ts +++ /dev/null @@ -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 -} diff --git a/src/components/Explore/ExploreFavoriteRelays.tsx b/src/components/Explore/ExploreFavoriteRelays.tsx deleted file mode 100644 index 54dbfa06..00000000 --- a/src/components/Explore/ExploreFavoriteRelays.tsx +++ /dev/null @@ -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 ( - - ) - } - - if (!relayInfo) { - return ( - - ) - } - - return ( - { - 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 ( -
-
-
-

{t('Favorite Relays')}

- - -
- {usingDefaults ? ( - {t('Using app default relays')} - ) : null} -
-
- {urls.map((url) => ( -
- -
- ))} -
-
- ) -} diff --git a/src/components/Explore/ExplorePopularRelays.tsx b/src/components/Explore/ExplorePopularRelays.tsx deleted file mode 100644 index f7d9f33c..00000000 --- a/src/components/Explore/ExplorePopularRelays.tsx +++ /dev/null @@ -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([]) - - 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 ( -

{t('No relays in your lists yet.')}

- ) - } - - return ( -
-

{t('Popular relays')}

-

- {t('From your mailbox, favorites, and cached relay lists on this device.')} -

-
    - {urls.map((url) => { - const key = normalizeAnyRelayUrl(url) || url - return ( -
  • - -
  • - ) - })} -
-
- ) -} diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx deleted file mode 100644 index 69804ac2..00000000 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ /dev/null @@ -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 ( - - ) -} - -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([]) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const bottomRef = useRef(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() - 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 ( -
- {showInitialSkeleton ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : showEmptyAfterLoad ? ( -

{t('no relays found')}

- ) : ( - <> - {groupedVisible.map(([relayUrl, relayEvents]) => ( -
- -
- {relayEvents.map((event) => ( - - ))} -
-
- ))} - {loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : null} - {showCount < events.length ?
: null} - {!loading && showCount >= events.length ? ( -

{t('no more relays')}

- ) : null} - - )} -
- ) -} diff --git a/src/components/Explore/index.tsx b/src/components/Explore/index.tsx deleted file mode 100644 index d46bd6ce..00000000 --- a/src/components/Explore/index.tsx +++ /dev/null @@ -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(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - let timeoutId: ReturnType | 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 ( -
-
- -
-
- -
-
- ) - } - - if (error) { - return ( -
-
Error: {error}
- -
- ) - } - - if (collections.length === 0) { - return ( -
- No relay collections available -
- ) - } - - return ( -
- {collections.map((collection) => ( - - ))} -
- ) -} - -function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) { - return ( -
-
- {collection.name} -
-
- {collection.relays.map((url) => ( - - ))} -
-
- ) -} - -function RelayItem({ url }: { url: string }) { - const { navigateToRelay } = useSmartRelayNavigation() - const { relayInfo, isFetching } = useFetchRelayInfo(url) - - if (isFetching) { - return - } - - if (!relayInfo) { - return null - } - - return ( -
- { - e.stopPropagation() - navigateToRelay(toRelay(relayInfo.url)) - }} - /> -
- ) -} diff --git a/src/components/FollowingFavoriteRelayList/index.tsx b/src/components/FollowingFavoriteRelayList/index.tsx deleted file mode 100644 index 5d0351cd..00000000 --- a/src/components/FollowingFavoriteRelayList/index.tsx +++ /dev/null @@ -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(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 ( -
- {relays.slice(0, showCount).map(([url, users]) => ( - - ))} - {showCount < relays.length &&
} - {loading && } - {!loading && ( -
- {relays.length === 0 ? t('no relays found') : t('no more relays')} -
- )} -
- ) -} - -function RelayItem({ url, users }: { url: string; users: string[] }) { - const { navigateToRelay } = useSmartRelayNavigation() - const { relayInfo } = useFetchRelayInfo(url) - - return ( - { - e.stopPropagation() - navigateToRelay(toRelay(url)) - }} - /> - ) -} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index ff52a048..c50f3df4 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -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( [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() + 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( hideReplies, hideContentMentioningMutedUsers, mutePubkeySet, - pinnedEventIds, + pinnedEventHexIdSet, isEventDeleted, feedAttestedSuperchatIds, incomingPaymentRecipientPubkey, @@ -1924,11 +1928,12 @@ const NoteList = forwardRef( const handle = window.setTimeout(() => { const candidates = new Set() const emojiAuthors = new Set() - 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( }, FEED_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) }, [ - timelineEventsForFilter, + filteredEvents, newEvents, clientFilteredEvents, showCount, @@ -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( } const onScrollPrefetch = () => { + scrollActivity.markScrolling() if (scrollPrefetchRafId) return scrollPrefetchRafId = requestAnimationFrame(() => { scrollPrefetchRafId = 0 diff --git a/src/components/NoteStats/SeenOnButton.tsx b/src/components/NoteStats/SeenOnButton.tsx deleted file mode 100644 index a01cba80..00000000 --- a/src/components/NoteStats/SeenOnButton.tsx +++ /dev/null @@ -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 = ( - - ) - - if (isSmallScreen) { - return ( - <> - {trigger} - - setIsDrawerOpen(false)} /> - - - Seen on - -
- {relays.map((relay) => ( - - ))} -
-
-
- - ) - } - return ( - - {trigger} - - {t('Seen on')} - - {relays.map((relay) => ( - e.preventDefault()} - onClick={() => navigateToRelay(toRelay(relay))} - className="min-w-52" - > - - {simplifyUrl(relay)} - - ))} - - - ) -} diff --git a/src/components/NotificationThreadWatchButtons/index.tsx b/src/components/NotificationThreadWatchButtons/index.tsx deleted file mode 100644 index e4cfbeb0..00000000 --- a/src/components/NotificationThreadWatchButtons/index.tsx +++ /dev/null @@ -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 ( - <> - - - - ) -} diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx deleted file mode 100644 index 923231a7..00000000 --- a/src/components/Profile/ProfileTimeline.tsx +++ /dev/null @@ -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(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 ( -
-
No profile selected
-
- ) - } - - if (isLoading && timelineEvents.length === 0) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) - } - - if (!filteredEvents.length && !isLoading) { - return ( -
-
- {searchQuery.trim() ? emptySearchLabel : emptyLabel} -
-
- ) - } - - return ( -
- {isRefreshing && ( -
- - {refreshLabel} -
- )} - {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( -
- Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} -
- )} -
- {displayedEvents.map((event) => ( - - ))} -
- {displayedEvents.length < filteredEvents.length && ( -
-
Loading more...
-
- )} -
- ) - } -) - -ProfileTimeline.displayName = 'ProfileTimeline' - -export default ProfileTimeline - diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx deleted file mode 100644 index 0074d4f9..00000000 --- a/src/components/Tabs/index.tsx +++ /dev/null @@ -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(null) - const tabsContainerRef = useRef(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 ( -
-
-
- {tabs.map((tab, index) => ( - - ))} -
-
-
- {options ? ( -
{options}
- ) : null} -
- ) -} diff --git a/src/components/ui/ProfileSearchBar.tsx b/src/components/ui/ProfileSearchBar.tsx deleted file mode 100644 index ce05f9fb..00000000 --- a/src/components/ui/ProfileSearchBar.tsx +++ /dev/null @@ -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 ( -
-
- - 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 && ( - - )} -
-
- ) -} diff --git a/src/hooks/useBtcUsdRate.ts b/src/hooks/useBtcUsdRate.ts deleted file mode 100644 index 8ab4a81a..00000000 --- a/src/hooks/useBtcUsdRate.ts +++ /dev/null @@ -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(null) - - useEffect(() => { - let cancelled = false - fetchBtcUsdRate().then((rate) => { - if (!cancelled) setBtcUsd(rate) - }) - return () => { - cancelled = true - } - }, []) - - return btcUsd -} diff --git a/src/hooks/useRelayConnectionRows.ts b/src/hooks/useRelayConnectionRows.ts deleted file mode 100644 index 82034d5f..00000000 --- a/src/hooks/useRelayConnectionRows.ts +++ /dev/null @@ -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(() => 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() - 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]) -} diff --git a/src/lib/scroll-activity.service.ts b/src/lib/scroll-activity.service.ts new file mode 100644 index 00000000..cd6774e3 --- /dev/null +++ b/src/lib/scroll-activity.service.ts @@ -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 + } +} diff --git a/src/lib/youtube-iframe-api.ts b/src/lib/youtube-iframe-api.ts index afdab020..97c44c63 100644 --- a/src/lib/youtube-iframe-api.ts +++ b/src/lib/youtube-iframe-api.ts @@ -34,9 +34,14 @@ export function ensureYouTubeIframeApi(): Promise { if (scriptAlreadyPresent()) { chainReady() + const pollDeadlineMs = Date.now() + 5_000 const poll = () => { tryResolve() if (hasYtPlayer()) return + if (Date.now() >= pollDeadlineMs) { + resolve() + return + } requestAnimationFrame(poll) } poll() diff --git a/src/pages/secondary/WalletPage/QuickZapSwitch.tsx b/src/pages/secondary/WalletPage/QuickZapSwitch.tsx deleted file mode 100644 index 4c9765c0..00000000 --- a/src/pages/secondary/WalletPage/QuickZapSwitch.tsx +++ /dev/null @@ -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 ( -
- - -
- ) -} diff --git a/src/providers/ContentPolicyProvider.tsx b/src/providers/ContentPolicyProvider.tsx index b59ff696..dca86ce2 100644 --- a/src/providers/ContentPolicyProvider.tsx +++ b/src/providers/ContentPolicyProvider.tsx @@ -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 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 } 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 ( - + {children} ) diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index 13295498..e532aef3 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -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 }) { 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 }) { 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 }) { } 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 }) { } 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 }) { } 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 }) { } 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 }) { } 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 ( - + {children} ) diff --git a/src/services/Untitled b/src/services/Untitled deleted file mode 100644 index b4158c40..00000000 --- a/src/services/Untitled +++ /dev/null @@ -1 +0,0 @@ -I \ No newline at end of file diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index e13f445e..938badfb 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -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 { >( 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}` } ) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 59e0288e..fc7205f4 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { return } + const MAX_PROFILE_EVENTS_ITERATE = 8_000 + let truncated = false + const events = await new Promise((resolve, reject) => { const out: Event[] = [] const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readonly') @@ -937,8 +940,12 @@ class IndexedDbService { resolve(out) return } - const value = (cursor.value as TValue).value - if (value) out.push(value) + if (out.length < MAX_PROFILE_EVENTS_ITERATE) { + const value = (cursor.value as TValue).value + if (value) out.push(value) + } else { + truncated = true + } cursor.continue() } request.onerror = () => { @@ -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((resolve) => { if (typeof requestAnimationFrame === 'function') {