Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
616b6bbc92
  1. 51
      src/PageManager.tsx
  2. 58
      src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx
  3. 139
      src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx
  4. 12
      src/components/DrawerMenuItem/index.tsx
  5. 13
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  6. 98
      src/components/FavoriteRelaysActiveStrip/index.tsx
  7. 26
      src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts
  8. 54
      src/components/HelpAndAccountMenu.tsx
  9. 12
      src/components/NoteOptions/DesktopMenu.tsx
  10. 66
      src/components/NoteOptions/MobileMenu.tsx
  11. 25
      src/components/NoteOptions/index.tsx
  12. 19
      src/components/NoteStats/RepostButton.tsx
  13. 14
      src/components/NoteStats/SeenOnButton.tsx
  14. 28
      src/components/NoteStats/index.tsx
  15. 8
      src/hooks/useSeenOnRelays.ts
  16. 28
      src/layouts/PrimaryPageLayout/index.tsx
  17. 6
      src/layouts/SecondaryPageLayout/index.tsx
  18. 7
      src/lib/home-feed-relays.ts
  19. 5
      src/lib/nostr-land-relay-eligibility.ts
  20. 5
      src/pages/primary/NoteListPage/index.tsx
  21. 23
      src/services/note-stats.service.ts

51
src/PageManager.tsx

