diff --git a/src/components/AccountList/index.tsx b/src/components/AccountList/index.tsx index 2385c397..bcce7313 100644 --- a/src/components/AccountList/index.tsx +++ b/src/components/AccountList/index.tsx @@ -1,5 +1,6 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { isSameAccount } from '@/lib/account' import { formatPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' @@ -66,7 +67,7 @@ export default function AccountList({ {switchingAccount && isSameAccount(act, switchingAccount) && (
- +
)} diff --git a/src/components/AccountManager/NostrConnectionLogin.tsx b/src/components/AccountManager/NostrConnectionLogin.tsx index 0cb649b8..ae867ee2 100644 --- a/src/components/AccountManager/NostrConnectionLogin.tsx +++ b/src/components/AccountManager/NostrConnectionLogin.tsx @@ -1,9 +1,10 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import { Check, Copy, Loader, ScanQrCode } from 'lucide-react' +import { Check, Copy, ScanQrCode } from 'lucide-react' import { generateSecretKey, getPublicKey } from 'nostr-tools' import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' import QrScanner from 'qr-scanner' @@ -238,7 +239,7 @@ export default function NostrConnectLogin({ diff --git a/src/components/AccountManager/NpubLogin.tsx b/src/components/AccountManager/NpubLogin.tsx index eeb9c5c3..bc5de05b 100644 --- a/src/components/AccountManager/NpubLogin.tsx +++ b/src/components/AccountManager/NpubLogin.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { useNostr } from '@/providers/NostrProvider' -import { Loader } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -45,7 +45,7 @@ export default function NpubLogin({ {errMsg &&
{errMsg}
} - +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
) : ( <> @@ -1115,8 +1118,10 @@ export default function CacheRelaysSetting() { // Store items view <> {loadingItems ? ( -
- +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
) : ( <> diff --git a/src/components/Embedded/EmbeddedLNInvoice.tsx b/src/components/Embedded/EmbeddedLNInvoice.tsx index 376606f1..a1cc767d 100644 --- a/src/components/Embedded/EmbeddedLNInvoice.tsx +++ b/src/components/Embedded/EmbeddedLNInvoice.tsx @@ -1,9 +1,10 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { formatAmount, getAmountFromInvoice } from '@/lib/lightning' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import lightning from '@/services/lightning.service' -import { Loader, Zap } from 'lucide-react' +import { Zap } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -53,7 +54,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla {formatAmount(amount)} {t('sats')}
diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 98a5aec7..f9a8b0e0 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -467,7 +467,7 @@ function EmbeddedNoteNotFound({ > {isSearchingExternal ? ( <> - + {t('Searching...')} ) : ( diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index fd280b7e..960db49c 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -8,7 +8,6 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { Loader2 } from 'lucide-react' import type { Event } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -142,12 +141,13 @@ export default function ExploreRelayReviews() { {loading ? (
- - {t('Loading...')} + {Array.from({ length: 4 }).map((_, i) => ( + + ))}
) : null} {showCount < events.length ?
: null} diff --git a/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx index 6d02e5da..3dfc7409 100644 --- a/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx +++ b/src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx @@ -3,8 +3,9 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '../ui/button' +import { Skeleton } from '../ui/skeleton' import { Input } from '../ui/input' -import { Loader2, Check } from 'lucide-react' +import { Check } from 'lucide-react' import logger from '@/lib/logger' export default function AddBlockedRelay() { @@ -74,7 +75,7 @@ export default function AddBlockedRelay() { @@ -121,7 +126,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { onMouseLeave={() => setHover(false)} > {updating ? ( - + ) : hover ? ( t('Unfollow') ) : ( @@ -146,7 +151,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { ) : ( ) } diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 3f0076db..35435811 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -8,13 +8,14 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service' import mediaUpload from '@/services/media-upload.service' -import { ExternalLink, Loader2, X } from 'lucide-react' +import { ExternalLink, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -251,8 +252,15 @@ export default function GifPicker({ } > {loading ? ( -
- +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))}
) : (
diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index 73258b9d..51d696e9 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -1,5 +1,6 @@ import NoteCard from '@/components/NoteCard' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Skeleton } from '@/components/ui/skeleton' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, @@ -19,7 +20,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { queryService, replaceableEventService } from '@/services/client.service' import logger from '@/lib/logger' -import { ChevronDown, ChevronRight, Loader2, Star } from 'lucide-react' +import { ChevronDown, ChevronRight, Star } from 'lucide-react' import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -243,9 +244,9 @@ export default function LatestFromFollowsSection() { if (loadingFollowList) { return ( -
- - {t('Loading follow list…')} +
+ +
) } @@ -266,7 +267,7 @@ export default function LatestFromFollowsSection() { {heading} {batchBusy && postsByPubkey.size === 0 ? ( - + ) : null}
{batchBusy && postsByPubkey.size === 0 ? ( -
- - {t('Loading recent posts from follows…')} +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))}
) : null} {sortedRowPubkeys.map((pk) => { @@ -298,9 +301,8 @@ export default function LatestFromFollowsSection() { })}
{batchBusy && postsByPubkey.size > 0 ? ( -
- - {t('Loading more…')} +
+
) : null} diff --git a/src/components/MailboxSetting/DiscoveredRelays.tsx b/src/components/MailboxSetting/DiscoveredRelays.tsx index 6c0c1fd2..60352169 100644 --- a/src/components/MailboxSetting/DiscoveredRelays.tsx +++ b/src/components/MailboxSetting/DiscoveredRelays.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Checkbox } from '@/components/ui/checkbox' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05' @@ -158,9 +159,11 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: return (
{t('Discovered Relays')}
-
- - {t('Discovering relays...')} +
+

{t('Discovering relays...')}

+ {Array.from({ length: 4 }).map((_, i) => ( + + ))}
) @@ -223,7 +226,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: > {isAdding ? ( <> - + {t('Adding...')} ) : ( diff --git a/src/components/MailboxSetting/SaveButton.tsx b/src/components/MailboxSetting/SaveButton.tsx index 69793a7c..a5b83d1b 100644 --- a/src/components/MailboxSetting/SaveButton.tsx +++ b/src/components/MailboxSetting/SaveButton.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { createRelayListDraftEvent } from '@/lib/draft-event' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { useNostr } from '@/providers/NostrProvider' @@ -73,7 +74,7 @@ export default function SaveButton({ return ( ) diff --git a/src/components/MuteButton/index.tsx b/src/components/MuteButton/index.tsx index 5a8f86b8..9c8af6b2 100644 --- a/src/components/MuteButton/index.tsx +++ b/src/components/MuteButton/index.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' import { DropdownMenu, @@ -69,7 +70,11 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { onClick={handleUnmute} disabled={updating || changing} > - {updating ? : {t('Unmute')}} + {updating ? ( + + ) : ( + {t('Unmute')} + )} ) } @@ -80,7 +85,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { className="w-20 min-w-20 rounded-full" disabled={updating || changing} > - {updating ? : t('Mute')} + {updating ? : t('Mute')} ) @@ -96,7 +101,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { onClick={(e) => handleMute(e, true)} disabled={updating || changing} > - {updating ? : t('Mute user privately')} + {updating ? : t('Mute user privately')}
diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index 792e4741..e30c6249 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -7,7 +7,8 @@ import { cn, isPartiallyInViewport } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import pollResultsService from '@/services/poll-results.service' import dayjs from 'dayjs' -import { CheckCircle2, Loader2 } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { CheckCircle2 } from 'lucide-react' import { Event } from 'nostr-tools' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -253,7 +254,7 @@ export default function Poll({ event, className }: { event: Event; className?: s disabled={!selectedOptionIds.length || isVoting} className="w-full" > - {isVoting && } + {isVoting && } {t('Vote')} )} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 0affb438..f38ec15a 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -41,7 +41,6 @@ import PullToRefresh from 'react-simple-pull-to-refresh' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import type { TProfile } from '@/types' -import { Loader2 } from 'lucide-react' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 // Increased from 200 to load more events per request @@ -101,7 +100,11 @@ const NoteList = forwardRef( * {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells * (except Following). Refresh re-fetches. */ - oneShotFetch = false + oneShotFetch = false, + /** Max events kept after merging one-shot REQ batches (default 100). */ + oneShotMergedCap, + /** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then 2× per step). */ + revealBatchSize }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -124,6 +127,8 @@ const NoteList = forwardRef( spellFeedInstrumentToken?: number onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void oneShotFetch?: boolean + oneShotMergedCap?: number + revealBatchSize?: number }, ref ) => { @@ -484,6 +489,7 @@ const NoteList = forwardRef( if (!keepExistingTimelineEvents) { setEvents([]) setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) } setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh @@ -549,9 +555,10 @@ const NoteList = forwardRef( byId.set(ev.id, ev) } } + const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP const merged = [...byId.values()] .sort((a, b) => b.created_at - a.created_at) - .slice(0, ONE_SHOT_MERGED_CAP) + .slice(0, cap) setEvents(merged) lastEventsForTimelinePrefetchRef.current = merged } catch { @@ -734,7 +741,9 @@ const NoteList = forwardRef( showKind1111, useFilterAsIs, areAlgoRelays, - oneShotFetch + oneShotFetch, + oneShotMergedCap, + revealBatchSize ]) useEffect(() => { @@ -803,9 +812,9 @@ const NoteList = forwardRef( // Show more events immediately if we have them cached if (currentShowCount < currentEvents.length) { - // Show more aggressively: increase by SHOW_COUNT, but also check if we should show even more const remaining = currentEvents.length - currentShowCount - const increment = Math.min(SHOW_COUNT * 2, remaining) // Show up to 2x SHOW_COUNT if available + const step = revealBatchSize ?? SHOW_COUNT * 2 + const increment = Math.min(step, remaining) setShowCount((prev) => prev + increment) // Only preload more if we have plenty cached (more than 3/4 of LIMIT) // BUT: Always try to load more if we have very few events (might be due to filtering) @@ -819,7 +828,8 @@ const NoteList = forwardRef( } } - if (!currentTimelineKey || currentLoading || !currentHasMore) return + const canLoadFromTimeline = !!currentTimelineKey && currentHasMore + if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return // Schedule loadMore with a small delay to throttle rapid calls loadMoreTimeoutRef.current = setTimeout(async () => { @@ -948,8 +958,10 @@ const NoteList = forwardRef( } const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef.current) { - // Throttle: only trigger if not already loading and not already scheduled + if (!entries[0].isIntersecting || loadingRef.current) return + const ev = eventsRef.current + const sc = showCountRef.current + if (sc < ev.length || hasMoreRef.current) { loadMore() } }, options) @@ -1120,13 +1132,14 @@ const NoteList = forwardRef( {events.length === 0 && loading ? (
- -

{t('Loading...')}

+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
) : events.length > 0 && (hasMore || loading) ? (
diff --git a/src/components/NoteOptions/ReportDialog.tsx b/src/components/NoteOptions/ReportDialog.tsx index 0194f6f2..dbcc9bf4 100644 --- a/src/components/NoteOptions/ReportDialog.tsx +++ b/src/components/NoteOptions/ReportDialog.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, @@ -18,7 +19,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { createReportDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { Loader } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -137,7 +137,7 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog: handleReport() }} > - {reporting && } + {reporting && } {t('Report')}
diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 536537d6..08d4695c 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -4,6 +4,7 @@ import { DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { shouldHideInteractions } from '@/lib/event-filtering' @@ -15,7 +16,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider' import { eventService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { TEmoji } from '@/types' -import { Loader, SmilePlus } from 'lucide-react' +import { SmilePlus } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo, useState } from 'react' import logger from '@/lib/logger' @@ -189,7 +190,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; }} > {liking ? ( - + ) : myLastEmoji ? ( <> @@ -224,7 +225,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; }} > {liking && index === 0 ? ( - + ) : ( <> {emoji} diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 6109000a..5d87984e 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -1,4 +1,5 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { Skeleton } from '@/components/ui/skeleton' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ExtendedKind } from '@/constants' import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -175,7 +176,7 @@ export default function Likes({ event }: { event: Event }) { )}
{liking === key ? ( - + ) : (
- {reposting ? : } + {reposting ? : } {!hideCount && !!repostCount &&
{formatCount(repostCount)}
} ) diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 3f76ef32..eb239b23 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -1,3 +1,4 @@ +import { Skeleton } from '@/components/ui/skeleton' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { getLightningAddressFromProfile } from '@/lib/lightning' import { cn } from '@/lib/utils' @@ -8,7 +9,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata' import { kinds } from 'nostr-tools' import lightning from '@/services/lightning.service' import noteStatsService from '@/services/note-stats.service' -import { Loader, Zap } from 'lucide-react' +import { Zap } from 'lucide-react' import { Event } from 'nostr-tools' import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -146,7 +147,7 @@ export default function ZapButton({ event, hideCount = false }: { event: Event; onTouchEnd={handleClickEnd} > {zapping ? ( - + ) : ( )} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d1b8de05..1f6c679d 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' import { DropdownMenu, DropdownMenuContent, @@ -45,7 +46,6 @@ import { TPollCreateData } from '@/types' import { ImageUp, ListTodo, - LoaderCircle, MessageCircle, MessagesSquare, Settings, @@ -2346,7 +2346,9 @@ export default function PostContent({ {t('Cancel')}
@@ -2384,7 +2386,9 @@ export default function PostContent({ {t('Cancel')}
diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx index 84dc458a..a815af2d 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx @@ -17,7 +17,8 @@ import { SimpleUsername } from '@/components/Username' import { nip19, type Event as NEvent } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Loader2, Search } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { Search } from 'lucide-react' import type { Editor } from '@tiptap/core' import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion' @@ -136,8 +137,10 @@ export function NeventNaddrPickerDialog({
{loading && ( -
- +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))}
)} {!loading && debouncedQuery && events.length === 0 && ( diff --git a/src/components/Profile/Followings.tsx b/src/components/Profile/Followings.tsx index 07dca48b..ea28fadb 100644 --- a/src/components/Profile/Followings.tsx +++ b/src/components/Profile/Followings.tsx @@ -3,7 +3,7 @@ import { toFollowingList } from '@/lib/link' import { SecondaryPageLink } from '@/PageManager' import { useFollowList } from '@/providers/FollowListProvider' import { useNostr } from '@/providers/NostrProvider' -import { Loader } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' import { useTranslation } from 'react-i18next' export default function Followings({ pubkey }: { pubkey: string }) { @@ -20,7 +20,7 @@ export default function Followings({ pubkey }: { pubkey: string }) { {accountPubkey === pubkey ? ( selfFollowings.length ) : isFetching ? ( - + ) : ( followings.length )} diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index 104c87b2..8eea4270 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -117,7 +117,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) - const pinnedDisplayIds = useMemo(() => new Set(filteredPins.map((e) => e.id)), [filteredPins]) + /** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */ + const displayedPins = useMemo(() => { + if (filteredPins.length <= showCount) return filteredPins + return filteredPins.slice(0, showCount) + }, [filteredPins, showCount]) + + const displayedFeed = useMemo( + () => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)), + [filteredRest, showCount, displayedPins.length] + ) + + const totalVisible = displayedPins.length + displayedFeed.length useEffect(() => { setShowCount(INITIAL_SHOW_COUNT) @@ -138,16 +149,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) - const displayedEvents = useMemo( - () => mergedDisplay.slice(0, showCount), - [mergedDisplay, showCount] - ) - useEffect(() => { - if (!bottomRef.current || displayedEvents.length >= mergedDisplay.length) return + if (!bottomRef.current || totalVisible >= mergedDisplay.length) return const observer = new IntersectionObserver( (entries) => { - if (entries[0]?.isIntersecting && displayedEvents.length < mergedDisplay.length) { + if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) { setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length)) } }, @@ -155,7 +161,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string ) observer.observe(bottomRef.current) return () => observer.disconnect() - }, [displayedEvents.length, mergedDisplay.length]) + }, [totalVisible, mergedDisplay.length]) const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 @@ -210,29 +216,45 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string {searchQuery.trim() && (
{t('Showing {{filtered}} of {{total}} items', { - filtered: displayedEvents.length, + filtered: totalVisible, total: mergedDisplay.length })}
)}
- {displayedEvents.map((event, index) => ( -
- {index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && ( -
- {t('Feed')} -
- )} - + {displayedPins.length > 0 && ( +
+ {displayedPins.map((event) => ( + + ))} +
+ )} + {displayedPins.length > 0 && displayedFeed.length > 0 && ( +
+ {t('Feed')} +
+ )} + {displayedFeed.length > 0 && ( +
+ {displayedFeed.map((event) => ( + + ))}
- ))} + )}
- {displayedEvents.length < mergedDisplay.length && ( + {totalVisible < mergedDisplay.length && (
{t('Loading more...')}
diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx new file mode 100644 index 00000000..310899d2 --- /dev/null +++ b/src/components/Profile/ProfileMediaFeed.tsx @@ -0,0 +1,128 @@ +import NoteList, { type TNoteListRef } from '@/components/NoteList' +import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' +import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' +import { + applyFauxSpellCapsToSubRequests, + appendCuratedReadOnlyRelays, + buildProfileMediaSpellFilter, + MEDIA_SPELL_KINDS, + PROFILE_MEDIA_REQ_LIMIT +} from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { normalizeUrl } from '@/lib/url' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import client from '@/services/client.service' +import { NoteCardLoadingSkeleton } from '@/components/NoteCard' +import { forwardRef, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { + const fav = [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + return `${fav}\u0000${blk}` +} + +const ProfileMediaFeed = forwardRef(({ pubkey }, ref) => { + const { t } = useTranslation() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const relayListsKey = useMemo( + () => relayListsContentKey(favoriteRelays, blockedRelays), + [favoriteRelays, blockedRelays] + ) + + /** `null` = still resolving viewed profile NIP-65 + merged relay stack (same as pins / main profile feed). */ + const [profileRelayUrls, setProfileRelayUrls] = useState(null) + + useEffect(() => { + const pk = pubkey?.trim() + if (!pk) { + setProfileRelayUrls([]) + return + } + let cancelled = false + setProfileRelayUrls(null) + void (async () => { + const authorRl = await client.fetchRelayList(pk).catch(() => ({ + read: [] as string[], + write: [] as string[] + })) + if (cancelled) return + setProfileRelayUrls( + buildProfilePageReadRelayUrls(favoriteRelays, blockedRelays, authorRl, false) + ) + })() + return () => { + cancelled = true + } + }, [pubkey, relayListsKey, favoriteRelays, blockedRelays]) + + const subRequests = useMemo(() => { + const pk = pubkey?.trim() + if (!pk || profileRelayUrls === null) return [] + const urls = appendCuratedReadOnlyRelays(profileRelayUrls, blockedRelays) + if (!urls.length) return [] + return applyFauxSpellCapsToSubRequests([ + { urls, filter: buildProfileMediaSpellFilter(pk) } + ]) + }, [pubkey, profileRelayUrls, blockedRelays]) + + const feedSubscriptionKey = useMemo( + () => computeSpellSubRequestsIdentityKey(subRequests), + [subRequests] + ) + + const showKinds = useMemo(() => [...MEDIA_SPELL_KINDS], []) + + if (!pubkey?.trim()) { + return ( +
+ {t('Nothing to load for this feed.')} +
+ ) + } + + if (profileRelayUrls === null) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) + } + + if (!subRequests.length) { + return ( +
+ {t('Nothing to load for this feed.')} +
+ ) + } + + return ( +
+ +
+ ) +}) + +ProfileMediaFeed.displayName = 'ProfileMediaFeed' + +export default ProfileMediaFeed diff --git a/src/components/Profile/Relays.tsx b/src/components/Profile/Relays.tsx index d4772a4d..d7dbdbd5 100644 --- a/src/components/Profile/Relays.tsx +++ b/src/components/Profile/Relays.tsx @@ -1,8 +1,8 @@ import { useFetchRelayList } from '@/hooks' import { toOthersRelaySettings, toRelaySettings } from '@/lib/link' import { SecondaryPageLink } from '@/PageManager' +import { Skeleton } from '@/components/ui/skeleton' import { useNostr } from '@/providers/NostrProvider' -import { Loader } from 'lucide-react' import { useTranslation } from 'react-i18next' export default function Relays({ pubkey }: { pubkey: string }) { @@ -15,7 +15,7 @@ export default function Relays({ pubkey }: { pubkey: string }) { to={accountPubkey === pubkey ? toRelaySettings('mailbox') : toOthersRelaySettings(pubkey)} className="flex gap-1 hover:underline w-fit items-center" > - {isFetching ? : relayList.originalRelays.length} + {isFetching ? : relayList.originalRelays.length}
{t('Relays')}
) diff --git a/src/components/Profile/SmartFollowings.tsx b/src/components/Profile/SmartFollowings.tsx index 73b4ad0f..d255c19c 100644 --- a/src/components/Profile/SmartFollowings.tsx +++ b/src/components/Profile/SmartFollowings.tsx @@ -3,7 +3,7 @@ import { toFollowingList } from '@/lib/link' import { useSmartFollowingListNavigation } from '@/PageManager' import { useFollowList } from '@/providers/FollowListProvider' import { useNostr } from '@/providers/NostrProvider' -import { Loader } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' import { useTranslation } from 'react-i18next' export default function SmartFollowings({ pubkey }: { pubkey: string }) { @@ -25,7 +25,7 @@ export default function SmartFollowings({ pubkey }: { pubkey: string }) { {accountPubkey === pubkey ? ( selfFollowings.length ) : isFetching ? ( - + ) : ( followings.length )} diff --git a/src/components/Profile/SmartRelays.tsx b/src/components/Profile/SmartRelays.tsx index 30a384e4..67d301fe 100644 --- a/src/components/Profile/SmartRelays.tsx +++ b/src/components/Profile/SmartRelays.tsx @@ -1,7 +1,7 @@ import { useFetchRelayList } from '@/hooks' import { toOthersRelaySettings } from '@/lib/link' import { useSmartOthersRelaySettingsNavigation } from '@/PageManager' -import { Loader } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' import { useTranslation } from 'react-i18next' export default function SmartRelays({ pubkey }: { pubkey: string }) { @@ -19,7 +19,7 @@ export default function SmartRelays({ pubkey }: { pubkey: string }) { className="flex gap-1 hover:underline w-fit items-center cursor-pointer" onClick={handleClick} > - {isFetching ? : relayList.originalRelays.length} + {isFetching ? : relayList.originalRelays.length}
{t('Relays')}
) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 4ff4f4cb..75f3b91a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -28,13 +28,24 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react' -import { useEffect, useMemo, useRef, useState, type Ref } from 'react' +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type Ref +} from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' import ProfileFeedWithPins from './ProfileFeedWithPins' +import ProfileMediaFeed from './ProfileMediaFeed' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import type { TNoteListRef } from '@/components/NoteList' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' @@ -166,6 +177,8 @@ export default function Profile({ const { navigate: navigatePrimary } = usePrimaryPage() const internalFeedRef = useRef<{ refresh: () => void }>(null) const profileFeedRef = feedRef ?? internalFeedRef + const postsFeedRef = useRef<{ refresh: () => void }>(null) + const mediaFeedRef = useRef(null) const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey } = useNostr() @@ -323,6 +336,21 @@ export default function Profile({ }) } + useLayoutEffect(() => { + const r = profileFeedRef + if (typeof r === 'function') return + const m = r as MutableRefObject<{ refresh: () => void } | null> + m.current = { + refresh: () => { + postsFeedRef.current?.refresh() + mediaFeedRef.current?.refresh() + } + } + return () => { + m.current = null + } + }, []) + useEffect(() => { if (!profile?.pubkey) return @@ -361,14 +389,7 @@ export default function Profile({ if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile - - logger.component('Profile', 'Profile data loaded', { - pubkey, - username, - hasProfile: !!profile, - isFetching, - id - }) + return ( <>
@@ -572,7 +593,18 @@ export default function Profile({
- + + + {t('Posts')} + {t('Media')} + + + + + + + + {openPublicMessageTo && ( - {submitting && } + {submitting && } {t('Submit')}
diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index 8385937f..1de159bc 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -4,7 +4,8 @@ import { useNostr } from '@/providers/NostrProvider' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import { DEFAULT_RSS_FEEDS } from '@/constants' import RssFeedItem from '../RssFeedItem' -import { Loader, AlertCircle, Search, Plus } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { AlertCircle, Search, Plus } from 'lucide-react' import logger from '@/lib/logger' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Input } from '@/components/ui/input' @@ -511,9 +512,11 @@ export default function RssFeedList() { if (loading) { return ( -
- -

{t('Loading RSS feeds...')}

+
+

{t('Loading RSS feeds...')}

+ {Array.from({ length: 6 }).map((_, i) => ( + + ))}
) } @@ -651,9 +654,9 @@ export default function RssFeedList() {
{refreshing && ( -
- - {t('Refreshing feeds...')} +
+ +
)} @@ -672,8 +675,8 @@ export default function RssFeedList() { ))} {/* Bottom ref for infinite scroll */} {displayedItems.length < filteredItems.length && ( -
- +
+
)} diff --git a/src/components/SaveRelayDropdownMenu/index.tsx b/src/components/SaveRelayDropdownMenu/index.tsx index 5017a728..8a7fd0fa 100644 --- a/src/components/SaveRelayDropdownMenu/index.tsx +++ b/src/components/SaveRelayDropdownMenu/index.tsx @@ -15,12 +15,13 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { TRelaySet } from '@/types' -import { Ban, Check, FolderPlus, Loader2, Plus, Star } from 'lucide-react' +import { Ban, Check, FolderPlus, Plus, Star } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import DrawerMenuItem from '../DrawerMenuItem' @@ -268,7 +269,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) { onClick={isLoading ? undefined : handleClick} className={isLoading ? 'opacity-50 cursor-not-allowed' : ''} > - {isLoading ? : } + {isLoading ? : } {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} ) @@ -276,7 +277,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) { return ( - {isLoading ? : } + {isLoading ? : } {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} ) diff --git a/src/components/StartupSessionBanner.tsx b/src/components/StartupSessionBanner.tsx index 470e8a2a..90094960 100644 --- a/src/components/StartupSessionBanner.tsx +++ b/src/components/StartupSessionBanner.tsx @@ -1,6 +1,6 @@ import { useNostr } from '@/providers/NostrProvider' import { cn } from '@/lib/utils' -import { Loader2 } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,7 +36,7 @@ export default function StartupSessionBanner() { 'bg-background px-3 py-2 text-center text-sm text-muted-foreground' )} > - + {t('startupSessionHydrating', { defaultValue: 'Syncing your relays and profile from the network…' diff --git a/src/components/TopicSubscribeButton/index.tsx b/src/components/TopicSubscribeButton/index.tsx index c051cd4a..ab1fcd9d 100644 --- a/src/components/TopicSubscribeButton/index.tsx +++ b/src/components/TopicSubscribeButton/index.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { useInterestList } from '@/providers/InterestListProvider' import { useNostr } from '@/providers/NostrProvider' -import { Bell, BellOff, Loader2 } from 'lucide-react' +import { Bell, BellOff } from 'lucide-react' import { useTranslation } from 'react-i18next' interface TopicSubscribeButtonProps { @@ -50,7 +51,7 @@ export default function TopicSubscribeButton({ title={subscribed ? t('Unsubscribe') : t('Subscribe')} > {changing ? ( - + ) : subscribed ? ( ) : ( @@ -70,7 +71,7 @@ export default function TopicSubscribeButton({ > {changing ? ( <> - + {subscribed ? t('Unsubscribing...') : t('Subscribing...')} ) : subscribed ? ( diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx index e8eb02a8..e4535f4e 100644 --- a/src/components/TrendingNotes/index.tsx +++ b/src/components/TrendingNotes/index.tsx @@ -15,7 +15,8 @@ import noteStatsService from '@/services/note-stats.service' import { FAST_READ_RELAY_URLS } from '@/constants' import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' -import { ChevronDown, Loader2 } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { ChevronDown } from 'lucide-react' const SHOW_COUNT = 25 const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes @@ -395,7 +396,7 @@ export default function TrendingNotes({ variant = 'page' }: { variant?: Trending {headerTitle} {cacheLoading && cacheEvents.length === 0 ? ( - + ) : null} {isUpdating ? ( <> - + {t('Updating...')} ) : ( diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 624d0b9a..abd4355f 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -14,13 +14,13 @@ import { DrawerTitle } from '@/components/ui/drawer' import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' import { Label } from '@/components/ui/label' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useZap } from '@/providers/ZapProvider' import lightning from '@/services/lightning.service' import noteStatsService from '@/services/note-stats.service' -import { Loader } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -262,7 +262,8 @@ function ZapDialogContent({ {/* Zap button - fixed at bottom */}
diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index 63e8a204..c9ab0f22 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -1,13 +1,14 @@ -import { useCallback, useEffect, useState } from 'react' import { Event } from 'nostr-tools' import { buildProfilePageReadRelayUrls, PROFILE_PAGE_PINS_RESOLVE_LIMIT } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' +import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' import { queryService } from '@/services/client.service' +import { useCallback, useEffect, useMemo, useState } from 'react' const CACHE_DURATION = 5 * 60 * 1000 @@ -57,8 +58,18 @@ function orderPinEvents(pinList: Event, eventsById: Map): Event[] return ordered } +function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { + const fav = [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + return `${fav}\u0000${blk}` +} + export function useProfilePins(pubkey: string | undefined) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const relayListsKey = useMemo( + () => relayListsContentKey(favoriteRelays, blockedRelays), + [favoriteRelays, blockedRelays] + ) const [pinEvents, setPinEvents] = useState([]) const [loadingPins, setLoadingPins] = useState(false) @@ -84,6 +95,8 @@ export function useProfilePins(pubkey: string | undefined) { read: [] as string[], write: [] as string[] })) + // Same stack as profile feed: viewed npub NIP-65 read+write → your favorites → FAST_READ_RELAY_URLS, + // deduped, blocked stripped, max PROFILE_PAGE_FEED_MAX_RELAYS (6). Relays here accept `#d` on REQ. const profileRelays = buildProfilePageReadRelayUrls( favoriteRelays, blockedRelays, @@ -125,17 +138,18 @@ export function useProfilePins(pubkey: string | undefined) { ) } if (aTags.length > 0) { - const aTagFetches = aTags.map(async (aTag) => { - const parts = aTag.split(':') + const aTagFetches = aTags.map(async (aTagRaw) => { + const parts = aTagRaw.trim().split(':') if (parts.length < 2) return null const kind = parseInt(parts[0], 10) - const author = parts[1] - const d = parts[2] || '' + const author = parts[1]?.trim().toLowerCase() + if (!Number.isFinite(kind) || !author || !/^[0-9a-f]{64}$/.test(author)) return null + const d = parts.slice(2).join(':') const filter = d ? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } : { authors: [author], kinds: [kind], limit: 1 } - const events = await queryService.fetchEvents(profileRelays, [filter]) - return events[0] || null + const events = await queryService.fetchEvents(profileRelays, filter) + return events[0] ?? null }) eventPromises.push( Promise.all(aTagFetches).then((events) => events.filter((e): e is Event => e !== null)) @@ -151,7 +165,7 @@ export function useProfilePins(pubkey: string | undefined) { byId.set(e.id, e) } - const ordered = orderPinEvents(pinList, byId) + const ordered = orderPinEvents(pinList, byId).slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT) setPinEvents(ordered) pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) } catch (e) { @@ -161,7 +175,7 @@ export function useProfilePins(pubkey: string | undefined) { setLoadingPins(false) } }, - [pubkey, favoriteRelays, blockedRelays] + [pubkey, relayListsKey, favoriteRelays, blockedRelays] ) useEffect(() => { diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 907e6bda..e9f3e3ee 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' +import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' type ProfileTimelineMemoryEntry = { @@ -82,6 +83,12 @@ function postProcessEvents( return events.slice(0, limit) } +function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { + const fav = [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') + return `${fav}\u0000${blk}` +} + export function useProfileTimeline({ pubkey, cacheKey, @@ -90,6 +97,10 @@ export function useProfileTimeline({ filterPredicate }: UseProfileTimelineOptions): UseProfileTimelineResult { const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const relayListsKey = useMemo( + () => relayListsContentKey(favoriteRelays, blockedRelays), + [favoriteRelays, blockedRelays] + ) const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() const isEventDeletedRef = useRef(isEventDeleted) isEventDeletedRef.current = isEventDeleted @@ -216,16 +227,7 @@ export function useProfileTimeline({ subscriptionRef.current() subscriptionRef.current = () => {} } - }, [ - pubkey, - cacheKey, - JSON.stringify(kinds), - limit, - filterPredicate, - refreshToken, - favoriteRelays, - blockedRelays - ]) + }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey]) const refresh = useCallback(() => { subscriptionRef.current() diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index e378deda..9b9d1d96 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -111,7 +111,10 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( }) } -/** Profile page pins + feed: author's NIP-65 read/write, then favorites, then fast-read defaults, capped. */ +/** + * Profile page pins + feed: viewed author's NIP-65 read + write (REQ tier 1), then logged-in user's favorites, + * then fast-read defaults from constants, deduped and blocked-stripped, capped at this count. + */ export const PROFILE_PAGE_FEED_MAX_RELAYS = 6 export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10 diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index fb5a30e4..9784e90d 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -9,8 +9,9 @@ import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' +import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { TPageRef } from '@/types' -import { Compass, Info, Loader2 } from 'lucide-react' +import { Compass, Info } from 'lucide-react' import React, { Dispatch, forwardRef, @@ -85,17 +86,19 @@ const NoteListPage = forwardRef((_, ref) => { if (!isReady) { content = (
- -

+

{t('feedStarting', { defaultValue: 'Starting feeds and relays… This can take a few seconds after login.' })}

+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
) } else if (feedInfo.feedType === 'following' && !pubkey) { diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index fd8cbd07..9d90acd7 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -18,6 +18,9 @@ import { type Event, type Filter, kinds } from 'nostr-tools' export const FAUX_SPELL_MAX_RELAYS = 6 export const FAUX_SPELL_EVENT_LIMIT = 200 +/** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */ +export const PROFILE_MEDIA_REQ_LIMIT = 200 + /** * Trim relay lists and filter limits (and bookmark `ids`) so faux feeds stay cheap to open. */ @@ -110,6 +113,16 @@ export function buildMediaSpellFilter(): Filter { return { kinds: [...MEDIA_SPELL_KINDS], limit: FAUX_SPELL_EVENT_LIMIT } } +/** Media kinds for a single profile (same as {@link MEDIA_SPELL_KINDS}, scoped by `authors`). */ +export function buildProfileMediaSpellFilter(pubkey: string): Filter { + const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() + return { + authors: [pk], + kinds: [...MEDIA_SPELL_KINDS], + limit: PROFILE_MEDIA_REQ_LIMIT + } +} + export function buildCalendarSpellFilter(): Filter { return { kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index a0d0882c..cf91dc72 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -2,6 +2,7 @@ import MuteButton from '@/components/MuteButton' import Nip05 from '@/components/Nip05' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import { useFetchProfile } from '@/hooks' @@ -113,7 +114,7 @@ function UserItem({ pubkey }: { pubkey: string }) {
{switching ? ( ) : muteType === 'private' ? ( ) : ( {t('Preferred')} @@ -159,7 +160,7 @@ export default function BlossomServerListSetting() { title={t('Remove')} disabled={removingIndex >= 0 || adding || movingIndex >= 0} > - {removingIndex === idx ? : } + {removingIndex === idx ? : }
@@ -180,7 +181,7 @@ export default function BlossomServerListSetting() { }} title={t('Add')} > - {adding && } + {adding && } {t('Add')}
diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index d45be64b..f5a471e1 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' +import { Skeleton } from '@/components/ui/skeleton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { generateImageByPubkey } from '@/lib/pubkey' @@ -24,7 +25,7 @@ import { isEmail } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { ChevronDown, Loader, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' +import { ChevronDown, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import type { Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -299,11 +300,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { className="gap-1.5" title={t('Force-refresh profile and payment info from relays')} > - {refreshingCache ? : } + {refreshingCache ? : } {t('Refresh cache')}
) @@ -319,7 +320,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { >
- {uploadingBanner ? : } + {uploadingBanner ? : }
{
- {uploadingAvatar ? : } + {uploadingAvatar ? : }
@@ -495,7 +496,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { disabled={savingFullProfile || !hasChanged} className="gap-2" > - {savingFullProfile && } + {savingFullProfile && } {savingFullProfile ? t('Saving…') : t('Save full profile')} @@ -644,7 +645,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { {t('Cancel')} diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index ad0536e8..561db8c3 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -7,6 +7,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' @@ -637,7 +638,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index disabled={pushing || !hasChange} onClick={handleSave} > - {pushing ? : } + {pushing ? : } {t('Save')}
diff --git a/src/pages/secondary/WalletPage/LightningAddressInput.tsx b/src/pages/secondary/WalletPage/LightningAddressInput.tsx index 6fb6022f..4240b7bf 100644 --- a/src/pages/secondary/WalletPage/LightningAddressInput.tsx +++ b/src/pages/secondary/WalletPage/LightningAddressInput.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { createProfileDraftEvent } from '@/lib/draft-event' @@ -64,7 +65,7 @@ export default function LightningAddressInput() { }} />