Browse Source

bug-fixes

remove relay pulse
Nostr spec spell
imwald
Silberengel 3 weeks ago
parent
commit
7a9b7284dd
  1. 3
      src/App.tsx
  2. 9
      src/PageManager.tsx
  3. 205
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  4. 96
      src/components/FavoriteRelaysActiveStrip/index.tsx
  5. 26
      src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts
  6. 45
      src/components/KindFilter/index.tsx
  7. 14
      src/components/NormalFeed/index.tsx
  8. 93
      src/components/Note/NostrSpecCard.tsx
  9. 3
      src/components/Note/index.tsx
  10. 62
      src/components/NoteList/index.tsx
  11. 71
      src/components/NoteStats/index.tsx
  12. 1
      src/components/Profile/ProfileFeed.tsx
  13. 2
      src/components/Sidebar/index.tsx
  14. 1
      src/constants.ts
  15. 11
      src/i18n/locales/cs.ts
  16. 15
      src/i18n/locales/de.ts
  17. 14
      src/i18n/locales/en.ts
  18. 11
      src/i18n/locales/es.ts
  19. 11
      src/i18n/locales/fr.ts
  20. 11
      src/i18n/locales/nl.ts
  21. 11
      src/i18n/locales/pl.ts
  22. 11
      src/i18n/locales/ru.ts
  23. 11
      src/i18n/locales/tr.ts
  24. 11
      src/i18n/locales/zh.ts
  25. 24
      src/lib/download-event-markdown.ts
  26. 2
      src/lib/event-metadata.ts
  27. 63
      src/lib/home-feed-relays.ts
  28. 17
      src/lib/nostr-spec-affected-kinds.test.ts
  29. 14
      src/lib/nostr-spec-affected-kinds.ts
  30. 38
      src/lib/relay-pulse-active-npubs-cache.ts
  31. 29
      src/lib/relay-pulse-nip05.ts
  32. 19
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  33. 23
      src/pages/primary/NoteListPage/index.tsx
  34. 4
      src/pages/primary/SpellsPage/fauxSpellConfig.ts
  35. 7
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  36. 9
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  37. 447
      src/providers/FavoriteRelaysActivityProvider.tsx
  38. 37
      src/providers/FeedProvider.test.ts
  39. 7
      src/providers/FeedProvider.tsx
  40. 37
      src/providers/favorite-relays-activity-context.tsx
  41. 2
      src/services/session-interactive-prewarm-bridge.ts

3
src/App.tsx

@ -8,7 +8,6 @@ import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { NotificationThreadWatchProvider } from '@/providers/NotificationThreadWatchProvider' import { NotificationThreadWatchProvider } from '@/providers/NotificationThreadWatchProvider'
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'
@ -48,7 +47,6 @@ export default function App(): JSX.Element {
<FavoriteRelaysProvider> <FavoriteRelaysProvider>
<FollowListProvider> <FollowListProvider>
<MuteListProvider> <MuteListProvider>
<FavoriteRelaysActivityProvider>
<InterestListProvider> <InterestListProvider>
<BookmarksProvider> <BookmarksProvider>
<NotificationThreadWatchProvider> <NotificationThreadWatchProvider>
@ -70,7 +68,6 @@ export default function App(): JSX.Element {
</NotificationThreadWatchProvider> </NotificationThreadWatchProvider>
</BookmarksProvider> </BookmarksProvider>
</InterestListProvider> </InterestListProvider>
</FavoriteRelaysActivityProvider>
</MuteListProvider> </MuteListProvider>
</FollowListProvider> </FollowListProvider>
</FavoriteRelaysProvider> </FavoriteRelaysProvider>

9
src/PageManager.tsx

@ -101,9 +101,6 @@ 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 }))
)
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */ /** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage')) const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
@ -2378,9 +2375,6 @@ 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>
@ -2523,9 +2517,6 @@ 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>

205
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -1,205 +0,0 @@
import UserAvatar from '@/components/UserAvatar'
import ProfileAbout from '@/components/ProfileAbout'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { toProfile } from '@/lib/link'
import {
collectAggregatedNip05sFromKind0
} from '@/lib/relay-pulse-nip05'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { SecondaryPageLink } from '@/PageManager'
import { useRelativePastPhrase } from '@/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time'
import type { Event } from 'nostr-tools'
import { Users } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
function CompactProfileCard({ event }: { event: Event }) {
const profile = getProfileFromEvent(event)
const nip05s = collectAggregatedNip05sFromKind0(event)
const { setActiveNpubsDrawerOpen } = useFavoriteRelaysActivity()
const profileUrl = toProfile(event.pubkey)
const closeDrawer = () => setActiveNpubsDrawerOpen(false)
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={profileUrl}
className="font-semibold text-foreground hover:underline"
onClick={closeDrawer}
>
{profile.username}
</SecondaryPageLink>
<ProfileAbout
about={profile.about}
className="mt-1 line-clamp-4 text-xs leading-snug text-muted-foreground break-words"
/>
{nip05s.length > 0 ? (
<ul className="mt-2 space-y-0.5 text-xs">
{nip05s.map((id) => (
<li key={id} className="truncate font-mono">
<SecondaryPageLink
to={profileUrl}
className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
onClick={closeDrawer}
>
{id}
</SecondaryPageLink>
</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
const countLabel = (
<span className="tabular-nums font-medium">
{totalCount > 99 ? '99+' : totalCount}
</span>
)
return (
<Button
type="button"
variant={variant}
size={size}
className={cn(className, 'relative')}
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="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[0.6rem] font-medium text-primary-foreground">
{countLabel}
</span>
) : (
<>
<span className="ml-1.5 text-xs font-medium">{countLabel}</span>
<span className="ml-1 text-xs text-muted-foreground">
{t('Relay pulse active npubs')}
</span>
</>
)}
</Button>
)
}
/** Mounted once inside {@link FavoriteRelaysActivityProvider}. */
export function RelayPulseActiveNpubsSheet() {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const {
activeNpubsDrawerOpen,
setActiveNpubsDrawerOpen,
followPubkeys,
otherPubkeys,
profileKind0ByPubkey,
profilesLoading,
lastFetchedAtMs
} = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
const followWithProfile = useMemo(
() =>
followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet]
)
const othersWithProfile = useMemo(
() =>
otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, 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 space-y-1 text-left">
<SheetTitle>{t('Relay pulse active npubs')}</SheetTitle>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="text-xs text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<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>
)
}

96
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,96 +0,0 @@
import { cn } from '@/lib/utils'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet'
import { useTranslation } from 'react-i18next'
export { relativePastPhrase, useRelativePastPhrase } from './relay-pulse-relative-time'
/** Home feed / mobile: compact row above the page title (no section label — opens sheet for detail). */
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) {
const { t } = useTranslation()
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
if (!relayActivityReady && !loading) {
return (
<div
className={cn(
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4 animate-pulse',
className
)}
aria-hidden
>
<div className="ml-auto h-7 w-28 rounded-md bg-muted/50" />
</div>
)
}
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-1.5 sm:px-4',
className
)}
>
<p className="text-xs text-muted-foreground leading-snug">{t('Relay pulse empty')}</p>
</div>
)
}
return (
<div
className={cn(
'flex w-full min-w-0 max-w-full items-center justify-end border-b border-border/60 bg-muted/15 px-3 py-1.5 sm:px-4',
loading && 'animate-pulse',
className
)}
>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0 max-w-full" />
</div>
)
}
/** Desktop sidebar: compact row under nav */
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) {
const { t } = useTranslation()
const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity()
if (!relayActivityReady && !loading) {
return (
<div className={cn('px-1 py-2 xl:px-0 animate-pulse', className)}>
<p className="text-[0.65rem] font-medium leading-snug text-foreground">{t('Relay pulse')}</p>
<div className="mt-0.5 h-4 w-16 rounded bg-muted/50" aria-hidden />
</div>
)
}
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>
<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>
<div className="flex shrink-0 items-center gap-0.5">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
</div>
</div>
<div className="mb-1 flex justify-center gap-0.5 xl:hidden">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
</div>
</div>
)
}

