diff --git a/src/App.tsx b/src/App.tsx index 189abffd..66281d99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { BookmarksProvider } from '@/providers/BookmarksProvider' import { NotificationThreadWatchProvider } from '@/providers/NotificationThreadWatchProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' -import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' import { FontSizeProvider } from '@/providers/FontSizeProvider' @@ -48,8 +47,7 @@ export default function App(): JSX.Element { - - + @@ -70,7 +68,6 @@ export default function App(): JSX.Element { - diff --git a/src/PageManager.tsx b/src/PageManager.tsx index a8010920..693a71ed 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -101,9 +101,6 @@ const SidebarLazy = lazy(() => import('@/components/Sidebar')) const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar')) const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) 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). */ const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage')) @@ -2378,9 +2375,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { - - - @@ -2523,9 +2517,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { - - - diff --git a/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx b/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx deleted file mode 100644 index b30fc8c8..00000000 --- a/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx +++ /dev/null @@ -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 ( -
-
- -
- - {profile.username} - - - {nip05s.length > 0 ? ( -
    - {nip05s.map((id) => ( -
  • - - {id} - -
  • - ))} -
- ) : null} -
-
-
- ) -} - -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 = ( - - {totalCount > 99 ? '99+' : totalCount} - - ) - - return ( - - ) -} - -/** 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 ( - - - - {t('Relay pulse active npubs')} - {lastFetchedAtMs != null && relativeLabel ? ( -

- {t('Relay pulse updated', { relative: relativeLabel })} -

- ) : null} - {t('Relay pulse active npubs hint')} -
-
- {profilesLoading ? ( -

{t('Loading...')}

- ) : null} -
- {followWithProfile.length > 0 ? ( -
-

- {t('Relay pulse drawer following')} -

-
- {followWithProfile.map((pk) => { - const ev = profileKind0ByPubkey[pk] - return ev ? : null - })} -
-
- ) : null} - {othersWithProfile.length > 0 ? ( -
-

- {t('Relay pulse drawer others')} -

-
- {othersWithProfile.map((pk) => { - const ev = profileKind0ByPubkey[pk] - return ev ? : null - })} -
-
- ) : null} - {!profilesLoading && - followWithProfile.length === 0 && - othersWithProfile.length === 0 ? ( -

{t('Relay pulse drawer no profiles')}

- ) : null} -
-
-
-
- ) -} diff --git a/src/components/FavoriteRelaysActiveStrip/index.tsx b/src/components/FavoriteRelaysActiveStrip/index.tsx deleted file mode 100644 index 775e2ebf..00000000 --- a/src/components/FavoriteRelaysActiveStrip/index.tsx +++ /dev/null @@ -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 ( -
-
-
- ) - } - - if (relayActivityReady && !loading && totalCount === 0) { - return ( -
-

{t('Relay pulse empty')}

-
- ) - } - - return ( -
- -
- ) -} - -/** Desktop sidebar: compact row under nav */ -export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { - const { t } = useTranslation() - const { totalCount, loading, relayActivityReady } = useFavoriteRelaysActivity() - - if (!relayActivityReady && !loading) { - return ( -
-

{t('Relay pulse')}

-
-
- ) - } - - if (relayActivityReady && !loading && totalCount === 0) { - return ( -
-
-

{t('Relay pulse')}

- -
-

- {t('Relay pulse empty')} -

-
- ) - } - - return ( -
-
-

- {t('Relay pulse')} -

-
- -
-
-
- -
-
- ) -} diff --git a/src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts b/src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts deleted file mode 100644 index ab0216d9..00000000 --- a/src/components/FavoriteRelaysActiveStrip/relay-pulse-relative-time.ts +++ /dev/null @@ -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]) -} diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 743bad80..d0046330 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { ExtendedKind, NIP71_VIDEO_KINDS, PROFILE_FEED_KINDS } from '@/constants' @@ -199,10 +199,10 @@ export default function KindFilter({

{temporarySeeAllEvents ? t('See all events hint') : t('Use filter hint')}

-
+
{ @@ -215,8 +215,8 @@ export default function KindFilter({ setTemporaryShowKind1OPs(showKind1OPs) }} > -

{t('Posts')}

-

+

{t('Posts')}

+

{t('Feed filter posts group kinds', { kinds: [KIND_1, ...FEED_POSTS_GROUP_KINDS].join(', ') })} @@ -224,7 +224,7 @@ export default function KindFilter({

{ @@ -238,8 +238,8 @@ export default function KindFilter({ setTemporaryShowKind1111(showKind1111) }} > -

{t('Replies')}

-

+

{t('Replies')}

+

{t('Feed filter replies group kinds', { kinds: [KIND_1, KIND_1111, ...FEED_REPLIES_GROUP_KINDS].join(', ') })} @@ -247,15 +247,15 @@ export default function KindFilter({

{ setTemporaryShowKinds(applyFeedGitGroupToggle(temporaryShowKinds, !gitGroupEnabled)) }} > -

{t('Git')}

-

+

{t('Git')}

+

{t('Feed filter git group kinds', { kinds: FEED_GIT_GROUP_KINDS.join(', ') })}

@@ -266,7 +266,7 @@ export default function KindFilter({
{ @@ -277,14 +277,14 @@ export default function KindFilter({ } }} > -

{t(label)}

-

kind {kindGroup.join(', ')}

+

{t(label)}

+

kind {kindGroup.join(', ')}

) })}
-
+
+ ) : null} +
+
+
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 8a3708a1..7b592839 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -65,6 +65,7 @@ import LiveEvent from './LiveEvent' import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' +import NostrSpecCard from './NostrSpecCard' import WikiCard from './WikiCard' import LongFormCard from './LongFormCard' import MutedNote from './MutedNote' @@ -482,7 +483,7 @@ export default function Note({ content = showFull ? ( renderEventContent() ) : ( - + ) } else if (event.kind === ExtendedKind.PUBLICATION) { if (showFull) { diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 309cebd6..0b114692 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -26,7 +26,7 @@ import { } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' 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 { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' @@ -911,6 +911,8 @@ const NoteList = forwardRef( const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) /** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */ 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). */ const feedPaintSessionPendingRef = useRef(false) /** Relay / one-shot data was written to state; log once after commit. */ @@ -1095,15 +1097,40 @@ const NoteList = forwardRef( 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(() => { if (feedSubscriptionKey !== 'home-all-favorites') return undefined if (homeFeedListMode === 'postsAndReplies' || homeFeedListMode === 'media') { - return homeFeedSeenOnAllowlistReplies?.length ? homeFeedSeenOnAllowlistReplies : undefined + return homeFeedSeenOnAllowlistRepliesKey ? homeFeedSeenOnAllowlistReplies : undefined } - return homeFeedSeenOnAllowlistOp?.length ? homeFeedSeenOnAllowlistOp : undefined + return homeFeedSeenOnAllowlistOpKey ? homeFeedSeenOnAllowlistOp : undefined }, [ feedSubscriptionKey, homeFeedListMode, + homeFeedSeenOnAllowlistOpKey, + homeFeedSeenOnAllowlistRepliesKey, homeFeedSeenOnAllowlistOp, homeFeedSeenOnAllowlistReplies ]) @@ -1995,6 +2022,34 @@ const NoteList = forwardRef( useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) 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 timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current @@ -3402,6 +3457,7 @@ const NoteList = forwardRef( const promise = init() const snapshotKeyForCleanup = sessionSnapshotIdentityKey return () => { + lastTimelineLiveIdentityKeyRef.current = '' effectActive = false if (liveOnNewFlushTimerRef.current != null) { clearTimeout(liveOnNewFlushTimerRef.current) diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 56845f6b..ebb73ae4 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -9,14 +9,25 @@ import { ExtendedKind } from '@/constants' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { normalizeAnyRelayUrl } from '@/lib/url' import { Event } from 'nostr-tools' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import BookmarkButton from '../BookmarkButton' import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons' +import { useBookmarksOptional } from '@/providers/bookmarks-context' +import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' import { LikeButtonWithStats } from './LikeButton' import { ReplyButtonWithStats } from './ReplyButton' import { RepostButtonWithStats } from './RepostButton' 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 ( +
+ {children} +
+ ) +} + export default function NoteStats({ event, className, @@ -120,30 +131,61 @@ export default function NoteStats({ statsFetchRelayScopeKey ]) - const interactionButtons = ( - <> + const watch = useNotificationThreadWatchOptional() + const bookmarksContext = useBookmarksOptional() + const showThreadWatchButtons = Boolean(watch && pubkey) + const showBookmarkButton = Boolean(bookmarksContext && pubkey) + + const barItems: ReactNode[] = [ + - {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( + + ] + + if (!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot) { + barItems.push( + - )} + + ) + } + + barItems.push( + - {!isRssArticleRoot && ( - - )} - + ) - const utilityButtons = !isRssArticleRoot ? ( - <> - - - - ) : null + if (!isRssArticleRoot) { + barItems.push( + + + + ) + } + + if (!isRssArticleRoot && showThreadWatchButtons) { + barItems.push( + +
+ +
+
+ ) + } + + if (!isRssArticleRoot && showBookmarkButton) { + barItems.push( + + + + ) + } return (
- {interactionButtons} - {utilityButtons} + {barItems}
) diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index b45ca55d..4ccf4e99 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -129,6 +129,7 @@ const ProfileFeed = forwardRef< hostPrimaryPageName="profile" showKinds={profileTimelineShowKinds} seeAllFeedEvents={feedKindFilterBypass} + showAllKinds={feedKindFilterBypass} withKindFilter useFilterAsIs clientSideKindFilter diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 9602f8e8..bb70e95c 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -11,7 +11,6 @@ import SearchButton from './SearchButton' import FavoritesButton from './FavoritesButton' import SpellsButton from './SpellsButton' import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip' -import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import PaneModeToggle from './PaneModeToggle' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' @@ -46,7 +45,6 @@ export default function PrimaryPageSidebar() { -
diff --git a/src/constants.ts b/src/constants.ts index 8b79c8b5..7cedae53 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1005,6 +1005,7 @@ export const FAUX_SPELL_ORDER = [ 'followPacks', 'media', 'interests', + 'nostrSpecs', 'bookmarks', 'calendar' ] as const diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index b3153bb7..91d0b2c5 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -9,17 +9,6 @@ export default { Home: 'Home', Feed: 'Feed', 'Favorite Relays': 'Favorite Relays', - 'Relay pulse': 'Relay pulse', - 'Relay pulse empty': 'Quiet on your relays in the last hour.', - 'Relay pulse follows': 'Following ({{count}})', - 'Relay pulse others': 'Others ({{count}})', - 'Relay pulse updated': 'Updated {{relative}}', - 'Relay pulse active npubs': 'Active npubs', - 'Relay pulse active npubs hint': - 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).', - 'Relay pulse drawer following': 'Following', - 'Relay pulse drawer others': 'Others', - 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', 'See the newest notes from your follows': 'See the newest notes from your follows', 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 1658f5ab..0028138a 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -9,18 +9,6 @@ export default { Home: 'Startseite', Feed: 'Feed', 'Favorite Relays': 'Lieblings-Relays', - 'Relay pulse': 'Relay-Puls', - 'Relay pulse empty': 'In der letzten Stunde war es ruhig auf deinen Relays.', - 'Relay pulse follows': 'Folge ich ({{count}})', - 'Relay pulse others': 'Andere ({{count}})', - 'Relay pulse updated': 'Aktualisiert {{relative}}', - 'Relay pulse active npubs': 'Aktive npubs', - 'Relay pulse active npubs hint': - 'Kind-0-Profile für npubs, die in der letzten Stunde auf deinen Lieblingsrelais auftauchten (gleiche Stichprobe wie Relay-Puls).', - 'Relay pulse drawer following': 'Folge ich', - 'Relay pulse drawer others': 'Andere', - 'Relay pulse drawer no profiles': - 'Für diese Stichprobe wurden noch keine Kind-0-Profile geladen.', 'See the newest notes from your follows': 'Neueste Notizen von deinen Abos anzeigen', 'All favorite relays': 'Alle Lieblingsrelais', 'Pinned note': 'Angehefteter Beitrag', @@ -2561,6 +2549,9 @@ export default { 'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)', 'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)', '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 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.': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 65345e9c..838b0dfb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -7,17 +7,6 @@ export default { Home: 'Home', Feed: 'Feed', 'Favorite Relays': 'Favorite Relays', - 'Relay pulse': 'Relay pulse', - 'Relay pulse empty': 'Quiet on your relays in the last hour.', - 'Relay pulse follows': 'Following ({{count}})', - 'Relay pulse others': 'Others ({{count}})', - 'Relay pulse updated': 'Updated {{relative}}', - 'Relay pulse active npubs': 'Active npubs', - 'Relay pulse active npubs hint': - 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).', - 'Relay pulse drawer following': 'Following', - 'Relay pulse drawer others': 'Others', - 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', 'See the newest notes from your follows': 'See the newest notes from your follows', 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', @@ -2530,6 +2519,9 @@ export default { 'Website where LLM was accessed (optional)': 'Website where LLM was accessed (optional)', 'Wiki Article (AsciiDoc)': 'Wiki Article (AsciiDoc)', '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 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.': diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 0a2faf85..44c59500 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -9,17 +9,6 @@ export default { Home: 'Inicio', Feed: 'Feed', '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', 'All favorite relays': 'Todos los relés favoritos', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 213727ae..1c50b1de 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -9,17 +9,6 @@ export default { Home: 'Accueil', Feed: 'Feed', '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', 'All favorite relays': 'Tous les relais favoris', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index a1c786b8..37e4abaa 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -9,17 +9,6 @@ export default { Home: 'Home', Feed: 'Feed', 'Favorite Relays': 'Favorite Relays', - 'Relay pulse': 'Relay pulse', - 'Relay pulse empty': 'Quiet on your relays in the last hour.', - 'Relay pulse follows': 'Following ({{count}})', - 'Relay pulse others': 'Others ({{count}})', - 'Relay pulse updated': 'Updated {{relative}}', - 'Relay pulse active npubs': 'Active npubs', - 'Relay pulse active npubs hint': - 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).', - 'Relay pulse drawer following': 'Following', - 'Relay pulse drawer others': 'Others', - 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', 'See the newest notes from your follows': 'See the newest notes from your follows', 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 45ddfd3f..6552a664 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -9,17 +9,6 @@ export default { Home: 'Strona Główna', Feed: 'Feed', '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', 'All favorite relays': 'Wszystkie ulubione transmitery', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8db824bf..d883fb83 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -9,17 +9,6 @@ export default { Home: 'Главная', Feed: 'Feed', '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', 'All favorite relays': 'Все избранные ретрансляторы', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 613158de..da902559 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -9,17 +9,6 @@ export default { Home: 'Home', Feed: 'Feed', 'Favorite Relays': 'Favorite Relays', - 'Relay pulse': 'Relay pulse', - 'Relay pulse empty': 'Quiet on your relays in the last hour.', - 'Relay pulse follows': 'Following ({{count}})', - 'Relay pulse others': 'Others ({{count}})', - 'Relay pulse updated': 'Updated {{relative}}', - 'Relay pulse active npubs': 'Active npubs', - 'Relay pulse active npubs hint': - 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).', - 'Relay pulse drawer following': 'Following', - 'Relay pulse drawer others': 'Others', - 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', 'See the newest notes from your follows': 'See the newest notes from your follows', 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index ace98b14..598d9fb1 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -9,17 +9,6 @@ export default { Home: '主页', Feed: 'Feed', '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', 'All favorite relays': '所有收藏服务器', 'Pinned note': 'Pinned note', diff --git a/src/lib/download-event-markdown.ts b/src/lib/download-event-markdown.ts new file mode 100644 index 00000000..fdfda455 --- /dev/null +++ b/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) +} diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 296b1567..61e0d577 100644 --- a/src/lib/event-metadata.ts +++ b/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 - * 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( event: Event | null | undefined, diff --git a/src/lib/home-feed-relays.ts b/src/lib/home-feed-relays.ts index 69d8a348..7edd8537 100644 --- a/src/lib/home-feed-relays.ts +++ b/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 { getHttpRelayListFromEvent, getRelayListReadFromEventNoFastFallback } from '@/lib/event-metadata' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility' -import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' -import type { Event } from 'nostr-tools' export { stripNostrLandAggrFromRelayUrls } @@ -51,62 +47,3 @@ export function buildAllFavoritesFeedRelayUrls( ) ) } - -/** - * Relay pulse (sidebar active authors): only the viewer’s 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 - } - ) - ) -} diff --git a/src/lib/nostr-spec-affected-kinds.test.ts b/src/lib/nostr-spec-affected-kinds.test.ts index 8cabcc87..4b0bd40b 100644 --- a/src/lib/nostr-spec-affected-kinds.test.ts +++ b/src/lib/nostr-spec-affected-kinds.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseNostrSpecAffectedKinds } from './nostr-spec-affected-kinds' +import { parseNostrSpecAffectedKinds, parseNostrSpecAffectedKindsFromEvent } from './nostr-spec-affected-kinds' describe('parseNostrSpecAffectedKinds', () => { it('parses one kind per row and dedupes', () => { @@ -22,3 +22,18 @@ describe('parseNostrSpecAffectedKinds', () => { ).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]) + }) +}) diff --git a/src/lib/nostr-spec-affected-kinds.ts b/src/lib/nostr-spec-affected-kinds.ts index 2461d118..e5c6018d 100644 --- a/src/lib/nostr-spec-affected-kinds.ts +++ b/src/lib/nostr-spec-affected-kinds.ts @@ -18,3 +18,17 @@ export function parseNostrSpecAffectedKinds(rows: NostrSpecAffectedKindRow[]): n } return out } + +/** Kind numbers from `k` tags on a published Nostr specification (30817). */ +export function parseNostrSpecAffectedKindsFromEvent(event: { tags: string[][] }): number[] { + const seen = new Set() + 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) +} diff --git a/src/lib/relay-pulse-active-npubs-cache.ts b/src/lib/relay-pulse-active-npubs-cache.ts deleted file mode 100644 index 352489d3..00000000 --- a/src/lib/relay-pulse-active-npubs-cache.ts +++ /dev/null @@ -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 | 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 - 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 }) - } -} diff --git a/src/lib/relay-pulse-nip05.ts b/src/lib/relay-pulse-nip05.ts deleted file mode 100644 index 7235e894..00000000 --- a/src/lib/relay-pulse-nip05.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Event } from 'nostr-tools' - -function addNip05(set: Set, raw: unknown) { - if (typeof raw !== 'string') return - const t = raw.trim() - if (t) set.add(t) -} - -/** - * All NIP-05 identifiers from kind 0: every `nip05` tag plus JSON `nip05` (string or string array). - * Deduplicated, order not preserved. - */ -export function collectAggregatedNip05sFromKind0(event: Event): string[] { - const set = new Set() - for (const tag of event.tags) { - if (tag[0] === 'nip05' && tag[1]) addNip05(set, tag[1]) - } - try { - const obj = JSON.parse(event.content || '{}') as Record - const j = obj.nip05 - if (typeof j === 'string') addNip05(set, j) - else if (Array.isArray(j)) { - for (const x of j) addNip05(set, x) - } - } catch { - // ignore invalid JSON - } - return [...set] -} diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 15a2ddad..0d1c9312 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -30,6 +30,17 @@ const RelaysFeed = forwardRef< .join('|'), [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(() => { if (relayUrls.length === 0) { @@ -85,7 +96,7 @@ const RelaysFeed = forwardRef< } } ] - }, [canRenderFeed, relayUrls, defaultKinds]) + }, [canRenderFeed, relayUrlsKey, relayUrls, defaultKinds]) const repliesSubRequests = useMemo(() => { if (!canRenderFeed) return [] return [ @@ -96,7 +107,7 @@ const RelaysFeed = forwardRef< } } ] - }, [canRenderFeed, replyRelayUrls, relayUrls, defaultKinds]) + }, [canRenderFeed, replyRelayUrlsKey, replyRelayUrls, relayUrlsKey, relayUrls, defaultKinds]) if (!canRenderFeed) { return null @@ -117,8 +128,8 @@ const RelaysFeed = forwardRef< widenMainGalleryRelays={false} feedSubscriptionKey="home-all-favorites" feedTimelineScopeKey="all-favorites" - homeFeedSeenOnAllowlistOp={relayUrls} - homeFeedSeenOnAllowlistReplies={replyRelayUrls} + homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp} + homeFeedSeenOnAllowlistReplies={homeFeedSeenOnAllowlistReplies} showFeedClientFilter hostPrimaryPageName="feed" /> diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 976792ee..bf636b7c 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -5,6 +5,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFeed } from '@/providers/feed-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { normalizeUrl } from '@/lib/url' import type { TNoteListRef } from '@/components/NoteList' import { TPageRef } from '@/types' import { Calendar, Compass, Flame } from 'lucide-react' @@ -13,11 +14,11 @@ import React, { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip' import Logo from '@/assets/Logo' import RelaysFeed from './RelaysFeed' import { usePrimaryPage } from '@/contexts/primary-page-context' @@ -28,6 +29,15 @@ const NoteListPage = forwardRef((_, ref) => { const layoutRef = useRef(null) const feedRef = useRef(null) const { relayUrls } = useFeed() + const relayUrlsKey = useMemo( + () => + [...relayUrls] + .map((u) => normalizeUrl(u) || u) + .filter(Boolean) + .sort() + .join('|'), + [relayUrls] + ) const { isSmallScreen } = useScreenSize() const [homeSubHeader, setHomeSubHeader] = useState(null) @@ -52,19 +62,18 @@ const NoteListPage = forwardRef((_, ref) => { // The feed stays mounted and maintains scroll position at all times useEffect(() => { - if (relayUrls.length) { - addRelayUrls(relayUrls) - return () => { - removeRelayUrls(relayUrls) - } + const urls = relayUrlsKey.split('|').filter(Boolean) + if (!urls.length) return + addRelayUrls(urls) + return () => { + removeRelayUrls(urls) } - }, [relayUrls]) + }, [relayUrlsKey, addRelayUrls, removeRelayUrls]) const feedPageTitle = t('Favorite Relays') const subHeader = ( <> - {isSmallScreen ? : null}

{feedPageTitle}

diff --git a/src/pages/primary/SpellsPage/fauxSpellConfig.ts b/src/pages/primary/SpellsPage/fauxSpellConfig.ts index 79820cd5..3e53c948 100644 --- a/src/pages/primary/SpellsPage/fauxSpellConfig.ts +++ b/src/pages/primary/SpellsPage/fauxSpellConfig.ts @@ -12,6 +12,7 @@ import { CalendarDays, Flame, Map as MapIcon, + FileText, Gift, Hash, Image as ImageIcon, @@ -73,6 +74,8 @@ export function fauxSpellLabelKey(name: FauxSpellName): string { return 'Media' case 'interests': return 'Interests' + case 'nostrSpecs': + return 'Nostr specs' case 'bookmarks': return 'Bookmarks' case 'calendar': @@ -91,6 +94,7 @@ export const FAUX_SPELL_ICON: Record = { followPacks: Gift, media: ImageIcon, interests: Hash, + nostrSpecs: FileText, bookmarks: Bookmark, calendar: CalendarDays } diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 6ce5764e..39a09e6b 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/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 { if (!topic) return topic if (topic.endsWith('y') && topic.length > 1 && !/[aeiou]y$/i.test(topic)) { diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 72636f1c..7c1f6f2b 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -30,6 +30,7 @@ import { buildDiscussionFilter, buildInterestsSubRequests, buildMediaSpellFilter, + buildNostrSpecsSpellFilter, buildNotificationsFollowedThreadSubRequests, buildNotificationsSpellSubRequests, buildWebBookmarksSpellSubRequests, @@ -390,6 +391,7 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { selectedFauxSpell === 'calendar' || selectedFauxSpell === 'followPacks' || selectedFauxSpell === 'media' || + selectedFauxSpell === 'nostrSpecs' || selectedFauxSpell === 'bookmarks' || selectedFauxSpell === 'interests' const feedUrls = ensureFauxSpellRelayStackTouchesFastRead( @@ -426,6 +428,10 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { if (!feedUrls.length) return [] return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] } + if (selectedFauxSpell === 'nostrSpecs') { + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildNostrSpecsSpellFilter() }] + } if (selectedFauxSpell === 'interests') { if (!pubkey || !interestListEvent) return [] 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') { return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] } + if (selectedFauxSpell === 'nostrSpecs') { + return [ExtendedKind.NOSTR_SPECIFICATION] + } if (selectedFauxSpell === 'interests') { return [...DEFAULT_FEED_SHOW_KINDS] } diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx deleted file mode 100644 index 18526323..00000000 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ /dev/null @@ -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() - 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() - 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([]) - const [loading, setLoading] = useState(false) - const [relayActivityReady, setRelayActivityReady] = useState(false) - const [lastFetchedAtMs, setLastFetchedAtMs] = useState(null) - const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState>({}) - const [profilesLoading, setProfilesLoading] = useState(false) - const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false) - const [fallbackFollowings, setFallbackFollowings] = useState([]) - 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([]) - 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(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(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 | undefined - - const runTick = () => { - void fetchRef.current() - } - - const syncPolling = () => { - if (document.visibilityState !== 'visible') { - if (intervalId !== undefined) { - clearInterval(intervalId) - intervalId = undefined - } - return - } - if (intervalId === undefined) { - intervalId = setInterval(runTick, POLL_INTERVAL_MS) - } - if (Date.now() - lastCompletedFetchAtRef.current >= POLL_INTERVAL_MS) { - runTick() - } - } - - syncPolling() - document.addEventListener('visibilitychange', syncPolling) - return () => { - document.removeEventListener('visibilitychange', syncPolling) - if (intervalId !== undefined) clearInterval(intervalId) - } - }, []) - - const profileFetchKeys = useMemo(() => { - if (!viewerPubkey) return orderedPubkeys - return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) - }, [orderedPubkeys, viewerPubkey]) - - useEffect(() => { - if (profileFetchKeys.length === 0) { - setProfileKind0ByPubkey({}) - setProfilesLoading(false) - return - } - let cancelled = false - setProfilesLoading(true) - ;(async () => { - try { - const events = await replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - profileFetchKeys, - kinds.Metadata - ) - if (cancelled) return - const next: Record = {} - profileFetchKeys.forEach((pk, i) => { - const e = events[i] - if (e) next[pk] = e - }) - setProfileKind0ByPubkey(next) - } catch (err) { - logger.debug('[FavoriteRelaysActivity] profile batch failed', { err }) - if (!cancelled) setProfileKind0ByPubkey({}) - } finally { - if (!cancelled) setProfilesLoading(false) - } - })() - return () => { - cancelled = true - } - }, [profileFetchKeys]) - - const displayPubkeys = useMemo(() => { - if (!viewerPubkey) return orderedPubkeys - return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) - }, [orderedPubkeys, viewerPubkey]) - - const 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 {children} -} diff --git a/src/providers/FeedProvider.test.ts b/src/providers/FeedProvider.test.ts index 167dbd2f..3d9ebef0 100644 --- a/src/providers/FeedProvider.test.ts +++ b/src/providers/FeedProvider.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest' -import { FAST_READ_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' 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 type { Event } from 'nostr-tools' describe('home feed relay policy', () => { 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/']) }) - - 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) - }) }) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 6a2475db..01b52642 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -266,10 +266,13 @@ export function FeedProvider({ children }: { children: ReactNode }) { return ( ({ + relayUrls, + replyRelayUrls + }), + [relayUrls, replyRelayUrls] + )} > {children} diff --git a/src/providers/favorite-relays-activity-context.tsx b/src/providers/favorite-relays-activity-context.tsx deleted file mode 100644 index 3c06370b..00000000 --- a/src/providers/favorite-relays-activity-context.tsx +++ /dev/null @@ -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 - profilesLoading: boolean - activeNpubsDrawerOpen: boolean - setActiveNpubsDrawerOpen: (open: boolean) => void - refetch: () => void -} - -export const FavoriteRelaysActivityContext = createContext< - TFavoriteRelaysActivityContext | undefined ->(undefined) - -export function useFavoriteRelaysActivity(): TFavoriteRelaysActivityContext { - const ctx = useContext(FavoriteRelaysActivityContext) - if (!ctx) { - throw new Error('useFavoriteRelaysActivity must be used within FavoriteRelaysActivityProvider') - } - return ctx -} diff --git a/src/services/session-interactive-prewarm-bridge.ts b/src/services/session-interactive-prewarm-bridge.ts index efdeac6f..a79c4df3 100644 --- a/src/services/session-interactive-prewarm-bridge.ts +++ b/src/services/session-interactive-prewarm-bridge.ts @@ -1,6 +1,6 @@ /** * 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. */