@ -502,10 +502,8 @@ export function useSmartNoteNavigation() {
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) { if (isSmallScreen) {
// Mobile: always push to secondary stack AND update drawer // Mobile: full-screen secondary stack (no sheet drawer — overlay hid the stack and showed black).
// This ensures back button works when clicking embedded events
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
openDrawer(noteId, event)
} else { } else {
// Desktop: check panel mode // Desktop: check panel mode
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
@ -567,7 +565,6 @@ export function useSmartNoteNavigationOptional() {
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) { if (isSmallScreen) {
push(contextualUrl) push(contextualUrl)
openDrawer(noteId, event)
} else { } else {
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
@ -1217,12 +1214,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Drawer handlers // Drawer handlers
const [drawerInitialEvent, setDrawerInitialEvent] = useState<Event | null>(null) const [drawerInitialEvent, setDrawerInitialEvent] = useState<Event | null>(null)
const openDrawer = useCallback((noteId: string, initialEvent?: Event) => { const openDrawer = useCallback((noteId: string, initialEvent?: Event) => {
// Mobile uses the full-screen secondary stack; the sheet drawer only applies to desktop single-pane.
if (isSmallScreen || panelMode !== 'single') return
noteStatsService.setBackgroundStatsPaused(true) noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
setDrawerNoteId(noteId) setDrawerNoteId(noteId)
setDrawerInitialEvent(initialEvent ?? null) setDrawerInitialEvent(initialEvent ?? null)
setDrawerOpen(true) setDrawerOpen(true)
}, []) }, [isSmallScreen, panelMode])
const closeDrawer = useCallback(() => { const closeDrawer = useCallback(() => {
if (!drawerOpen) return // Already closed if (!drawerOpen) return // Already closed
@ -1361,9 +1360,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Open drawer immediately, then load background page asynchronously // Open drawer immediately, then load background page asynchronously
// This prevents the background page loading from blocking the drawer // This prevents the background page loading from blocking the drawer
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Seed stack so in-drawer navigation (e.g. quotes → back) can pop to this note // Seed stack so in-note navigation (e.g. quotes → back) can pop to this note
pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name)) pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name))
openDrawer(noteId) if (!isSmallScreen) {
openDrawer(noteId)
}
setTimeout(() => { setTimeout(() => {
setCurrentPrimaryPage(resolved.name) setCurrentPrimaryPage(resolved.name)
@ -1384,7 +1385,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
pushNoteUrlOnStack(contextualUrl) pushNoteUrlOnStack(contextualUrl)
openDrawer(noteId) if (!isSmallScreen) {
openDrawer(noteId)
}
return return
} else { } else {
pushNoteUrlOnStack(contextualUrl) pushNoteUrlOnStack(contextualUrl)
@ -1672,7 +1675,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
window.location.pathname + window.location.search + window.location.hash window.location.pathname + window.location.search + window.location.hash
if (locUrl !== '/' && locUrl !== '') { if (locUrl !== '/' && locUrl !== '') {
const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl) const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl)
if ((isSmallScreen || panelMode === 'single') && drawerOpen && drawerNoteId && synced.length > 0) { if ((panelMode === 'single' && !isSmallScreen) && drawerOpen && drawerNoteId && synced.length > 0) {
const topItemUrl = synced[synced.length - 1]?.url const topItemUrl = synced[synced.length - 1]?.url
if (topItemUrl) { if (topItemUrl) {
const topNoteUrlMatch = const topNoteUrlMatch =
@ -1787,8 +1790,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) { if (noteId) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Single-pane / mobile: align stack with history (returning `pre` left stale UI). if (!isSmallScreen) {
openDrawer(noteId) openDrawer(noteId)
}
const built = findAndCreateComponent(state.url, state.index) const built = findAndCreateComponent(state.url, state.index)
if (built.component) { if (built.component) {
return [ return [
@ -1834,7 +1838,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} else if (newStack.length > 0) { } else if (newStack.length > 0) {
// Stack still has items - update drawer to show the top item's note (for mobile/single-pane) // Stack still has items - update drawer to show the top item's note (for mobile/single-pane)
// Only update drawer if drawer is currently open (not in the process of closing) // Only update drawer if drawer is currently open (not in the process of closing)
if ((isSmallScreen || panelMode === 'single') && drawerOpen && drawerNoteId) { if (panelMode === 'single' && !isSmallScreen && drawerOpen && drawerNoteId) {
// Extract noteId from top item's URL or from state.url // Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) { if (topItemUrl) {
@ -2153,7 +2157,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
const syncDrawerToSecondaryStackTop = (stack: TStackItem[]) => { const syncDrawerToSecondaryStackTop = (stack: TStackItem[]) => {
if (!(isSmallScreen || panelMode === 'single')) return if (isSmallScreen || panelMode !== 'single') return
const top = stack[stack.length - 1] const top = stack[stack.length - 1]
if (!top) return if (!top) return
const noteId = noteHexIdFromSecondaryNoteUrl(top.url) const noteId = noteHexIdFromSecondaryNoteUrl(top.url)
@ -2234,10 +2238,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
popSecondaryPageRef.current = popSecondaryPage popSecondaryPageRef.current = popSecondaryPage
const mobileSecondaryPanelOpen = const mobileSecondaryPanelOpen =
isSmallScreen && isSmallScreen && secondaryStack.length > 0 && !primaryNoteView
secondaryStack.length > 0 &&
!primaryNoteView &&
!(drawerOpen && drawerNoteId)
useMobileSwipeBackOnElement(mobileSecondaryPanelOpen ? mobileSecondarySwipeRoot : null, () => useMobileSwipeBackOnElement(mobileSecondaryPanelOpen ? mobileSecondarySwipeRoot : null, () =>
popSecondaryPageRef.current() popSecondaryPageRef.current()
, { , {
@ -2352,7 +2353,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
) : ( ) : (
<> <>
{secondaryStack.length > 0 && !(drawerOpen && drawerNoteId) ? ( {secondaryStack.length > 0 ? (
<div <div
ref={setMobileSecondarySwipeRoot} ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y" className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"
@ -2368,20 +2369,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</> </>
)} )}
</div> </div>
{drawerNoteId && (
<NoteDrawer
open={drawerOpen}
initialEvent={drawerInitialEvent}
onOpenChange={(open) => {
if (open) {
setDrawerOpen(true)
return
}
hardCloseSecondaryPanel()
}}
noteId={drawerNoteId}
/>
)}
<Suspense fallback={null}> <Suspense fallback={null}>
<BottomNavigationBarLazy /> <BottomNavigationBarLazy />
</Suspense> </Suspense>

58
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -0,0 +1,58 @@
import { useSmartRelayNavigation } from '@/PageManager'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
function rowMuted(connected: boolean) {
return !connected
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
function rowClass(connected: boolean) {
return cn(rowMuted(connected) && 'opacity-45 text-muted-foreground')
}
/** Relay list block for account (or similar) dropdown menus. */
export function ActiveRelaysDropdownSection() {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows, connectedCount } = useRelayConnectionRows()
if (rows.length === 0) return null
const countSummary = `${connectedCount}/${rows.length}`
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 text-xs font-normal">
<span>{t('Active relays')}</span>
<span className="tabular-nums text-muted-foreground">{countSummary}</span>
</DropdownMenuLabel>
{rows.map(({ url, connected }) => (
<DropdownMenuItem
key={url}
title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
className={cn('min-w-52 gap-2', rowClass(connected))}
>
<RelayIcon url={url} />
{simplifyUrl(url)}
</DropdownMenuItem>
))}
</>
)
}

139
src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx

@ -1,139 +0,0 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Server } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
function rowMuted(connected: boolean) {
return !connected
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
/**
* Server icon + menu listing relays with an open WebSocket in the pool.
*/
export function ActiveRelaysTitlebarButton() {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows, connectedCount } = useRelayConnectionRows()
const [drawerOpen, setDrawerOpen] = useState(false)
const countSummary =
rows.length > 0 ? `${connectedCount}/${rows.length}` : ''
const trigger = (
<Button
variant="ghost"
size="titlebar-icon"
className={cn(
'shrink-0 text-muted-foreground hover:text-primary disabled:opacity-40',
!isSmallScreen && rows.length > 0 && 'gap-0.5'
)}
title={countSummary ? `${t('Active relays')} (${countSummary})` : t('Active relays')}
aria-label={
countSummary ? `${t('Active relays')} (${countSummary})` : t('Active relays')
}
disabled={rows.length === 0}
onClick={() => {
if (isSmallScreen) setDrawerOpen(true)
}}
>
<Server className="size-5 shrink-0" />
{!isSmallScreen && rows.length > 0 ? (
<span className="text-xs tabular-nums leading-none">
<span className="text-foreground">{connectedCount}</span>
<span className="text-muted-foreground">/{rows.length}</span>
</span>
) : null}
</Button>
)
const rowClass = (connected: boolean) =>
cn(rowMuted(connected) && 'opacity-45 text-muted-foreground')
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer handleOnly open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerOverlay onClick={() => setDrawerOpen(false)} />
<DrawerContent
hideOverlay
dragHandle="vaul"
className="flex max-h-[min(85dvh,32rem)] flex-col gap-0"
>
<DrawerHeader className="border-b border-border/60 px-4 pb-3 pt-1 text-left">
<DrawerTitle className="text-base">{t('Active relays')}</DrawerTitle>
{rows.length > 0 ? (
<p className="mt-1.5 text-sm tabular-nums text-muted-foreground">
<span className="font-semibold text-foreground">{connectedCount}</span>
<span>/</span>
<span>{rows.length}</span>
</p>
) : null}
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 py-2 pb-4">
{rows.map(({ url, connected }) => (
<Button
className={cn('h-auto w-full justify-start gap-3 p-4 text-base', rowClass(connected))}
variant="ghost"
key={url}
title={rowTitle(url, connected, t)}
onClick={() => {
setDrawerOpen(false)
setTimeout(() => navigateToRelay(toRelay(url)), 50)
}}
>
<RelayIcon url={url} />
{simplifyUrl(url)}
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('Active relays')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{rows.map(({ url, connected }) => (
<DropdownMenuItem
key={url}
title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
className={cn('min-w-52 gap-2', rowClass(connected))}
>
<RelayIcon url={url} />
{simplifyUrl(url)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

12
src/components/DrawerMenuItem/index.tsx

@ -2,6 +2,16 @@ import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/drawer' import { DrawerClose } from '@/components/ui/drawer'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** Large-font / accessibility: wrap labels, top-align icons, scrollable sheet padding. */
export const drawerMenuButtonClassName =
'flex h-auto min-h-0 w-full items-start justify-start gap-3 whitespace-normal px-4 py-3 text-left text-base leading-snug [&_svg]:size-5 [&_svg]:shrink-0'
export const drawerMenuContentClassName =
'flex max-h-[min(90dvh,calc(100dvh-1rem))] flex-col overflow-hidden'
export const drawerMenuScrollClassName =
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 pt-2 pb-[calc(1.25rem+env(safe-area-inset-bottom,0px))]'
export default function DrawerMenuItem({ export default function DrawerMenuItem({
children, children,
className, className,
@ -15,7 +25,7 @@ export default function DrawerMenuItem({
<DrawerClose className="w-full"> <DrawerClose className="w-full">
<Button <Button
onClick={onClick} onClick={onClick}
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)} className={cn(drawerMenuButtonClassName, className)}
variant="ghost" variant="ghost"
> >
{children} {children}

13
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -18,6 +18,7 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { useRelativePastPhrase } from '@/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { Users } from 'lucide-react' import { Users } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -124,9 +125,12 @@ export function RelayPulseActiveNpubsSheet() {
followPubkeys, followPubkeys,
otherPubkeys, otherPubkeys,
profileKind0ByPubkey, profileKind0ByPubkey,
profilesLoading profilesLoading,
lastFetchedAtMs
} = useFavoriteRelaysActivity() } = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
const followWithProfile = useMemo( const followWithProfile = useMemo(
() => () =>
followPubkeys.filter( followPubkeys.filter(
@ -148,8 +152,13 @@ export function RelayPulseActiveNpubsSheet() {
side="right" side="right"
className="flex h-full max-h-[100dvh] w-full flex-col overflow-hidden sm:max-w-md" className="flex h-full max-h-[100dvh] w-full flex-col overflow-hidden sm:max-w-md"
> >
<SheetHeader className="shrink-0 text-left"> <SheetHeader className="shrink-0 space-y-1 text-left">
<SheetTitle>{t('Relay pulse active npubs')}</SheetTitle> <SheetTitle>{t('Relay pulse active npubs')}</SheetTitle>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="text-xs text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<SheetDescription>{t('Relay pulse active npubs hint')}</SheetDescription> <SheetDescription>{t('Relay pulse active npubs hint')}</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-3"> <div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-3">

98
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,50 +1,25 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet'
import type { TFunction } from 'i18next'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
function relativePastPhrase(timestampMs: number, t: TFunction): string { export { relativePastPhrase, useRelativePastPhrase } from './relay-pulse-relative-time'
const sec = Math.floor((Date.now() - timestampMs) / 1000)
if (sec < 45) return t('just now')
const min = Math.floor(sec / 60)
if (min < 60) return t('n minutes ago', { n: min })
const h = Math.floor(min / 60)
if (h < 48) return t('n hours ago', { n: h })
const d = Math.floor(h / 24)
return t('n days ago', { n: d })
}
function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string {
const [tick, setTick] = useState(0)
useEffect(() => {
if (timestampMs == null) return
const id = window.setInterval(() => setTick((x) => x + 1), 30_000)
return () => clearInterval(id)
}, [timestampMs])
return useMemo(() => {
if (timestampMs == null) return ''
return relativePastPhrase(timestampMs, t)
}, [timestampMs, t, tick])
}
/** Home feed / mobile: full label above the page title */ /** Home feed / mobile: compact row above the page title (no section label — opens sheet for detail). */
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) { if (!relayActivityReady && !loading) {
return ( return (
<div <div
className={cn( className={cn(
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4 animate-pulse', 'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4 animate-pulse',
className className
)} )}
aria-hidden
> >
<p className="text-xs font-medium text-foreground">{t('Relay pulse')}</p> <div className="ml-auto h-7 w-28 rounded-md bg-muted/50" />
</div> </div>
) )
} }
@ -53,19 +28,11 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
return ( return (
<div <div
className={cn( className={cn(
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/20 px-3 py-2 sm:px-4', 'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/20 px-3 py-1.5 sm:px-4',
className className
)} )}
> >
<p className="text-xs font-medium text-foreground">{t('Relay pulse')}</p> <p className="text-xs text-muted-foreground leading-snug">{t('Relay pulse empty')}</p>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="mt-0.5 text-[0.65rem] text-muted-foreground">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground leading-snug">
{t('Relay pulse empty')}
</p>
</div> </div>
) )
} }
@ -73,24 +40,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
return ( return (
<div <div
className={cn( className={cn(
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4', 'flex w-full min-w-0 max-w-full items-center justify-end border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4',
loading && 'animate-pulse', loading && 'animate-pulse',
className className
)} )}
> >
<div className="flex w-full min-w-0 flex-col gap-1.5"> <RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0 max-w-full" />
<div className="flex min-w-0 max-w-full items-center justify-between gap-2">
<div className="flex min-w-0 shrink items-center gap-2">
<p className="text-xs font-medium leading-tight text-foreground">{t('Relay pulse')}</p>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0" />
</div>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
</div>
</div>
</div> </div>
) )
} }
@ -98,21 +53,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
/** Desktop sidebar: compact row under nav */ /** Desktop sidebar: compact row under nav */
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) { if (!relayActivityReady && !loading) {
return ( return (
<div <div className={cn('px-1 py-2 xl:px-0 animate-pulse', className)}>
className={cn( <p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p>
'px-1 py-2 xl:px-0 animate-pulse',
className
)}
>
<p className="text-[0.65rem] font-medium leading-snug text-foreground">
{t('Relay pulse')}
</p>
<div className="mt-0.5 h-4 w-16 rounded bg-muted/50" aria-hidden /> <div className="mt-0.5 h-4 w-16 rounded bg-muted/50" aria-hidden />
</div> </div>
) )
@ -125,11 +71,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
<p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p> <p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p>
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> <RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</div> </div>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="mt-0.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<p className="mt-1 px-1 text-[0.65rem] leading-snug text-muted-foreground"> <p className="mt-1 px-1 text-[0.65rem] leading-snug text-muted-foreground">
{t('Relay pulse empty')} {t('Relay pulse empty')}
</p> </p>
@ -138,13 +79,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
} }
return ( return (
<div <div className={cn('px-1 py-2 xl:px-0', loading && 'animate-pulse', className)}>
className={cn(
'px-1 py-2 xl:px-0',
loading && 'animate-pulse',
className
)}
>
<div className="max-xl:hidden mb-0.5 flex flex-wrap items-center gap-1 px-1"> <div className="max-xl:hidden mb-0.5 flex flex-wrap items-center gap-1 px-1">
<p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground"> <p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground">
{t('Relay pulse')} {t('Relay pulse')}
@ -153,11 +88,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> <RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</div> </div>
</div> </div>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="max-xl:hidden mb-1.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<div className="mb-1 flex justify-center gap-0.5 xl:hidden"> <div className="mb-1 flex justify-center gap-0.5 xl:hidden">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" /> <RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
</div> </div>

26
src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts

@ -0,0 +1,26 @@
import type { TFunction } from 'i18next'
import { useEffect, useMemo, useState } from 'react'
export function relativePastPhrase(timestampMs: number, t: TFunction): string {
const sec = Math.floor((Date.now() - timestampMs) / 1000)
if (sec < 45) return t('just now')
const min = Math.floor(sec / 60)
if (min < 60) return t('n minutes ago', { n: min })
const h = Math.floor(min / 60)
if (h < 48) return t('n hours ago', { n: h })
const d = Math.floor(h / 24)
return t('n days ago', { n: d })
}
export function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string {
const [tick, setTick] = useState(0)
useEffect(() => {
if (timestampMs == null) return
const id = window.setInterval(() => setTick((x) => x + 1), 30_000)
return () => clearInterval(id)
}, [timestampMs])
return useMemo(() => {
if (timestampMs == null) return ''
return relativePastPhrase(timestampMs, t)
}, [timestampMs, t, tick])
}

54
src/components/HelpAndAccountMenu.tsx

@ -20,20 +20,27 @@ import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ActiveRelaysDropdownSection } from '@/components/ConnectedRelays/ActiveRelaysDropdownSection'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const titlebarAccountMenuContentClassName =
'z-[220] max-h-[min(85dvh,32rem)] w-72 overflow-y-auto overscroll-contain'
export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({ function AccountDropdownItems({
onSwitchAccount, onSwitchAccount,
onLogoutClick, onLogoutClick,
onBrowseCache onBrowseCache,
showActiveRelays = false
}: { }: {
onSwitchAccount: () => void onSwitchAccount: () => void
onLogoutClick: () => void onLogoutClick: () => void
onBrowseCache: () => void onBrowseCache: () => void
showActiveRelays?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
@ -52,6 +59,7 @@ function AccountDropdownItems({
<Database className="size-4" /> <Database className="size-4" />
{t('Browse Cache')} {t('Browse Cache')}
</DropdownMenuItem> </DropdownMenuItem>
{showActiveRelays ? <ActiveRelaysDropdownSection /> : null}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}> <DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" /> <ArrowDownUp className="size-4" />
@ -178,20 +186,50 @@ function TitlebarAccountMenu({
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="z-[220]"> <DropdownMenuContent align="end" side="bottom" className={titlebarAccountMenuContentClassName}>
<AccountDropdownItems <AccountDropdownItems
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
showActiveRelays
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
} }
function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) {
const { t } = useTranslation()
const { rows } = useRelayConnectionRows()
if (rows.length === 0) {
return (
<Button variant="ghost" size="titlebar-icon" onClick={onLogin} title={t('Login')}>
<UserRound />
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="titlebar-icon" title={t('Login')} aria-label={t('Login')}>
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className={titlebarAccountMenuContentClassName}>
<DropdownMenuItem onClick={onLogin}>
<LogIn className="size-4" />
{t('Login')}
</DropdownMenuItem>
<ActiveRelaysDropdownSection />
</DropdownMenuContent>
</DropdownMenu>
)
}
/** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */ /** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => { const onBrowseCache = useCallback(() => {
@ -218,18 +256,14 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
/> />
) )
} else if (variant === 'sidebar') { } else if (variant === 'titlebar') {
account = <LoggedOutTitlebarMenu onLogin={() => checkLogin()} />
} else {
account = ( account = (
<SidebarItem onClick={() => checkLogin()} title="Login"> <SidebarItem onClick={() => checkLogin()} title="Login">
<LogIn strokeWidth={3} /> <LogIn strokeWidth={3} />
</SidebarItem> </SidebarItem>
) )
} else {
account = (
<Button variant="ghost" size="titlebar-icon" onClick={() => checkLogin()} title={t('Login')}>
<UserRound />
</Button>
)
} }
const wrapClass = const wrapClass =

12
src/components/NoteOptions/DesktopMenu.tsx

@ -18,6 +18,8 @@ interface DesktopMenuProps {
menuActions: MenuAction[] menuActions: MenuAction[]
trigger: React.ReactNode trigger: React.ReactNode
header?: React.ReactNode header?: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
} }
function filterSubMenuRows( function filterSubMenuRows(
@ -137,10 +139,16 @@ const MenuContent = memo(
) )
MenuContent.displayName = 'MenuContent' MenuContent.displayName = 'MenuContent'
export function DesktopMenu({ menuActions, trigger, header }: DesktopMenuProps) { export function DesktopMenu({ menuActions, trigger, header, open, onOpenChange }: DesktopMenuProps) {
const [subMenuFilter, setSubMenuFilter] = useState('') const [subMenuFilter, setSubMenuFilter] = useState('')
return ( return (
<DropdownMenu onOpenChange={(open) => !open && setSubMenuFilter('')}> <DropdownMenu
open={open}
onOpenChange={(nextOpen) => {
onOpenChange?.(nextOpen)
if (!nextOpen) setSubMenuFilter('')
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto p-0"> <DropdownMenuContent className="max-h-[50vh] overflow-y-auto p-0">
{header} {header}

66
src/components/NoteOptions/MobileMenu.tsx

@ -1,5 +1,10 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
drawerMenuButtonClassName,
drawerMenuContentClassName,
drawerMenuScrollClassName
} from '@/components/DrawerMenuItem'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
@ -31,6 +36,29 @@ function filterSubMenuRows(
return items.filter((s) => !s.filterHaystack || s.filterHaystack.includes(q)) return items.filter((s) => !s.filterHaystack || s.filterHaystack.includes(q))
} }
function MobileMenuActionButton({
icon: Icon,
label,
className,
onClick
}: {
icon: MenuAction['icon']
label: React.ReactNode
className?: string
onClick?: () => void
}) {
return (
<Button
onClick={() => onClick?.()}
className={cn(drawerMenuButtonClassName, className)}
variant="ghost"
>
<Icon />
<span className="min-w-0 flex-1 text-left">{label}</span>
</Button>
)
}
export function MobileMenu({ export function MobileMenu({
menuActions, menuActions,
trigger, trigger,
@ -59,39 +87,38 @@ export function MobileMenu({
{trigger} {trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={closeDrawer} /> <DrawerOverlay onClick={closeDrawer} />
<DrawerContent hideOverlay className="max-h-[80vh]"> <DrawerContent hideOverlay className={drawerMenuContentClassName}>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Options</DrawerTitle> <DrawerTitle>Options</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="overflow-y-auto overscroll-contain py-2" style={{ touchAction: 'pan-y' }}> <div
className={drawerMenuScrollClassName}
style={{ touchAction: 'pan-y' }}
>
{!showSubMenu ? ( {!showSubMenu ? (
<> <>
{header} {header}
{menuActions.map((action, index) => { {menuActions.map((action, index) => {
const Icon = action.icon const Icon = action.icon
return ( return (
<Button <MobileMenuActionButton
key={index} key={index}
icon={Icon}
label={action.label}
className={action.className}
onClick={action.onClick} onClick={action.onClick}
className={`w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 ${action.className || ''}`} />
variant="ghost"
>
<Icon />
{action.label}
</Button>
) )
})} })}
</> </>
) : ( ) : (
<> <>
<Button <MobileMenuActionButton
icon={ArrowLeft}
label={subMenuTitle}
className="mb-2"
onClick={goBackToMainMenu} onClick={goBackToMainMenu}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2" />
variant="ghost"
>
<ArrowLeft />
{subMenuTitle}
</Button>
<div className="border-t border-border mb-2" /> <div className="border-t border-border mb-2" />
{subMenuSearchable ? ( {subMenuSearchable ? (
<div className="px-3 pb-2"> <div className="px-3 pb-2">
@ -114,13 +141,10 @@ export function MobileMenu({
<Button <Button
key={index} key={index}
onClick={subAction.onClick} onClick={subAction.onClick}
className={cn( className={cn(drawerMenuButtonClassName, subAction.className)}
'w-full justify-start gap-2 px-4 py-3 h-auto min-h-0 text-left whitespace-normal',
subAction.className
)}
variant="ghost" variant="ghost"
> >
{subAction.label} <span className="min-w-0 flex-1 text-left">{subAction.label}</span>
</Button> </Button>
)) ))
)} )}

25
src/components/NoteOptions/index.tsx

@ -63,6 +63,7 @@ export default function NoteOptions({
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([]) const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
const [subMenuTitle, setSubMenuTitle] = useState('') const [subMenuTitle, setSubMenuTitle] = useState('')
const [subMenuSearchable, setSubMenuSearchable] = useState(false) const [subMenuSearchable, setSubMenuSearchable] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const closeDrawer = () => { const closeDrawer = () => {
setIsDrawerOpen(false) setIsDrawerOpen(false)
@ -70,6 +71,12 @@ export default function NoteOptions({
setSubMenuSearchable(false) setSubMenuSearchable(false)
} }
const closeDesktopMenu = () => {
setMenuOpen(false)
setShowSubMenu(false)
setSubMenuSearchable(false)
}
const goBackToMainMenu = () => { const goBackToMainMenu = () => {
setShowSubMenu(false) setShowSubMenu(false)
setSubMenuSearchable(false) setSubMenuSearchable(false)
@ -128,17 +135,15 @@ export default function NoteOptions({
[] []
) )
const menuHeader = useMemo( const menuHeader =
() => ( isDrawerOpen || menuOpen ? (
<NoteOptionsMetaHeader <NoteOptionsMetaHeader
event={event} event={event}
allowedRelays={seenOnAllowlist} allowedRelays={seenOnAllowlist}
onNavigate={closeDrawer} onNavigate={isSmallScreen ? closeDrawer : closeDesktopMenu}
inDropdown={!isSmallScreen} inDropdown={!isSmallScreen}
/> />
), ) : null
[event, seenOnAllowlist, isSmallScreen]
)
return ( return (
<div className={className} onClick={(e) => e.stopPropagation()}> <div className={className} onClick={(e) => e.stopPropagation()}>
@ -157,7 +162,13 @@ export default function NoteOptions({
goBackToMainMenu={goBackToMainMenu} goBackToMainMenu={goBackToMainMenu}
/> />
) : ( ) : (
<DesktopMenu menuActions={menuActions} trigger={trigger} header={menuHeader} /> <DesktopMenu
menuActions={menuActions}
trigger={trigger}
header={menuHeader}
open={menuOpen}
onOpenChange={setMenuOpen}
/>
)} )}
<RawEventDialog <RawEventDialog

19
src/components/NoteStats/RepostButton.tsx

@ -1,5 +1,10 @@
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
drawerMenuContentClassName,
drawerMenuScrollClassName
} from '@/components/DrawerMenuItem'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { import {
@ -142,11 +147,11 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
</div> </div>
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> <DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay> <DrawerContent hideOverlay className={drawerMenuContentClassName}>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>{t('Boost')}</DrawerTitle> <DrawerTitle>{t('Boost')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="py-2"> <div className={drawerMenuScrollClassName}>
<Button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@ -154,10 +159,11 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
repost() repost()
}} }}
disabled={!canRepost} disabled={!canRepost}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5" className={drawerMenuButtonClassName}
variant="ghost" variant="ghost"
> >
<Repeat /> {t('Boost')} <Repeat />
<span className="min-w-0 flex-1 text-left">{t('Boost')}</span>
</Button> </Button>
<Button <Button
onClick={(e) => { onClick={(e) => {
@ -167,10 +173,11 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
setIsPostDialogOpen(true) setIsPostDialogOpen(true)
}) })
}} }}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5" className={drawerMenuButtonClassName}
variant="ghost" variant="ghost"
> >
<PencilLine /> {t('Quote')} <PencilLine />
<span className="min-w-0 flex-1 text-left">{t('Quote')}</span>
</Button> </Button>
</div> </div>
</DrawerContent> </DrawerContent>

14
src/components/NoteStats/SeenOnButton.tsx

@ -1,5 +1,10 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
drawerMenuContentClassName,
drawerMenuScrollClassName
} from '@/components/DrawerMenuItem'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { import {
DropdownMenu, DropdownMenu,
@ -55,14 +60,14 @@ export default function SeenOnButton({
{trigger} {trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> <DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay> <DrawerContent hideOverlay className={drawerMenuContentClassName}>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Seen on</DrawerTitle> <DrawerTitle>Seen on</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="py-2"> <div className={drawerMenuScrollClassName}>
{relays.map((relay) => ( {relays.map((relay) => (
<Button <Button
className="w-full p-6 justify-start text-lg gap-4" className={drawerMenuButtonClassName}
variant="ghost" variant="ghost"
key={relay} key={relay}
onClick={() => { onClick={() => {
@ -72,7 +77,8 @@ export default function SeenOnButton({
}, 50) }, 50)
}} }}
> >
<RelayIcon url={relay} /> {simplifyUrl(relay)} <RelayIcon url={relay} className="size-5 shrink-0" />
<span className="min-w-0 flex-1 text-left">{simplifyUrl(relay)}</span>
</Button> </Button>
))} ))}
</div> </div>

28
src/components/NoteStats/index.tsx

@ -66,11 +66,6 @@ export default function NoteStats({
? seenOnAllowlist ? seenOnAllowlist
: hintRelays : hintRelays
/** At most two background refetches per card: before vs after inbox/favorite hints hydrate. */ /** At most two background refetches per card: before vs after inbox/favorite hints hydrate. */
const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0
const statsRelaysRef = useRef(statsRelays)
statsRelaysRef.current = statsRelays
const seenOnAllowlistRef = useRef(seenOnAllowlist)
seenOnAllowlistRef.current = seenOnAllowlist
const seenOnAllowlistKey = seenOnAllowlist?.length const seenOnAllowlistKey = seenOnAllowlist?.length
? [...seenOnAllowlist] ? [...seenOnAllowlist]
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) .map((u) => normalizeAnyRelayUrl(u) || u.trim())
@ -78,6 +73,22 @@ export default function NoteStats({
.sort() .sort()
.join('|') .join('|')
: '' : ''
/** Home favorites feed: stats are scoped to the feed allowlist — ignore hint/current-relay churn. */
const usesFeedStatsAllowlist = Boolean(seenOnAllowlistKey)
const statsRelayFetchTier = isRssArticleRoot
? relayMergeTier
: usesFeedStatsAllowlist
? 0
: hintRelays.length > 0
? 1
: 0
const statsFetchRelayScopeKey = usesFeedStatsAllowlist
? seenOnAllowlistKey
: `${statsRelayFetchTier}|${currentRelaysKey}`
const statsRelaysRef = useRef(statsRelays)
statsRelaysRef.current = statsRelays
const seenOnAllowlistRef = useRef(seenOnAllowlist)
seenOnAllowlistRef.current = seenOnAllowlist
const shouldDeferStatsFetch = const shouldDeferStatsFetch =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats) deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -95,8 +106,7 @@ export default function NoteStats({
.finally(() => setLoading(false)) .finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render; // Intentionally omit `event` object: parent feeds often pass new references each render;
// id/sig/kind/created_at identify the note for refetch boundaries. // id/sig/kind/created_at identify the note for refetch boundaries.
// `statsRelayFetchTier` (not full sorted relay key) avoids a REQ storm when favorites/current relays hydrate. // `statsFetchRelayScopeKey` bundles tier + current relays, or feed allowlist only on home favorites.
// `seenOnAllowlistKey` (not the array ref) avoids refetch loops when parents pass a new [] each render.
}, [ }, [
event.id, event.id,
event.kind, event.kind,
@ -107,9 +117,7 @@ export default function NoteStats({
shouldDeferStatsFetch, shouldDeferStatsFetch,
isNearViewport, isNearViewport,
pubkey, pubkey,
statsRelayFetchTier, statsFetchRelayScopeKey
currentRelaysKey,
seenOnAllowlistKey
]) ])
const interactionButtons = ( const interactionButtons = (

8
src/hooks/useSeenOnRelays.ts

@ -3,6 +3,10 @@ import { normalizeAnyRelayUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
function relayListsEqual(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((url, i) => url === b[i])
}
export function useSeenOnRelays( export function useSeenOnRelays(
eventId: string, eventId: string,
allowedRelays?: readonly string[] allowedRelays?: readonly string[]
@ -27,7 +31,9 @@ export function useSeenOnRelays(
const allowlist = allowedRelaysRef.current const allowlist = allowedRelaysRef.current
const visible = const visible =
allowlist?.length ? filterRelaysToUserAllowlist(seenOn, allowlist) : seenOn allowlist?.length ? filterRelaysToUserAllowlist(seenOn, allowlist) : seenOn
if (!cancelled) setRelays(visible) if (!cancelled) {
setRelays((prev) => (relayListsEqual(prev, visible) ? prev : visible))
}
return visible.length > 0 return visible.length > 0
} }
if (apply()) return if (apply()) return

28
src/layouts/PrimaryPageLayout/index.tsx

@ -1,4 +1,4 @@
import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import ScrollToTopButton from '@/components/ScrollToTopButton' import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
@ -26,8 +26,7 @@ const PrimaryPageLayout = forwardRef(
pageName, pageName,
displayScrollToTopButton = false, displayScrollToTopButton = false,
hideTitlebarBottomBorder = false, hideTitlebarBottomBorder = false,
subHeader, subHeader
suppressMobileDefaultActiveRelaysButton = false
}: { }: {
children?: React.ReactNode children?: React.ReactNode
titlebar: React.ReactNode titlebar: React.ReactNode
@ -36,11 +35,6 @@ const PrimaryPageLayout = forwardRef(
hideTitlebarBottomBorder?: boolean hideTitlebarBottomBorder?: boolean
/** Rendered between titlebar and scroll area; not in scroll flow so it never overlaps content */ /** Rendered between titlebar and scroll area; not in scroll flow so it never overlaps content */
subHeader?: React.ReactNode subHeader?: React.ReactNode
/**
* When true on small screens, omit the trailing {@link ActiveRelaysTitlebarButton} so the page can
* place it next to the account control (e.g. feed titlebar).
*/
suppressMobileDefaultActiveRelaysButton?: boolean
}, },
ref ref
) => { ) => {
@ -140,10 +134,7 @@ const PrimaryPageLayout = forwardRef(
}} }}
> >
{hasTitlebarRow ? ( {hasTitlebarRow ? (
<PrimaryPageTitlebar <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
hideBottomBorder={hideTitlebarBottomBorder}
suppressMobileActiveRelays={suppressMobileDefaultActiveRelaysButton}
>
{titlebar} {titlebar}
</PrimaryPageTitlebar> </PrimaryPageTitlebar>
) : null} ) : null}
@ -164,10 +155,7 @@ const PrimaryPageLayout = forwardRef(
> >
<div className="flex h-full min-h-0 min-w-0 flex-col"> <div className="flex h-full min-h-0 min-w-0 flex-col">
{hasTitlebarRow ? ( {hasTitlebarRow ? (
<PrimaryPageTitlebar <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
hideBottomBorder={hideTitlebarBottomBorder}
suppressMobileActiveRelays={false}
>
{titlebar} {titlebar}
</PrimaryPageTitlebar> </PrimaryPageTitlebar>
) : null} ) : null}
@ -197,16 +185,12 @@ export type TPrimaryPageLayoutRef = {
function PrimaryPageTitlebar({ function PrimaryPageTitlebar({
children, children,
hideBottomBorder = false, hideBottomBorder = false
suppressMobileActiveRelays = false
}: { }: {
children?: React.ReactNode children?: React.ReactNode
hideBottomBorder?: boolean hideBottomBorder?: boolean
suppressMobileActiveRelays?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
/** Desktop: relay strip lives in the sidebar only. Narrow screens: titlebar control (or inline on feed). */
const showTrailingActiveRelays = isSmallScreen && !suppressMobileActiveRelays
return ( return (
<Titlebar <Titlebar
@ -218,7 +202,7 @@ function PrimaryPageTitlebar({
<div className="flex w-full min-w-0 items-center gap-2"> <div className="flex w-full min-w-0 items-center gap-2">
<ReadOnlySessionIndicator variant="titlebar" /> <ReadOnlySessionIndicator variant="titlebar" />
<div className="relative min-w-0 flex-1">{children}</div> <div className="relative min-w-0 flex-1">{children}</div>
{showTrailingActiveRelays ? <ActiveRelaysTitlebarButton /> : null} {isSmallScreen ? <HelpAndAccountMenu variant="titlebar" /> : null}
</div> </div>
</Titlebar> </Titlebar>
) )

6
src/layouts/SecondaryPageLayout/index.tsx

@ -1,4 +1,4 @@
import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import ScrollToTopButton from '@/components/ScrollToTopButton' import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
@ -175,7 +175,7 @@ function SecondaryPageTitlebar({
<div className="flex w-full min-w-0 items-center gap-2"> <div className="flex w-full min-w-0 items-center gap-2">
<ReadOnlySessionIndicator variant="titlebar" /> <ReadOnlySessionIndicator variant="titlebar" />
<div className="min-w-0 flex-1">{titlebar}</div> <div className="min-w-0 flex-1">{titlebar}</div>
{isSmallScreen ? <ActiveRelaysTitlebarButton /> : null} {isSmallScreen ? <HelpAndAccountMenu variant="titlebar" /> : null}
</div> </div>
</Titlebar> </Titlebar>
) )
@ -203,7 +203,7 @@ function SecondaryPageTitlebar({
{controls} {controls}
</div> </div>
</div> </div>
{isSmallScreen ? <ActiveRelaysTitlebarButton /> : null} {isSmallScreen ? <HelpAndAccountMenu variant="titlebar" /> : null}
</div> </div>
</Titlebar> </Titlebar>
) )

7
src/lib/home-feed-relays.ts

@ -2,14 +2,11 @@ import { MAX_REQ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { relayUrlIsAggrNostrLand } from '@/lib/nostr-land-relay-eligibility' import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
/** Drop nostr.land aggregate from REQ stacks where it must not appear (e.g. home feeds). */ export { stripNostrLandAggrFromRelayUrls }
export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string[] {
return urls.filter((url) => !relayUrlIsAggrNostrLand(url))
}
/** /**
* Home timeline REQs (Notes, Replies, and Gallery tabs on `home-all-favorites`) must never hit aggr only * Home timeline REQs (Notes, Replies, and Gallery tabs on `home-all-favorites`) must never hit aggr only

5
src/lib/nostr-land-relay-eligibility.ts

@ -19,6 +19,11 @@ export function relayUrlIsAggrNostrLand(url: string): boolean {
} }
} }
/** Drop `wss://aggr.nostr.land` from REQ stacks where it must not appear (e.g. home feeds). */
export function stripNostrLandAggrFromRelayUrls(urls: readonly string[]): string[] {
return urls.filter((url) => !relayUrlIsAggrNostrLand(url))
}
/** /**
* True when any URL is the canonical nostr.land **inbox** relay (`wss://nostr.land`), i.e. host `nostr.land` * True when any URL is the canonical nostr.land **inbox** relay (`wss://nostr.land`), i.e. host `nostr.land`
* exactly not `aggr.nostr.land`, `hist.nostr.land`, or other subdomains. * exactly not `aggr.nostr.land`, `hist.nostr.land`, or other subdomains.

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

@ -18,8 +18,6 @@ import React, {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip' import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import Logo from '@/assets/Logo' import Logo from '@/assets/Logo'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
@ -81,7 +79,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
<PrimaryPageLayout <PrimaryPageLayout
pageName="feed" pageName="feed"
ref={layoutRef} ref={layoutRef}
suppressMobileDefaultActiveRelaysButton
titlebar={ titlebar={
showNoteListTitlebar ? ( showNoteListTitlebar ? (
<NoteListPageTitlebar onFeedRefresh={runFeedRefresh} showTitlebarRefresh={false} /> <NoteListPageTitlebar onFeedRefresh={runFeedRefresh} showTitlebarRefresh={false} />
@ -205,8 +202,6 @@ function NoteListPageTitlebar({
</div> </div>
<div className="flex min-h-0 min-w-0 items-center justify-end gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide sm:gap-1"> <div className="flex min-h-0 min-w-0 items-center justify-end gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide sm:gap-1">
{showTitlebarRefresh ? <RefreshButton onClick={onFeedRefresh} /> : null} {showTitlebarRefresh ? <RefreshButton onClick={onFeedRefresh} /> : null}
<ActiveRelaysTitlebarButton />
<HelpAndAccountMenu variant="titlebar" />
</div> </div>
</div> </div>
) )

23
src/services/note-stats.service.ts

@ -23,7 +23,10 @@ import {
import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import { filterRelaysToUserAllowlist, isRelayInUserAllowlist } from '@/lib/relay-allowlist' import { filterRelaysToUserAllowlist, isRelayInUserAllowlist } from '@/lib/relay-allowlist'
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import {
prependAggrNostrLandIfViewerEligible,
stripNostrLandAggrFromRelayUrls
} from '@/lib/nostr-land-relay-eligibility'
import { buildComprehensiveRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
@ -230,6 +233,11 @@ class NoteStatsService {
return return
} }
/** Background feed cards: one relay wave per note — effect re-runs must not stack REQs. */
if (!foreground && this.noteStatsMap.get(eventId)?.updatedAt != null) {
return
}
if (this.processingCache.has(eventId)) { if (this.processingCache.has(eventId)) {
this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays) this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays)
rememberRoot() rememberRoot()
@ -632,10 +640,15 @@ class NoteStatsService {
if (relayAllowlist?.length) { if (relayAllowlist?.length) {
const onAllowlist = (u: string) => isRelayInUserAllowlist(u, relayAllowlist) const onAllowlist = (u: string) => isRelayInUserAllowlist(u, relayAllowlist)
return this.finalizeNoteStatsRelayUrls( // Match home feed timeline policy: allowlisted stats must not hit aggr.nostr.land.
filterRelaysToUserAllowlist( return stripNostrLandAggrFromRelayUrls(
[...relayAllowlist, ...relayHints.filter(onAllowlist)], sanitizeRelayUrlsForFetch(
relayAllowlist dedupeNormalizeRelayUrlsOrdered(
filterRelaysToUserAllowlist(
[...relayAllowlist, ...relayHints.filter(onAllowlist)],
relayAllowlist
)
)
) )
) )
} }

Loading…
Cancel
Save