26
src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts

@ -1,26 +0,0 @@
import type { TFunction } from 'i18next'
import { useEffect, useMemo, useState } from 'react'
export 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 })
}
export 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])
}

45
src/components/KindFilter/index.tsx

@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants' import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants'
@ -199,10 +199,10 @@ export default function KindFilter({
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
{temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')} {temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')}
</p> </p>
<div className={cn('grid grid-cols-2 gap-2', temporarySeeAllEvents && 'opacity-50')}> <div className={cn('grid grid-cols-1 gap-2 min-[480px]:grid-cols-2', temporarySeeAllEvents && 'pointer-events-none opacity-50')}>
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid min-w-0 gap-1.5 rounded-lg border px-4 py-3 text-left',
postsGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable' postsGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => { onClick={() => {
@ -215,8 +215,8 @@ export default function KindFilter({
setTemporaryShowKind1OPs(showKind1OPs) setTemporaryShowKind1OPs(showKind1OPs)
}} }}
> >
<p className="leading-none font-medium">{t('Posts')}</p> <p className="leading-snug font-medium whitespace-normal">{t('Posts')}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs whitespace-normal break-words">
{t('Feed filter posts group kinds', { {t('Feed filter posts group kinds', {
kinds: [KIND_1, ...FEED_POSTS_GROUP_KINDS].join(', ') kinds: [KIND_1, ...FEED_POSTS_GROUP_KINDS].join(', ')
})} })}
@ -224,7 +224,7 @@ export default function KindFilter({
</div> </div>
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid min-w-0 gap-1.5 rounded-lg border px-4 py-3 text-left',
repliesGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable' repliesGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => { onClick={() => {
@ -238,8 +238,8 @@ export default function KindFilter({
setTemporaryShowKind1111(showKind1111) setTemporaryShowKind1111(showKind1111)
}} }}
> >
<p className="leading-none font-medium">{t('Replies')}</p> <p className="leading-snug font-medium whitespace-normal">{t('Replies')}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs whitespace-normal break-words">
{t('Feed filter replies group kinds', { {t('Feed filter replies group kinds', {
kinds: [KIND_1, KIND_1111, ...FEED_REPLIES_GROUP_KINDS].join(', ') kinds: [KIND_1, KIND_1111, ...FEED_REPLIES_GROUP_KINDS].join(', ')
})} })}
@ -247,15 +247,15 @@ export default function KindFilter({
</div> </div>
<div <div
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid min-w-0 gap-1.5 rounded-lg border px-4 py-3 text-left',
gitGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable' gitGroupEnabled ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => { onClick={() => {
setTemporaryShowKinds(applyFeedGitGroupToggle(temporaryShowKinds, !gitGroupEnabled)) setTemporaryShowKinds(applyFeedGitGroupToggle(temporaryShowKinds, !gitGroupEnabled))
}} }}
> >
<p className="leading-none font-medium">{t('Git')}</p> <p className="leading-snug font-medium whitespace-normal">{t('Git')}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs whitespace-normal break-words">
{t('Feed filter git group kinds', { kinds: FEED_GIT_GROUP_KINDS.join(', ') })} {t('Feed filter git group kinds', { kinds: FEED_GIT_GROUP_KINDS.join(', ') })}
</p> </p>
</div> </div>
@ -266,7 +266,7 @@ export default function KindFilter({
<div <div
key={kindGroup.join('-')} key={kindGroup.join('-')}
className={cn( className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3', 'cursor-pointer grid min-w-0 gap-1.5 rounded-lg border px-4 py-3 text-left',
checked ? 'border-primary/60 bg-primary/5' : 'clickable' checked ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => { onClick={() => {
@ -277,14 +277,14 @@ export default function KindFilter({
} }
}} }}
> >
<p className="leading-none font-medium">{t(label)}</p> <p className="leading-snug font-medium whitespace-normal">{t(label)}</p>
<p className="text-muted-foreground text-xs">kind {kindGroup.join(', ')}</p> <p className="text-muted-foreground text-xs whitespace-normal break-words">kind {kindGroup.join(', ')}</p>
</div> </div>
) )
})} })}
</div> </div>
<div className="grid grid-cols-3 gap-2 mt-4"> <div className="mt-4 grid grid-cols-1 gap-2 min-[480px]:grid-cols-3">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
@ -354,13 +354,18 @@ export default function KindFilter({
return ( return (
<> <>
{trigger} {trigger}
<Drawer open={open} onOpenChange={setOpen}> <Drawer handleOnly open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild></DrawerTrigger> <DrawerContent
<DrawerContent className="flex max-h-[90dvh] flex-col px-4 min-h-0"> dragHandle="vaul"
className="flex max-h-[90dvh] min-h-0 flex-col overflow-hidden px-4"
>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Filter</DrawerTitle> <DrawerTitle>Filter</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-4"> <div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-4 pr-4 [scrollbar-gutter:stable]"
style={{ touchAction: 'pan-y' }}
>
{content} {content}
</div> </div>
</DrawerContent> </DrawerContent>
@ -380,7 +385,7 @@ export default function KindFilter({
sideOffset={6} sideOffset={6}
sticky="always" sticky="always"
> >
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">{content}</div> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pr-5 [scrollbar-gutter:stable]">{content}</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

14
src/components/NormalFeed/index.tsx

@ -287,10 +287,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
) )
/** /**
* Relay explorer passes {@link showAllKinds} explicitly. Home feeds must not tie this to * Relay explorer passes {@link showAllKinds} explicitly. Home feeds tie bypass to visible rows so
* {@link feedKindFilterBypass}: bypass widens REQ only; the kind picker still narrows visible rows. * "See all events" shows the full merged batch, not only REQ-widened fetches with picker filtering.
*/ */
const listShowAllKinds = showAllKindsProp ?? false const listShowAllKinds = showAllKindsProp ?? feedKindFilterBypass
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */ /** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}` const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}`
@ -339,6 +339,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
* Intentionally omit `tabsElement` from deps covered by `listMode` + `subHeaderFilterDepsKey`. * Intentionally omit `tabsElement` from deps covered by `listMode` + `subHeaderFilterDepsKey`.
* Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable * Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable
* identities there would retrigger every render and loop with parent state. * identities there would retrigger every render and loop with parent state.
* Do not clear subHeader between dep updates nulling remounts the filter portal slot and retriggers
* NoteList subscriptions / layout churn on the home feed.
*/ */
useEffect(() => { useEffect(() => {
if (!isMainFeed || !setSubHeader) return if (!isMainFeed || !setSubHeader) return
@ -355,7 +357,6 @@ const NormalFeed = forwardRef<TNoteListRef, {
} else { } else {
setSubHeader(tabsElement) setSubHeader(tabsElement)
} }
return () => setSubHeader(null)
}, [ }, [
isMainFeed, isMainFeed,
setSubHeader, setSubHeader,
@ -366,6 +367,11 @@ const NormalFeed = forwardRef<TNoteListRef, {
mergeFilterWithTabsRow mergeFilterWithTabsRow
]) ])
useEffect(() => {
if (!isMainFeed || !setSubHeader) return
return () => setSubHeader(null)
}, [isMainFeed, setSubHeader])
return ( return (
<> <>
{renderTabsInFeed && {renderTabsInFeed &&

93
src/components/Note/NostrSpecCard.tsx

@ -0,0 +1,93 @@
import { Button } from '@/components/ui/button'
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { downloadEventAsMarkdownFile } from '@/lib/download-event-markdown'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { parseNostrSpecAffectedKindsFromEvent } from '@/lib/nostr-spec-affected-kinds'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional } from '@/PageManager'
import { Download } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
/**
* Compact feed card for Nostr specifications (kind 30817): title, short blurb, affected kinds no cover images.
*/
export default function NostrSpecCard({
event,
className,
interactive = true
}: {
event: Event
className?: string
interactive?: boolean
}) {
const { t } = useTranslation()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => {
window.location.href = url
})
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const affectedKinds = useMemo(() => parseNostrSpecAffectedKindsFromEvent(event), [event])
const displayTitle = metadata.title?.trim() || t('Nostr Specification')
const handleCardClick = (e: React.MouseEvent) => {
if (!interactive) return
e.stopPropagation()
push(toNote(event))
}
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
try {
downloadEventAsMarkdownFile(event, metadata.title)
toast.success(t('Article exported as Markdown'))
} catch {
toast.error(t('Failed to export article'))
}
}
const cardClass = cn(
'rounded-lg border px-3 py-2.5 transition-colors',
interactive && 'cursor-pointer hover:bg-muted/50'
)
return (
<div className={cn(className, !interactive && 'pointer-events-none')}>
<div className={cardClass} onClick={interactive ? handleCardClick : undefined}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-1">
<div className="text-base font-semibold leading-snug break-words line-clamp-2">{displayTitle}</div>
{summaryText ? (
<p className="text-sm leading-snug text-muted-foreground line-clamp-2 break-words">{summaryText}</p>
) : null}
{affectedKinds.length > 0 ? (
<p className="text-xs tabular-nums text-muted-foreground/90">
{t('Nostr spec affected kinds', {
kinds: affectedKinds.join(', ')
})}
</p>
) : null}
</div>
{interactive ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
title={t('Download as Markdown file')}
aria-label={t('Download as Markdown file')}
onClick={handleDownload}
>
<Download className="size-4" />
</Button>
) : null}
</div>
</div>
</div>
)
}

3
src/components/Note/index.tsx

@ -65,6 +65,7 @@ import LiveEvent from './LiveEvent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import NostrSpecCard from './NostrSpecCard'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard' import LongFormCard from './LongFormCard'
import MutedNote from './MutedNote' import MutedNote from './MutedNote'
@ -482,7 +483,7 @@ export default function Note({
content = showFull ? ( content = showFull ? (
renderEventContent() renderEventContent()
) : ( ) : (
<WikiCard className="mt-2" event={displayEvent} /> <NostrSpecCard className="mt-2" event={displayEvent} />
) )
} else if (event.kind === ExtendedKind.PUBLICATION) { } else if (event.kind === ExtendedKind.PUBLICATION) {
if (showFull) { if (showFull) {

62
src/components/NoteList/index.tsx

@ -26,7 +26,7 @@ import {
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
@ -911,6 +911,8 @@ const NoteList = forwardRef(
const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) const timelineEstablishedCloserRef = useRef<(() => void) | null>(null)
/** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */ /** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */
const timelineEffectGenerationRef = useRef(0) const timelineEffectGenerationRef = useRef(0)
/** Skip closing/reopening the live REQ when effect deps churn but subscription shape is unchanged. */
const lastTimelineLiveIdentityKeyRef = useRef('')
/** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */ /** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */
const feedPaintSessionPendingRef = useRef(false) const feedPaintSessionPendingRef = useRef(false)
/** Relay / one-shot data was written to state; log once after commit. */ /** Relay / one-shot data was written to state; log once after commit. */
@ -1095,15 +1097,40 @@ const NoteList = forwardRef(
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const homeFeedSeenOnAllowlistOpKey = useMemo(
() =>
homeFeedSeenOnAllowlistOp?.length
? [...homeFeedSeenOnAllowlistOp]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
: '',
[homeFeedSeenOnAllowlistOp]
)
const homeFeedSeenOnAllowlistRepliesKey = useMemo(
() =>
homeFeedSeenOnAllowlistReplies?.length
? [...homeFeedSeenOnAllowlistReplies]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
: '',
[homeFeedSeenOnAllowlistReplies]
)
const homeFeedActiveSeenOnAllowlist = useMemo(() => { const homeFeedActiveSeenOnAllowlist = useMemo(() => {
if (feedSubscriptionKey !== 'home-all-favorites') return undefined if (feedSubscriptionKey !== 'home-all-favorites') return undefined
if (homeFeedListMode === 'postsAndReplies' || homeFeedListMode === 'media') { if (homeFeedListMode === 'postsAndReplies' || homeFeedListMode === 'media') {
return homeFeedSeenOnAllowlistReplies?.length ? homeFeedSeenOnAllowlistReplies : undefined return homeFeedSeenOnAllowlistRepliesKey ? homeFeedSeenOnAllowlistReplies : undefined
} }
return homeFeedSeenOnAllowlistOp?.length ? homeFeedSeenOnAllowlistOp : undefined return homeFeedSeenOnAllowlistOpKey ? homeFeedSeenOnAllowlistOp : undefined
}, [ }, [
feedSubscriptionKey, feedSubscriptionKey,
homeFeedListMode, homeFeedListMode,
homeFeedSeenOnAllowlistOpKey,
homeFeedSeenOnAllowlistRepliesKey,
homeFeedSeenOnAllowlistOp, homeFeedSeenOnAllowlistOp,
homeFeedSeenOnAllowlistReplies homeFeedSeenOnAllowlistReplies
]) ])
@ -1995,6 +2022,34 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => { useEffect(() => {
const timelineLiveIdentityKey = [
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live',
timelineSubscriptionKey,
feedSubscriptionKey ?? '',
sessionSnapshotIdentityKey,
subRequestsKey,
timelineResubscribeKindKey,
seeAllFeedEvents ? '1' : '0',
useFilterAsIs ? '1' : '0',
areAlgoRelays ? '1' : '0',
allowKindlessRelayExplore ? '1' : '0',
clientSideKindFilter ? '1' : '0',
showAllKinds ? '1' : '0',
withKindFilter ? '1' : '0',
feedTimelineScopeKey ?? '',
String(refreshCount),
relayCapabilityReady ? '1' : '0'
].join('\x1e')
if (
!pauseTimelineForPrimaryFreeze &&
lastTimelineLiveIdentityKeyRef.current === timelineLiveIdentityKey &&
timelineEstablishedCloserRef.current
) {
return () => {}
}
lastTimelineLiveIdentityKeyRef.current = timelineLiveIdentityKey
const effectGen = ++timelineEffectGenerationRef.current const effectGen = ++timelineEffectGenerationRef.current
const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current
@ -3402,6 +3457,7 @@ const NoteList = forwardRef(
const promise = init() const promise = init()
const snapshotKeyForCleanup = sessionSnapshotIdentityKey const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => { return () => {
lastTimelineLiveIdentityKeyRef.current = ''
effectActive = false effectActive = false
if (liveOnNewFlushTimerRef.current != null) { if (liveOnNewFlushTimerRef.current != null) {
clearTimeout(liveOnNewFlushTimerRef.current) clearTimeout(liveOnNewFlushTimerRef.current)

71
src/components/NoteStats/index.tsx

@ -9,14 +9,25 @@ import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons' import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons'
import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton' import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton' import { RepostButtonWithStats } from './RepostButton'
import { ZapButtonWithStats } from './ZapButton' import { ZapButtonWithStats } from './ZapButton'
/** One equal-width column in the note action bar; keeps icons centered as button count varies. */
function NoteStatsBarItem({ children }: { children: ReactNode }) {
return (
<div className="flex min-w-0 flex-1 basis-0 items-center justify-center [&>*]:min-w-0">
{children}
</div>
)
}
export default function NoteStats({ export default function NoteStats({
event, event,
className, className,
@ -120,30 +131,61 @@ export default function NoteStats({
statsFetchRelayScopeKey statsFetchRelayScopeKey
]) ])
const interactionButtons = ( const watch = useNotificationThreadWatchOptional()
<> const bookmarksContext = useBookmarksOptional()
const showThreadWatchButtons = Boolean(watch && pubkey)
const showBookmarkButton = Boolean(bookmarksContext && pubkey)
const barItems: ReactNode[] = [
<NoteStatsBarItem key="reply">
<ReplyButtonWithStats event={event} noteStats={noteStats} /> <ReplyButtonWithStats event={event} noteStats={noteStats} />
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( </NoteStatsBarItem>
]
if (!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="repost">
<RepostButtonWithStats event={event} noteStats={noteStats} /> <RepostButtonWithStats event={event} noteStats={noteStats} />
)} </NoteStatsBarItem>
)
}
barItems.push(
<NoteStatsBarItem key="like">
<LikeButtonWithStats <LikeButtonWithStats
event={event} event={event}
noteStats={noteStats} noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion} isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger} useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/> />
{!isRssArticleRoot && ( </NoteStatsBarItem>
)
if (!isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="tip">
<ZapButtonWithStats event={event} noteStats={noteStats} /> <ZapButtonWithStats event={event} noteStats={noteStats} />
)} </NoteStatsBarItem>
</>
) )
}
const utilityButtons = !isRssArticleRoot ? ( if (!isRssArticleRoot && showThreadWatchButtons) {
<> barItems.push(
<NoteStatsBarItem key="thread-watch">
<div className="flex items-center justify-center gap-0.5">
<NotificationThreadWatchButtons event={event} /> <NotificationThreadWatchButtons event={event} />
</div>
</NoteStatsBarItem>
)
}
if (!isRssArticleRoot && showBookmarkButton) {
barItems.push(
<NoteStatsBarItem key="bookmark">
<BookmarkButton event={event} /> <BookmarkButton event={event} />
</> </NoteStatsBarItem>
) : null )
}
return ( return (
<div <div
@ -154,13 +196,12 @@ export default function NoteStats({
> >
<div <div
className={cn( className={cn(
'flex min-w-0 flex-nowrap items-center gap-0 overflow-x-auto overscroll-x-contain [&_svg]:size-[15px] [&_button]:px-1.5', 'flex w-full min-w-0 items-stretch [&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
loading ? 'animate-pulse' : '', loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}
> >
{interactionButtons} {barItems}
{utilityButtons}
</div> </div>
</div> </div>
) )

1
src/components/Profile/ProfileFeed.tsx

@ -129,6 +129,7 @@ const ProfileFeed = forwardRef<
hostPrimaryPageName="profile" hostPrimaryPageName="profile"
showKinds={profileTimelineShowKinds} showKinds={profileTimelineShowKinds}
seeAllFeedEvents={feedKindFilterBypass} seeAllFeedEvents={feedKindFilterBypass}
showAllKinds={feedKindFilterBypass}
withKindFilter withKindFilter
useFilterAsIs useFilterAsIs
clientSideKindFilter clientSideKindFilter

2
src/components/Sidebar/index.tsx

@ -11,7 +11,6 @@ import SearchButton from './SearchButton'
import FavoritesButton from './FavoritesButton' import FavoritesButton from './FavoritesButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip' import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip'
import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
@ -46,7 +45,6 @@ export default function PrimaryPageSidebar() {
<FavoritesButton /> <FavoritesButton />
<SpellsButton /> <SpellsButton />
<RssButton /> <RssButton />
<FavoriteRelaysActiveStripSidebar />
<ConnectedRelaysSidebarStrip /> <ConnectedRelaysSidebarStrip />
<PostButton /> <PostButton />
<div className="max-xl:hidden w-full min-w-0 space-y-2 px-1"> <div className="max-xl:hidden w-full min-w-0 space-y-2 px-1">

1
src/constants.ts

@ -1005,6 +1005,7 @@ export const FAUX_SPELL_ORDER = [
'followPacks', 'followPacks',
'media', 'media',
'interests', 'interests',
'nostrSpecs',
'bookmarks', 'bookmarks',
'calendar' 'calendar'
] as const ] as const

11
src/i18n/locales/cs.ts

@ -9,17 +9,6 @@ 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'All favorite relays', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

15
src/i18n/locales/de.ts

@ -9,18 +9,6 @@ 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.',
'See the newest notes from your follows': 'Neueste Notizen von deinen Abos anzeigen', 'See the newest notes from your follows': 'Neueste Notizen von deinen Abos anzeigen',
'All favorite relays': 'Alle Lieblingsrelais', 'All favorite relays': 'Alle Lieblingsrelais',
'Pinned note': 'Angehefteter Beitrag', 'Pinned note': 'Angehefteter Beitrag',
@ -2561,6 +2549,9 @@ export default {
'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)', 'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)',
'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)', 'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)',
'Nostr Specification': 'Nostr Specification', 'Nostr Specification': 'Nostr Specification',
'Nostr specs': 'Nostr-Spezifikationen',
'Nostr spec affected kinds': 'Kinds {{kinds}}',
'Download as Markdown file': 'Als Markdown-Datei herunterladen',
'You can only delete your own notes': 'You can only delete your own notes', 'You can only delete your own notes': 'You can only delete your own notes',
'You must be logged in to create a thread': 'You must be logged in to create a thread', 'You must be logged in to create a thread': 'You must be logged in to create a thread',
'You need to add at least one media server in order to upload media files.': 'You need to add at least one media server in order to upload media files.':

14
src/i18n/locales/en.ts

@ -7,17 +7,6 @@ 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'All favorite relays', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',
@ -2530,6 +2519,9 @@ export default {
'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)', 'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)',
'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)', 'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)',
'Nostr Specification': 'Nostr Specification', 'Nostr Specification': 'Nostr Specification',
'Nostr specs': 'Nostr specs',
'Nostr spec affected kinds': 'Kinds {{kinds}}',
'Download as Markdown file': 'Download as Markdown file',
'You can only delete your own notes': 'You can only delete your own notes', 'You can only delete your own notes': 'You can only delete your own notes',
'You must be logged in to create a thread': 'You must be logged in to create a thread', 'You must be logged in to create a thread': 'You must be logged in to create a thread',
'You need to add at least one media server in order to upload media files.': 'You need to add at least one media server in order to upload media files.':

11
src/i18n/locales/es.ts

@ -9,17 +9,6 @@ export default {
Home: 'Inicio', Home: 'Inicio',
Feed: 'Feed', Feed: 'Feed',
'Favorite Relays': 'Relés favoritos', 'Favorite Relays': 'Relés favoritos',
'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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'Todos los relés favoritos', 'All favorite relays': 'Todos los relés favoritos',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/fr.ts

@ -9,17 +9,6 @@ export default {
Home: 'Accueil', Home: 'Accueil',
Feed: 'Feed', Feed: 'Feed',
'Favorite Relays': 'Relais favoris', 'Favorite Relays': 'Relais favoris',
'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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'Tous les relais favoris', 'All favorite relays': 'Tous les relais favoris',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/nl.ts

@ -9,17 +9,6 @@ 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'All favorite relays', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/pl.ts

@ -9,17 +9,6 @@ export default {
Home: 'Strona Główna', Home: 'Strona Główna',
Feed: 'Feed', Feed: 'Feed',
'Favorite Relays': 'Ulubione transmitery', 'Favorite Relays': 'Ulubione transmitery',
'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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'Wszystkie ulubione transmitery', 'All favorite relays': 'Wszystkie ulubione transmitery',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/ru.ts

@ -9,17 +9,6 @@ export default {
Home: 'Главная', Home: 'Главная',
Feed: 'Feed', 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'Все избранные ретрансляторы', 'All favorite relays': 'Все избранные ретрансляторы',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/tr.ts

@ -9,17 +9,6 @@ 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': 'All favorite relays', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

11
src/i18n/locales/zh.ts

@ -9,17 +9,6 @@ export default {
Home: '主页', Home: '主页',
Feed: 'Feed', 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.',
'See the newest notes from your follows': 'See the newest notes from your follows', 'See the newest notes from your follows': 'See the newest notes from your follows',
'All favorite relays': '所有收藏服务器', 'All favorite relays': '所有收藏服务器',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',

24
src/lib/download-event-markdown.ts

@ -0,0 +1,24 @@
import type { Event } from 'nostr-tools'
function markdownFilename(title: string | undefined): string {
const base = (title?.trim() || 'document')
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120)
return `${base || 'document'}.md`
}
/** Trigger a browser download of the event body as a `.md` file. */
export function downloadEventAsMarkdownFile(event: Event, title?: string): void {
const filename = markdownFilename(title)
const blob = new Blob([event.content], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}

2
src/lib/event-metadata.ts

@ -130,7 +130,7 @@ export function getRelayListFromEvent(
/** /**
* Read-side `r` tags from a relay list event (e.g. kind 10012) without {@link FAST_READ_RELAY_URLS} fallback * Read-side `r` tags from a relay list event (e.g. kind 10012) without {@link FAST_READ_RELAY_URLS} fallback
* when the list is empty or oversized for strict viewer-owned REQ stacks (relay pulse). * when the list is empty or oversized for strict viewer-owned REQ stacks.
*/ */
export function getRelayListReadFromEventNoFastFallback( export function getRelayListReadFromEventNoFastFallback(
event: Event | null | undefined, event: Event | null | undefined,

63
src/lib/home-feed-relays.ts

@ -1,10 +1,6 @@
import { MAX_REQ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import type { Event } from 'nostr-tools'
export { stripNostrLandAggrFromRelayUrls } export { stripNostrLandAggrFromRelayUrls }
@ -51,62 +47,3 @@ export function buildAllFavoritesFeedRelayUrls(
) )
) )
} }
/**
* Relay pulse (sidebar active authors): only the viewers own stack favorites (+ relay sets),
* NIP-65 read, kind 10012 cache read, and HTTP index reads never the global fast-read layer.
*/
export function buildRelayPulseQueryRelayUrls(options: {
viewerPubkey: string | null | undefined
favoriteRelayUrls: string[]
blockedRelays: string[]
relayList: { read?: string[]; httpRead?: string[] } | null | undefined
cacheRelayListEvent: Event | null | undefined
httpRelayListEvent: Event | null | undefined
}): string[] {
const {
viewerPubkey,
favoriteRelayUrls,
blockedRelays,
relayList,
cacheRelayListEvent,
httpRelayListEvent
} = options
const useGlobalFavoriteDefaults = viewerUsesGlobalRelayDefaults({
viewerPubkey,
favoriteRelayUrls,
relayList
})
const primaryRelays = getFavoritesFeedRelayUrls(favoriteRelayUrls, blockedRelays, useGlobalFavoriteDefaults)
const inboxRelayUrls = relayList?.read?.length ? relayList.read : []
const cacheRelayUrls: string[] = []
if (cacheRelayListEvent) {
cacheRelayUrls.push(...getRelayListReadFromEventNoFastFallback(cacheRelayListEvent, blockedRelays))
}
const httpRelayUrls: string[] = [...(relayList?.httpRead ?? [])]
if (httpRelayListEvent) {
httpRelayUrls.push(...getHttpRelayListFromEvent(httpRelayListEvent, blockedRelays).httpRead)
}
return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
{ source: 'favorites', urls: primaryRelays },
{ source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls }
],
{
operation: 'read',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true,
maxRelays: MAX_REQ_RELAY_URLS
}
)
)
}

17
src/lib/nostr-spec-affected-kinds.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parseNostrSpecAffectedKinds } from './nostr-spec-affected-kinds' import { parseNostrSpecAffectedKinds, parseNostrSpecAffectedKindsFromEvent } from './nostr-spec-affected-kinds'
describe('parseNostrSpecAffectedKinds', () => { describe('parseNostrSpecAffectedKinds', () => {
it('parses one kind per row and dedupes', () => { it('parses one kind per row and dedupes', () => {
@ -22,3 +22,18 @@ describe('parseNostrSpecAffectedKinds', () => {
).toEqual([]) ).toEqual([])
}) })
}) })
describe('parseNostrSpecAffectedKindsFromEvent', () => {
it('reads numeric k tags from the event', () => {
expect(
parseNostrSpecAffectedKindsFromEvent({
tags: [
['d', 'nip-01'],
['k', '1'],
['k', '7'],
['k', '1']
]
})
).toEqual([1, 7])
})
})

14
src/lib/nostr-spec-affected-kinds.ts

@ -18,3 +18,17 @@ export function parseNostrSpecAffectedKinds(rows: NostrSpecAffectedKindRow[]): n
} }
return out return out
} }
/** Kind numbers from `k` tags on a published Nostr specification (30817). */
export function parseNostrSpecAffectedKindsFromEvent(event: { tags: string[][] }): number[] {
const seen = new Set<number>()
const out: number[] = []
for (const tag of event.tags) {
if (tag[0] !== 'k' || !tag[1]) continue
const n = Number.parseInt(tag[1], 10)
if (!Number.isInteger(n) || n < 0 || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out.sort((a, b) => a - b)
}

38
src/lib/relay-pulse-active-npubs-cache.ts

@ -1,38 +0,0 @@
import logger from '@/lib/logger'
/** One row per browser; overwritten whenever a new active-npub list is fetched for the same relay + viewer scope. */
export type RelayPulseActiveNpubsCacheRow = {
relayKey: string
viewerPubkey: string | null
orderedPubkeys: string[]
lastFetchedAtMs: number
}
const STORAGE_KEY = 'jumble.relayPulse.activeNpubs.v1'
export function readRelayPulseActiveNpubsCache(
relayKey: string,
viewerPubkey: string | null
): Pick<RelayPulseActiveNpubsCacheRow, 'orderedPubkeys' | 'lastFetchedAtMs'> | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== 'object') return null
const o = data as Record<string, unknown>
if (o.relayKey !== relayKey || o.viewerPubkey !== viewerPubkey) return null
if (!Array.isArray(o.orderedPubkeys) || typeof o.lastFetchedAtMs !== 'number') return null
const orderedPubkeys = o.orderedPubkeys.filter((x): x is string => typeof x === 'string')
return { orderedPubkeys, lastFetchedAtMs: o.lastFetchedAtMs }
} catch {
return null
}
}
export function writeRelayPulseActiveNpubsCache(row: RelayPulseActiveNpubsCacheRow): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(row))
} catch (e) {
logger.debug('[RelayPulseActiveNpubsCache] write failed', { error: e })
}
}

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

@ -1,29 +0,0 @@
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]
}

19
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -30,6 +30,17 @@ const RelaysFeed = forwardRef<
.join('|'), .join('|'),
[relayUrls] [relayUrls]
) )
const replyRelayUrlsKey = useMemo(
() =>
[...replyRelayUrls]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('|'),
[replyRelayUrls]
)
const homeFeedSeenOnAllowlistOp = useMemo(() => relayUrls, [relayUrlsKey])
const homeFeedSeenOnAllowlistReplies = useMemo(() => replyRelayUrls, [replyRelayUrlsKey])
useEffect(() => { useEffect(() => {
if (relayUrls.length === 0) { if (relayUrls.length === 0) {
@ -85,7 +96,7 @@ const RelaysFeed = forwardRef<
} }
} }
] ]
}, [canRenderFeed, relayUrls, defaultKinds]) }, [canRenderFeed, relayUrlsKey, relayUrls, defaultKinds])
const repliesSubRequests = useMemo(() => { const repliesSubRequests = useMemo(() => {
if (!canRenderFeed) return [] if (!canRenderFeed) return []
return [ return [
@ -96,7 +107,7 @@ const RelaysFeed = forwardRef<
} }
} }
] ]
}, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) }, [canRenderFeed, replyRelayUrlsKey, replyRelayUrls, relayUrlsKey, relayUrls, defaultKinds])
if (!canRenderFeed) { if (!canRenderFeed) {
return null return null
@ -117,8 +128,8 @@ const RelaysFeed = forwardRef<
widenMainGalleryRelays={false} widenMainGalleryRelays={false}
feedSubscriptionKey="home-all-favorites" feedSubscriptionKey="home-all-favorites"
feedTimelineScopeKey="all-favorites" feedTimelineScopeKey="all-favorites"
homeFeedSeenOnAllowlistOp={relayUrls} homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp}
homeFeedSeenOnAllowlistReplies={replyRelayUrls} homeFeedSeenOnAllowlistReplies={homeFeedSeenOnAllowlistReplies}
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName="feed" hostPrimaryPageName="feed"
/> />

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

@ -5,6 +5,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFeed } from '@/providers/feed-context' import { useFeed } from '@/providers/feed-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { normalizeUrl } from '@/lib/url'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Calendar, Compass, Flame } from 'lucide-react' import { Calendar, Compass, Flame } from 'lucide-react'
@ -13,11 +14,11 @@ import React, {
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip'
import Logo from '@/assets/Logo' import Logo from '@/assets/Logo'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
@ -28,6 +29,15 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)
const { relayUrls } = useFeed() const { relayUrls } = useFeed()
const relayUrlsKey = useMemo(
() =>
[...relayUrls]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('|'),
[relayUrls]
)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null) const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
@ -52,19 +62,18 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
// The feed stays mounted and maintains scroll position at all times // The feed stays mounted and maintains scroll position at all times
useEffect(() => { useEffect(() => {
if (relayUrls.length) { const urls = relayUrlsKey.split('|').filter(Boolean)
addRelayUrls(relayUrls) if (!urls.length) return
addRelayUrls(urls)
return () => { return () => {
removeRelayUrls(relayUrls) removeRelayUrls(urls)
}
} }
}, [relayUrls]) }, [relayUrlsKey, addRelayUrls, removeRelayUrls])
const feedPageTitle = t('Favorite Relays') const feedPageTitle = t('Favorite Relays')
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.5 sm:px-4 sm:py-3"> <div className="w-full min-w-0 border-b border-border/80 bg-background px-3 py-2.5 sm:px-4 sm:py-3">
<h1 className="app-chrome-title leading-tight tracking-tight">{feedPageTitle}</h1> <h1 className="app-chrome-title leading-tight tracking-tight">{feedPageTitle}</h1>
</div> </div>

4
src/pages/primary/SpellsPage/fauxSpellConfig.ts

@ -12,6 +12,7 @@ import {
CalendarDays, CalendarDays,
Flame, Flame,
Map as MapIcon, Map as MapIcon,
FileText,
Gift, Gift,
Hash, Hash,
Image as ImageIcon, Image as ImageIcon,
@ -73,6 +74,8 @@ export function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Media' return 'Media'
case 'interests': case 'interests':
return 'Interests' return 'Interests'
case 'nostrSpecs':
return 'Nostr specs'
case 'bookmarks': case 'bookmarks':
return 'Bookmarks' return 'Bookmarks'
case 'calendar': case 'calendar':
@ -91,6 +94,7 @@ export const FAUX_SPELL_ICON: Record<FauxSpellName, LucideIcon> = {
followPacks: Gift, followPacks: Gift,
media: ImageIcon, media: ImageIcon,
interests: Hash, interests: Hash,
nostrSpecs: FileText,
bookmarks: Bookmark, bookmarks: Bookmark,
calendar: CalendarDays calendar: CalendarDays
} }

7
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -244,6 +244,13 @@ export function buildCalendarSpellFilter(): Filter {
} }
} }
export function buildNostrSpecsSpellFilter(): Filter {
return {
kinds: [ExtendedKind.NOSTR_SPECIFICATION],
limit: FAUX_SPELL_EVENT_LIMIT
}
}
function pluralizeTopic(topic: string): string { function pluralizeTopic(topic: string): string {
if (!topic) return topic if (!topic) return topic
if (topic.endsWith('y') && topic.length > 1 && !/[aeiou]y$/i.test(topic)) { if (topic.endsWith('y') && topic.length > 1 && !/[aeiou]y$/i.test(topic)) {

9
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -30,6 +30,7 @@ import {
buildDiscussionFilter, buildDiscussionFilter,
buildInterestsSubRequests, buildInterestsSubRequests,
buildMediaSpellFilter, buildMediaSpellFilter,
buildNostrSpecsSpellFilter,
buildNotificationsFollowedThreadSubRequests, buildNotificationsFollowedThreadSubRequests,
buildNotificationsSpellSubRequests, buildNotificationsSpellSubRequests,
buildWebBookmarksSpellSubRequests, buildWebBookmarksSpellSubRequests,
@ -390,6 +391,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
selectedFauxSpell === 'calendar' || selectedFauxSpell === 'calendar' ||
selectedFauxSpell === 'followPacks' || selectedFauxSpell === 'followPacks' ||
selectedFauxSpell === 'media' || selectedFauxSpell === 'media' ||
selectedFauxSpell === 'nostrSpecs' ||
selectedFauxSpell === 'bookmarks' || selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'interests' selectedFauxSpell === 'interests'
const feedUrls = ensureFauxSpellRelayStackTouchesFastRead( const feedUrls = ensureFauxSpellRelayStackTouchesFastRead(
@ -426,6 +428,10 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (!feedUrls.length) return [] if (!feedUrls.length) return []
return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }]
} }
if (selectedFauxSpell === 'nostrSpecs') {
if (!feedUrls.length) return []
return [{ urls: feedUrls, filter: buildNostrSpecsSpellFilter() }]
}
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
if (!pubkey || !interestListEvent) return [] if (!pubkey || !interestListEvent) return []
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!)
@ -547,6 +553,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (selectedFauxSpell === 'calendar') { if (selectedFauxSpell === 'calendar') {
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
} }
if (selectedFauxSpell === 'nostrSpecs') {
return [ExtendedKind.NOSTR_SPECIFICATION]
}
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
return [...DEFAULT_FEED_SHOW_KINDS] return [...DEFAULT_FEED_SHOW_KINDS]
} }

