Browse Source

add media tab to profile page. speed page up, bug fixes

imwald
Silberengel 1 month ago
parent
commit
7b37798932
  1. 3
      src/components/AccountList/index.tsx
  2. 5
      src/components/AccountManager/NostrConnectionLogin.tsx
  3. 4
      src/components/AccountManager/NpubLogin.tsx
  4. 5
      src/components/BookmarkButton/index.tsx
  5. 7
      src/components/Bookstr/BookstrContent.tsx
  6. 17
      src/components/CacheRelaysSetting/index.tsx
  7. 5
      src/components/Embedded/EmbeddedLNInvoice.tsx
  8. 2
      src/components/Embedded/EmbeddedNote.tsx
  9. 8
      src/components/Explore/ExploreRelayReviews.tsx
  10. 5
      src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx
  11. 5
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  12. 11
      src/components/FollowButton/index.tsx
  13. 14
      src/components/GifPicker/index.tsx
  14. 24
      src/components/LatestFromFollowsSection/index.tsx
  15. 11
      src/components/MailboxSetting/DiscoveredRelays.tsx
  16. 3
      src/components/MailboxSetting/SaveButton.tsx
  17. 13
      src/components/MuteButton/index.tsx
  18. 5
      src/components/Note/Poll.tsx
  19. 37
      src/components/NoteList/index.tsx
  20. 4
      src/components/NoteOptions/ReportDialog.tsx
  21. 7
      src/components/NoteStats/LikeButton.tsx
  22. 3
      src/components/NoteStats/Likes.tsx
  23. 5
      src/components/NoteStats/RepostButton.tsx
  24. 5
      src/components/NoteStats/ZapButton.tsx
  25. 10
      src/components/PostEditor/PostContent.tsx
  26. 9
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  27. 4
      src/components/Profile/Followings.tsx
  28. 72
      src/components/Profile/ProfileFeedWithPins.tsx
  29. 128
      src/components/Profile/ProfileMediaFeed.tsx
  30. 4
      src/components/Profile/Relays.tsx
  31. 4
      src/components/Profile/SmartFollowings.tsx
  32. 4
      src/components/Profile/SmartRelays.tsx
  33. 52
      src/components/Profile/index.tsx
  34. 5
      src/components/RelayInfo/ReviewEditor.tsx
  35. 21
      src/components/RssFeedList/index.tsx
  36. 7
      src/components/SaveRelayDropdownMenu/index.tsx
  37. 4
      src/components/StartupSessionBanner.tsx
  38. 7
      src/components/TopicSubscribeButton/index.tsx
  39. 5
      src/components/TrendingNotes/index.tsx
  40. 3
      src/components/VersionUpdateBanner/index.tsx
  41. 5
      src/components/ZapDialog/index.tsx
  42. 32
      src/hooks/useProfilePins.tsx
  43. 22
      src/hooks/useProfileTimeline.tsx
  44. 5
      src/lib/favorites-feed-relays.ts
  45. 11
      src/pages/primary/NoteListPage/index.tsx
  46. 13
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  47. 3
      src/pages/secondary/MuteListPage/index.tsx
  48. 3
      src/pages/secondary/NotePage/NotFound.tsx
  49. 9
      src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
  50. 15
      src/pages/secondary/ProfileEditorPage/index.tsx
  51. 3
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  52. 3
      src/pages/secondary/WalletPage/LightningAddressInput.tsx

3
src/components/AccountList/index.tsx

