Browse Source

add relay pulse

imwald
Silberengel 1 month ago
parent
commit
987232a0b6
  1. 43
      src/App.tsx
  2. 72
      src/PageManager.tsx
  3. 3
      src/components/Embedded/EmbeddedNote.tsx
  4. 173
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  5. 378
      src/components/FavoriteRelaysActiveStrip/index.tsx
  6. 2
      src/components/Note/Zap.tsx
  7. 6
      src/components/NoteDrawer/index.tsx
  8. 3
      src/components/ReplyNote/index.tsx
  9. 2
      src/components/Sidebar/index.tsx
  10. 4
      src/contexts/note-drawer-context.tsx
  11. 16
      src/hooks/useFetchEvent.tsx
  12. 11
      src/i18n/locales/de.ts
  13. 11
      src/i18n/locales/en.ts
  14. 36
      src/lib/relay-pulse-nip05.ts
  15. 4
      src/pages/primary/NoteListPage/index.tsx
  16. 260
      src/providers/FavoriteRelaysActivityProvider.tsx
  17. 5
      src/providers/FollowListProvider.tsx
  18. 37
      src/providers/favorite-relays-activity-context.tsx
  19. 9
      src/services/client.service.ts

43
src/App.tsx

@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider' import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider' import { FeedProvider } from '@/providers/FeedProvider'
import { FontSizeProvider } from '@/providers/FontSizeProvider' import { FontSizeProvider } from '@/providers/FontSizeProvider'
@ -39,26 +40,28 @@ export default function App(): JSX.Element {
<FavoriteRelaysProvider> <FavoriteRelaysProvider>
<FollowListProvider> <FollowListProvider>
<MuteListProvider> <MuteListProvider>
<InterestListProvider> <FavoriteRelaysActivityProvider>
<GroupListProvider> <InterestListProvider>
<UserTrustProvider> <GroupListProvider>
<BookmarksProvider> <UserTrustProvider>
<FeedProvider> <BookmarksProvider>
<ReplyProvider> <FeedProvider>
<MediaUploadServiceProvider> <ReplyProvider>
<KindFilterProvider> <MediaUploadServiceProvider>
<UserPreferencesProvider> <KindFilterProvider>
<PageManager /> <UserPreferencesProvider>
<Toaster /> <PageManager />
</UserPreferencesProvider> <Toaster />
</KindFilterProvider> </UserPreferencesProvider>
</MediaUploadServiceProvider> </KindFilterProvider>
</ReplyProvider> </MediaUploadServiceProvider>
</FeedProvider> </ReplyProvider>
</BookmarksProvider> </FeedProvider>
</UserTrustProvider> </BookmarksProvider>
</GroupListProvider> </UserTrustProvider>
</InterestListProvider> </GroupListProvider>
</InterestListProvider>
</FavoriteRelaysActivityProvider>
</MuteListProvider> </MuteListProvider>
</FollowListProvider> </FollowListProvider>
</FavoriteRelaysProvider> </FavoriteRelaysProvider>

72
src/PageManager.tsx

@ -76,6 +76,9 @@ const SidebarLazy = lazy(() => import('@/components/Sidebar'))
const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar')) const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar'))
const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog'))
const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast')) const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast'))
const RelayPulseActiveNpubsSheetLazy = lazy(
() => import('@/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet').then((m) => ({ default: m.RelayPulseActiveNpubsSheet }))
)
type TStackItem = { type TStackItem = {
index: number index: number
@ -314,16 +317,21 @@ export function useSmartNoteNavigation() {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToNote = (url: string, event?: Event) => { const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => {
// Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id}) // Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id})
const { noteId } = parseNoteUrl(url) const { noteId } = parseNoteUrl(url)
// If event is provided, store it in navigation event store to avoid re-fetching // If event is provided, store it in navigation event store to avoid re-fetching
if (event) { if (event) {
navigationEventStore.setEvent(event) navigationEventStore.setEvent(event)
// Also add to cache for future use
client.addEventToCache(event) client.addEventToCache(event)
} }
// Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) client.addEventToCache(ev)
}
}
// Build contextual URL based on current page // Build contextual URL based on current page
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
@ -332,7 +340,7 @@ export function useSmartNoteNavigation() {
// Mobile: always push to secondary stack AND update drawer // Mobile: always push to secondary stack AND update drawer
// This ensures back button works when clicking embedded events // This ensures back button works when clicking embedded events
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
openDrawer(noteId) openDrawer(noteId, event)
} else { } else {
// Desktop: check panel mode // Desktop: check panel mode
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
@ -342,11 +350,11 @@ export function useSmartNoteNavigation() {
if (isDrawerOpen) { if (isDrawerOpen) {
// Navigating from within drawer - push to stack for back button support // Navigating from within drawer - push to stack for back button support
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
openDrawer(noteId) openDrawer(noteId, event)
} else { } else {
// Opening drawer for first time // Opening drawer for first time
window.history.pushState(null, '', contextualUrl) window.history.pushState(null, '', contextualUrl)
openDrawer(noteId) openDrawer(noteId, event)
} }
} else { } else {
// Double-pane: use secondary panel // Double-pane: use secondary panel
@ -751,8 +759,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
// Drawer handlers // Drawer handlers
const openDrawer = useCallback((noteId: string) => { const [drawerInitialEvent, setDrawerInitialEvent] = useState<Event | null>(null)
const openDrawer = useCallback((noteId: string, initialEvent?: Event) => {
setDrawerNoteId(noteId) setDrawerNoteId(noteId)
setDrawerInitialEvent(initialEvent ?? null)
setDrawerOpen(true) setDrawerOpen(true)
}, []) }, [])
@ -1126,6 +1136,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => { setTimeout(() => {
setDrawerNoteId(null) setDrawerNoteId(null)
setDrawerInitialEvent(null)
// Restore URL to current primary page // Restore URL to current primary page
const pageUrl = buildPrimaryPageUrl( const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage, currentPrimaryPage,
@ -1184,6 +1195,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const historyUrl = state!.url const historyUrl = state!.url
setTimeout(() => { setTimeout(() => {
setDrawerNoteId(null) setDrawerNoteId(null)
setDrawerInitialEvent(null)
// Ensure URL matches the primary page (preserve /spells?spell=) // Ensure URL matches the primary page (preserve /spells?spell=)
const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl) const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl)
window.history.replaceState(null, '', pageUrl) window.history.replaceState(null, '', pageUrl)
@ -1460,7 +1472,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (drawerOpen && secondaryStack.length === 0) { if (drawerOpen && secondaryStack.length === 0) {
// Close drawer and reveal the background page // Close drawer and reveal the background page
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => setDrawerNoteId(null), 350) setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
return return
} }
@ -1468,7 +1483,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) { if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) {
// Close drawer (this will restore the URL to the correct primary page) // Close drawer (this will restore the URL to the correct primary page)
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => setDrawerNoteId(null), 350) setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
// Clear stack // Clear stack
setSecondaryStack([]) setSecondaryStack([])
@ -1558,7 +1576,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
triggerPrimaryPanelRefresh triggerPrimaryPanelRefresh
}} }}
> >
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}>
{primaryNoteView ? ( {primaryNoteView ? (
// Show primary note view with back button on mobile // Show primary note view with back button on mobile
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
@ -1629,8 +1647,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</> </>
)} )}
{drawerNoteId && ( {drawerNoteId && (
<NoteDrawer <NoteDrawer
open={drawerOpen} open={drawerOpen}
initialEvent={drawerInitialEvent}
onOpenChange={(open) => { onOpenChange={(open) => {
setDrawerOpen(open) setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes) // Only clear noteId when Sheet is fully closed (after animation completes)
@ -1642,10 +1661,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
) )
window.history.replaceState(null, '', pageUrl) window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350) setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
} }
}} }}
noteId={drawerNoteId} noteId={drawerNoteId}
/> />
)} )}
<Suspense fallback={null}> <Suspense fallback={null}>
@ -1657,6 +1679,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<Suspense fallback={null}> <Suspense fallback={null}>
<CreateWalletGuideToastLazy /> <CreateWalletGuideToastLazy />
</Suspense> </Suspense>
<Suspense fallback={null}>
<RelayPulseActiveNpubsSheetLazy />
</Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>
</CurrentRelaysProvider> </CurrentRelaysProvider>
@ -1684,7 +1709,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
triggerPrimaryPanelRefresh triggerPrimaryPanelRefresh
}} }}
> >
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}>
<div className="flex flex-col items-center bg-surface-background"> <div className="flex flex-col items-center bg-surface-background">
<div <div
className="flex h-[var(--vh)] w-full bg-surface-background" className="flex h-[var(--vh)] w-full bg-surface-background"
@ -1756,8 +1781,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
</div> </div>
{drawerNoteId && ( {drawerNoteId && (
<NoteDrawer <NoteDrawer
open={drawerOpen} open={drawerOpen}
initialEvent={drawerInitialEvent}
onOpenChange={(open) => { onOpenChange={(open) => {
setDrawerOpen(open) setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes) // Only clear noteId when Sheet is fully closed (after animation completes)
@ -1769,10 +1795,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
) )
window.history.replaceState(null, '', pageUrl) window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350) setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
} }
}} }}
noteId={drawerNoteId} noteId={drawerNoteId}
/> />
)} )}
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
@ -1807,6 +1836,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<Suspense fallback={null}> <Suspense fallback={null}>
<CreateWalletGuideToastLazy /> <CreateWalletGuideToastLazy />
</Suspense> </Suspense>
<Suspense fallback={null}>
<RelayPulseActiveNpubsSheetLazy />
</Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>
</CurrentRelaysProvider> </CurrentRelaysProvider>

