diff --git a/src/contexts/note-drawer-context.tsx b/src/contexts/note-drawer-context.tsx
index b648320d..51ca906b 100644
--- a/src/contexts/note-drawer-context.tsx
+++ b/src/contexts/note-drawer-context.tsx
@@ -1,10 +1,12 @@
import { createContext, useContext } from 'react'
+import type { Event } from 'nostr-tools'
export type NoteDrawerContextValue = {
- openDrawer: (noteId: string) => void
+ openDrawer: (noteId: string, initialEvent?: Event) => void
closeDrawer: () => void
isDrawerOpen: boolean
drawerNoteId: string | null
+ drawerInitialEvent: Event | null
}
/**
diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx
index a715f83e..af42cfb7 100644
--- a/src/hooks/useFetchEvent.tsx
+++ b/src/hooks/useFetchEvent.tsx
@@ -1,3 +1,4 @@
+import { getNoteBech32Id } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import { eventService } from '@/services/client.service'
@@ -27,11 +28,18 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
const skipShortcuts = refetchToken > 0
// If we have an initial event that matches the eventId, use it and skip fetching
- if (
- !skipShortcuts &&
+ const initialMatches =
initialEvent &&
- (initialEvent.id === eventId || eventId.includes(initialEvent.id))
- ) {
+ (initialEvent.id === eventId ||
+ eventId.includes(initialEvent.id) ||
+ (() => {
+ try {
+ return getNoteBech32Id(initialEvent) === eventId
+ } catch {
+ return false
+ }
+ })())
+ if (!skipShortcuts && initialMatches && initialEvent) {
if (!isEventDeleted(initialEvent)) {
setEvent(initialEvent)
addReplies([initialEvent])
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 13d9584f..fdac6d13 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -9,6 +9,17 @@ export default {
Home: 'Startseite',
Feed: 'Feed',
'Favorite Relays': 'Lieblings-Relays',
+ 'Relay pulse': 'Relay-Puls',
+ 'Relay pulse empty': 'In der letzten Stunde war es ruhig auf deinen Relays.',
+ 'Relay pulse follows': 'Folge ich ({{count}})',
+ 'Relay pulse others': 'Andere ({{count}})',
+ 'Relay pulse updated': 'Aktualisiert {{relative}}',
+ 'Relay pulse active npubs': 'Aktive npubs',
+ 'Relay pulse active npubs hint':
+ 'Kind-0-Profile für npubs, die in der letzten Stunde auf deinen Lieblingsrelais auftauchten (gleiche Stichprobe wie Relay-Puls).',
+ 'Relay pulse drawer following': 'Folge ich',
+ 'Relay pulse drawer others': 'Andere',
+ 'Relay pulse drawer no profiles': 'Für diese Stichprobe wurden noch keine Kind-0-Profile geladen.',
'All favorite relays': 'Alle Lieblingsrelais',
'Pinned note': 'Angehefteter Beitrag',
'Relay settings': 'Relay-Einstellungen',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 3dfaaae5..23481ca6 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -7,6 +7,17 @@ export default {
Home: 'Home',
Feed: 'Feed',
'Favorite Relays': 'Favorite Relays',
+ 'Relay pulse': 'Relay pulse',
+ 'Relay pulse empty': 'Quiet on your relays in the last hour.',
+ 'Relay pulse follows': 'Following ({{count}})',
+ 'Relay pulse others': 'Others ({{count}})',
+ 'Relay pulse updated': 'Updated {{relative}}',
+ 'Relay pulse active npubs': 'Active npubs',
+ 'Relay pulse active npubs hint':
+ 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).',
+ 'Relay pulse drawer following': 'Following',
+ 'Relay pulse drawer others': 'Others',
+ 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.',
'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings',
diff --git a/src/lib/relay-pulse-nip05.ts b/src/lib/relay-pulse-nip05.ts
new file mode 100644
index 00000000..5fc5cc21
--- /dev/null
+++ b/src/lib/relay-pulse-nip05.ts
@@ -0,0 +1,36 @@
+import type { Event } from 'nostr-tools'
+
+function addNip05(set: Set
, raw: unknown) {
+ if (typeof raw !== 'string') return
+ const t = raw.trim()
+ if (t) set.add(t)
+}
+
+/**
+ * All NIP-05 identifiers from kind 0: every `nip05` tag plus JSON `nip05` (string or string array).
+ * Deduplicated, order not preserved.
+ */
+export function collectAggregatedNip05sFromKind0(event: Event): string[] {
+ const set = new Set()
+ for (const tag of event.tags) {
+ if (tag[0] === 'nip05' && tag[1]) addNip05(set, tag[1])
+ }
+ try {
+ const obj = JSON.parse(event.content || '{}') as Record
+ const j = obj.nip05
+ if (typeof j === 'string') addNip05(set, j)
+ else if (Array.isArray(j)) {
+ for (const x of j) addNip05(set, x)
+ }
+ } catch {
+ // ignore invalid JSON
+ }
+ return [...set]
+}
+
+export function truncateAbout(about: string | undefined, maxLen: number): string {
+ if (!about) return ''
+ const t = about.trim()
+ if (t.length <= maxLen) return t
+ return `${t.slice(0, maxLen)}…`
+}
diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx
index ea030e27..4f00d9e7 100644
--- a/src/pages/primary/NoteListPage/index.tsx
+++ b/src/pages/primary/NoteListPage/index.tsx
@@ -24,13 +24,13 @@ import React, {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
+import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import FollowingFeed from './FollowingFeed'
import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
-
const NoteListPage = forwardRef((_, ref) => {
const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
@@ -39,6 +39,7 @@ const NoteListPage = forwardRef((_, ref) => {
const bookmarkRef = useRef<{ refresh: () => void }>(null)
const { pubkey, checkLogin } = useNostr()
const { feedInfo, relayUrls, isReady } = useFeed()
+ const { isSmallScreen } = useScreenSize()
const [showRelayDetails, setShowRelayDetails] = useState(false)
const [homeSubHeader, setHomeSubHeader] = useState(null)
@@ -168,6 +169,7 @@ const NoteListPage = forwardRef((_, ref) => {
const subHeader = (
<>
+ {isSmallScreen ? : null}
{feedPageTitle}
diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx
new file mode 100644
index 00000000..f5c8c24e
--- /dev/null
+++ b/src/providers/FavoriteRelaysActivityProvider.tsx
@@ -0,0 +1,260 @@
+import logger from '@/lib/logger'
+import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useFollowListOptional } from '@/providers/FollowListProvider'
+import { useNostr } from '@/providers/NostrProvider'
+import { queryService, replaceableEventService } from '@/services/client.service'
+import type { Event } from 'nostr-tools'
+import { kinds } from 'nostr-tools'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ FavoriteRelaysActivityContext,
+ type TFavoriteRelaysActivityContext
+} from './favorite-relays-activity-context'
+
+const ACTIVE_WINDOW_SEC = 3600
+/** Wall-clock cadence while the tab is visible */
+const POLL_INTERVAL_MS = 60 * 60 * 1000
+/** Enough events to surface many distinct authors without overloading relays */
+const REQ_LIMIT = 400
+
+function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] {
+ const lastByPk = new Map()
+ for (const e of events) {
+ const prev = lastByPk.get(e.pubkey) ?? 0
+ if (e.created_at > prev) lastByPk.set(e.pubkey, e.created_at)
+ }
+ return [...lastByPk.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([pk]) => pk)
+}
+
+function partitionByFollows(orderedPubkeys: string[], followings: string[]) {
+ if (followings.length === 0) {
+ return {
+ followPubkeys: [] as string[],
+ otherPubkeys: orderedPubkeys,
+ followCount: 0,
+ otherCount: orderedPubkeys.length
+ }
+ }
+ const followSet = new Set(
+ followings.map((p) => normalizeHexPubkey(p)).filter((p) => p.length === 64)
+ )
+ const followPubkeys: string[] = []
+ const otherPubkeys: string[] = []
+ for (const pk of orderedPubkeys) {
+ const normalized = normalizeHexPubkey(pk)
+ if (normalized.length === 64 && followSet.has(normalized)) followPubkeys.push(pk)
+ else otherPubkeys.push(pk)
+ }
+ return {
+ followPubkeys,
+ otherPubkeys,
+ followCount: followPubkeys.length,
+ otherCount: otherPubkeys.length
+ }
+}
+
+export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) {
+ const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const followList = useFollowListOptional()
+ const followings = followList?.followings ?? []
+ const { pubkey: viewerPubkey } = useNostr()
+ const [orderedPubkeys, setOrderedPubkeys] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [relayActivityReady, setRelayActivityReady] = useState(false)
+ const [lastFetchedAtMs, setLastFetchedAtMs] = useState(null)
+ const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState>({})
+ const [profilesLoading, setProfilesLoading] = useState(false)
+ const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false)
+ const lastCompletedFetchAtRef = useRef(Date.now())
+ const relayKey = useMemo(
+ () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'),
+ [favoriteRelays, blockedRelays]
+ )
+
+ const fetchActive = useCallback(async () => {
+ const urls = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
+ if (urls.length === 0) {
+ setOrderedPubkeys([])
+ setProfileKind0ByPubkey({})
+ setLoading(false)
+ setRelayActivityReady(true)
+ const now = Date.now()
+ lastCompletedFetchAtRef.current = now
+ setLastFetchedAtMs(now)
+ return
+ }
+ setLoading(true)
+ const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC
+ try {
+ const events = await queryService.fetchEvents(
+ urls,
+ { since, limit: REQ_LIMIT },
+ {
+ firstRelayResultGraceMs: false,
+ eoseTimeout: 1800,
+ globalTimeout: 14_000
+ }
+ )
+ setOrderedPubkeys(aggregatePubkeysByRecency(events))
+ } catch (error) {
+ logger.debug('[FavoriteRelaysActivity] fetch failed', { error })
+ setOrderedPubkeys([])
+ setProfileKind0ByPubkey({})
+ } finally {
+ setLoading(false)
+ setRelayActivityReady(true)
+ const now = Date.now()
+ lastCompletedFetchAtRef.current = now
+ setLastFetchedAtMs(now)
+ }
+ }, [favoriteRelays, blockedRelays])
+
+ const fetchRef = useRef(fetchActive)
+ fetchRef.current = fetchActive
+
+ /** Favorite relay set changed after initial hydration — refresh snapshot (not the hourly cadence). */
+ const prevRelayKeyRef = useRef(undefined)
+ useEffect(() => {
+ if (prevRelayKeyRef.current === undefined) {
+ prevRelayKeyRef.current = relayKey
+ return
+ }
+ if (prevRelayKeyRef.current === relayKey) return
+ prevRelayKeyRef.current = relayKey
+ void fetchRef.current()
+ }, [relayKey])
+
+ /** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */
+ const prevViewerRef = useRef(undefined)
+ useEffect(() => {
+ if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) {
+ void fetchRef.current()
+ }
+ prevViewerRef.current = viewerPubkey ?? undefined
+ }, [viewerPubkey])
+
+ /** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */
+ useEffect(() => {
+ let intervalId: ReturnType | undefined
+
+ const runTick = () => {
+ void fetchRef.current()
+ }
+
+ const syncPolling = () => {
+ if (document.visibilityState !== 'visible') {
+ if (intervalId !== undefined) {
+ clearInterval(intervalId)
+ intervalId = undefined
+ }
+ return
+ }
+ if (intervalId === undefined) {
+ intervalId = setInterval(runTick, POLL_INTERVAL_MS)
+ }
+ if (Date.now() - lastCompletedFetchAtRef.current >= POLL_INTERVAL_MS) {
+ runTick()
+ }
+ }
+
+ syncPolling()
+ document.addEventListener('visibilitychange', syncPolling)
+ return () => {
+ document.removeEventListener('visibilitychange', syncPolling)
+ if (intervalId !== undefined) clearInterval(intervalId)
+ }
+ }, [])
+
+ const profileFetchKeys = useMemo(() => {
+ if (!viewerPubkey) return orderedPubkeys
+ return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey))
+ }, [orderedPubkeys, viewerPubkey])
+
+ useEffect(() => {
+ if (profileFetchKeys.length === 0) {
+ setProfileKind0ByPubkey({})
+ setProfilesLoading(false)
+ return
+ }
+ let cancelled = false
+ setProfilesLoading(true)
+ ;(async () => {
+ try {
+ const events = await replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
+ profileFetchKeys,
+ kinds.Metadata
+ )
+ if (cancelled) return
+ const next: Record = {}
+ profileFetchKeys.forEach((pk, i) => {
+ const e = events[i]
+ if (e) next[pk] = e
+ })
+ setProfileKind0ByPubkey(next)
+ } catch (err) {
+ logger.debug('[FavoriteRelaysActivity] profile batch failed', { err })
+ if (!cancelled) setProfileKind0ByPubkey({})
+ } finally {
+ if (!cancelled) setProfilesLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [profileFetchKeys])
+
+ const displayPubkeys = useMemo(() => {
+ if (!viewerPubkey) return orderedPubkeys
+ return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey))
+ }, [orderedPubkeys, viewerPubkey])
+
+ const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo(
+ () => partitionByFollows(displayPubkeys, followings),
+ [displayPubkeys, followings]
+ )
+
+ const pubkeys = useMemo(
+ () => [...followPubkeys, ...otherPubkeys],
+ [followPubkeys, otherPubkeys]
+ )
+
+ const value: TFavoriteRelaysActivityContext = useMemo(
+ () => ({
+ followPubkeys,
+ otherPubkeys,
+ followCount,
+ otherCount,
+ pubkeys,
+ totalCount: displayPubkeys.length,
+ loading,
+ relayActivityReady,
+ lastFetchedAtMs,
+ profileKind0ByPubkey,
+ profilesLoading,
+ activeNpubsDrawerOpen,
+ setActiveNpubsDrawerOpen,
+ refetch: fetchActive
+ }),
+ [
+ followPubkeys,
+ otherPubkeys,
+ followCount,
+ otherCount,
+ pubkeys,
+ displayPubkeys.length,
+ loading,
+ relayActivityReady,
+ lastFetchedAtMs,
+ profileKind0ByPubkey,
+ profilesLoading,
+ activeNpubsDrawerOpen,
+ fetchActive
+ ]
+ )
+
+ return {children}
+}
diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx
index c7d7cdae..b7d8a966 100644
--- a/src/providers/FollowListProvider.tsx
+++ b/src/providers/FollowListProvider.tsx
@@ -29,6 +29,11 @@ export const useFollowList = () => {
return context
}
+/** Same as {@link useFollowList} but returns undefined outside the provider (avoids HMR / refresh-boundary crashes). */
+export function useFollowListOptional(): TFollowListContext | undefined {
+ return useContext(FollowListContext)
+}
+
export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr()
diff --git a/src/providers/favorite-relays-activity-context.tsx b/src/providers/favorite-relays-activity-context.tsx
new file mode 100644
index 00000000..3c06370b
--- /dev/null
+++ b/src/providers/favorite-relays-activity-context.tsx
@@ -0,0 +1,37 @@
+import type { Event } from 'nostr-tools'
+import { createContext, useContext } from 'react'
+
+export type TFavoriteRelaysActivityContext = {
+ /** Active pubkeys you follow, most recent global activity first within this group */
+ followPubkeys: string[]
+ /** Active pubkeys you do not follow */
+ otherPubkeys: string[]
+ followCount: number
+ otherCount: number
+ /** `followPubkeys` then `otherPubkeys` */
+ pubkeys: string[]
+ totalCount: number
+ loading: boolean
+ /** True after at least one fetch has finished (so empty state is meaningful) */
+ relayActivityReady: boolean
+ /** Wall-clock ms when the last sample completed; null before first fetch */
+ lastFetchedAtMs: number | null
+ /** Kind 0 events loaded for active pubkeys (viewer excluded); used for avatars + drawer */
+ profileKind0ByPubkey: Record
+ profilesLoading: boolean
+ activeNpubsDrawerOpen: boolean
+ setActiveNpubsDrawerOpen: (open: boolean) => void
+ refetch: () => void
+}
+
+export const FavoriteRelaysActivityContext = createContext<
+ TFavoriteRelaysActivityContext | undefined
+>(undefined)
+
+export function useFavoriteRelaysActivity(): TFavoriteRelaysActivityContext {
+ const ctx = useContext(FavoriteRelaysActivityContext)
+ if (!ctx) {
+ throw new Error('useFavoriteRelaysActivity must be used within FavoriteRelaysActivityProvider')
+ }
+ return ctx
+}
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 382140dc..0caf7854 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -672,9 +672,13 @@ class ClientService extends EventTarget {
private recordSessionRelayFailure(url: string) {
const n = normalizeUrl(url) || url
if (!n) return
- const count = (this.publishStrikeCount.get(n) ?? 0) + 1
+ const prev = this.publishStrikeCount.get(n) ?? 0
+ if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
+ return
+ }
+ const count = prev + 1
this.publishStrikeCount.set(n, count)
- if (count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
+ if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) {
logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', {
url: n,
strikes: count
@@ -1932,6 +1936,7 @@ class ClientService extends EventTarget {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
}
+ relays = this.filterSessionStrikedRelays(relays)
const events = await this.queryService.query(relays, filter, onevent, {
eoseTimeout,
globalTimeout,