Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
616b6bbc92
  1. 45
      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. 17
      src/services/note-stats.service.ts

45
src/PageManager.tsx

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

58
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -0,0 +1,58 @@ @@ -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 @@ @@ -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' @@ -2,6 +2,16 @@ import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/drawer'
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({
children,
className,
@ -15,7 +25,7 @@ export default function DrawerMenuItem({ @@ -15,7 +25,7 @@ export default function DrawerMenuItem({
<DrawerClose className="w-full">
<Button
onClick={onClick}
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
className={cn(drawerMenuButtonClassName, className)}
variant="ghost"
>
{children}

13
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -18,6 +18,7 @@ import { useMuteList } from '@/contexts/mute-list-context' @@ -18,6 +18,7 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { SecondaryPageLink } from '@/PageManager'
import { useRelativePastPhrase } from '@/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time'
import type { Event } from 'nostr-tools'
import { Users } from 'lucide-react'
import { useMemo } from 'react'
@ -124,9 +125,12 @@ export function RelayPulseActiveNpubsSheet() { @@ -124,9 +125,12 @@ export function RelayPulseActiveNpubsSheet() {
followPubkeys,
otherPubkeys,
profileKind0ByPubkey,
profilesLoading
profilesLoading,
lastFetchedAtMs
} = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
const followWithProfile = useMemo(
() =>
followPubkeys.filter(
@ -148,8 +152,13 @@ export function RelayPulseActiveNpubsSheet() { @@ -148,8 +152,13 @@ export function RelayPulseActiveNpubsSheet() {
side="right"
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>
{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>
</SheetHeader>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-3">

98
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,50 +1,25 @@ @@ -1,50 +1,25 @@
import { cn } from '@/lib/utils'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet'
import type { TFunction } from 'i18next'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 })
}
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])
}
export { relativePastPhrase, useRelativePastPhrase } from './relay-pulse-relative-time'
/** 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 }) {
const { t } = useTranslation()
const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
if (!relayActivityReady && !loading) {
return (
<div
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
)}
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>
)
}
@ -53,19 +28,11 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -53,19 +28,11 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
return (
<div
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
)}
>
<p className="text-xs font-medium text-foreground">{t('Relay pulse')}</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>
<p className="text-xs text-muted-foreground leading-snug">{t('Relay pulse empty')}</p>
</div>
)
}
@ -73,24 +40,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -73,24 +40,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
return (
<div
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',
className
)}
>
<div className="flex w-full min-w-0 flex-col gap-1.5">
<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>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0 max-w-full" />
</div>
)
}
@ -98,21 +53,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -98,21 +53,12 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
/** Desktop sidebar: compact row under nav */
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) {
const { t } = useTranslation()
const { totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
if (!relayActivityReady && !loading) {
return (
<div
className={cn(
'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={cn('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>
)
@ -125,11 +71,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -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>
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</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">
{t('Relay pulse empty')}
</p>
@ -138,13 +79,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -138,13 +79,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
}
return (
<div
className={cn(
'px-1 py-2 xl:px-0',
loading && 'animate-pulse',
className
)}
>
<div 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">
<p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground">
{t('Relay pulse')}
@ -153,11 +88,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -153,11 +88,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</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">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
</div>

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

@ -0,0 +1,26 @@ @@ -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' @@ -20,20 +20,27 @@ import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile'
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 { useCallback, useMemo, useState, type ReactNode } from 'react'
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'
function AccountDropdownItems({
onSwitchAccount,
onLogoutClick,
onBrowseCache
onBrowseCache,
showActiveRelays = false
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache: () => void
showActiveRelays?: boolean
}) {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
@ -52,6 +59,7 @@ function AccountDropdownItems({ @@ -52,6 +59,7 @@ function AccountDropdownItems({
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
{showActiveRelays ? <ActiveRelaysDropdownSection /> : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" />
@ -178,20 +186,50 @@ function TitlebarAccountMenu({ @@ -178,20 +186,50 @@ function TitlebarAccountMenu({
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="z-[220]">
<DropdownMenuContent align="end" side="bottom" className={titlebarAccountMenuContentClassName}>
<AccountDropdownItems
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache}
showActiveRelays
/>
</DropdownMenuContent>
</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. */
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => {
@ -218,18 +256,14 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun @@ -218,18 +256,14 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
onBrowseCache={onBrowseCache}
/>
)
} else if (variant === 'sidebar') {
} else if (variant === 'titlebar') {
account = <LoggedOutTitlebarMenu onLogin={() => checkLogin()} />
} else {
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 =

12
src/components/NoteOptions/DesktopMenu.tsx

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

66
src/components/NoteOptions/MobileMenu.tsx

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

25
src/components/NoteOptions/index.tsx

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

19
src/components/NoteStats/RepostButton.tsx

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

14
src/components/NoteStats/SeenOnButton.tsx

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

28
src/components/NoteStats/index.tsx

@ -66,11 +66,6 @@ export default function NoteStats({ @@ -66,11 +66,6 @@ export default function NoteStats({
? seenOnAllowlist
: hintRelays
/** 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
? [...seenOnAllowlist]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
@ -78,6 +73,22 @@ export default function NoteStats({ @@ -78,6 +73,22 @@ export default function NoteStats({
.sort()
.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 =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null)
@ -95,8 +106,7 @@ export default function NoteStats({ @@ -95,8 +106,7 @@ export default function NoteStats({
.finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render;
// 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.
// `seenOnAllowlistKey` (not the array ref) avoids refetch loops when parents pass a new [] each render.
// `statsFetchRelayScopeKey` bundles tier + current relays, or feed allowlist only on home favorites.
}, [
event.id,
event.kind,
@ -107,9 +117,7 @@ export default function NoteStats({ @@ -107,9 +117,7 @@ export default function NoteStats({
shouldDeferStatsFetch,
isNearViewport,
pubkey,
statsRelayFetchTier,
currentRelaysKey,
seenOnAllowlistKey
statsFetchRelayScopeKey
])
const interactionButtons = (

8
src/hooks/useSeenOnRelays.ts

@ -3,6 +3,10 @@ import { normalizeAnyRelayUrl } from '@/lib/url' @@ -3,6 +3,10 @@ import { normalizeAnyRelayUrl } from '@/lib/url'
import client from '@/services/client.service'
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(
eventId: string,
allowedRelays?: readonly string[]
@ -27,7 +31,9 @@ export function useSeenOnRelays( @@ -27,7 +31,9 @@ export function useSeenOnRelays(
const allowlist = allowedRelaysRef.current
const visible =
allowlist?.length ? filterRelaysToUserAllowlist(seenOn, allowlist) : seenOn
if (!cancelled) setRelays(visible)
if (!cancelled) {
setRelays((prev) => (relayListsEqual(prev, visible) ? prev : visible))
}
return visible.length > 0
}
if (apply()) return

28
src/layouts/PrimaryPageLayout/index.tsx

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

6
src/layouts/SecondaryPageLayout/index.tsx

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

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

@ -2,14 +2,11 @@ import { MAX_REQ_RELAY_URLS } from '@/constants' @@ -2,14 +2,11 @@ import { MAX_REQ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata'
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 type { Event } from 'nostr-tools'
/** Drop nostr.land aggregate 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))
}
export { stripNostrLandAggrFromRelayUrls }
/**
* 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 { @@ -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`
* exactly not `aggr.nostr.land`, `hist.nostr.land`, or other subdomains.

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

@ -18,8 +18,6 @@ import React, { @@ -18,8 +18,6 @@ import React, {
} from 'react'
import { useTranslation } from 'react-i18next'
import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import Logo from '@/assets/Logo'
import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context'
@ -81,7 +79,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => { @@ -81,7 +79,6 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
<PrimaryPageLayout
pageName="feed"
ref={layoutRef}
suppressMobileDefaultActiveRelaysButton
titlebar={
showNoteListTitlebar ? (
<NoteListPageTitlebar onFeedRefresh={runFeedRefresh} showTitlebarRefresh={false} />
@ -205,8 +202,6 @@ function NoteListPageTitlebar({ @@ -205,8 +202,6 @@ function NoteListPageTitlebar({
</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">
{showTitlebarRefresh ? <RefreshButton onClick={onFeedRefresh} /> : null}
<ActiveRelaysTitlebarButton />
<HelpAndAccountMenu variant="titlebar" />
</div>
</div>
)

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

@ -23,7 +23,10 @@ import { @@ -23,7 +23,10 @@ import {
import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
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 { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
@ -230,6 +233,11 @@ class NoteStatsService { @@ -230,6 +233,11 @@ class NoteStatsService {
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)) {
this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays)
rememberRoot()
@ -632,12 +640,17 @@ class NoteStatsService { @@ -632,12 +640,17 @@ class NoteStatsService {
if (relayAllowlist?.length) {
const onAllowlist = (u: string) => isRelayInUserAllowlist(u, relayAllowlist)
return this.finalizeNoteStatsRelayUrls(
// Match home feed timeline policy: allowlisted stats must not hit aggr.nostr.land.
return stripNostrLandAggrFromRelayUrls(
sanitizeRelayUrlsForFetch(
dedupeNormalizeRelayUrlsOrdered(
filterRelaysToUserAllowlist(
[...relayAllowlist, ...relayHints.filter(onAllowlist)],
relayAllowlist
)
)
)
)
}
let useGlobal = true

Loading…
Cancel
Save