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)