10 changed files with 393 additions and 23 deletions
@ -0,0 +1,115 @@ |
|||||||
|
import { useSecondaryPage } 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' |
||||||
|
|
||||||
|
/** |
||||||
|
* Same interaction pattern as {@link SeenOnButton}: Server + counts, menu lists relays with {@link RelayIcon}. |
||||||
|
* Shows favorites + default/inbox relays; disconnected sockets are muted. |
||||||
|
*/ |
||||||
|
export function ActiveRelaysTitlebarButton() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { rows, connectedCount } = useRelayConnectionRows() |
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false) |
||||||
|
|
||||||
|
const trigger = ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="titlebar-icon" |
||||||
|
className="shrink-0 gap-0.5 text-muted-foreground hover:text-primary disabled:opacity-40" |
||||||
|
title={t('Active relays')} |
||||||
|
aria-label={t('Active relays')} |
||||||
|
disabled={rows.length === 0} |
||||||
|
onClick={() => { |
||||||
|
if (isSmallScreen) setDrawerOpen(true) |
||||||
|
}} |
||||||
|
> |
||||||
|
<Server className="size-5 shrink-0" /> |
||||||
|
{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(!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="sr-only"> |
||||||
|
<DrawerTitle>{t('Active relays')}</DrawerTitle> |
||||||
|
</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={connected ? simplifyUrl(url) : `${simplifyUrl(url)} — ${t('Not connected')}`} |
||||||
|
onClick={() => { |
||||||
|
setDrawerOpen(false) |
||||||
|
setTimeout(() => push(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={connected ? simplifyUrl(url) : `${simplifyUrl(url)} — ${t('Not connected')}`} |
||||||
|
onClick={() => push(toRelay(url))} |
||||||
|
className={cn('min-w-52 gap-2', rowClass(connected))} |
||||||
|
> |
||||||
|
<RelayIcon url={url} /> |
||||||
|
{simplifyUrl(url)} |
||||||
|
</DropdownMenuItem> |
||||||
|
))} |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,97 @@ |
|||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
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 { useTranslation } from 'react-i18next' |
||||||
|
import RelayIcon from '../RelayIcon' |
||||||
|
|
||||||
|
const MAX_ICONS = 14 |
||||||
|
|
||||||
|
function rowMenuClass(connected: boolean) { |
||||||
|
return cn(!connected && 'opacity-50 text-muted-foreground') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Desktop sidebar: relay avatars for favorites + defaults + inbox; muted when the pool socket is down. |
||||||
|
*/ |
||||||
|
export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { rows } = useRelayConnectionRows() |
||||||
|
const shown = rows.slice(0, MAX_ICONS) |
||||||
|
const overflowRows = rows.slice(MAX_ICONS) |
||||||
|
const overflow = overflowRows.length |
||||||
|
|
||||||
|
if (rows.length === 0) { |
||||||
|
return ( |
||||||
|
<div className={cn('px-1 py-1.5 xl:px-0', className)} title={t('Active relays')}> |
||||||
|
<p className="text-center text-[0.6rem] font-medium text-muted-foreground xl:text-left">{t('Active relays')}</p> |
||||||
|
<p className="mt-0.5 text-center text-[0.55rem] text-muted-foreground/80 xl:text-left">—</p> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('px-1 py-2 xl:px-0', className)} title={t('Active relays')}> |
||||||
|
<p className="mb-1.5 text-center text-[0.65rem] font-medium leading-snug text-foreground xl:text-left"> |
||||||
|
{t('Active relays')} |
||||||
|
</p> |
||||||
|
<div className="flex flex-wrap justify-center gap-1 xl:justify-start"> |
||||||
|
{shown.map(({ url, connected }) => ( |
||||||
|
<span |
||||||
|
key={url} |
||||||
|
title={ |
||||||
|
connected ? simplifyUrl(url) : `${simplifyUrl(url)} — ${t('Not connected')}` |
||||||
|
} |
||||||
|
className={cn('inline-flex', !connected && 'opacity-40 grayscale')} |
||||||
|
> |
||||||
|
<RelayIcon url={url} className="h-5 w-5" iconSize={11} /> |
||||||
|
</span> |
||||||
|
))} |
||||||
|
{overflow > 0 ? ( |
||||||
|
<DropdownMenu> |
||||||
|
<DropdownMenuTrigger asChild> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className="h-5 min-h-5 min-w-5 shrink-0 rounded-full bg-muted px-1 py-0 text-[0.6rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground" |
||||||
|
title={t('More relays', { count: overflow })} |
||||||
|
aria-label={t('More relays', { count: overflow })} |
||||||
|
> |
||||||
|
+{overflow} |
||||||
|
</Button> |
||||||
|
</DropdownMenuTrigger> |
||||||
|
<DropdownMenuContent align="start" side="right" className="max-h-[min(70vh,24rem)] w-72 overflow-y-auto"> |
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> |
||||||
|
{t('More relays', { count: overflow })} |
||||||
|
</DropdownMenuLabel> |
||||||
|
<DropdownMenuSeparator /> |
||||||
|
{overflowRows.map(({ url, connected }) => ( |
||||||
|
<DropdownMenuItem |
||||||
|
key={url} |
||||||
|
className={cn('min-w-0 gap-2', rowMenuClass(connected))} |
||||||
|
title={connected ? simplifyUrl(url) : `${simplifyUrl(url)} — ${t('Not connected')}`} |
||||||
|
onClick={() => push(toRelay(url))} |
||||||
|
> |
||||||
|
<RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} /> |
||||||
|
<span className="truncate">{simplifyUrl(url)}</span> |
||||||
|
</DropdownMenuItem> |
||||||
|
))} |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' |
||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
|
||||||
|
const POLL_MS = 1500 |
||||||
|
|
||||||
|
function canon(url: string): string { |
||||||
|
return (normalizeAnyRelayUrl(url) || url).trim().toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function mergeUniquePreserveOrder(...lists: (readonly string[] | undefined)[]): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
for (const list of lists) { |
||||||
|
if (!list?.length) continue |
||||||
|
for (const raw of list) { |
||||||
|
const n = normalizeAnyRelayUrl(raw) || raw |
||||||
|
const k = canon(n) |
||||||
|
if (!k || seen.has(k)) continue |
||||||
|
seen.add(k) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export type TRelayConnectionRow = { url: string; connected: boolean } |
||||||
|
|
||||||
|
/** |
||||||
|
* Relays to show in “active relays” UI: favorites + NIP-65 read/write + defaults + fast-read, |
||||||
|
* then any pool-connected URL not already listed. {@link row.connected} reflects the live WebSocket. |
||||||
|
*/ |
||||||
|
export function useRelayConnectionRows(): { |
||||||
|
rows: TRelayConnectionRow[] |
||||||
|
connectedCount: number |
||||||
|
} { |
||||||
|
const { relayList } = useNostr() |
||||||
|
const { favoriteRelays } = useFavoriteRelays() |
||||||
|
const [connectedCanon, setConnectedCanon] = useState<Set<string>>(() => |
||||||
|
new Set(client.getConnectedRelayUrls().map(canon)) |
||||||
|
) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const tick = () => setConnectedCanon(new Set(client.getConnectedRelayUrls().map(canon))) |
||||||
|
tick() |
||||||
|
const id = window.setInterval(tick, POLL_MS) |
||||||
|
return () => clearInterval(id) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return useMemo(() => { |
||||||
|
const inbox = [...(relayList?.read ?? []), ...(relayList?.write ?? [])] |
||||||
|
const base = mergeUniquePreserveOrder( |
||||||
|
favoriteRelays, |
||||||
|
inbox, |
||||||
|
DEFAULT_FAVORITE_RELAYS, |
||||||
|
FAST_READ_RELAY_URLS |
||||||
|
) |
||||||
|
const baseCanon = new Set(base.map(canon)) |
||||||
|
|
||||||
|
const rows: TRelayConnectionRow[] = base.map((url) => ({ |
||||||
|
url, |
||||||
|
connected: connectedCanon.has(canon(url)) |
||||||
|
})) |
||||||
|
|
||||||
|
for (const url of client.getConnectedRelayUrls()) { |
||||||
|
const k = canon(url) |
||||||
|
if (baseCanon.has(k)) continue |
||||||
|
rows.push({ url, connected: true }) |
||||||
|
} |
||||||
|
|
||||||
|
const connectedCount = rows.filter((r) => r.connected).length |
||||||
|
return { rows, connectedCount } |
||||||
|
}, [favoriteRelays, relayList?.read, relayList?.write, connectedCanon]) |
||||||
|
} |
||||||
Loading…
Reference in new issue