3
src/components/Embedded/EmbeddedNote.tsx

@ -572,9 +572,8 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve
return return
} }
e.stopPropagation() e.stopPropagation()
client.addEventToCache(event)
const noteUrl = toNote(originalNoteId ?? event) const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl) navigateToNote(noteUrl, event)
}} }}
> >
{/* Header */} {/* Header */}

173
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -0,0 +1,173 @@
import UserAvatar from '@/components/UserAvatar'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { toProfile } from '@/lib/link'
import {
collectAggregatedNip05sFromKind0,
truncateAbout
} from '@/lib/relay-pulse-nip05'
import { useMuteList } from '@/contexts/mute-list-context'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { SecondaryPageLink } from '@/PageManager'
import type { Event } from 'nostr-tools'
import { Users } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
const ABOUT_PREVIEW_LEN = 250
function CompactProfileCard({ event }: { event: Event }) {
const profile = getProfileFromEvent(event)
const nip05s = collectAggregatedNip05sFromKind0(event)
const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN)
return (
<div className="rounded-lg border border-border/80 bg-muted/20 p-3">
<div className="flex gap-3">
<UserAvatar userId={event.pubkey} size="semiBig" />
<div className="min-w-0 flex-1">
<SecondaryPageLink
to={toProfile(event.pubkey)}
className="font-semibold text-foreground hover:underline"
>
{profile.username}
</SecondaryPageLink>
{about ? (
<p className="mt-1 text-xs leading-snug text-muted-foreground whitespace-pre-wrap break-words">
{about}
</p>
) : null}
{nip05s.length > 0 ? (
<ul className="mt-2 space-y-0.5 text-xs text-primary">
{nip05s.map((id) => (
<li key={id} className="truncate font-mono">
{id}
</li>
))}
</ul>
) : null}
</div>
</div>
</div>
)
}
export function RelayPulseActiveNpubsOpenButton({
className,
size = 'sm',
variant = 'outline'
}: {
className?: string
size?: 'sm' | 'icon'
variant?: 'outline' | 'ghost'
}) {
const { t } = useTranslation()
const { setActiveNpubsDrawerOpen, totalCount } = useFavoriteRelaysActivity()
if (totalCount === 0) return null
return (
<Button
type="button"
variant={variant}
size={size}
className={className}
aria-label={t('Relay pulse active npubs')}
title={t('Relay pulse active npubs')}
onClick={() => setActiveNpubsDrawerOpen(true)}
>
<Users className={size === 'icon' ? 'size-4' : 'size-3.5 shrink-0'} />
{size !== 'icon' ? (
<span className="ml-1.5 text-xs font-medium">{t('Relay pulse active npubs')}</span>
) : null}
</Button>
)
}
/** Mounted once inside {@link FavoriteRelaysActivityProvider}. */
export function RelayPulseActiveNpubsSheet() {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const {
activeNpubsDrawerOpen,
setActiveNpubsDrawerOpen,
followPubkeys,
otherPubkeys,
profileKind0ByPubkey,
profilesLoading
} = useFavoriteRelaysActivity()
const followWithProfile = useMemo(
() =>
followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const othersWithProfile = useMemo(
() =>
otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
return (
<Sheet open={activeNpubsDrawerOpen} onOpenChange={setActiveNpubsDrawerOpen}>
<SheetContent
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">
<SheetTitle>{t('Relay pulse active npubs')}</SheetTitle>
<SheetDescription>{t('Relay pulse active npubs hint')}</SheetDescription>
</SheetHeader>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-3">
{profilesLoading ? (
<p className="text-sm text-muted-foreground">{t('Loading...')}</p>
) : null}
<div className="space-y-6 pb-6">
{followWithProfile.length > 0 ? (
<section>
<h3 className="mb-2 text-sm font-semibold text-foreground">
{t('Relay pulse drawer following')}
</h3>
<div className="space-y-2">
{followWithProfile.map((pk) => {
const ev = profileKind0ByPubkey[pk]
return ev ? <CompactProfileCard key={pk} event={ev} /> : null
})}
</div>
</section>
) : null}
{othersWithProfile.length > 0 ? (
<section>
<h3 className="mb-2 text-sm font-semibold text-foreground">
{t('Relay pulse drawer others')}
</h3>
<div className="space-y-2">
{othersWithProfile.map((pk) => {
const ev = profileKind0ByPubkey[pk]
return ev ? <CompactProfileCard key={pk} event={ev} /> : null
})}
</div>
</section>
) : null}
{!profilesLoading &&
followWithProfile.length === 0 &&
othersWithProfile.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('Relay pulse drawer no profiles')}</p>
) : null}
</div>
</div>
</SheetContent>
</Sheet>
)
}

