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. 54
      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. 50
      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 @@ @@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
@ -66,7 +67,7 @@ export default function AccountList({ @@ -66,7 +67,7 @@ export default function AccountList({
</div>
{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">
<Loader size={16} className="animate-spin" />
<Skeleton className="size-8 shrink-0 rounded-full" aria-hidden />
</div>
)}
</div>

5
src/components/AccountManager/NostrConnectionLogin.tsx

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Loader, ScanQrCode } from 'lucide-react'
import { Check, Copy, ScanQrCode } from 'lucide-react'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
import QrScanner from 'qr-scanner'
@ -238,7 +239,7 @@ export default function NostrConnectLogin({ @@ -238,7 +239,7 @@ export default function NostrConnectLogin({
</Button>
</div>
<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')}
</Button>
</div>

4
src/components/AccountManager/NpubLogin.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -45,7 +45,7 @@ export default function NpubLogin({ @@ -45,7 +45,7 @@ export default function NpubLogin({
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<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')}
</Button>
<Button variant="secondary" onClick={back}>

5
src/components/BookmarkButton/index.tsx

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

7
src/components/Bookstr/BookstrContent.tsx

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

17
src/components/CacheRelaysSetting/index.tsx

@ -29,8 +29,9 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' @@ -29,8 +29,9 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays'
import { createCacheRelaysDraftEvent } from '@/lib/draft-event'
import { getRelayListFromEvent } from '@/lib/event-metadata'
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 { Skeleton } from '@/components/ui/skeleton'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service'
@ -817,7 +818,7 @@ export default function CacheRelaysSetting() { @@ -817,7 +818,7 @@ export default function CacheRelaysSetting() {
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} />
<RelayCountWarning relays={relays} />
<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')}
</Button>
<DndContext
@ -935,8 +936,10 @@ export default function CacheRelaysSetting() { @@ -935,8 +936,10 @@ export default function CacheRelaysSetting() {
) : (
// Store items view
loadingItems ? (
<div className="flex items-center justify-center py-8">
<Loader className="animate-spin h-6 w-6" />
<div className="space-y-2 py-6" role="status" aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : (
<>
@ -1115,8 +1118,10 @@ export default function CacheRelaysSetting() { @@ -1115,8 +1118,10 @@ export default function CacheRelaysSetting() {
// Store items view
<>
{loadingItems ? (
<div className="flex items-center justify-center py-8">
<Loader className="animate-spin h-6 w-6" />
<div className="space-y-2 py-6" role="status" aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : (
<>

5
src/components/Embedded/EmbeddedLNInvoice.tsx

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAmount, getAmountFromInvoice } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import lightning from '@/services/lightning.service'
import { Loader, Zap } from 'lucide-react'
import { Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -53,7 +54,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla @@ -53,7 +54,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla
{formatAmount(amount)} {t('sats')}
</div>
<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')}
</Button>
</div>

2
src/components/Embedded/EmbeddedNote.tsx

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

8
src/components/Explore/ExploreRelayReviews.tsx

@ -8,7 +8,6 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel @@ -8,7 +8,6 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { Loader2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -142,12 +141,13 @@ export default function ExploreRelayReviews() { @@ -142,12 +141,13 @@ export default function ExploreRelayReviews() {
</div>
{loading ? (
<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-live="polite"
>
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden />
{t('Loading...')}
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-lg border md:border" />
))}
</div>
) : null}
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null}

5
src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx

@ -3,8 +3,9 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -3,8 +3,9 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import { Input } from '../ui/input'
import { Loader2, Check } from 'lucide-react'
import { Check } from 'lucide-react'
import logger from '@/lib/logger'
export default function AddBlockedRelay() {
@ -74,7 +75,7 @@ export default function AddBlockedRelay() { @@ -74,7 +75,7 @@ export default function AddBlockedRelay() {
<Button onClick={saveRelay} disabled={isLoading || !input.trim()}>
{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...')}
</>
) : (

5
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -1,10 +1,11 @@ @@ -1,10 +1,11 @@
import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { X, Loader2 } from 'lucide-react'
import { X } from 'lucide-react'
import { useState } from 'react'
import RelayIcon from '../RelayIcon'
import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import logger from '@/lib/logger'
export default function BlockedRelayItem({ relay }: { relay: string }) {
@ -43,7 +44,7 @@ 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"
>
{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" />
)}

11
src/components/FollowButton/index.tsx

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

14
src/components/GifPicker/index.tsx

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

24
src/components/LatestFromFollowsSection/index.tsx

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

11
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05'
@ -158,9 +159,11 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: @@ -158,9 +159,11 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
return (
<div className="space-y-2">
<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">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t('Discovering relays...')}
<div className="space-y-2 py-4" role="status" aria-busy="true" aria-live="polite">
<p className="text-sm text-muted-foreground">{t('Discovering relays...')}</p>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
</div>
)
@ -223,7 +226,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: @@ -223,7 +226,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
>
{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...')}
</>
) : (

3
src/components/MailboxSetting/SaveButton.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { createRelayListDraftEvent } from '@/lib/draft-event'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { useNostr } from '@/providers/NostrProvider'
@ -73,7 +74,7 @@ export default function SaveButton({ @@ -73,7 +74,7 @@ export default function SaveButton({
return (
<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')}
</Button>
)

13
src/components/MuteButton/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
DropdownMenu,
@ -69,7 +70,11 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -69,7 +70,11 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={handleUnmute}
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>
)
}
@ -80,7 +85,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -80,7 +85,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-20 min-w-20 rounded-full"
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>
)
@ -96,7 +101,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -96,7 +101,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={(e) => handleMute(e, true)}
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
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 }) { @@ -104,7 +109,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
onClick={(e) => handleMute(e, false)}
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>
</div>
</DrawerContent>

5
src/components/Note/Poll.tsx

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

37
src/components/NoteList/index.tsx

@ -41,7 +41,6 @@ import PullToRefresh from 'react-simple-pull-to-refresh' @@ -41,7 +41,6 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import type { TProfile } from '@/types'
import { Loader2 } from 'lucide-react'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100 // Increased from 200 to load more events per request
@ -101,7 +100,11 @@ const NoteList = forwardRef( @@ -101,7 +100,11 @@ const NoteList = forwardRef(
* {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
* (except Following). Refresh re-fetches.
*/
oneShotFetch = false
oneShotFetch = false,
/** Max events kept after merging one-shot REQ batches (default 100). */
oneShotMergedCap,
/** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then 2× per step). */
revealBatchSize
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@ -124,6 +127,8 @@ const NoteList = forwardRef( @@ -124,6 +127,8 @@ const NoteList = forwardRef(
spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
oneShotFetch?: boolean
oneShotMergedCap?: number
revealBatchSize?: number
},
ref
) => {
@ -484,6 +489,7 @@ const NoteList = forwardRef( @@ -484,6 +489,7 @@ const NoteList = forwardRef(
if (!keepExistingTimelineEvents) {
setEvents([])
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
}
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
@ -549,9 +555,10 @@ const NoteList = forwardRef( @@ -549,9 +555,10 @@ const NoteList = forwardRef(
byId.set(ev.id, ev)
}
}
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
const merged = [...byId.values()]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, ONE_SHOT_MERGED_CAP)
.slice(0, cap)
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
} catch {
@ -734,7 +741,9 @@ const NoteList = forwardRef( @@ -734,7 +741,9 @@ const NoteList = forwardRef(
showKind1111,
useFilterAsIs,
areAlgoRelays,
oneShotFetch
oneShotFetch,
oneShotMergedCap,
revealBatchSize
])
useEffect(() => {
@ -803,9 +812,9 @@ const NoteList = forwardRef( @@ -803,9 +812,9 @@ const NoteList = forwardRef(
// Show more events immediately if we have them cached
if (currentShowCount < currentEvents.length) {
// Show more aggressively: increase by SHOW_COUNT, but also check if we should show even more
const remaining = currentEvents.length - currentShowCount
const increment = Math.min(SHOW_COUNT * 2, remaining) // Show up to 2x SHOW_COUNT if available
const step = revealBatchSize ?? SHOW_COUNT * 2
const increment = Math.min(step, remaining)
setShowCount((prev) => prev + increment)
// Only preload more if we have plenty cached (more than 3/4 of LIMIT)
// BUT: Always try to load more if we have very few events (might be due to filtering)
@ -819,7 +828,8 @@ const NoteList = forwardRef( @@ -819,7 +828,8 @@ const NoteList = forwardRef(
}
}
if (!currentTimelineKey || currentLoading || !currentHasMore) return
const canLoadFromTimeline = !!currentTimelineKey && currentHasMore
if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return
// Schedule loadMore with a small delay to throttle rapid calls
loadMoreTimeoutRef.current = setTimeout(async () => {
@ -948,8 +958,10 @@ const NoteList = forwardRef( @@ -948,8 +958,10 @@ const NoteList = forwardRef(
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef.current) {
// Throttle: only trigger if not already loading and not already scheduled
if (!entries[0].isIntersecting || loadingRef.current) return
const ev = eventsRef.current
const sc = showCountRef.current
if (sc < ev.length || hasMoreRef.current) {
loadMore()
}
}, options)
@ -1120,13 +1132,14 @@ const NoteList = forwardRef( @@ -1120,13 +1132,14 @@ const NoteList = forwardRef(
{events.length === 0 && loading ? (
<div
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"
aria-live="polite"
aria-busy="true"
>
<Loader2 className="size-8 shrink-0 animate-spin text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">{t('Loading...')}</p>
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
) : events.length > 0 && (hasMore || loading) ? (
<div ref={bottomRef}>

4
src/components/NoteOptions/ReportDialog.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
@ -18,7 +19,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' @@ -18,7 +19,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { createReportDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -137,7 +137,7 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog: @@ -137,7 +137,7 @@ function ReportContent({ event, closeDialog }: { event: NostrEvent; closeDialog:
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')}
</Button>
</div>

7
src/components/NoteStats/LikeButton.tsx

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

3
src/components/NoteStats/Likes.tsx

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

5
src/components/NoteStats/RepostButton.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
@ -14,7 +15,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -14,7 +15,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -101,7 +102,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -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>}
</button>
)

5
src/components/NoteStats/ZapButton.tsx

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

10
src/components/PostEditor/PostContent.tsx

@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
@ -45,7 +46,6 @@ import { TPollCreateData } from '@/types' @@ -45,7 +46,6 @@ import { TPollCreateData } from '@/types'
import {
ImageUp,
ListTodo,
LoaderCircle,
MessageCircle,
MessagesSquare,
Settings,
@ -2346,7 +2346,9 @@ export default function PostContent({ @@ -2346,7 +2346,9 @@ export default function PostContent({
{t('Cancel')}
</Button>
<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')}
</Button>
</div>
@ -2384,7 +2386,9 @@ export default function PostContent({ @@ -2384,7 +2386,9 @@ export default function PostContent({
{t('Cancel')}
</Button>
<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')}
</Button>
</div>

9
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

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

4
src/components/Profile/Followings.tsx

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

54
src/components/Profile/ProfileFeedWithPins.tsx

@ -117,7 +117,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -117,7 +117,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
const pinnedDisplayIds = useMemo(() => new Set(filteredPins.map((e) => e.id)), [filteredPins])
/** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */
const displayedPins = useMemo(() => {
if (filteredPins.length <= showCount) return filteredPins
return filteredPins.slice(0, showCount)
}, [filteredPins, showCount])
const displayedFeed = useMemo(
() => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)),
[filteredRest, showCount, displayedPins.length]
)
const totalVisible = displayedPins.length + displayedFeed.length
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
@ -138,16 +149,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -138,16 +149,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])
const displayedEvents = useMemo(
() => mergedDisplay.slice(0, showCount),
[mergedDisplay, showCount]
)
useEffect(() => {
if (!bottomRef.current || displayedEvents.length >= mergedDisplay.length) return
if (!bottomRef.current || totalVisible >= mergedDisplay.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && displayedEvents.length < mergedDisplay.length) {
if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length))
}
},
@ -155,7 +161,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -155,7 +161,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
)
observer.observe(bottomRef.current)
return () => observer.disconnect()
}, [displayedEvents.length, mergedDisplay.length])
}, [totalVisible, mergedDisplay.length])
const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0
@ -210,29 +216,45 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -210,29 +216,45 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: displayedEvents.length,
filtered: totalVisible,
total: mergedDisplay.length
})}
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event, index) => (
<div key={event.id}>
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && (
{displayedPins.length > 0 && (
<div className="space-y-2" aria-label={t('Pinned posts')}>
{displayedPins.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
pinned
/>
))}
</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={pinnedDisplayIds.has(event.id)}
pinned={false}
/>
</div>
))}
</div>
{displayedEvents.length < mergedDisplay.length && (
)}
</div>
{totalVisible < mergedDisplay.length && (
<div ref={bottomRef} className="flex h-10 items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div>

128
src/components/Profile/ProfileMediaFeed.tsx

@ -0,0 +1,128 @@ @@ -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 @@ @@ -1,8 +1,8 @@
import { useFetchRelayList } from '@/hooks'
import { toOthersRelaySettings, toRelaySettings } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { Skeleton } from '@/components/ui/skeleton'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function Relays({ pubkey }: { pubkey: string }) {
@ -15,7 +15,7 @@ export default function Relays({ pubkey }: { pubkey: string }) { @@ -15,7 +15,7 @@ export default function Relays({ pubkey }: { pubkey: string }) {
to={accountPubkey === pubkey ? toRelaySettings('mailbox') : toOthersRelaySettings(pubkey)}
className="flex gap-1 hover:underline w-fit items-center"
>
{isFetching ? <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>
</SecondaryPageLink>
)

4
src/components/Profile/SmartFollowings.tsx

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

4
src/components/Profile/SmartRelays.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { useFetchRelayList } from '@/hooks'
import { toOthersRelaySettings } from '@/lib/link'
import { useSmartOthersRelaySettingsNavigation } from '@/PageManager'
import { Loader } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
export default function SmartRelays({ pubkey }: { pubkey: string }) {
@ -19,7 +19,7 @@ export default function SmartRelays({ pubkey }: { pubkey: string }) { @@ -19,7 +19,7 @@ export default function SmartRelays({ pubkey }: { pubkey: string }) {
className="flex gap-1 hover:underline w-fit items-center cursor-pointer"
onClick={handleClick}
>
{isFetching ? <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>
</span>
)

50
src/components/Profile/index.tsx

@ -28,13 +28,24 @@ import { @@ -28,13 +28,24 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type Ref } from 'react'
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type Ref
} from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import logger from '@/lib/logger'
import NotFound from '../NotFound'
import FollowedBy from './FollowedBy'
import ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileMediaFeed from './ProfileMediaFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList'
import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
@ -166,6 +177,8 @@ export default function Profile({ @@ -166,6 +177,8 @@ export default function Profile({
const { navigate: navigatePrimary } = usePrimaryPage()
const internalFeedRef = useRef<{ refresh: () => void }>(null)
const profileFeedRef = feedRef ?? internalFeedRef
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(null)
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
@ -323,6 +336,21 @@ export default function Profile({ @@ -323,6 +336,21 @@ export default function Profile({
})
}
useLayoutEffect(() => {
const r = profileFeedRef
if (typeof r === 'function') return
const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = {
refresh: () => {
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
}
}
return () => {
m.current = null
}
}, [])
useEffect(() => {
if (!profile?.pubkey) return
@ -362,13 +390,6 @@ export default function Profile({ @@ -362,13 +390,6 @@ export default function Profile({
const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile
logger.component('Profile', 'Profile data loaded', {
pubkey,
username,
hasProfile: !!profile,
isFetching,
id
})
return (
<>
<div>
@ -572,7 +593,18 @@ export default function Profile({ @@ -572,7 +593,18 @@ export default function Profile({
</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 && (
<PostEditor
open={!!openPublicMessageTo}

5
src/components/RelayInfo/ReviewEditor.tsx

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { createRelayReviewDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { Loader2, Star } from 'lucide-react'
import { Star } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -81,7 +82,7 @@ export default function ReviewEditor({ @@ -81,7 +82,7 @@ export default function ReviewEditor({
variant={canSubmit ? 'default' : 'secondary'}
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')}
</Button>
</div>

21
src/components/RssFeedList/index.tsx

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

7
src/components/SaveRelayDropdownMenu/index.tsx

@ -15,12 +15,13 @@ import { @@ -15,12 +15,13 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TRelaySet } from '@/types'
import { Ban, Check, FolderPlus, Loader2, Plus, Star } from 'lucide-react'
import { Ban, Check, FolderPlus, Plus, Star } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem'
@ -268,7 +269,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) { @@ -268,7 +269,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) {
onClick={isLoading ? undefined : handleClick}
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')}
</DrawerMenuItem>
)
@ -276,7 +277,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) { @@ -276,7 +277,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) {
return (
<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')}
</DropdownMenuItem>
)

4
src/components/StartupSessionBanner.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { useNostr } from '@/providers/NostrProvider'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -36,7 +36,7 @@ export default function StartupSessionBanner() { @@ -36,7 +36,7 @@ export default function StartupSessionBanner() {
'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>
{t('startupSessionHydrating', {
defaultValue: 'Syncing your relays and profile from the network…'

7
src/components/TopicSubscribeButton/index.tsx

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

5
src/components/TrendingNotes/index.tsx

@ -15,7 +15,8 @@ import noteStatsService from '@/services/note-stats.service' @@ -15,7 +15,8 @@ import noteStatsService from '@/services/note-stats.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { ChevronDown, Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { ChevronDown } from 'lucide-react'
const SHOW_COUNT = 25
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
@ -395,7 +396,7 @@ export default function TrendingNotes({ variant = 'page' }: { variant?: Trending @@ -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="text-base font-semibold leading-tight">{headerTitle}</span>
{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}
</span>
<ChevronDown

3
src/components/VersionUpdateBanner/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -121,7 +122,7 @@ export default function VersionUpdateBanner() { @@ -121,7 +122,7 @@ export default function VersionUpdateBanner() {
>
{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...')}
</>
) : (

5
src/components/ZapDialog/index.tsx

@ -14,13 +14,13 @@ import { @@ -14,13 +14,13 @@ import {
DrawerTitle
} from '@/components/ui/drawer'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service'
import { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -262,7 +262,8 @@ function ZapDialogContent({ @@ -262,7 +262,8 @@ function ZapDialogContent({
{/* 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)' }}>
<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>
</div>
</div>

32
src/hooks/useProfilePins.tsx

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

22
src/hooks/useProfileTimeline.tsx

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

5
src/lib/favorites-feed-relays.ts

@ -111,7 +111,10 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( @@ -111,7 +111,10 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
})
}
/** Profile page pins + feed: author's NIP-65 read/write, then favorites, then fast-read defaults, capped. */
/**
* Profile page pins + feed: viewed author's NIP-65 read + write (REQ tier 1), then logged-in user's favorites,
* then fast-read defaults from constants, deduped and blocked-stripped, capped at this count.
*/
export const PROFILE_PAGE_FEED_MAX_RELAYS = 6
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10

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

@ -9,8 +9,9 @@ import { useFeed } from '@/providers/FeedProvider' @@ -9,8 +9,9 @@ import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types'
import { Compass, Info, Loader2 } from 'lucide-react'
import { Compass, Info } from 'lucide-react'
import React, {
Dispatch,
forwardRef,
@ -85,17 +86,19 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -85,17 +86,19 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
if (!isReady) {
content = (
<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"
aria-live="polite"
aria-busy="true"
>
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">
<p className="px-3 text-sm text-muted-foreground">
{t('feedStarting', {
defaultValue: 'Starting feeds and relays… This can take a few seconds after login.'
})}
</p>
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
)
} 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' @@ -18,6 +18,9 @@ import { type Event, type Filter, kinds } from 'nostr-tools'
export const FAUX_SPELL_MAX_RELAYS = 6
export const FAUX_SPELL_EVENT_LIMIT = 200
/** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */
export const PROFILE_MEDIA_REQ_LIMIT = 200
/**
* Trim relay lists and filter limits (and bookmark `ids`) so faux feeds stay cheap to open.
*/
@ -110,6 +113,16 @@ export function buildMediaSpellFilter(): Filter { @@ -110,6 +113,16 @@ export function buildMediaSpellFilter(): Filter {
return { kinds: [...MEDIA_SPELL_KINDS], limit: FAUX_SPELL_EVENT_LIMIT }
}
/** Media kinds for a single profile (same as {@link MEDIA_SPELL_KINDS}, scoped by `authors`). */
export function buildProfileMediaSpellFilter(pubkey: string): Filter {
const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim()
return {
authors: [pk],
kinds: [...MEDIA_SPELL_KINDS],
limit: PROFILE_MEDIA_REQ_LIMIT
}
}
export function buildCalendarSpellFilter(): Filter {
return {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],

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

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

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

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

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

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

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

@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button' @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey'
@ -24,7 +25,7 @@ import { isEmail } from '@/lib/utils' @@ -24,7 +25,7 @@ import { isEmail } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, Loader, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import { ChevronDown, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -299,11 +300,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -299,11 +300,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
className="gap-1.5"
title={t('Force-refresh profile and payment info from relays')}
>
{refreshingCache ? <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')}
</Button>
<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>
</div>
)
@ -319,7 +320,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -319,7 +320,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
>
<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">
{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>
</Uploader>
<Uploader
@ -335,7 +336,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -335,7 +336,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</AvatarFallback>
</Avatar>
<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>
</Uploader>
</div>
@ -495,7 +496,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -495,7 +496,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
disabled={savingFullProfile || !hasChanged}
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')}
</Button>
</CollapsibleContent>
@ -644,7 +645,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -644,7 +645,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{t('Cancel')}
</Button>
<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')}
</Button>
</DialogFooter>

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

@ -7,6 +7,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react' @@ -7,6 +7,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
@ -637,7 +638,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -637,7 +638,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
disabled={pushing || !hasChange}
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')}
</Button>
</div>

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createProfileDraftEvent } from '@/lib/draft-event'
@ -64,7 +65,7 @@ export default function LightningAddressInput() { @@ -64,7 +65,7 @@ export default function LightningAddressInput() {
}}
/>
<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>
</div>
</div>

Loading…
Cancel
Save