From aaa41e8df4f2027fd8d397defc95a62006b17c73 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 13 Apr 2026 18:16:40 +0200 Subject: [PATCH] add active relays component --- .../ActiveRelaysTitlebarButton.tsx | 115 ++++++++++++++++++ .../ConnectedRelaysSidebarStrip.tsx | 97 +++++++++++++++ src/components/Sidebar/index.tsx | 2 + src/hooks/useRelayConnectionRows.ts | 77 ++++++++++++ src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + src/layouts/PrimaryPageLayout/index.tsx | 55 +++++++-- src/layouts/SecondaryPageLayout/index.tsx | 17 ++- src/pages/primary/NoteListPage/index.tsx | 35 ++++-- src/services/client.service.ts | 12 ++ 10 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx create mode 100644 src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx create mode 100644 src/hooks/useRelayConnectionRows.ts diff --git a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx new file mode 100644 index 00000000..7bff047e --- /dev/null +++ b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx @@ -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 = ( + + ) + + const rowClass = (connected: boolean) => + cn(!connected && 'opacity-45 text-muted-foreground') + + if (isSmallScreen) { + return ( + <> + {trigger} + + setDrawerOpen(false)} /> + + + {t('Active relays')} + +
+ {rows.map(({ url, connected }) => ( + + ))} +
+
+
+ + ) + } + + return ( + + {trigger} + + {t('Active relays')} + + {rows.map(({ url, connected }) => ( + push(toRelay(url))} + className={cn('min-w-52 gap-2', rowClass(connected))} + > + + {simplifyUrl(url)} + + ))} + + + ) +} diff --git a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx new file mode 100644 index 00000000..30df026b --- /dev/null +++ b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx @@ -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 ( +
+

{t('Active relays')}

+

+
+ ) + } + + return ( +
+

+ {t('Active relays')} +

+
+ {shown.map(({ url, connected }) => ( + + + + ))} + {overflow > 0 ? ( + + + + + + + {t('More relays', { count: overflow })} + + + {overflowRows.map(({ url, connected }) => ( + push(toRelay(url))} + > + + {simplifyUrl(url)} + + ))} + + + ) : null} +
+
+ ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 5fde0649..ab2c3f70 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -12,6 +12,7 @@ import SearchButton from './SearchButton' import FollowsLatestButton from './FollowsLatestButton' import FavoritesButton from './FavoritesButton' import SpellsButton from './SpellsButton' +import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip' import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import PaneModeToggle from './PaneModeToggle' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' @@ -48,6 +49,7 @@ export default function PrimaryPageSidebar() { +
diff --git a/src/hooks/useRelayConnectionRows.ts b/src/hooks/useRelayConnectionRows.ts new file mode 100644 index 00000000..de6e17c4 --- /dev/null +++ b/src/hooks/useRelayConnectionRows.ts @@ -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() + 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>(() => + 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]) +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 880341d0..e9c672f0 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -531,6 +531,9 @@ export default { 'Möchtest du diesem Benutzer wirklich nicht mehr folgen?', 'Recent Supporters': 'Neueste Unterstützer', 'Seen on': 'Gesehen auf', + 'Active relays': 'Aktive Relays', + 'Not connected': 'Nicht verbunden', + 'More relays': '+{{count}} Relays', 'Temporarily display this reply': 'Antwort vorübergehend anzeigen', 'Note not found': 'Die Notiz wurde nicht gefunden', 'Invalid embedded note reference': 'Ungültige eingebettete Notiz-Referenz', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6af3ede0..b312561f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -527,6 +527,9 @@ export default { 'Are you sure you want to unfollow this user?': 'Are you sure you want to unfollow this user?', 'Recent Supporters': 'Recent Supporters', 'Seen on': 'Seen on', + 'Active relays': 'Active relays', + 'Not connected': 'Not connected', + 'More relays': '+{{count}} relays', 'Temporarily display this reply': 'Temporarily display this reply', 'Note not found': 'Note not found', 'Invalid embedded note reference': 'Invalid embedded note reference', diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index ad5596a5..cd84af23 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -1,3 +1,4 @@ +import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import ScrollToTopButton from '@/components/ScrollToTopButton' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { Titlebar } from '@/components/Titlebar' @@ -10,6 +11,7 @@ import { isRadixDialogOpen, shouldIgnoreKeyboardShortcutEvent } from '@/lib/keyboard-shortcuts' +import { cn } from '@/lib/utils' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' const PrimaryPageLayout = forwardRef( @@ -20,7 +22,8 @@ const PrimaryPageLayout = forwardRef( pageName, displayScrollToTopButton = false, hideTitlebarBottomBorder = false, - subHeader + subHeader, + suppressMobileDefaultActiveRelaysButton = false }: { children?: React.ReactNode titlebar: React.ReactNode @@ -29,6 +32,11 @@ 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 ) => { @@ -94,6 +102,8 @@ const PrimaryPageLayout = forwardRef( return () => document.removeEventListener('keydown', onKeyDown) }, [isSmallScreen, current, pageName, display]) + const hasTitlebarRow = titlebar != null + if (isSmallScreen) { return ( @@ -104,9 +114,14 @@ const PrimaryPageLayout = forwardRef( paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)' }} > - - {titlebar} - + {hasTitlebarRow ? ( + + {titlebar} + + ) : null} {subHeader &&
{subHeader}
}
{children} @@ -120,9 +135,14 @@ const PrimaryPageLayout = forwardRef( return (
- - {titlebar} - + {hasTitlebarRow ? ( + + {titlebar} + + ) : null} {subHeader && (
{subHeader}
)} @@ -132,7 +152,9 @@ const PrimaryPageLayout = forwardRef( className={ subHeader ? 'min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto' - : 'absolute bottom-0 left-0 right-0 top-12 min-w-0 overflow-y-auto overflow-x-auto' + : hasTitlebarRow + ? 'absolute bottom-0 left-0 right-0 top-12 min-w-0 overflow-y-auto overflow-x-auto' + : 'absolute bottom-0 left-0 right-0 top-0 min-w-0 overflow-y-auto overflow-x-auto' } > {children} @@ -153,16 +175,29 @@ export type TPrimaryPageLayoutRef = { function PrimaryPageTitlebar({ children, - hideBottomBorder = false + hideBottomBorder = false, + suppressMobileActiveRelays = 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 ( - +
{children}
+ {showTrailingActiveRelays ? : null}
) diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index a3725f9e..a8e40b8e 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -1,4 +1,5 @@ import { ImwaldBrandBar } from '@/assets/Logo' +import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import ScrollToTopButton from '@/components/ScrollToTopButton' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { Titlebar } from '@/components/Titlebar' @@ -11,6 +12,7 @@ import { import { useSecondaryPage } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { cn } from '@/lib/utils' import { ChevronLeft } from 'lucide-react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -158,20 +160,26 @@ function SecondaryPageTitlebar({ hideBottomBorder?: boolean titlebar?: React.ReactNode }): JSX.Element { + const { isSmallScreen } = useScreenSize() + const titlebarInset = isSmallScreen + ? 'py-1 pl-2 pr-[max(0.75rem,env(safe-area-inset-right,0px))]' + : 'p-1' + if (titlebar) { return (
{titlebar}
+ {isSmallScreen ? : null}
) } return ( @@ -185,7 +193,10 @@ function SecondaryPageTitlebar({ {title}
)} -
{controls}
+
+ {controls} + {isSmallScreen ? : null} +
) diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index aa3d94ac..b24b3a11 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -24,6 +24,7 @@ import React, { import { useTranslation } from 'react-i18next' import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip' import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker' +import { ActiveRelaysTitlebarButton } from '@/components/ConnectedRelays/ActiveRelaysTitlebarButton' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import Logo from '@/assets/Logo' import RelaysFeed from './RelaysFeed' @@ -136,20 +137,29 @@ const NoteListPage = forwardRef((_, ref) => { ) + /** Desktop: nav/logo/account live in titlebar only on small screens; refresh moves to subheader when present. Omit empty h-12 strip. */ + const showNoteListTitlebar = + isSmallScreen || + !usesSubHeader || + (feedInfo.feedType === 'relay' && !!feedInfo.id) + return ( + showNoteListTitlebar ? ( + + ) : null } subHeader={subHeader} displayScrollToTopButton @@ -283,7 +293,12 @@ function NoteListPageTitlebar({ )} - {isSmallScreen && } + {isSmallScreen ? ( + <> + + + + ) : null}
) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 03ec6e1d..d721806c 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2758,6 +2758,18 @@ class ClientService extends EventTarget { return this.getSeenEventRelayUrls(eventId).find((url) => !isLocalNetworkUrl(url)) ?? '' } + /** Relay URLs in the pool whose WebSocket is currently connected (`listConnectionStatus`). */ + getConnectedRelayUrls(): string[] { + const status = this.pool.listConnectionStatus() + const out: string[] = [] + for (const [url, connected] of status) { + if (!connected) continue + const n = normalizeAnyRelayUrl(url) || url + out.push(n) + } + return [...new Set(out)].sort((a, b) => a.localeCompare(b)) + } + trackEventSeenOn(eventId: string, relay: AbstractRelay) { const key = canonicalSeenOnEventId(eventId) let set = this.pool.seenOn.get(key)