378
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -0,0 +1,378 @@
import UserAvatar from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
import { useMuteList } from '@/contexts/mute-list-context'
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'
const MOBILE_MAX_FOLLOW = 8
const MOBILE_MAX_OTHER = 8
const SIDEBAR_MAX_FOLLOW = 5
const SIDEBAR_MAX_OTHER = 5
/** Slight overlap so faces stay recognizable */
const AVATAR_OVERLAP = '-ml-1'
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])
}
function OverlappingAvatars({
pubkeys,
max,
avatarSize,
rowClassName,
scrollableRow = false
}: {
pubkeys: string[]
max: number
avatarSize: 'small' | 'xSmall' | 'tiny'
rowClassName?: string
/** Narrow screens: horizontal scroll inside the viewport instead of overflowing the page */
scrollableRow?: boolean
}) {
const slice = pubkeys.slice(0, max)
const extra = pubkeys.length - slice.length
const row = (
<div
className={cn(
'flex flex-row items-center pl-0.5',
scrollableRow && 'w-max max-w-none'
)}
>
{slice.map((pk, i) => (
<HoverCard key={pk} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
<div
className={cn(
'relative shrink-0 rounded-full ring-2 ring-background transition-[z-index] duration-150',
i > 0 && AVATAR_OVERLAP
)}
style={{ zIndex: i + 1 }}
>
<UserAvatar userId={pk} size={avatarSize} />
</div>
</HoverCardTrigger>
<HoverCardContent side="top" className="w-auto max-w-[min(18rem,calc(100vw-2rem))] py-2 px-3">
<SimpleUsername userId={pk} showAt className="text-sm font-medium" />
</HoverCardContent>
</HoverCard>
))}
{extra > 0 ? (
<div
className={cn(
'relative z-[20] flex h-7 min-w-7 shrink-0 items-center justify-center rounded-full bg-muted px-1.5 text-xs font-medium text-muted-foreground ring-2 ring-background',
slice.length > 0 && AVATAR_OVERLAP
)}
title={String(extra)}
>
+{extra > 99 ? '99+' : extra}
</div>
) : null}
</div>
)
if (scrollableRow) {
return (
<div
className={cn(
'w-full min-w-0 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]',
rowClassName
)}
>
{row}
</div>
)
}
return (
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end sm:justify-start',
rowClassName
)}
>
{row}
</div>
)
}
function ActiveAvatarGroups({
followPubkeysForAvatars,
otherPubkeysForAvatars,
followCount,
otherCount,
maxFollow,
maxOther,
avatarSize,
labelClassName,
stackClassName,
variant = 'default'
}: {
/** Subset with kind 0 only (shown as circles); counts use full totals */
followPubkeysForAvatars: string[]
otherPubkeysForAvatars: string[]
followCount: number
otherCount: number
maxFollow: number
maxOther: number
avatarSize: 'small' | 'xSmall' | 'tiny'
labelClassName: string
stackClassName?: string
/** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */
variant?: 'default' | 'mobileBar'
}) {
const { t } = useTranslation()
const mobileBar = variant === 'mobileBar'
const groupRowClass = mobileBar
? 'flex w-full min-w-0 flex-col gap-1.5'
: 'flex min-w-0 flex-col gap-1 min-[380px]:flex-row min-[380px]:items-center min-[380px]:gap-2'
return (
<div className={cn('flex min-w-0 flex-col gap-2', stackClassName)}>
{followCount > 0 ? (
<div className={groupRowClass}>
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}>
{t('Relay pulse follows', { count: followCount })}
</span>
<OverlappingAvatars
pubkeys={followPubkeysForAvatars}
max={maxFollow}
avatarSize={avatarSize}
scrollableRow={mobileBar}
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'}
/>
</div>
) : null}
{otherCount > 0 ? (
<div className={groupRowClass}>
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}>
{t('Relay pulse others', { count: otherCount })}
</span>
<OverlappingAvatars
pubkeys={otherPubkeysForAvatars}
max={maxOther}
avatarSize={avatarSize}
scrollableRow={mobileBar}
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'}
/>
</div>
) : null}
</div>
)
}
/** Home feed / mobile: full label above the page title */
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const {
followPubkeys,
otherPubkeys,
followCount,
otherCount,
totalCount,
loading,
relayActivityReady,
lastFetchedAtMs,
profileKind0ByPubkey
} = useFavoriteRelaysActivity()
const followPubkeysForAvatars = useMemo(
() =>
followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const otherPubkeysForAvatars = useMemo(
() =>
otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) {
return null
}
if (relayActivityReady && !loading && totalCount === 0) {
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',
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>
</div>
)
}
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',
loading && 'animate-pulse',
className
)}
>
<div className="flex w-full min-w-0 flex-col gap-3">
<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>
<ActiveAvatarGroups
variant="mobileBar"
followPubkeysForAvatars={followPubkeysForAvatars}
otherPubkeysForAvatars={otherPubkeysForAvatars}
followCount={followCount}
otherCount={otherCount}
maxFollow={MOBILE_MAX_FOLLOW}
maxOther={MOBILE_MAX_OTHER}
avatarSize="small"
labelClassName="text-[0.7rem] font-medium text-muted-foreground"
stackClassName="w-full min-w-0 max-w-full"
/>
</div>
</div>
)
}
/** Desktop sidebar: compact row under nav */
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const {
followPubkeys,
otherPubkeys,
followCount,
otherCount,
totalCount,
loading,
relayActivityReady,
lastFetchedAtMs,
profileKind0ByPubkey
} = useFavoriteRelaysActivity()
const followPubkeysForAvatars = useMemo(
() =>
followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const otherPubkeysForAvatars = useMemo(
() =>
otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk)
),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) {
return null
}
if (relayActivityReady && !loading && totalCount === 0) {
return (
<div className={cn('hidden px-1 py-2 xl:block xl:px-0', className)}>
<div className="flex flex-wrap items-center gap-1.5 px-1">
<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>
</div>
)
}
return (
<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')}
</p>
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</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 xl:hidden">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
</div>
<div className="max-xl:flex max-xl:justify-center">
<ActiveAvatarGroups
followPubkeysForAvatars={followPubkeysForAvatars}
otherPubkeysForAvatars={otherPubkeysForAvatars}
followCount={followCount}
otherCount={otherCount}
maxFollow={SIDEBAR_MAX_FOLLOW}
maxOther={SIDEBAR_MAX_OTHER}
avatarSize="xSmall"
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center"
/>
</div>
</div>
)
}