@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { isSameAccount } from '@/lib/account' import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -66,7 +67,7 @@ export default function AccountList({
</div> </div>
{switchingAccount && isSameAccount(act, switchingAccount) && ( {switchingAccount && isSameAccount(act, switchingAccount) && (
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60"> <div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
<Loader size={16} className="animate-spin" /> <Skeleton className="size-8 shrink-0 rounded-full" aria-hidden />
</div> </div>
)} )}
</div> </div>

5
src/components/AccountManager/NostrConnectionLogin.tsx

@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants' import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' 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 { generateSecretKey, getPublicKey } from 'nostr-tools'
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
import QrScanner from 'qr-scanner' import QrScanner from 'qr-scanner'
@ -238,7 +239,7 @@ export default function NostrConnectLogin({
</Button> </Button>
</div> </div>
<Button onClick={() => handleLogin()} disabled={pending}> <Button onClick={() => handleLogin()} disabled={pending}>
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} /> {pending && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}
{t('Login')} {t('Login')}
</Button> </Button>
</div> </div>

4
src/components/AccountManager/NpubLogin.tsx

@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -45,7 +45,7 @@ export default function NpubLogin({
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>} {errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div> </div>
<Button onClick={handleLogin} disabled={pending}> <Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} /> {pending && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}
{t('Login')} {t('Login')}
</Button> </Button>
<Button variant="secondary" onClick={back}> <Button variant="secondary" onClick={back}>

5
src/components/BookmarkButton/index.tsx

@ -1,7 +1,8 @@
import { Skeleton } from '@/components/ui/skeleton'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useBookmarks } from '@/providers/BookmarksProvider' import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, Loader } from 'lucide-react' import { BookmarkIcon } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -65,7 +66,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
> >
{updating ? ( {updating ? (
<Loader className="animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} /> <BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)} )}

7
src/components/Bookstr/BookstrContent.tsx

@ -4,7 +4,8 @@ import { parseBookWikilink, extractBookMetadata, BookReference } from '@/lib/boo
import client from '@/services/client.service' import client from '@/services/client.service'
import { macroService } from '@/services/client.service' import { macroService } from '@/services/client.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { Loader2, AlertCircle, ExternalLink } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, ExternalLink } from 'lucide-react'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -880,7 +881,7 @@ export function BookstrContent({ wikilink, sourceUrl, className, skipWebPreview
return ( return (
<span className={cn('inline-flex items-center gap-1', className)}> <span className={cn('inline-flex items-center gap-1', className)}>
<span>{wikilink}</span> <span>{wikilink}</span>
<Loader2 className="h-3 w-3 animate-spin" /> <Skeleton className="h-3 w-3 shrink-0 rounded-sm" aria-hidden />
</span> </span>
) )
} }
@ -953,7 +954,7 @@ export function BookstrContent({ wikilink, sourceUrl, className, skipWebPreview
</h4> </h4>
{/* Only show spinner if section is still loading AND has no events */} {/* Only show spinner if section is still loading AND has no events */}
{isSectionLoading && filteredEvents.length === 0 && ( {isSectionLoading && filteredEvents.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> <Skeleton className="h-3 w-3 shrink-0 rounded-sm" aria-hidden />
)} )}
<VersionSelector <VersionSelector
section={section} section={section}

17
src/components/CacheRelaysSetting/index.tsx

@ -29,8 +29,9 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays'
import { createCacheRelaysDraftEvent } from '@/lib/draft-event' import { createCacheRelaysDraftEvent } from '@/lib/draft-event'
import { getRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent } from '@/lib/event-metadata'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { CloudUpload, Loader, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react' import { CloudUpload, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
@ -817,7 +818,7 @@ export default function CacheRelaysSetting() {
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} /> <DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} />
<RelayCountWarning relays={relays} /> <RelayCountWarning relays={relays} />
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}> <Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />} {pushing ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <CloudUpload />}
{t('Save')} {t('Save')}
</Button> </Button>
<DndContext <DndContext
@ -935,8 +936,10 @@ export default function CacheRelaysSetting() {
) : ( ) : (
// Store items view // Store items view
loadingItems ? ( loadingItems ? (
<div className="flex items-center justify-center py-8"> <div className="space-y-2 py-6" role="status" aria-busy="true">
<Loader className="animate-spin h-6 w-6" /> {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div> </div>
) : ( ) : (
<> <>
@ -1115,8 +1118,10 @@ export default function CacheRelaysSetting() {
// Store items view // Store items view
<> <>
{loadingItems ? ( {loadingItems ? (
<div className="flex items-center justify-center py-8"> <div className="space-y-2 py-6" role="status" aria-busy="true">
<Loader className="animate-spin h-6 w-6" /> {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div> </div>
) : ( ) : (
<> <>

5
src/components/Embedded/EmbeddedLNInvoice.tsx

@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAmount, getAmountFromInvoice } from '@/lib/lightning' import { formatAmount, getAmountFromInvoice } from '@/lib/lightning'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import { Loader, Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -53,7 +54,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla
{formatAmount(amount)} {t('sats')} {formatAmount(amount)} {t('sats')}
</div> </div>
<Button onClick={handlePayClick}> <Button onClick={handlePayClick}>
{paying && <Loader className="w-4 h-4 animate-spin" />} {paying && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}
{t('Pay')} {t('Pay')}
</Button> </Button>
</div> </div>

2
src/components/Embedded/EmbeddedNote.tsx

@ -467,7 +467,7 @@ function EmbeddedNoteNotFound({
> >
{isSearchingExternal ? ( {isSearchingExternal ? (
<> <>
<Search className="w-4 h-4 animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
{t('Searching...')} {t('Searching...')}
</> </>
) : ( ) : (

8
src/components/Explore/ExploreRelayReviews.tsx

@ -8,7 +8,6 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Loader2 } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -142,12 +141,13 @@ export default function ExploreRelayReviews() {
</div> </div>
{loading ? ( {loading ? (
<div <div
className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground" className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
> >
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden /> {Array.from({ length: 4 }).map((_, i) => (
{t('Loading...')} <Skeleton key={i} className="h-28 rounded-lg border md:border" />
))}
</div> </div>
) : null} ) : null}
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null} {showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}

5
src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx

@ -3,8 +3,9 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Loader2, Check } from 'lucide-react' import { Check } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
export default function AddBlockedRelay() { export default function AddBlockedRelay() {
@ -74,7 +75,7 @@ export default function AddBlockedRelay() {
<Button onClick={saveRelay} disabled={isLoading || !input.trim()}> <Button onClick={saveRelay} disabled={isLoading || !input.trim()}>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Skeleton className="mr-2 size-4 shrink-0 rounded-sm" aria-hidden />
{t('Blocking...')} {t('Blocking...')}
</> </>
) : ( ) : (

5
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -1,10 +1,11 @@
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { X, Loader2 } from 'lucide-react' import { X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import logger from '@/lib/logger' import logger from '@/lib/logger'
export default function BlockedRelayItem({ relay }: { relay: string }) { export default function BlockedRelayItem({ relay }: { relay: string }) {
@ -43,7 +44,7 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : ( ) : (
<X className="h-4 w-4" /> <X className="h-4 w-4" />
)} )}

11
src/components/FollowButton/index.tsx

@ -10,6 +10,7 @@ import {
AlertDialogTrigger AlertDialogTrigger
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowList } from '@/providers/FollowListProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -89,7 +90,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
variant="secondary" variant="secondary"
disabled={updating} disabled={updating}
> >
{updating ? <Loader className="animate-spin" /> : <span className="text-destructive text-center">{t('Muted')}</span>} {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
) : (
<span className="text-destructive text-center">{t('Muted')}</span>
)}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@ -121,7 +126,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
> >
{updating ? ( {updating ? (
<Loader className="animate-spin" /> <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
) : hover ? ( ) : hover ? (
t('Unfollow') t('Unfollow')
) : ( ) : (
@ -146,7 +151,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
</AlertDialog> </AlertDialog>
) : ( ) : (
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}> <Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
{updating ? <Loader className="animate-spin" /> : t('Follow')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Follow')}
</Button> </Button>
) )
} }

14
src/components/GifPicker/index.tsx

@ -8,13 +8,14 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service' import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service'
import mediaUpload from '@/services/media-upload.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 { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -251,8 +252,15 @@ export default function GifPicker({
} }
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full min-h-[200px]"> <div
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-1 p-2"> <div className="grid grid-cols-2 gap-1 p-2">

24
src/components/LatestFromFollowsSection/index.tsx

@ -1,5 +1,6 @@
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS, FAST_WRITE_RELAY_URLS,
@ -19,7 +20,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import logger from '@/lib/logger' 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 { Event, kinds, nip19, NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -243,9 +244,9 @@ export default function LatestFromFollowsSection() {
if (loadingFollowList) { if (loadingFollowList) {
return ( return (
<div className="mb-6 flex items-center gap-2 text-sm text-muted-foreground"> <div className="mb-6 space-y-2" role="status" aria-busy="true" aria-live="polite">
<Loader2 className="size-4 animate-spin" /> <Skeleton className="h-4 w-56 max-w-full" />
{t('Loading follow list…')} <Skeleton className="h-4 w-72 max-w-full" />
</div> </div>
) )
} }
@ -266,7 +267,7 @@ export default function LatestFromFollowsSection() {
<span className="flex min-w-0 flex-1 items-center gap-2"> <span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold">{heading}</span> <span className="text-base font-semibold">{heading}</span>
{batchBusy && postsByPubkey.size === 0 ? ( {batchBusy && postsByPubkey.size === 0 ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : null} ) : null}
</span> </span>
<ChevronDown <ChevronDown
@ -276,9 +277,11 @@ export default function LatestFromFollowsSection() {
<CollapsibleContent className="overflow-hidden"> <CollapsibleContent className="overflow-hidden">
<div className="mt-2 space-y-0 rounded-lg border border-border/60 overflow-hidden"> <div className="mt-2 space-y-0 rounded-lg border border-border/60 overflow-hidden">
{batchBusy && postsByPubkey.size === 0 ? ( {batchBusy && postsByPubkey.size === 0 ? (
<div className="flex items-center gap-2 px-4 py-6 text-sm text-muted-foreground"> <div className="space-y-2 px-4 py-4" role="status" aria-busy="true" aria-live="polite">
<Loader2 className="size-4 animate-spin" /> <Skeleton className="h-3 w-64 max-w-full" />
{t('Loading recent posts from follows…')} {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div> </div>
) : null} ) : null}
{sortedRowPubkeys.map((pk) => { {sortedRowPubkeys.map((pk) => {
@ -298,9 +301,8 @@ export default function LatestFromFollowsSection() {
})} })}
</div> </div>
{batchBusy && postsByPubkey.size > 0 ? ( {batchBusy && postsByPubkey.size > 0 ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground px-1"> <div className="mt-2 px-1">
<Loader2 className="size-3 animate-spin" /> <Skeleton className="h-3 w-28" aria-hidden />
{t('Loading more…')}
</div> </div>
) : null} ) : null}
</CollapsibleContent> </CollapsibleContent>

11
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05' import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05'
@ -158,9 +159,11 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-muted-foreground font-semibold select-none">{t('Discovered Relays')}</div> <div className="text-muted-foreground font-semibold select-none">{t('Discovered Relays')}</div>
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="space-y-2 py-4" role="status" aria-busy="true" aria-live="polite">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> <p className="text-sm text-muted-foreground">{t('Discovering relays...')}</p>
{t('Discovering relays...')} {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div> </div>
</div> </div>
) )
@ -223,7 +226,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
> >
{isAdding ? ( {isAdding ? (
<> <>
<Loader2 className="mr-2 h-3 w-3 animate-spin" /> <Skeleton className="mr-2 size-3 shrink-0 rounded-sm" aria-hidden />
{t('Adding...')} {t('Adding...')}
</> </>
) : ( ) : (

3
src/components/MailboxSetting/SaveButton.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { createRelayListDraftEvent } from '@/lib/draft-event' import { createRelayListDraftEvent } from '@/lib/draft-event'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -73,7 +74,7 @@ export default function SaveButton({
return ( return (
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}> <Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />} {pushing ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <CloudUpload />}
{t('Save')} {t('Save')}
</Button> </Button>
) )

13
src/components/MuteButton/index.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { import {
DropdownMenu, DropdownMenu,
@ -69,7 +70,11 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={handleUnmute} onClick={handleUnmute}
disabled={updating || changing} disabled={updating || changing}
> >
{updating ? <Loader className="animate-spin" /> : <span className="text-destructive text-center">{t('Unmute')}</span>} {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
) : (
<span className="text-destructive text-center">{t('Unmute')}</span>
)}
</Button> </Button>
) )
} }
@ -80,7 +85,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-20 min-w-20 rounded-full" className="w-20 min-w-20 rounded-full"
disabled={updating || changing} disabled={updating || changing}
> >
{updating ? <Loader className="animate-spin" /> : t('Mute')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute')}
</Button> </Button>
) )
@ -96,7 +101,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={(e) => handleMute(e, true)} onClick={(e) => handleMute(e, true)}
disabled={updating || changing} disabled={updating || changing}
> >
{updating ? <Loader className="animate-spin" /> : t('Mute user privately')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user privately')}
</Button> </Button>
<Button <Button
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
@ -104,7 +109,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={(e) => handleMute(e, false)} onClick={(e) => handleMute(e, false)}
disabled={updating || changing} disabled={updating || changing}
> >
{updating ? <Loader className="animate-spin" /> : t('Mute user publicly')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user publicly')}
</Button> </Button>
</div> </div>
</DrawerContent> </DrawerContent>

5
src/components/Note/Poll.tsx

@ -7,7 +7,8 @@ import { cn, isPartiallyInViewport } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import pollResultsService from '@/services/poll-results.service' import pollResultsService from '@/services/poll-results.service'
import dayjs from 'dayjs' 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 { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -253,7 +254,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
disabled={!selectedOptionIds.length || isVoting} disabled={!selectedOptionIds.length || isVoting}
className="w-full" className="w-full"
> >
{isVoting && <Loader2 className="animate-spin" />} {isVoting && <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />}
{t('Vote')} {t('Vote')}
</Button> </Button>
)} )}

37
src/components/NoteList/index.tsx

@ -41,7 +41,6 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { Loader2 } from 'lucide-react'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100 // Increased from 200 to load more events per request 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 * {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
* (except Following). Refresh re-fetches. * (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[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -124,6 +127,8 @@ const NoteList = forwardRef(
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
oneShotFetch?: boolean oneShotFetch?: boolean
oneShotMergedCap?: number
revealBatchSize?: number
}, },
ref ref
) => { ) => {
@ -484,6 +489,7 @@ const NoteList = forwardRef(
if (!keepExistingTimelineEvents) { if (!keepExistingTimelineEvents) {
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
} }
setHasMore(true) setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh consecutiveEmptyRef.current = 0 // Reset counter on refresh
@ -549,9 +555,10 @@ const NoteList = forwardRef(
byId.set(ev.id, ev) byId.set(ev.id, ev)
} }
} }
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const merged = [...byId.values()] const merged = [...byId.values()]
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.slice(0, ONE_SHOT_MERGED_CAP) .slice(0, cap)
setEvents(merged) setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged lastEventsForTimelinePrefetchRef.current = merged
} catch { } catch {
@ -734,7 +741,9 @@ const NoteList = forwardRef(
showKind1111, showKind1111,
useFilterAsIs, useFilterAsIs,
areAlgoRelays, areAlgoRelays,
oneShotFetch oneShotFetch,
oneShotMergedCap,
revealBatchSize
]) ])
useEffect(() => { useEffect(() => {
@ -803,9 +812,9 @@ const NoteList = forwardRef(
// Show more events immediately if we have them cached // Show more events immediately if we have them cached
if (currentShowCount < currentEvents.length) { 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 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) setShowCount((prev) => prev + increment)
// Only preload more if we have plenty cached (more than 3/4 of LIMIT) // 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) // 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 // Schedule loadMore with a small delay to throttle rapid calls
loadMoreTimeoutRef.current = setTimeout(async () => { loadMoreTimeoutRef.current = setTimeout(async () => {
@ -948,8 +958,10 @@ const NoteList = forwardRef(
} }
const observerInstance = new IntersectionObserver((entries) => { const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef.current) { if (!entries[0].isIntersecting || loadingRef.current) return
// Throttle: only trigger if not already loading and not already scheduled const ev = eventsRef.current
const sc = showCountRef.current
if (sc < ev.length || hasMoreRef.current) {
loadMore() loadMore()
} }
}, options) }, options)
@ -1120,13 +1132,14 @@ const NoteList = forwardRef(
{events.length === 0 && loading ? ( {events.length === 0 && loading ? (
<div <div
ref={bottomRef} ref={bottomRef}
className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4 py-8" className="min-h-[40vh] space-y-2 px-1 py-4"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
> >
<Loader2 className="size-8 shrink-0 animate-spin text-muted-foreground" aria-hidden /> {Array.from({ length: 5 }).map((_, i) => (
<p className="text-sm text-muted-foreground">{t('Loading...')}</p> <NoteCardLoadingSkeleton key={i} />
))}
</div> </div>
) : events.length > 0 && (hasMore || loading) ? ( ) : events.length > 0 && (hasMore || loading) ? (
<div ref={bottomRef}> <div ref={bottomRef}>

4
src/components/NoteOptions/ReportDialog.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,7 +19,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { createReportDraftEvent } from '@/lib/draft-event' import { createReportDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -137,7 +137,7 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog:
handleReport() handleReport()
}} }}
> >
{reporting && <Loader className="animate-spin" />} {reporting && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}
{t('Report')} {t('Report')}
</Button> </Button>
</div> </div>

7
src/components/NoteStats/LikeButton.tsx

@ -4,6 +4,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
@ -15,7 +16,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react' import { SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -189,7 +190,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
}} }}
> >
{liking ? ( {liking ? (
<Loader className="animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji ? ( ) : myLastEmoji ? (
<> <>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} /> <Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} />
@ -224,7 +225,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
}} }}
> >
{liking && index === 0 ? ( {liking && index === 0 ? (
<Loader className="animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<> <>
<span className="text-base">{emoji}</span> <span className="text-base">{emoji}</span>

3
src/components/NoteStats/Likes.tsx

@ -1,4 +1,5 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
@ -175,7 +176,7 @@ export default function Likes({ event }: { event: Event }) {
)} )}
<div className="relative z-10 flex items-center gap-2"> <div className="relative z-10 flex items-center gap-2">
{liking === key ? ( {liking === key ? (
<Loader className="animate-spin size-4" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<div <div
style={{ style={{

5
src/components/NoteStats/RepostButton.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { import {
DropdownMenu, DropdownMenu,
@ -14,7 +15,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { Loader, PencilLine, Repeat } from 'lucide-react' import { PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -101,7 +102,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
} }
}} }}
> >
{reposting ? <Loader className="animate-spin" /> : <Repeat />} {reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />}
{!hideCount && !!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>} {!hideCount && !!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button> </button>
) )

5
src/components/NoteStats/ZapButton.tsx

@ -1,3 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { getLightningAddressFromProfile } from '@/lib/lightning' import { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -8,7 +9,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.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 { Event } from 'nostr-tools'
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react' import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -146,7 +147,7 @@ export default function ZapButton({ event, hideCount = false }: { event: Event;
onTouchEnd={handleClickEnd} onTouchEnd={handleClickEnd}
> >
{zapping ? ( {zapping ? (
<Loader className="animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} /> <Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)} )}

10
src/components/PostEditor/PostContent.tsx

@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -45,7 +46,6 @@ import { TPollCreateData } from '@/types'
import { import {
ImageUp, ImageUp,
ListTodo, ListTodo,
LoaderCircle,
MessageCircle, MessageCircle,
MessagesSquare, MessagesSquare,
Settings, Settings,
@ -2346,7 +2346,9 @@ export default function PostContent({
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button type="submit" disabled={!canPost} onClick={post}> <Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
{parentEvent ? t('Reply') : isPublicMessage ? t('Send Public Message') : t('Post')} {parentEvent ? t('Reply') : isPublicMessage ? t('Send Public Message') : t('Post')}
</Button> </Button>
</div> </div>
@ -2384,7 +2386,9 @@ export default function PostContent({
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}> <Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
{parentEvent ? t('Reply') : t('Post')} {parentEvent ? t('Reply') : t('Post')}
</Button> </Button>
</div> </div>

9
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 { nip19, type Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion' import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
@ -136,8 +137,10 @@ export function NeventNaddrPickerDialog({
<div className="min-h-[200px] max-h-[50vh] border rounded-md overflow-y-auto overflow-x-hidden"> <div className="min-h-[200px] max-h-[50vh] border rounded-md overflow-y-auto overflow-x-hidden">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="space-y-2 p-2" role="status" aria-busy="true" aria-live="polite">
<Loader2 className="h-6 w-6 animate-spin" /> {Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div> </div>
)} )}
{!loading && debouncedQuery && events.length === 0 && ( {!loading && debouncedQuery && events.length === 0 && (

4
src/components/Profile/Followings.tsx

@ -3,7 +3,7 @@ import { toFollowingList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function Followings({ pubkey }: { pubkey: string }) { export default function Followings({ pubkey }: { pubkey: string }) {
@ -20,7 +20,7 @@ export default function Followings({ pubkey }: { pubkey: string }) {
{accountPubkey === pubkey ? ( {accountPubkey === pubkey ? (
selfFollowings.length selfFollowings.length
) : isFetching ? ( ) : isFetching ? (
<Loader className="animate-spin size-4" /> <Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden />
) : ( ) : (
followings.length followings.length
)} )}

72
src/components/Profile/ProfileFeedWithPins.tsx

@ -117,7 +117,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) 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(() => { useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT) setShowCount(INITIAL_SHOW_COUNT)
@ -138,16 +149,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])
const displayedEvents = useMemo(
() => mergedDisplay.slice(0, showCount),
[mergedDisplay, showCount]
)
useEffect(() => { useEffect(() => {
if (!bottomRef.current || displayedEvents.length >= mergedDisplay.length) return if (!bottomRef.current || totalVisible >= mergedDisplay.length) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (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)) 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) observer.observe(bottomRef.current)
return () => observer.disconnect() return () => observer.disconnect()
}, [displayedEvents.length, mergedDisplay.length]) }, [totalVisible, mergedDisplay.length])
const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0
@ -210,29 +216,45 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
{searchQuery.trim() && ( {searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground"> <div className="px-4 py-2 text-sm text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', { {t('Showing {{filtered}} of {{total}} items', {
filtered: displayedEvents.length, filtered: totalVisible,
total: mergedDisplay.length total: mergedDisplay.length
})} })}
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{displayedEvents.map((event, index) => ( {displayedPins.length > 0 && (
<div key={event.id}> <div className="space-y-2" aria-label={t('Pinned posts')}>
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && ( {displayedPins.map((event) => (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2"> <NoteCard
{t('Feed')} key={event.id}
</div> className="w-full"
)} event={event}
<NoteCard filterMutedNotes={false}
className="w-full" pinned
event={event} />
filterMutedNotes={false} ))}
pinned={pinnedDisplayIds.has(event.id)} </div>
/> )}
{displayedPins.length > 0 && displayedFeed.length > 0 && (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2">
{t('Feed')}
</div>
)}
{displayedFeed.length > 0 && (
<div className="space-y-2" aria-label={t('Posts')}>
{displayedFeed.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
pinned={false}
/>
))}
</div> </div>
))} )}
</div> </div>
{displayedEvents.length < mergedDisplay.length && ( {totalVisible < mergedDisplay.length && (
<div ref={bottomRef} className="flex h-10 items-center justify-center"> <div ref={bottomRef} className="flex h-10 items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div> <div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div> </div>

128
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<TNoteListRef, { pubkey: string }>(({ 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<string[] | null>(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 (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t('Nothing to load for this feed.')}
</div>
)
}
if (profileRelayUrls === null) {
return (
<div
className="min-h-[min(40vh,320px)] space-y-2 px-1 py-4"
role="status"
aria-live="polite"
aria-busy="true"
>
{Array.from({ length: 4 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
)
}
if (!subRequests.length) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t('Nothing to load for this feed.')}
</div>
)
}
return (
<div className="min-h-[min(40vh,320px)] min-w-0">
<NoteList
ref={ref}
subRequests={subRequests}
feedSubscriptionKey={feedSubscriptionKey}
showKinds={showKinds}
useFilterAsIs
oneShotFetch
oneShotMergedCap={PROFILE_MEDIA_REQ_LIMIT}
revealBatchSize={20}
showKind1OPs
showKind1Replies
showKind1111
hideReplies={false}
/>
</div>
)
})
ProfileMediaFeed.displayName = 'ProfileMediaFeed'
export default ProfileMediaFeed

4
src/components/Profile/Relays.tsx

@ -1,8 +1,8 @@
import { useFetchRelayList } from '@/hooks' import { useFetchRelayList } from '@/hooks'
import { toOthersRelaySettings, toRelaySettings } from '@/lib/link' import { toOthersRelaySettings, toRelaySettings } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { Skeleton } from '@/components/ui/skeleton'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function Relays({ pubkey }: { pubkey: string }) { 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)} to={accountPubkey === pubkey ? toRelaySettings('mailbox') : toOthersRelaySettings(pubkey)}
className="flex gap-1 hover:underline w-fit items-center" className="flex gap-1 hover:underline w-fit items-center"
> >
{isFetching ? <Loader className="animate-spin size-4" /> : relayList.originalRelays.length} {isFetching ? <Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden /> : relayList.originalRelays.length}
<div className="text-muted-foreground">{t('Relays')}</div> <div className="text-muted-foreground">{t('Relays')}</div>
</SecondaryPageLink> </SecondaryPageLink>
) )

4
src/components/Profile/SmartFollowings.tsx

@ -3,7 +3,7 @@ import { toFollowingList } from '@/lib/link'
import { useSmartFollowingListNavigation } from '@/PageManager' import { useSmartFollowingListNavigation } from '@/PageManager'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function SmartFollowings({ pubkey }: { pubkey: string }) { export default function SmartFollowings({ pubkey }: { pubkey: string }) {
@ -25,7 +25,7 @@ export default function SmartFollowings({ pubkey }: { pubkey: string }) {
{accountPubkey === pubkey ? ( {accountPubkey === pubkey ? (
selfFollowings.length selfFollowings.length
) : isFetching ? ( ) : isFetching ? (
<Loader className="animate-spin size-4" /> <Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden />
) : ( ) : (
followings.length followings.length
)} )}

4
src/components/Profile/SmartRelays.tsx

@ -1,7 +1,7 @@
import { useFetchRelayList } from '@/hooks' import { useFetchRelayList } from '@/hooks'
import { toOthersRelaySettings } from '@/lib/link' import { toOthersRelaySettings } from '@/lib/link'
import { useSmartOthersRelaySettingsNavigation } from '@/PageManager' import { useSmartOthersRelaySettingsNavigation } from '@/PageManager'
import { Loader } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function SmartRelays({ pubkey }: { pubkey: string }) { 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" className="flex gap-1 hover:underline w-fit items-center cursor-pointer"
onClick={handleClick} onClick={handleClick}
> >
{isFetching ? <Loader className="animate-spin size-4" /> : relayList.originalRelays.length} {isFetching ? <Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden /> : relayList.originalRelays.length}
<div className="text-muted-foreground">{t('Relays')}</div> <div className="text-muted-foreground">{t('Relays')}</div>
</span> </span>
) )

52
src/components/Profile/index.tsx

@ -28,13 +28,24 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import ProfileFeedWithPins from './ProfileFeedWithPins' 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 SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink' import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays' import SmartRelays from './SmartRelays'
@ -166,6 +177,8 @@ export default function Profile({
const { navigate: navigatePrimary } = usePrimaryPage() const { navigate: navigatePrimary } = usePrimaryPage()
const internalFeedRef = useRef<{ refresh: () => void }>(null) const internalFeedRef = useRef<{ refresh: () => void }>(null)
const profileFeedRef = feedRef ?? internalFeedRef const profileFeedRef = feedRef ?? internalFeedRef
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(null)
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr() 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(() => { useEffect(() => {
if (!profile?.pubkey) return 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 if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker
const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile
logger.component('Profile', 'Profile data loaded', {
pubkey,
username,
hasProfile: !!profile,
isFetching,
id
})
return ( return (
<> <>
<div> <div>
@ -572,7 +593,18 @@ export default function Profile({
</div> </div>
</div> </div>
</div> </div>
<ProfileFeedWithPins ref={profileFeedRef} pubkey={pubkey} /> <Tabs defaultValue="posts" className="min-w-0">
<TabsList className="mb-2 ml-1 w-auto justify-start md:ml-4">
<TabsTrigger value="posts">{t('Posts')}</TabsTrigger>
<TabsTrigger value="media">{t('Media')}</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="min-w-0 focus-visible:outline-none">
<ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="media" className="min-w-0 focus-visible:outline-none">
<ProfileMediaFeed ref={mediaFeedRef} pubkey={pubkey} />
</TabsContent>
</Tabs>
{openPublicMessageTo && ( {openPublicMessageTo && (
<PostEditor <PostEditor
open={!!openPublicMessageTo} open={!!openPublicMessageTo}

5
src/components/RelayInfo/ReviewEditor.tsx

@ -1,8 +1,9 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { createRelayReviewDraftEvent } from '@/lib/draft-event' import { createRelayReviewDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader2, Star } from 'lucide-react' import { Star } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -81,7 +82,7 @@ export default function ReviewEditor({
variant={canSubmit ? 'default' : 'secondary'} variant={canSubmit ? 'default' : 'secondary'}
onClick={submit} onClick={submit}
> >
{submitting && <Loader2 className="animate-spin" />} {submitting && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}
{t('Submit')} {t('Submit')}
</Button> </Button>
</div> </div>

21
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 rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import { DEFAULT_RSS_FEEDS } from '@/constants' import { DEFAULT_RSS_FEEDS } from '@/constants'
import RssFeedItem from '../RssFeedItem' 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 logger from '@/lib/logger'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@ -511,9 +512,11 @@ export default function RssFeedList() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-12"> <div className="space-y-3 px-4 py-8" role="status" aria-busy="true" aria-live="polite">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" /> <p className="text-sm text-muted-foreground">{t('Loading RSS feeds...')}</p>
<p className="mt-4 text-sm text-muted-foreground">{t('Loading RSS feeds...')}</p> {Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</div> </div>
) )
} }
@ -651,9 +654,9 @@ export default function RssFeedList() {
<div className="space-y-4 px-4 py-3"> <div className="space-y-4 px-4 py-3">
<ManualRssUrlAddRow /> <ManualRssUrlAddRow />
{refreshing && ( {refreshing && (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-muted-foreground border-b"> <div className="flex items-center gap-2 border-b py-2" role="status" aria-busy="true">
<Loader className="h-4 w-4 animate-spin" /> <Skeleton className="h-4 w-4 shrink-0 rounded-sm" aria-hidden />
<span>{t('Refreshing feeds...')}</span> <Skeleton className="h-4 flex-1 max-w-[200px]" />
</div> </div>
)} )}
@ -672,8 +675,8 @@ export default function RssFeedList() {
))} ))}
{/* Bottom ref for infinite scroll */} {/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && ( {displayedItems.length < filteredItems.length && (
<div ref={bottomRef} className="flex items-center justify-center py-4"> <div ref={bottomRef} className="flex justify-center py-4">
<Loader className="h-4 w-4 animate-spin text-muted-foreground" /> <Skeleton className="h-8 w-8 rounded-md" aria-hidden />
</div> </div>
)} )}
</> </>

7
src/components/SaveRelayDropdownMenu/index.tsx

@ -15,12 +15,13 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types' 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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem' import DrawerMenuItem from '../DrawerMenuItem'
@ -268,7 +269,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) {
onClick={isLoading ? undefined : handleClick} onClick={isLoading ? undefined : handleClick}
className={isLoading ? 'opacity-50 cursor-not-allowed' : ''} className={isLoading ? 'opacity-50 cursor-not-allowed' : ''}
> >
{isLoading ? <Loader2 className="animate-spin" /> : <Ban />} {isLoading ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <Ban />}
{isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')}
</DrawerMenuItem> </DrawerMenuItem>
) )
@ -276,7 +277,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) {
return ( return (
<DropdownMenuItem onClick={handleClick} disabled={isLoading}> <DropdownMenuItem onClick={handleClick} disabled={isLoading}>
{isLoading ? <Loader2 className="animate-spin" /> : <Ban />} {isLoading ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <Ban />}
{isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')} {isLoading ? t('Processing...') : blocked ? t('Unblock') : t('Block')}
</DropdownMenuItem> </DropdownMenuItem>
) )

4
src/components/StartupSessionBanner.tsx

@ -1,6 +1,6 @@
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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' 'bg-background px-3 py-2 text-center text-sm text-muted-foreground'
)} )}
> >
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
<span> <span>
{t('startupSessionHydrating', { {t('startupSessionHydrating', {
defaultValue: 'Syncing your relays and profile from the network…' defaultValue: 'Syncing your relays and profile from the network…'

7
src/components/TopicSubscribeButton/index.tsx

@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useInterestList } from '@/providers/InterestListProvider' import { useInterestList } from '@/providers/InterestListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff, Loader2 } from 'lucide-react' import { Bell, BellOff } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface TopicSubscribeButtonProps { interface TopicSubscribeButtonProps {
@ -50,7 +51,7 @@ export default function TopicSubscribeButton({
title={subscribed ? t('Unsubscribe') : t('Subscribe')} title={subscribed ? t('Unsubscribe') : t('Subscribe')}
> >
{changing ? ( {changing ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : subscribed ? ( ) : subscribed ? (
<Bell className="h-4 w-4" fill="currentColor" /> <Bell className="h-4 w-4" fill="currentColor" />
) : ( ) : (
@ -70,7 +71,7 @@ export default function TopicSubscribeButton({
> >
{changing ? ( {changing ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
{subscribed ? t('Unsubscribing...') : t('Subscribing...')} {subscribed ? t('Unsubscribing...') : t('Subscribing...')}
</> </>
) : subscribed ? ( ) : subscribed ? (

5
src/components/TrendingNotes/index.tsx

@ -15,7 +15,8 @@ import noteStatsService from '@/services/note-stats.service'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' 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 SHOW_COUNT = 25
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
@ -395,7 +396,7 @@ export default function TrendingNotes({ variant = 'page' }: { variant?: Trending
<span className="flex min-w-0 flex-1 items-center gap-2"> <span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold leading-tight">{headerTitle}</span> <span className="text-base font-semibold leading-tight">{headerTitle}</span>
{cacheLoading && cacheEvents.length === 0 ? ( {cacheLoading && cacheEvents.length === 0 ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : null} ) : null}
</span> </span>
<ChevronDown <ChevronDown

3
src/components/VersionUpdateBanner/index.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { RefreshCw, X } from 'lucide-react' import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -121,7 +122,7 @@ export default function VersionUpdateBanner() {
> >
{isUpdating ? ( {isUpdating ? (
<> <>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" /> <Skeleton className="mr-2 size-4 shrink-0 rounded-sm" aria-hidden />
{t('Updating...')} {t('Updating...')}
</> </>
) : ( ) : (

5
src/components/ZapDialog/index.tsx

@ -14,13 +14,13 @@ import {
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -262,7 +262,8 @@ function ZapDialogContent({
{/* Zap button - fixed at bottom */} {/* Zap button - fixed at bottom */}
<div className="flex-shrink-0 bg-background pt-2 border-t border-border px-4" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}> <div className="flex-shrink-0 bg-background pt-2 border-t border-border px-4" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<Button onClick={handleZap} className="w-full"> <Button onClick={handleZap} className="w-full">
{zapping && <Loader className="animate-spin" />} {t('Zap n sats', { n: sats })} {zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })}
</Button> </Button>
</div> </div>
</div> </div>

32
src/hooks/useProfilePins.tsx

@ -1,13 +1,14 @@
import { useCallback, useEffect, useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { import {
buildProfilePageReadRelayUrls, buildProfilePageReadRelayUrls,
PROFILE_PAGE_PINS_RESOLVE_LIMIT PROFILE_PAGE_PINS_RESOLVE_LIMIT
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { useCallback, useEffect, useMemo, useState } from 'react'
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
@ -57,8 +58,18 @@ function orderPinEvents(pinList: Event, eventsById: Map<string, Event>): Event[]
return ordered 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) { export function useProfilePins(pubkey: string | undefined) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
)
const [pinEvents, setPinEvents] = useState<Event[]>([]) const [pinEvents, setPinEvents] = useState<Event[]>([])
const [loadingPins, setLoadingPins] = useState(false) const [loadingPins, setLoadingPins] = useState(false)
@ -84,6 +95,8 @@ export function useProfilePins(pubkey: string | undefined) {
read: [] as string[], read: [] as string[],
write: [] 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( const profileRelays = buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
@ -125,17 +138,18 @@ export function useProfilePins(pubkey: string | undefined) {
) )
} }
if (aTags.length > 0) { if (aTags.length > 0) {
const aTagFetches = aTags.map(async (aTag) => { const aTagFetches = aTags.map(async (aTagRaw) => {
const parts = aTag.split(':') const parts = aTagRaw.trim().split(':')
if (parts.length < 2) return null if (parts.length < 2) return null
const kind = parseInt(parts[0], 10) const kind = parseInt(parts[0], 10)
const author = parts[1] const author = parts[1]?.trim().toLowerCase()
const d = parts[2] || '' if (!Number.isFinite(kind) || !author || !/^[0-9a-f]{64}$/.test(author)) return null
const d = parts.slice(2).join(':')
const filter = d const filter = d
? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } ? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] }
: { authors: [author], kinds: [kind], limit: 1 } : { authors: [author], kinds: [kind], limit: 1 }
const events = await queryService.fetchEvents(profileRelays, [filter]) const events = await queryService.fetchEvents(profileRelays, filter)
return events[0] || null return events[0] ?? null
}) })
eventPromises.push( eventPromises.push(
Promise.all(aTagFetches).then((events) => events.filter((e): e is Event => e !== null)) 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) byId.set(e.id, e)
} }
const ordered = orderPinEvents(pinList, byId) const ordered = orderPinEvents(pinList, byId).slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT)
setPinEvents(ordered) setPinEvents(ordered)
pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() })
} catch (e) { } catch (e) {
@ -161,7 +175,7 @@ export function useProfilePins(pubkey: string | undefined) {
setLoadingPins(false) setLoadingPins(false)
} }
}, },
[pubkey, favoriteRelays, blockedRelays] [pubkey, relayListsKey, favoriteRelays, blockedRelays]
) )
useEffect(() => { useEffect(() => {

22
src/hooks/useProfileTimeline.tsx

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
type ProfileTimelineMemoryEntry = { type ProfileTimelineMemoryEntry = {
@ -82,6 +83,12 @@ function postProcessEvents(
return events.slice(0, limit) 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({ export function useProfileTimeline({
pubkey, pubkey,
cacheKey, cacheKey,
@ -90,6 +97,10 @@ export function useProfileTimeline({
filterPredicate filterPredicate
}: UseProfileTimelineOptions): UseProfileTimelineResult { }: UseProfileTimelineOptions): UseProfileTimelineResult {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
)
const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() const { isEventDeleted, tombstoneEpoch } = useDeletedEvent()
const isEventDeletedRef = useRef(isEventDeleted) const isEventDeletedRef = useRef(isEventDeleted)
isEventDeletedRef.current = isEventDeleted isEventDeletedRef.current = isEventDeleted
@ -216,16 +227,7 @@ export function useProfileTimeline({
subscriptionRef.current() subscriptionRef.current()
subscriptionRef.current = () => {} subscriptionRef.current = () => {}
} }
}, [ }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey])
pubkey,
cacheKey,
JSON.stringify(kinds),
limit,
filterPredicate,
refreshToken,
favoriteRelays,
blockedRelays
])
const refresh = useCallback(() => { const refresh = useCallback(() => {
subscriptionRef.current() subscriptionRef.current()

5
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_FEED_MAX_RELAYS = 6
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10 export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10

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

@ -9,8 +9,9 @@ import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Compass, Info, Loader2 } from 'lucide-react' import { Compass, Info } from 'lucide-react'
import React, { import React, {
Dispatch, Dispatch,
forwardRef, forwardRef,
@ -85,17 +86,19 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
if (!isReady) { if (!isReady) {
content = ( content = (
<div <div
className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4 text-center" className="min-h-[40vh] space-y-2 px-1 py-4"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
> >
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden /> <p className="px-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t('feedStarting', { {t('feedStarting', {
defaultValue: 'Starting feeds and relays… This can take a few seconds after login.' defaultValue: 'Starting feeds and relays… This can take a few seconds after login.'
})} })}
</p> </p>
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div> </div>
) )
} else if (feedInfo.feedType === 'following' && !pubkey) { } else if (feedInfo.feedType === 'following' && !pubkey) {

13
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_MAX_RELAYS = 6
export const FAUX_SPELL_EVENT_LIMIT = 200 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. * 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 } 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 { export function buildCalendarSpellFilter(): Filter {
return { return {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],

3
src/pages/secondary/MuteListPage/index.tsx

@ -2,6 +2,7 @@ import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05' import Nip05 from '@/components/Nip05'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
@ -113,7 +114,7 @@ function UserItem({ pubkey }: { pubkey: string }) {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{switching ? ( {switching ? (
<Button disabled variant="ghost" size="icon"> <Button disabled variant="ghost" size="icon">
<Loader className="animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
</Button> </Button>
) : muteType === 'private' ? ( ) : muteType === 'private' ? (
<Button <Button

3
src/pages/secondary/NotePage/NotFound.tsx

@ -1,5 +1,6 @@
import ClientSelect from '@/components/ClientSelect' import ClientSelect from '@/components/ClientSelect'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -229,7 +230,7 @@ export default function NotFound({
> >
{isSearchingExternal ? ( {isSearchingExternal ? (
<> <>
<Search className="w-4 h-4 animate-spin" /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
{t('Searching external relays...')} {t('Searching external relays...')}
</> </>
) : ( ) : (

9
src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx

@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
@ -9,7 +10,7 @@ import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { AlertCircle, ArrowUpToLine, Loader, X } from 'lucide-react' import { AlertCircle, ArrowUpToLine, X } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -147,7 +148,7 @@ export default function BlossomServerListSetting() {
disabled={removingIndex >= 0 || adding || movingIndex >= 0} disabled={removingIndex >= 0 || adding || movingIndex >= 0}
className="text-muted-foreground" className="text-muted-foreground"
> >
{movingIndex === idx ? <Loader className="animate-spin" /> : <ArrowUpToLine />} {movingIndex === idx ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <ArrowUpToLine />}
</Button> </Button>
) : ( ) : (
<Badge>{t('Preferred')}</Badge> <Badge>{t('Preferred')}</Badge>
@ -159,7 +160,7 @@ export default function BlossomServerListSetting() {
title={t('Remove')} title={t('Remove')}
disabled={removingIndex >= 0 || adding || movingIndex >= 0} disabled={removingIndex >= 0 || adding || movingIndex >= 0}
> >
{removingIndex === idx ? <Loader className="animate-spin" /> : <X />} {removingIndex === idx ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <X />}
</Button> </Button>
</div> </div>
</div> </div>
@ -180,7 +181,7 @@ export default function BlossomServerListSetting() {
}} }}
title={t('Add')} title={t('Add')}
> >
{adding && <Loader className="animate-spin" />} {adding && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />}
{t('Add')} {t('Add')}
</Button> </Button>
</div> </div>

15
src/pages/secondary/ProfileEditorPage/index.tsx

@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
@ -24,7 +25,7 @@ import { isEmail } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' 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 type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -299,11 +300,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
className="gap-1.5" className="gap-1.5"
title={t('Force-refresh profile and payment info from relays')} title={t('Force-refresh profile and payment info from relays')}
> >
{refreshingCache ? <Loader className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />} {refreshingCache ? <Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> : <RefreshCw className="h-3.5 w-3.5" />}
{t('Refresh cache')} {t('Refresh cache')}
</Button> </Button>
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}> <Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}>
{saving ? <Loader className="animate-spin" /> : t('Save')} {saving ? <Skeleton className="mx-auto h-4 w-12 rounded-md" aria-hidden /> : t('Save')}
</Button> </Button>
</div> </div>
) )
@ -319,7 +320,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
> >
<ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" /> <ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center"> <div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
{uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />} {uploadingBanner ? <Skeleton className="size-9 shrink-0 rounded-md" aria-hidden /> : <Upload size={36} />}
</div> </div>
</Uploader> </Uploader>
<Uploader <Uploader
@ -335,7 +336,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center"> <div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center">
{uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />} {uploadingAvatar ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <Upload />}
</div> </div>
</Uploader> </Uploader>
</div> </div>
@ -495,7 +496,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
disabled={savingFullProfile || !hasChanged} disabled={savingFullProfile || !hasChanged}
className="gap-2" className="gap-2"
> >
{savingFullProfile && <Loader className="h-4 w-4 animate-spin" />} {savingFullProfile && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />}
{savingFullProfile ? t('Saving…') : t('Save full profile')} {savingFullProfile ? t('Saving…') : t('Save full profile')}
</Button> </Button>
</CollapsibleContent> </CollapsibleContent>
@ -644,7 +645,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2"> <Button onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2">
{savingPaymentInfo && <Loader className="h-4 w-4 animate-spin" />} {savingPaymentInfo && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />}
{savingPaymentInfo ? t('Saving…') : t('Save')} {savingPaymentInfo ? t('Saving…') : t('Save')}
</Button> </Button>
</DialogFooter> </DialogFooter>

3
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -7,6 +7,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
@ -637,7 +638,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
disabled={pushing || !hasChange} disabled={pushing || !hasChange}
onClick={handleSave} onClick={handleSave}
> >
{pushing ? <Loader className="animate-spin mr-2" /> : <CloudUpload className="mr-2" />} {pushing ? <Skeleton className="mr-2 size-4 shrink-0 rounded-sm" aria-hidden /> : <CloudUpload className="mr-2" />}
{t('Save')} {t('Save')}
</Button> </Button>
</div> </div>

3
src/pages/secondary/WalletPage/LightningAddressInput.tsx

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { createProfileDraftEvent } from '@/lib/draft-event' import { createProfileDraftEvent } from '@/lib/draft-event'
@ -64,7 +65,7 @@ export default function LightningAddressInput() {
}} }}
/> />
<Button onClick={handleSave} disabled={saving || !hasChanged} className="w-20"> <Button onClick={handleSave} disabled={saving || !hasChanged} className="w-20">
{saving ? <Loader className="animate-spin" /> : t('Save')} {saving ? <Skeleton className="mx-auto h-4 w-10 rounded-md" aria-hidden /> : t('Save')}
</Button> </Button>
</div> </div>
</div> </div>

Loading…
Cancel
Save