Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
298f4f1659
  1. 6
      src/components/GifPicker/index.tsx
  2. 207
      src/components/HelpAndAccountMenu.tsx
  3. 10
      src/components/Profile/index.tsx
  4. 107
      src/components/Sidebar/AccountButton.tsx
  5. 6
      src/components/Sidebar/index.tsx
  6. 43
      src/components/Titlebar/AccountButton.tsx
  7. 10
      src/pages/primary/NoteListPage/index.tsx
  8. 58
      src/services/gif.service.ts

6
src/components/GifPicker/index.tsx

@ -270,6 +270,12 @@ export default function GifPicker({ @@ -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'
}}
/>

207
src/components/HelpAndAccountMenu.tsx

@ -0,0 +1,207 @@ @@ -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 (
<>
<DropdownMenuItem onClick={() => navigate('profile')}>
<User className="size-4" />
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('settings')}>
<Settings className="size-4" />
{t('Settings')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" />
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onLogoutClick}>
<LogOut className="size-4" />
{t('Logout')}
</DropdownMenuItem>
</>
)
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
title={t('Account menu')}
aria-label={t('Account menu')}
className={cn(
'clickable h-12 min-w-0 justify-start gap-2 rounded-lg bg-transparent p-2 text-lg font-semibold text-foreground shadow-none hover:text-accent-foreground',
'w-12 xl:w-full xl:px-2 xl:py-2',
active && 'bg-accent/50'
)}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
</AvatarFallback>
</Avatar>
<span className="truncate max-xl:hidden">{username}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="z-[220]">
<AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="titlebar-icon"
className={cn(active ? 'bg-accent/50' : '')}
title={t('Account menu')}
aria-label={t('Account menu')}
>
{profile ? (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={profile.avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
</AvatarFallback>
</Avatar>
) : (
<Skeleton className={cn('w-6 h-6 rounded-full', active ? 'ring-primary ring-1' : '')} />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="z-[220]">
<AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
</DropdownMenuContent>
</DropdownMenu>
)
}
/**
* 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' ? <KeyboardShortcutsHelpSidebarButton /> : <KeyboardShortcutsHelpButton />
let account: ReactNode
if (pubkey) {
account =
variant === 'sidebar' ? (
<SidebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
/>
) : (
<TitlebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
/>
)
} else if (variant === 'sidebar') {
account = (
<SidebarItem onClick={() => checkLogin()} title="Login">
<LogIn strokeWidth={3} />
</SidebarItem>
)
} else {
account = (
<Button variant="ghost" size="titlebar-icon" onClick={() => checkLogin()} title={t('Login')}>
<UserRound />
</Button>
)
}
const wrapClass =
variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2'
return (
<>
<div className={wrapClass}>
{help}
{account}
</div>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</>
)
}

10
src/components/Profile/index.tsx

@ -331,12 +331,12 @@ export default function Profile({ id }: { id?: string }) { @@ -331,12 +331,12 @@ export default function Profile({ id }: { id?: string }) {
<div>
<div className="relative bg-cover bg-center mb-2">
<Skeleton className="w-full aspect-[3/1] rounded-none" />
<Skeleton className="w-24 h-24 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
<Skeleton className="w-24 h-24 md:w-48 md:h-48 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
</div>
</div>
<div className="px-4">
<Skeleton className="h-5 w-28 mt-14 mb-1" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
<Skeleton className="h-5 w-28 mt-14 md:mt-28 mb-1 md:ml-56" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full md:ml-56" />
</div>
<div className="px-4 pt-4 flex items-center justify-center">
<div className="text-sm text-muted-foreground">
@ -364,7 +364,7 @@ export default function Profile({ id }: { id?: string }) { @@ -364,7 +364,7 @@ export default function Profile({ id }: { id?: string }) {
<div>
<div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
<Avatar className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultImage} />
@ -435,7 +435,7 @@ export default function Profile({ id }: { id?: string }) { @@ -435,7 +435,7 @@ export default function Profile({ id }: { id?: string }) {
</>
)}
</div>
<div className="pt-2">
<div className="pt-2 md:pl-56">
<div className="flex gap-2 items-center">
<div className="text-xl font-semibold truncate select-text">{username}</div>
{isFollowingYou && (

107
src/components/Sidebar/AccountButton.tsx

@ -1,107 +0,0 @@ @@ -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 <ProfileButton />
} else {
return <LoginButton />
}
}
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 (
<div className="flex w-full min-w-0 items-center gap-0.5 xl:gap-1">
<Button
type="button"
variant="ghost"
title={t('Profile')}
className="clickable h-12 min-w-0 flex-1 justify-start gap-2 rounded-lg bg-transparent p-2 text-lg font-semibold text-foreground shadow-none hover:text-accent-foreground xl:px-2 xl:py-2"
onClick={() => navigate('profile')}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
</AvatarFallback>
</Avatar>
<span className="truncate max-xl:hidden">{username}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 shrink-0 rounded-lg"
title={t('Account menu')}
aria-label={t('Account menu')}
>
<MoreVertical className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onClick={() => navigate('settings')}>
<Settings />
{t('Settings')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp />
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
<LogOut />
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</div>
)
}
function LoginButton() {
const { checkLogin } = useNostr()
return (
<SidebarItem onClick={() => checkLogin()} title="Login">
<LogIn strokeWidth={3} />
</SidebarItem>
)
}

6
src/components/Sidebar/index.tsx

@ -1,8 +1,7 @@ @@ -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() { @@ -39,8 +38,7 @@ export default function PrimaryPageSidebar() {
<PostButton />
</div>
<div className="space-y-2">
<KeyboardShortcutsHelpSidebarButton />
<AccountButton />
<HelpAndAccountMenu variant="sidebar" />
<PaneModeToggle />
</div>
</div>

43
src/components/Titlebar/AccountButton.tsx

@ -1,43 +0,0 @@ @@ -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 (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => navigate(pubkey ? 'profile' : 'me')}
className={active ? 'bg-accent/50' : ''}
>
{pubkey ? (
profile ? (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={profile.avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
) : (
<Skeleton className={cn('w-6 h-6 rounded-full', active ? 'ring-primary ring-1' : '')} />
)
) : (
<UserRound />
)}
</Button>
)
}

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

@ -20,8 +20,7 @@ import React, { @@ -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({ @@ -173,12 +172,7 @@ function NoteListPageTitlebar({
<Info />
</Button>
)}
{isSmallScreen && (
<>
<KeyboardShortcutsHelpButton />
<AccountButton />
</>
)}
{isSmallScreen && <HelpAndAccountMenu variant="titlebar" />}
</div>
</div>
)

58
src/services/gif.service.ts

@ -3,7 +3,7 @@ @@ -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 { @@ -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( @@ -198,37 +204,55 @@ export async function fetchGifs(
): Promise<GifMetadata[]> {
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<string>()
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( @@ -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())
}

Loading…
Cancel
Save