2
src/components/Note/Zap.tsx

@ -75,7 +75,7 @@ export default function Zap({ event, className }: { event: Event; className?: st
if (isEventZap) { if (isEventZap) {
// Event zap - navigate to the zapped event // Event zap - navigate to the zapped event
if (targetEvent) { if (targetEvent) {
navigateToNote(toNote(targetEvent.id)) navigateToNote(toNote(targetEvent.id), targetEvent)
} else if (zapInfo.eventId) { } else if (zapInfo.eventId) {
navigateToNote(toNote(zapInfo.eventId)) navigateToNote(toNote(zapInfo.eventId))
} }

6
src/components/NoteDrawer/index.tsx

@ -1,14 +1,16 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from '@/components/ui/sheet'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
import type { Event } from 'nostr-tools'
interface NoteDrawerProps { interface NoteDrawerProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
noteId: string | null noteId: string | null
initialEvent?: Event | null
} }
export default function NoteDrawer({ open, onOpenChange, noteId }: NoteDrawerProps) { export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) {
const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId) const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -43,7 +45,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId }: NoteDrawerPro
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0"> <SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0">
<div className="h-full"> <div className="h-full">
<NotePage id={displayNoteId} index={0} hideTitlebar={false} /> <NotePage id={displayNoteId} index={0} hideTitlebar={false} initialEvent={initialEvent ?? undefined} />
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

3
src/components/ReplyNote/index.tsx

@ -66,8 +66,7 @@ export default function ReplyNote({
if (onClickReply) { if (onClickReply) {
onClickReply(event) onClickReply(event)
} else { } else {
client.addEventToCache(event) navigateToNote(toNote(event), event)
navigateToNote(toNote(event))
} }
}} }}
> >

