diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 87aaabbf..3f0076db 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -270,6 +270,12 @@ export default function GifPicker({ loading="lazy" onError={(e) => { const el = e.target as HTMLImageElement + const fallback = gif.fallbackUrl?.trim() + if (fallback && el.dataset.gifFallbackTried !== '1') { + el.dataset.gifFallbackTried = '1' + el.src = fallback + return + } el.style.display = 'none' }} /> diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx new file mode 100644 index 00000000..5727b1af --- /dev/null +++ b/src/components/HelpAndAccountMenu.tsx @@ -0,0 +1,207 @@ +import LoginDialog from '@/components/LoginDialog' +import LogoutDialog from '@/components/LogoutDialog' +import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' +import KeyboardShortcutsHelpSidebarButton from '@/components/Sidebar/KeyboardShortcutsHelpSidebarButton' +import SidebarItem from '@/components/Sidebar/SidebarItem' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Skeleton } from '@/components/ui/skeleton' +import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { cn } from '@/lib/utils' +import { usePrimaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' +import { useMemo, useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' + +export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' + +function AccountDropdownItems({ + onSwitchAccount, + onLogoutClick +}: { + onSwitchAccount: () => void + onLogoutClick: () => void +}) { + const { t } = useTranslation() + const { navigate } = usePrimaryPage() + + return ( + <> + navigate('profile')}> + + {t('Profile')} + + navigate('settings')}> + + {t('Settings')} + + + + + {t('Switch account')} + + + + {t('Logout')} + + + ) +} + +function SidebarAccountMenu({ + onSwitchAccount, + onLogoutClick +}: { + onSwitchAccount: () => void + onLogoutClick: () => void +}) { + const { t } = useTranslation() + const { account, profile } = useNostr() + const { current, display } = usePrimaryPage() + const pubkey = account?.pubkey + const active = useMemo(() => current === 'profile' && display, [display, current]) + + if (!pubkey) return null + + const defaultAvatar = generateImageByPubkey(pubkey) + const npub = pubkeyToNpub(pubkey) + const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) + const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar } + + return ( + + + + + + + + + ) +} + +function TitlebarAccountMenu({ + onSwitchAccount, + onLogoutClick +}: { + onSwitchAccount: () => void + onLogoutClick: () => void +}) { + const { t } = useTranslation() + const { profile } = useNostr() + const { current, display } = usePrimaryPage() + const defaultAvatar = useMemo( + () => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), + [profile] + ) + const active = useMemo(() => current === 'profile' && display, [display, current]) + + return ( + + + + + + + + + ) +} + +/** + * Help (?) + account avatar with the same dropdown on sidebar (desktop) and titlebar (mobile). + */ +export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { + const { t } = useTranslation() + const { pubkey, checkLogin } = useNostr() + const [loginDialogOpen, setLoginDialogOpen] = useState(false) + const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) + + const help = + variant === 'sidebar' ? : + + let account: ReactNode + if (pubkey) { + account = + variant === 'sidebar' ? ( + setLoginDialogOpen(true)} + onLogoutClick={() => setLogoutDialogOpen(true)} + /> + ) : ( + setLoginDialogOpen(true)} + onLogoutClick={() => setLogoutDialogOpen(true)} + /> + ) + } else if (variant === 'sidebar') { + account = ( + checkLogin()} title="Login"> + + + ) + } else { + account = ( + + ) + } + + const wrapClass = + variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2' + + return ( + <> +
+ {help} + {account} +
+ + + + ) +} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 1b10d215..3e543f73 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -331,12 +331,12 @@ export default function Profile({ id }: { id?: string }) {
- +
- - + +
@@ -364,7 +364,7 @@ export default function Profile({ id }: { id?: string }) {
- + @@ -435,7 +435,7 @@ export default function Profile({ id }: { id?: string }) { )}
-
+
{username}
{isFollowingYou && ( diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx deleted file mode 100644 index 96cd200b..00000000 --- a/src/components/Sidebar/AccountButton.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' -import { usePrimaryPage } from '@/PageManager' -import { useNostr } from '@/providers/NostrProvider' -import { ArrowDownUp, LogIn, LogOut, MoreVertical, Settings } from 'lucide-react' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import LoginDialog from '../LoginDialog' -import LogoutDialog from '../LogoutDialog' -import SidebarItem from './SidebarItem' - -export default function AccountButton() { - const { pubkey } = useNostr() - - if (pubkey) { - return - } else { - return - } -} - -function ProfileButton() { - const { t } = useTranslation() - const { account, profile } = useNostr() - const pubkey = account?.pubkey - const { navigate } = usePrimaryPage() - const [loginDialogOpen, setLoginDialogOpen] = useState(false) - const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) - if (!pubkey) return null - - const defaultAvatar = generateImageByPubkey(pubkey) - const npub = pubkeyToNpub(pubkey) - const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) - const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar } - - return ( -
- - - - - - - navigate('settings')}> - - {t('Settings')} - - - setLoginDialogOpen(true)}> - - {t('Switch account')} - - setLogoutDialogOpen(true)} - > - - {t('Logout')} - - - - - -
- ) -} - -function LoginButton() { - const { checkLogin } = useNostr() - - return ( - checkLogin()} title="Login"> - - - ) -} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 95787c04..0d2b72a8 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -1,8 +1,7 @@ import Icon from '@/assets/Icon' import Logo from '@/assets/Logo' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import AccountButton from './AccountButton' -import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarButton' +import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import DiscussionsButton from './DiscussionsButton' import FeedButton from './FeedButton' import HomeButton from './HomeButton' @@ -39,8 +38,7 @@ export default function PrimaryPageSidebar() {
- - +
diff --git a/src/components/Titlebar/AccountButton.tsx b/src/components/Titlebar/AccountButton.tsx deleted file mode 100644 index 849c0b15..00000000 --- a/src/components/Titlebar/AccountButton.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { generateImageByPubkey } from '@/lib/pubkey' -import { cn } from '@/lib/utils' -import { usePrimaryPage } from '@/PageManager' -import { useNostr } from '@/providers/NostrProvider' -import { UserRound } from 'lucide-react' -import { useMemo } from 'react' - -export default function AccountButton() { - const { navigate, current, display } = usePrimaryPage() - const { pubkey, profile } = useNostr() - const defaultAvatar = useMemo( - () => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), - [profile] - ) - const active = useMemo(() => current === 'profile' && display, [display, current]) - - return ( - - ) -} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index b0e540f1..50eb10ed 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -20,8 +20,7 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' -import AccountButton from '@/components/Titlebar/AccountButton' +import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import FollowingFeed from './FollowingFeed' import RelaysFeed from './RelaysFeed' import { usePrimaryNoteView } from '@/PageManager' @@ -173,12 +172,7 @@ function NoteListPageTitlebar({ )} - {isSmallScreen && ( - <> - - - - )} + {isSmallScreen && }
) diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index 54951b2c..c08c501a 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -3,7 +3,7 @@ * Same approach as aitherboard for 1063; for 1/1111 we parse content and tags for .gif URLs. */ -import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' +import { ExtendedKind, FAST_READ_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { kinds } from 'nostr-tools' import type { Event as NEvent } from 'nostr-tools' @@ -181,6 +181,12 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { } const CACHE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes; cache lives in IndexedDB +/** Partial fetches (timeouts, relay issues) used to get cached as-is and hide the grid for 5 minutes. */ +const MIN_GIF_CACHE_ENTRIES = 8 + +/** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */ +const THECITADEL_FOR_GIF_METADATA = + normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' /** * Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays. @@ -198,37 +204,55 @@ export async function fetchGifs( ): Promise { if (!forceRefresh && !searchQuery) { const cached = await indexedDb.getGifCache() - if (cached && cached.gifs.length > 0 && Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS) { + if ( + cached && + cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && + Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS + ) { return cached.gifs.slice(0, limit) as GifMetadata[] } } + // GIF-focused relays often fail (e.g. gifbuddy/damus down); merge fast read indexers so kind 1063 / GIF notes still resolve. const readUrls = [ ...GIF_RELAY_URLS, - ...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter(Boolean) + ...FAST_READ_RELAY_URLS, + ...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter((u): u is string => !!u) ] const seen = new Set() - const dedupedUrls = readUrls.filter((u) => { - const n = u.toLowerCase() - if (seen.has(n)) return false - seen.add(n) - return true - }) + const dedupedUrls = readUrls + .map((u) => normalizeUrl(u) || u) + .filter(Boolean) + .filter((u) => { + const n = u.toLowerCase() + if (seen.has(n)) return false + seen.add(n) + return true + }) + + const fetchOpts = { eoseTimeout: 20000, globalTimeout: 28000 } - const fetchOpts = { eoseTimeout: 10000, globalTimeout: 15000 } + const limit1063 = Math.max(limit * 15, 400) + const limitNotes = Math.max(limit * 15, 500) - // Two separate requests so kind 1063 isn't overwhelmed by the volume of kind 1/1111 + const relays1063 = dedupedUrls.some( + (u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase() + ) + ? dedupedUrls + : [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] + + // Kind 1063 (incl. thecitadel) + kind 1/1111 on the broad list (thecitadel omitted for kind 1 via KIND_1_BLOCKED). const [events1063, eventsNotes] = await Promise.all([ - queryService.fetchEvents( - dedupedUrls, - { kinds: [ExtendedKind.FILE_METADATA], limit: Math.max(limit * 10, 200) }, + queryService.fetchEvents( + relays1063, + { kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, fetchOpts ), - queryService.fetchEvents( + queryService.fetchEvents( dedupedUrls, { kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT], - limit: Math.max(limit * 10, 300) + limit: limitNotes }, fetchOpts ) @@ -263,7 +287,7 @@ export async function fetchGifs( gifs.sort((a, b) => b.createdAt - a.createdAt) const result = gifs.slice(0, limit) - if (result.length > 0 && !searchQuery) { + if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) { await indexedDb.setGifCache(result, Date.now()) }