447
src/providers/FavoriteRelaysActivityProvider.tsx

@ -1,447 +0,0 @@
import storage from '@/services/local-storage.service'
import logger from '@/lib/logger'
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants'
import { buildRelayPulseQueryRelayUrls } from '@/lib/home-feed-relays'
import {
readRelayPulseActiveNpubsCache,
writeRelayPulseActiveNpubsCache
} from '@/lib/relay-pulse-active-npubs-cache'
import { hexPubkeysEqual, normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { queryService, replaceableEventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge'
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
/** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */
const PULSE_RECENT_TAIL_SEC = 1200
/**
* Per-REQ event caps for the sidebar relay pulse. Keep small: each event is Schnorr-verified on the WebSocket
* thread in nostr-tools; limits of 900+1400 caused main-thread timeouts in verifyEvent when relays returned large batches.
*/
const PULSE_REQ_LIMIT_RECENT = 120
const PULSE_REQ_LIMIT_EARLIER = 160
/** Hard cap after merging two slices — enough for pubkey diversity without megabytes of verification work. */
const PULSE_MERGED_EVENT_CAP = 400
const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000
/** Keep relay pulse focused on note-like activity to avoid expensive all-kind signature verification bursts. */
const ACTIVE_PULSE_KINDS = [
kinds.ShortTextNote,
kinds.Repost,
kinds.LongFormArticle,
kinds.Highlights,
ExtendedKind.DISCUSSION,
ExtendedKind.PICTURE,
...NIP71_VIDEO_KINDS,
ExtendedKind.COMMENT,
ExtendedKind.GENERIC_REPOST
] as number[]
const PULSE_QUERY_OPTS = {
firstRelayResultGraceMs: false as const,
eoseTimeout: 1800,
globalTimeout: 14_000
}
function mergeRelayPulseEventsById(events: { id: string; pubkey: string; created_at: number }[]) {
const byId = new Map<string, (typeof events)[0]>()
for (const e of events) {
const id = e.id?.trim().toLowerCase()
if (!id || !/^[0-9a-f]{64}$/i.test(id)) continue
const prev = byId.get(id)
if (!prev || e.created_at > prev.created_at) byId.set(id, e)
}
return [...byId.values()]
}
/**
* One REQ with a high `limit` over a full hour mostly returns the newest notes, so a few threads can
* exhaust the cap and hide many active npubs. Two slices (recent tail + earlier in the same hour)
* merge by id, then we dedupe by pubkey for the widget.
*/
async function fetchRelayPulseNoteEvents(
urls: string[],
anchorSec: number
): Promise<{ pubkey: string; created_at: number; id: string }[]> {
const sinceFull = anchorSec - ACTIVE_WINDOW_SEC
const recentSince = anchorSec - PULSE_RECENT_TAIL_SEC
const kinds = [...ACTIVE_PULSE_KINDS]
const settled = await Promise.allSettled([
queryService.fetchEvents(
urls,
{ since: recentSince, limit: PULSE_REQ_LIMIT_RECENT, kinds },
PULSE_QUERY_OPTS
),
queryService.fetchEvents(
urls,
{
since: sinceFull,
until: recentSince,
limit: PULSE_REQ_LIMIT_EARLIER,
kinds
},
PULSE_QUERY_OPTS
)
])
const merged: { id: string; pubkey: string; created_at: number }[] = []
for (const r of settled) {
if (r.status === 'fulfilled') merged.push(...r.value)
}
const deduped = mergeRelayPulseEventsById(merged)
deduped.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))
return deduped.slice(0, PULSE_MERGED_EVENT_CAP)
}
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) => userIdToPubkey(p))
.filter((hex): hex is string => !!hex && /^[0-9a-f]{64}$/i.test(hex))
.map((hex) => hex.toLowerCase())
)
const followPubkeys: string[] = []
const otherPubkeys: string[] = []
for (const pk of orderedPubkeys) {
const hex = normalizeHexPubkey(pk)
if (hex.length === 64 && followSet.has(hex)) 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, relaySets } = useFavoriteRelays()
const { pubkey: viewerPubkey, followListEvent, relayList, cacheRelayListEvent, httpRelayListEvent } =
useNostr()
const followings = useMemo(
() => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []),
[followListEvent]
)
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 [fallbackFollowings, setFallbackFollowings] = useState<string[]>([])
const lastCompletedFetchAtRef = useRef(Date.now())
/** Nostr pubkey hydrates async after reload; storage already has current account (init before React mount). */
const viewerForPulseCache = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null
const orderedPubkeysRef = useRef<string[]>([])
orderedPubkeysRef.current = orderedPubkeys
/** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */
const skipFirstEmptyNetworkOverwriteRef = useRef(false)
const favoriteRelayUrlsForPulse = useMemo(
() => [...favoriteRelays, ...relaySets.flatMap((rs) => rs.relayUrls)],
[favoriteRelays, relaySets]
)
const pulseQueryUrls = useMemo(
() =>
buildRelayPulseQueryRelayUrls({
viewerPubkey,
favoriteRelayUrls: favoriteRelayUrlsForPulse,
blockedRelays,
relayList,
cacheRelayListEvent,
httpRelayListEvent
}),
[
viewerPubkey,
favoriteRelayUrlsForPulse,
blockedRelays,
relayList,
cacheRelayListEvent,
httpRelayListEvent
]
)
const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls])
const fetchActive = useCallback(async () => {
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null
const urls = pulseQueryUrls
if (urls.length === 0) {
setLoading(false)
setRelayActivityReady(true)
const now = Date.now()
setOrderedPubkeys([])
lastCompletedFetchAtRef.current = now
setLastFetchedAtMs(now)
writeRelayPulseActiveNpubsCache({
relayKey,
viewerPubkey: cacheViewer,
orderedPubkeys: [],
lastFetchedAtMs: now
})
return
}
setLoading(true)
const anchorSec = Math.floor(Date.now() / 1000)
try {
const events = await fetchRelayPulseNoteEvents(urls, anchorSec)
const now = Date.now()
const nextPubkeys = aggregatePubkeysByRecency(events)
const prev = orderedPubkeysRef.current
if (
skipFirstEmptyNetworkOverwriteRef.current &&
nextPubkeys.length === 0 &&
prev.length > 0
) {
skipFirstEmptyNetworkOverwriteRef.current = false
logger.debug('[FavoriteRelaysActivity] kept relay pulse from cache; first fetch returned empty')
} else {
skipFirstEmptyNetworkOverwriteRef.current = false
setOrderedPubkeys(nextPubkeys)
lastCompletedFetchAtRef.current = now
setLastFetchedAtMs(now)
writeRelayPulseActiveNpubsCache({
relayKey,
viewerPubkey: cacheViewer,
orderedPubkeys: nextPubkeys,
lastFetchedAtMs: now
})
}
} catch (error) {
logger.debug('[FavoriteRelaysActivity] fetch failed', { error })
if (pulseQueryUrls.length > 0) {
setTimeout(() => void fetchRef.current(), FETCH_RETRY_DELAY_MS)
}
} finally {
setLoading(false)
setRelayActivityReady(true)
}
}, [relayKey, viewerPubkey, pulseQueryUrls])
const fetchRef = useRef(fetchActive)
fetchRef.current = fetchActive
/** Reset pulse state when account or relay set changes so we show loading until fresh data. */
const resetForRefetch = useCallback(() => {
skipFirstEmptyNetworkOverwriteRef.current = false
setRelayActivityReady(false)
setOrderedPubkeys([])
setProfileKind0ByPubkey({})
}, [])
/** Initial fetch on mount and when relay set changes. Use stale-while-revalidate: keep previous
* data visible until new fetch completes instead of clearing and showing skeleton. */
const prevRelayKeyRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (prevRelayKeyRef.current === undefined) {
prevRelayKeyRef.current = relayKey
void fetchRef.current()
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) {
resetForRefetch()
setFallbackFollowings([])
void fetchRef.current()
}
prevViewerRef.current = viewerPubkey ?? undefined
}, [viewerPubkey, resetForRefetch])
/** Restore last successful relay-pulse author list from localStorage (same relay set + viewer). */
useEffect(() => {
const row = readRelayPulseActiveNpubsCache(relayKey, viewerForPulseCache)
if (!row) return
setOrderedPubkeys(row.orderedPubkeys)
setLastFetchedAtMs(row.lastFetchedAtMs)
setRelayActivityReady(true)
lastCompletedFetchAtRef.current = row.lastFetchedAtMs
skipFirstEmptyNetworkOverwriteRef.current = row.orderedPubkeys.length > 0
}, [relayKey, viewerForPulseCache])
/** When follow list from context is empty but we have a logged-in viewer, try IndexedDB cache.
* Fixes race where pulse data arrives before NostrProvider has hydrated follow list from cache. */
useEffect(() => {
if (!viewerPubkey || followings.length > 0) {
setFallbackFollowings((prev) => (prev.length ? [] : prev))
return
}
let cancelled = false
indexedDb
.getReplaceableEvent(viewerPubkey, kinds.Contacts)
.then((evt) => {
if (cancelled || !evt) return
setFallbackFollowings(getPubkeysFromPTags(evt.tags))
})
.catch(() => {})
return () => {
cancelled = true
}
}, [viewerPubkey, followings.length])
/** After session interactive prewarm, relay URLs / follow context are stable — refresh pulse once. */
useEffect(() => {
return registerSessionInteractivePrewarmListener(() => {
void fetchRef.current()
})
}, [])
/** 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 effectiveFollowings = followings.length > 0 ? followings : fallbackFollowings
const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo(
() => partitionByFollows(displayPubkeys, effectiveFollowings),
[displayPubkeys, effectiveFollowings]
)
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>
}

37
src/providers/FeedProvider.test.ts

@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { buildRelayPulseQueryRelayUrls, buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import type { Event } from 'nostr-tools'
describe('home feed relay policy', () => { describe('home feed relay policy', () => {
it('keeps aggr.nostr.land out of the main home feed', () => { it('keeps aggr.nostr.land out of the main home feed', () => {
@ -50,37 +48,4 @@ describe('home feed relay policy', () => {
]) ])
expect(stripped).toEqual(['wss://relay.example/']) expect(stripped).toEqual(['wss://relay.example/'])
}) })
it('relay pulse stack excludes global fast-read and aggr', () => {
const nineReadTags: string[][] = Array.from({ length: 9 }, (_, i) => [
'r',
`wss://many-${i}.example/`,
'read'
])
const oversizedCacheList = {
kind: 10012,
tags: [...nineReadTags],
content: '',
created_at: 0,
pubkey: 'a'.repeat(64),
id: 'b'.repeat(64),
sig: 'c'.repeat(128)
} satisfies Event
const urls = buildRelayPulseQueryRelayUrls({
viewerPubkey: 'd'.repeat(64),
favoriteRelayUrls: ['wss://fav.example/'],
blockedRelays: [],
relayList: { read: ['wss://nip65.example/'], httpRead: ['https://http-index.example/'] },
cacheRelayListEvent: oversizedCacheList,
httpRelayListEvent: null
})
for (const u of FAST_READ_RELAY_URLS) {
expect(urls).not.toContain(u)
}
expect(urls).not.toContain(AGGR_NOSTR_LAND_WSS)
expect(urls).not.toContain('wss://aggr.nostr.land/')
expect(urls.filter((u) => u.startsWith('wss://many-')).length).toBe(8)
})
}) })

7
src/providers/FeedProvider.tsx

@ -266,10 +266,13 @@ export function FeedProvider({ children }: { children: ReactNode }) {
return ( return (
<FeedContext.Provider <FeedContext.Provider
value={{ value={useMemo(
() => ({
relayUrls, relayUrls,
replyRelayUrls replyRelayUrls
}} }),
[relayUrls, replyRelayUrls]
)}
> >
{children} {children}
</FeedContext.Provider> </FeedContext.Provider>

37
src/providers/favorite-relays-activity-context.tsx

@ -1,37 +0,0 @@
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
}

2
src/services/session-interactive-prewarm-bridge.ts

@ -1,6 +1,6 @@
/** /**
* Multicast hook for {@link ClientService.runSessionPrewarm}'s **interactive** phase (IndexedDB @-mention * Multicast hook for {@link ClientService.runSessionPrewarm}'s **interactive** phase (IndexedDB @-mention
* index + NIP-66). Widgets that depend on a settled relay/follow picture (live activities, relay pulse, * index + NIP-66). Widgets that depend on a settled relay/follow picture (live activities,
* sidebar calendar) can register here so they refresh once without waiting for the follow-graph background pass. * sidebar calendar) can register here so they refresh once without waiting for the follow-graph background pass.
*/ */

Loading…
Cancel
Save