2
src/components/Sidebar/index.tsx

@ -10,6 +10,7 @@ import PostButton from './PostButton'
import RssButton from './RssButton' import RssButton from './RssButton'
import SearchButton from './SearchButton' import SearchButton from './SearchButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton'
@ -36,6 +37,7 @@ export default function PrimaryPageSidebar() {
<SearchButton /> <SearchButton />
<SpellsButton /> <SpellsButton />
<RssButton /> <RssButton />
<FavoriteRelaysActiveStripSidebar />
<PostButton /> <PostButton />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

4
src/contexts/note-drawer-context.tsx

@ -1,10 +1,12 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import type { Event } from 'nostr-tools'
export type NoteDrawerContextValue = { export type NoteDrawerContextValue = {
openDrawer: (noteId: string) => void openDrawer: (noteId: string, initialEvent?: Event) => void
closeDrawer: () => void closeDrawer: () => void
isDrawerOpen: boolean isDrawerOpen: boolean
drawerNoteId: string | null drawerNoteId: string | null
drawerInitialEvent: Event | null
} }
/** /**

16
src/hooks/useFetchEvent.tsx

@ -1,3 +1,4 @@
import { getNoteBech32Id } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
@ -27,11 +28,18 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
const skipShortcuts = refetchToken > 0 const skipShortcuts = refetchToken > 0
// If we have an initial event that matches the eventId, use it and skip fetching // If we have an initial event that matches the eventId, use it and skip fetching
if ( const initialMatches =
!skipShortcuts &&
initialEvent && 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)) { if (!isEventDeleted(initialEvent)) {
setEvent(initialEvent) setEvent(initialEvent)
addReplies([initialEvent]) addReplies([initialEvent])

11
src/i18n/locales/de.ts

@ -9,6 +9,17 @@ export default {
Home: 'Startseite', Home: 'Startseite',
Feed: 'Feed', Feed: 'Feed',
'Favorite Relays': 'Lieblings-Relays', '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', 'All favorite relays': 'Alle Lieblingsrelais',
'Pinned note': 'Angehefteter Beitrag', 'Pinned note': 'Angehefteter Beitrag',
'Relay settings': 'Relay-Einstellungen', 'Relay settings': 'Relay-Einstellungen',

11
src/i18n/locales/en.ts

@ -7,6 +7,17 @@ export default {
Home: 'Home', Home: 'Home',
Feed: 'Feed', Feed: 'Feed',
'Favorite Relays': 'Favorite Relays', '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', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings', 'Relay settings': 'Relays and Storage Settings',

36
src/lib/relay-pulse-nip05.ts

@ -0,0 +1,36 @@
import type { Event } from 'nostr-tools'
function addNip05(set: Set<string>, 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<string>()
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<string, unknown>
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)}`
}

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

@ -24,13 +24,13 @@ import React, {
useState useState
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker' import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import FollowingFeed from './FollowingFeed' import FollowingFeed from './FollowingFeed'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
const NoteListPage = forwardRef<TPageRef>((_, ref) => { const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
@ -39,6 +39,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const bookmarkRef = useRef<{ refresh: () => void }>(null) const bookmarkRef = useRef<{ refresh: () => void }>(null)
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { feedInfo, relayUrls, isReady } = useFeed() const { feedInfo, relayUrls, isReady } = useFeed()
const { isSmallScreen } = useScreenSize()
const [showRelayDetails, setShowRelayDetails] = useState(false) const [showRelayDetails, setShowRelayDetails] = useState(false)
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null) const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
@ -168,6 +169,7 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const subHeader = ( const subHeader = (
<> <>
{isSmallScreen ? <FavoriteRelaysActiveStripMobileBar /> : null}
<div className="w-full min-w-0 border-b border-border/80 bg-background px-3 py-2 sm:px-4"> <div className="w-full min-w-0 border-b border-border/80 bg-background px-3 py-2 sm:px-4">
<h1 className="text-lg font-semibold leading-tight tracking-tight">{feedPageTitle}</h1> <h1 className="text-lg font-semibold leading-tight tracking-tight">{feedPageTitle}</h1>
</div> </div>

260
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<string, number>()
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<string[]>([])
const [loading, setLoading] = useState(false)
const [relayActivityReady, setRelayActivityReady] = useState(false)
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(null)
const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState<Record<string, Event>>({})
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<string | undefined>(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<string | undefined>(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<typeof setInterval> | 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<string, Event> = {}
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 <FavoriteRelaysActivityContext.Provider value={value}>{children}</FavoriteRelaysActivityContext.Provider>
}

5
src/providers/FollowListProvider.tsx

@ -29,6 +29,11 @@ export const useFollowList = () => {
return context 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 }) { export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr() const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr()

37
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<string, Event>
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
}

9
src/services/client.service.ts

@ -672,9 +672,13 @@ class ClientService extends EventTarget {
private recordSessionRelayFailure(url: string) { private recordSessionRelayFailure(url: string) {
const n = normalizeUrl(url) || url const n = normalizeUrl(url) || url
if (!n) return 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) 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', { logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', {
url: n, url: n,
strikes: count strikes: count
@ -1932,6 +1936,7 @@ class ClientService extends EventTarget {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
} }
relays = this.filterSessionStrikedRelays(relays)
const events = await this.queryService.query(relays, filter, onevent, { const events = await this.queryService.query(relays, filter, onevent, {
eoseTimeout, eoseTimeout,
globalTimeout, globalTimeout,

Loading…
Cancel
Save