diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 43d77d8b..411b8d14 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -39,16 +39,21 @@ import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHel import { PrimaryPageContext, usePrimaryPage, + usePrimaryPageOptional, type PrimaryPageContextValue } from '@/contexts/primary-page-context' import { normalizeUrl } from './lib/url' import modalManager from './services/modal-manager.service' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { routes } from './routes' -import { useScreenSize } from './providers/ScreenSizeProvider' -import { NoteDrawerContext, useNoteDrawer } from '@/contexts/note-drawer-context' -import { PrimaryNoteViewContext, usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { SecondaryPageContext, useSecondaryPage } from '@/contexts/secondary-page-context' +import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider' +import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context' +import { + PrimaryNoteViewContext, + usePrimaryNoteView, + usePrimaryNoteViewOptional +} from '@/contexts/primary-note-view-context' +import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-context' /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */ const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage')) @@ -366,6 +371,59 @@ export function useSmartNoteNavigation() { return { navigateToNote } } +/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */ +export function useSmartNoteNavigationOptional() { + const pushSecondaryPage = useSecondaryPageOptional() + const noteDrawer = useNoteDrawerOptional() + const screenSize = useScreenSizeOptional() + const primaryPage = usePrimaryPageOptional() + + if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) { + return { + navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => { + window.location.href = url + } + } + } + + const { push } = pushSecondaryPage + const { openDrawer, isDrawerOpen } = noteDrawer + const { isSmallScreen } = screenSize + const { current: currentPrimaryPage } = primaryPage + + const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => { + const { noteId } = parseNoteUrl(url) + if (event) { + navigationEventStore.setEvent(event) + client.addEventToCache(event) + } + if (relatedEvents?.length) { + for (const ev of relatedEvents) { + if (ev && ev !== event) client.addEventToCache(ev) + } + } + const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) + if (isSmallScreen) { + push(contextualUrl) + openDrawer(noteId, event) + } else { + const currentPanelMode = storage.getPanelMode() + if (currentPanelMode === 'single') { + if (isDrawerOpen) { + push(contextualUrl) + openDrawer(noteId, event) + } else { + window.history.pushState(null, '', contextualUrl) + openDrawer(noteId, event) + } + } else { + push(contextualUrl) + } + } + } + return { navigateToNote } +} + // Fixed: Relay navigation now uses primary note view on mobile, secondary routing (drawer in single-pane, side panel in double-pane) on desktop export function useSmartRelayNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() @@ -396,6 +454,35 @@ export function useSmartRelayNavigation() { return { navigateToRelay } } +/** Safe variant for createRoot trees. Returns fallback navigation when outside providers. */ +export function useSmartRelayNavigationOptional() { + const primaryNoteView = usePrimaryNoteViewOptional() + const secondaryPage = useSecondaryPageOptional() + const screenSize = useScreenSizeOptional() + const primaryPage = usePrimaryPageOptional() + if (!primaryNoteView || !secondaryPage || !screenSize || !primaryPage) { + return { navigateToRelay: (url: string) => { window.location.href = url } } + } + const { setPrimaryNoteView } = primaryNoteView + const { push: pushSecondaryPage } = secondaryPage + const { isSmallScreen } = screenSize + const { current: currentPrimaryPage } = primaryPage + const navigateToRelay = (url: string) => { + const relayUrlMatch = + url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/) || + url.match(/\/relays\/(.+)$/) + const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) + const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage) + if (isSmallScreen) { + window.history.pushState(null, '', contextualUrl) + setPrimaryNoteView(, 'relay') + } else { + pushSecondaryPage(contextualUrl) + } + } + return { navigateToRelay } +} + // Fixed: Profile navigation now uses primary note view on mobile, secondary routing on desktop export function useSmartProfileNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() @@ -437,6 +524,51 @@ export function useSmartProfileNavigation() { return { navigateToProfile } } +/** Safe variant for createRoot trees (e.g. AsciidocArticle embedded mentions). Returns fallback navigation when outside providers. */ +export function useSmartProfileNavigationOptional() { + const primaryNoteView = usePrimaryNoteViewOptional() + const secondaryPage = useSecondaryPageOptional() + const screenSize = useScreenSizeOptional() + const noteDrawer = useNoteDrawerOptional() + + if (!primaryNoteView || !secondaryPage || !screenSize || !noteDrawer) { + return { + navigateToProfile: (url: string) => { + window.location.href = url + } + } + } + + const { setPrimaryNoteView } = primaryNoteView + const { push: pushSecondaryPage } = secondaryPage + const { isSmallScreen } = screenSize + const { closeDrawer, isDrawerOpen } = noteDrawer + + const navigateToProfile = (url: string) => { + if (isDrawerOpen) { + closeDrawer() + setTimeout(() => { + if (isSmallScreen) { + const profileId = url.replace('/users/', '') + window.history.pushState(null, '', url) + setPrimaryNoteView(, 'profile') + } else { + pushSecondaryPage(url) + } + }, 400) + } else { + if (isSmallScreen) { + const profileId = url.replace('/users/', '') + window.history.pushState(null, '', url) + setPrimaryNoteView(, 'profile') + } else { + pushSecondaryPage(url) + } + } + } + return { navigateToProfile } +} + // Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled export function useSmartHashtagNavigation() { const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView() @@ -471,6 +603,31 @@ export function useSmartHashtagNavigation() { return { navigateToHashtag } } +/** Safe variant for createRoot trees. Returns fallback navigation when outside providers. */ +export function useSmartHashtagNavigationOptional() { + const primaryNoteView = usePrimaryNoteViewOptional() + if (!primaryNoteView) { + return { navigateToHashtag: (url: string) => { window.location.href = url.startsWith('/') ? url : `/${url}` } } + } + const { setPrimaryNoteView, getNavigationCounter } = primaryNoteView + const navigateToHashtag = (url: string) => { + const parsedUrl = url.startsWith('/') ? url : `/${url}` + window.history.pushState(null, '', parsedUrl) + const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '') + const hashtag = searchParams.get('t') || '' + const counter = getNavigationCounter() + const key = `hashtag-${hashtag}-${counter + 1}` + setPrimaryNoteView( + + + , + 'hashtag' + ) + window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } })) + } + return { navigateToHashtag } +} + // Fixed: Following list navigation now uses primary note view on mobile, secondary routing on desktop export function useSmartFollowingListNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx index 5c850b09..b051545f 100644 --- a/src/components/BookmarkButton/index.tsx +++ b/src/components/BookmarkButton/index.tsx @@ -1,17 +1,21 @@ import { Skeleton } from '@/components/ui/skeleton' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { useBookmarks } from '@/providers/BookmarksProvider' -import { useNostr } from '@/providers/NostrProvider' +import { NostrContext } from '@/providers/nostr-context' +import { useBookmarksOptional } from '@/providers/BookmarksProvider' import { BookmarkIcon } from 'lucide-react' import { Event } from 'nostr-tools' -import { useMemo, useState } from 'react' +import { useContext, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' export default function BookmarkButton({ event }: { event: Event }) { const { t } = useTranslation() - const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr() - const { addBookmark, removeBookmark } = useBookmarks() + const nostrContext = useContext(NostrContext) + const bookmarksContext = useBookmarksOptional() + const accountPubkey = nostrContext?.pubkey ?? null + const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null + const checkLogin = nostrContext?.checkLogin ?? (async () => {}) + const { addBookmark, removeBookmark } = bookmarksContext ?? { addBookmark: async () => {}, removeBookmark: async () => {} } const [updating, setUpdating] = useState(false) const isBookmarked = useMemo(() => { const isReplaceable = isReplaceableEvent(event.kind) @@ -22,7 +26,7 @@ export default function BookmarkButton({ event }: { event: Event }) { ) }, [bookmarkListEvent, event]) - if (!accountPubkey) return null + if (!bookmarksContext || !accountPubkey) return null const handleBookmark = async (e: React.MouseEvent) => { e.stopPropagation() diff --git a/src/components/ClientSelect/index.tsx b/src/components/ClientSelect/index.tsx index 317bf7fe..5ca579de 100644 --- a/src/components/ClientSelect/index.tsx +++ b/src/components/ClientSelect/index.tsx @@ -5,7 +5,7 @@ import { Separator } from '@/components/ui/separator' import { ExtendedKind } from '@/constants' import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event' import { toChachiChat } from '@/lib/link' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import clientService from '@/services/client.service' import { ExternalLink } from 'lucide-react' import { Event, kinds, nip19 } from 'nostr-tools' @@ -85,7 +85,8 @@ export default function ClientSelect({ event?: Event originalNoteId?: string }) { - const { isSmallScreen } = useScreenSize() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false const [open, setOpen] = useState(false) const { t } = useTranslation() diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index f425bd99..babfc8ed 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -1,8 +1,8 @@ import { ExtendedKind } from '@/constants' import { isMentioningMutedUsers } from '@/lib/event' import { cn } from '@/lib/utils' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useMuteList } from '@/contexts/mute-list-context' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useMuteListOptional } from '@/contexts/mute-list-context' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -29,8 +29,10 @@ export default function ContentPreview({ className?: string }) { const { t } = useTranslation() - const { mutePubkeySet } = useMuteList() - const { hideContentMentioningMutedUsers } = useContentPolicy() + const muteList = useMuteListOptional() + const mutePubkeySet = muteList?.mutePubkeySet ?? new Set() + const contentPolicy = useContentPolicyOptional() + const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false const isMuted = useMemo( () => (event ? mutePubkeySet.has(event.pubkey) : false), [mutePubkeySet, event] diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index c7fce2a6..9a7df6cf 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -18,7 +18,7 @@ import { Search } from 'lucide-react' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { contentParserService } from '@/services/content-parser.service' -import { useSmartNoteNavigation } from '@/PageManager' +import { useSmartNoteNavigationOptional } from '@/PageManager' import { toNote } from '@/lib/link' import { type EmbeddedNoteIdValidation, @@ -532,7 +532,7 @@ function EmbeddedNoteNotFound({ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) { const [parsedContent, setParsedContent] = useState(null) const bookMetadata = extractBookMetadata(event) - const { navigateToNote } = useSmartNoteNavigation() + const { navigateToNote } = useSmartNoteNavigationOptional() useEffect(() => { const parseContent = async () => { diff --git a/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx b/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx index 82683d6e..a8db5982 100644 --- a/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx +++ b/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx @@ -8,6 +8,7 @@ import { SheetTitle } from '@/components/ui/sheet' import { getProfileFromEvent } from '@/lib/event-metadata' +import { cn } from '@/lib/utils' import { toProfile } from '@/lib/link' import { collectAggregatedNip05sFromKind0, @@ -73,20 +74,35 @@ export function RelayPulseActiveNpubsOpenButton({ if (totalCount === 0) return null + const countLabel = ( + + {totalCount > 99 ? '99+' : totalCount} + + ) + return ( setActiveNpubsDrawerOpen(true)} > - {size !== 'icon' ? ( - {t('Relay pulse active npubs')} - ) : null} + {size === 'icon' ? ( + + {countLabel} + + ) : ( + <> + {countLabel} + + {t('Relay pulse active npubs')} + + > + )} ) } diff --git a/src/components/FavoriteRelaysActiveStrip/index.tsx b/src/components/FavoriteRelaysActiveStrip/index.tsx index 9e799fd8..2937bbc3 100644 --- a/src/components/FavoriteRelaysActiveStrip/index.tsx +++ b/src/components/FavoriteRelaysActiveStrip/index.tsx @@ -1,18 +1,22 @@ import UserAvatar from '@/components/UserAvatar' import { SimpleUsername } from '@/components/Username' +import { Button } from '@/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useMuteList } from '@/contexts/mute-list-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' import type { TFunction } from 'i18next' +import { FileText } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -const MOBILE_MAX_FOLLOW = 8 -const MOBILE_MAX_OTHER = 8 -const SIDEBAR_MAX_FOLLOW = 5 -const SIDEBAR_MAX_OTHER = 5 +const MOBILE_MAX_FOLLOW = 30 +const MOBILE_MAX_OTHER = 30 +const SIDEBAR_MAX_FOLLOW = 50 +const SIDEBAR_MAX_OTHER = 50 /** Slight overlap so faces stay recognizable */ const AVATAR_OVERLAP = '-ml-1' @@ -45,26 +49,18 @@ function OverlappingAvatars({ pubkeys, max, avatarSize, - rowClassName, - scrollableRow = false + rowClassName }: { pubkeys: string[] max: number avatarSize: 'small' | 'xSmall' | 'tiny' rowClassName?: string - /** Narrow screens: horizontal scroll inside the viewport instead of overflowing the page */ - scrollableRow?: boolean }) { const slice = pubkeys.slice(0, max) const extra = pubkeys.length - slice.length const row = ( - + {slice.map((pk, i) => ( @@ -97,26 +93,8 @@ function OverlappingAvatars({ ) - if (scrollableRow) { - return ( - - {row} - - ) - } - return ( - + {row} ) @@ -132,7 +110,8 @@ function ActiveAvatarGroups({ avatarSize, labelClassName, stackClassName, - variant = 'default' + variant = 'default', + onOpenFollowsNotes }: { /** Subset with kind 0 only (shown as circles); counts use full totals */ followPubkeysForAvatars: string[] @@ -146,6 +125,8 @@ function ActiveAvatarGroups({ stackClassName?: string /** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */ variant?: 'default' | 'mobileBar' + /** Opens search page and expands the notes-from-follows section */ + onOpenFollowsNotes?: () => void }) { const { t } = useTranslation() const mobileBar = variant === 'mobileBar' @@ -153,24 +134,67 @@ function ActiveAvatarGroups({ ? 'flex w-full min-w-0 flex-col gap-1.5' : 'flex min-w-0 flex-col gap-1 min-[380px]:flex-row min-[380px]:items-center min-[380px]:gap-2' + const followsLabelBlock = ( + + + {t('Relay pulse follows', { count: followCount })} + + {onOpenFollowsNotes && mobileBar ? ( + + + + ) : null} + + ) + + const sidebarSectionClass = 'flex min-w-0 flex-col gap-1' + return ( {followCount > 0 ? ( - - - {t('Relay pulse follows', { count: followCount })} - + + {mobileBar ? ( + + + {t('Relay pulse follows', { count: followCount })} + + {onOpenFollowsNotes ? ( + + + + ) : null} + + ) : ( + followsLabelBlock + )} ) : null} {otherCount > 0 ? ( - + {t('Relay pulse others', { count: otherCount })} @@ -178,8 +202,7 @@ function ActiveAvatarGroups({ pubkeys={otherPubkeysForAvatars} max={maxOther} avatarSize={avatarSize} - scrollableRow={mobileBar} - rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'} + rowClassName={mobileBar ? undefined : 'justify-start'} /> ) : null} @@ -190,6 +213,8 @@ function ActiveAvatarGroups({ /** Home feed / mobile: full label above the page title */ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { const { t } = useTranslation() + const { navigate } = usePrimaryPage() + const { pubkey } = useNostr() const { mutePubkeySet } = useMuteList() const { followPubkeys, @@ -221,7 +246,16 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) if (!relayActivityReady && !loading) { - return null + return ( + + {t('Relay pulse')} + + ) } if (relayActivityReady && !loading && totalCount === 0) { @@ -276,6 +310,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: avatarSize="small" labelClassName="text-[0.7rem] font-medium text-muted-foreground" stackClassName="w-full min-w-0 max-w-full" + onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined} /> @@ -285,6 +320,8 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: /** Desktop sidebar: compact row under nav */ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { const { t } = useTranslation() + const { navigate } = usePrimaryPage() + const { pubkey } = useNostr() const { mutePubkeySet } = useMuteList() const { followPubkeys, @@ -316,7 +353,19 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) if (!relayActivityReady && !loading) { - return null + return ( + + + {t('Relay pulse')} + + + + ) } if (relayActivityReady && !loading && totalCount === 0) { @@ -350,15 +399,41 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st {t('Relay pulse')} - + + + {pubkey && followCount > 0 ? ( + navigate('search', { expandFollows: true })} + > + + + ) : null} + {lastFetchedAtMs != null && relativeLabel ? ( {t('Relay pulse updated', { relative: relativeLabel })} ) : null} - + + {pubkey && followCount > 0 ? ( + navigate('search', { expandFollows: true })} + > + + + ) : null} navigate('search', { expandFollows: true }) : undefined} /> diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index 1cceee76..ab720045 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -85,11 +85,11 @@ function recommendedCuratorHexPubkey(): string | null { } } -export default function LatestFromFollowsSection() { +export default function LatestFromFollowsSection({ defaultOpen = false }: { defaultOpen?: boolean } = {}) { const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey, followListEvent, isInitialized } = useNostr() - const { blockedRelays } = useFavoriteRelays() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { mutePubkeySet } = useMuteList() const { isEventDeleted } = useDeletedEvent() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() @@ -105,7 +105,7 @@ export default function LatestFromFollowsSection() { const [postsByPubkey, setPostsByPubkey] = useState>(() => new Map()) const [batchBusy, setBatchBusy] = useState(false) /** Search page: start collapsed so the bar doesn’t push the search field; data still prefetches in the background. */ - const [sectionOpen, setSectionOpen] = useState(false) + const [sectionOpen, setSectionOpen] = useState(defaultOpen) const abortedRef = useRef(false) const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys @@ -195,12 +195,18 @@ export default function LatestFromFollowsSection() { allLists.push(...lists) } if (cancelled) return - const urls = buildFollowOutboxAggregateReadUrls(allLists, blockedRelays) + const urls = buildFollowOutboxAggregateReadUrls( + allLists, + blockedRelays, + favoriteRelays + ) setAggregateRelayUrls(urls) } catch (err) { logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) if (!cancelled) { - setAggregateRelayUrls(buildFollowOutboxAggregateReadUrls([], blockedRelays)) + setAggregateRelayUrls( + buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) + ) } } finally { if (!cancelled) setAggregateRelaysReady(true) @@ -210,7 +216,7 @@ export default function LatestFromFollowsSection() { return () => { cancelled = true } - }, [followPubkeys, blockedRelays, isInitialized, loadingFollowList]) + }, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList]) // Batch-fetch posts per slice of authors against the aggregate relay set. useEffect(() => { diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 7a127c73..d83b828f 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1,4 +1,4 @@ -import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' +import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' @@ -344,9 +344,10 @@ export default function AsciidocArticle({ hideImagesAndInfo?: boolean parentImageUrl?: string }) { - const { push } = useSecondaryPage() - const { navigateToHashtag } = useSmartHashtagNavigation() - const { navigateToRelay } = useSmartRelayNavigation() + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) + const { navigateToHashtag } = useSmartHashtagNavigationOptional() + const { navigateToRelay } = useSmartRelayNavigationOptional() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book diff --git a/src/components/Note/CommunityDefinition.tsx b/src/components/Note/CommunityDefinition.tsx index 673aef3a..deb1e89d 100644 --- a/src/components/Note/CommunityDefinition.tsx +++ b/src/components/Note/CommunityDefinition.tsx @@ -1,5 +1,5 @@ import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { Event } from 'nostr-tools' import { useMemo } from 'react' import ClientSelect from '../ClientSelect' @@ -12,7 +12,8 @@ export default function CommunityDefinition({ event: Event className?: string }) { - const { autoLoadMedia } = useContentPolicy() + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event]) const communityNameComponent = ( diff --git a/src/components/Note/GroupMetadata.tsx b/src/components/Note/GroupMetadata.tsx index bff5d407..519ec31a 100644 --- a/src/components/Note/GroupMetadata.tsx +++ b/src/components/Note/GroupMetadata.tsx @@ -1,5 +1,5 @@ import { getGroupMetadataFromEvent } from '@/lib/event-metadata' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { Event } from 'nostr-tools' import { useMemo } from 'react' import ClientSelect from '../ClientSelect' @@ -14,7 +14,8 @@ export default function GroupMetadata({ originalNoteId?: string className?: string }) { - const { autoLoadMedia } = useContentPolicy() + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event]) const groupNameComponent = ( diff --git a/src/components/Note/Highlight/index.tsx b/src/components/Note/Highlight/index.tsx index 2b62f9a5..eecf2da1 100644 --- a/src/components/Note/Highlight/index.tsx +++ b/src/components/Note/Highlight/index.tsx @@ -5,7 +5,7 @@ import logger from '@/lib/logger' import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' -import { useSmartNoteNavigation } from '@/PageManager' +import { useSmartNoteNavigationOptional } from '@/PageManager' import { toNote } from '@/lib/link' import { useFetchEvent } from '@/hooks' import { useEffect, useState, useMemo } from 'react' @@ -61,7 +61,7 @@ function HighlightAuthorCard({ eventId?: string onClick?: () => void }) { - const { navigateToNote } = useSmartNoteNavigation() + const { navigateToNote } = useSmartNoteNavigationOptional() const handleNoteClick = (e: React.MouseEvent) => { e.stopPropagation() diff --git a/src/components/Note/LiveEvent.tsx b/src/components/Note/LiveEvent.tsx index e9409d15..af27699c 100644 --- a/src/components/Note/LiveEvent.tsx +++ b/src/components/Note/LiveEvent.tsx @@ -1,16 +1,17 @@ import { Badge } from '@/components/ui/badge' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { Event } from 'nostr-tools' import { useMemo } from 'react' import ClientSelect from '../ClientSelect' import Image from '../Image' export default function LiveEvent({ event, className }: { event: Event; className?: string }) { - const { isSmallScreen } = useScreenSize() - - const { autoLoadMedia } = useContentPolicy() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const liveStatusComponent = diff --git a/src/components/Note/LongFormArticlePreview.tsx b/src/components/Note/LongFormArticlePreview.tsx index dd7d84a3..85975558 100644 --- a/src/components/Note/LongFormArticlePreview.tsx +++ b/src/components/Note/LongFormArticlePreview.tsx @@ -1,9 +1,9 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' -import { useSecondaryPage } from '@/PageManager' +import { useSecondaryPageOptional } from '@/PageManager' import client from '@/services/client.service' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import Image from '../Image' @@ -15,9 +15,12 @@ export default function LongFormArticlePreview({ event: Event className?: string }) { - const { isSmallScreen } = useScreenSize() - const { push } = useSecondaryPage() - const { autoLoadMedia } = useContentPolicy() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const handleCardClick = (e: React.MouseEvent) => { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 28d24e55..676d08ef 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1,4 +1,4 @@ -import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' +import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' @@ -3225,9 +3225,10 @@ export default function MarkdownArticle({ /** When viewing a kind-24 invite, render full calendar card with RSVP in place of the naddr embed */ fullCalendarInvite?: { naddr: string; event: Event } }) { - const { push } = useSecondaryPage() - const { navigateToHashtag } = useSmartHashtagNavigation() - const { navigateToRelay } = useSmartRelayNavigation() + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) + const { navigateToHashtag } = useSmartHashtagNavigationOptional() + const { navigateToRelay } = useSmartRelayNavigationOptional() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event]) const iArticleCleaned = useMemo( diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index e30c6249..0c8f4b09 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -4,7 +4,7 @@ import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { createPollResponseDraftEvent } from '@/lib/draft-event' import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { cn, isPartiallyInViewport } from '@/lib/utils' -import { useNostr } from '@/providers/NostrProvider' +import { useNostrOptional } from '@/providers/nostr-context' import pollResultsService from '@/services/poll-results.service' import dayjs from 'dayjs' import { Skeleton } from '@/components/ui/skeleton' @@ -18,7 +18,10 @@ import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishi export default function Poll({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() - const { pubkey, publish, startLogin } = useNostr() + const nostr = useNostrOptional() + const pubkey = nostr?.pubkey ?? null + const publish = nostr?.publish ?? (async () => { throw new Error('Not logged in') }) + const startLogin = nostr?.startLogin ?? (() => {}) const [isVoting, setIsVoting] = useState(false) const [selectedOptionIds, setSelectedOptionIds] = useState([]) const pollResults = useFetchPollResults(event.id) diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index e6539cd2..dc594539 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -1,8 +1,8 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' -import { useSecondaryPage } from '@/PageManager' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useSecondaryPageOptional } from '@/PageManager' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import Image from '../Image' @@ -16,9 +16,12 @@ export default function PublicationCard({ event: Event className?: string }) { - const { isSmallScreen } = useScreenSize() - const { push } = useSecondaryPage() - const { autoLoadMedia } = useContentPolicy() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index 12a180f2..be9a3d73 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { RefreshCw, ArrowUp } from 'lucide-react' import indexedDb from '@/services/indexed-db.service' import { isReplaceableEvent } from '@/lib/event' -import { useSecondaryPage } from '@/PageManager' +import { useSecondaryPageOptional } from '@/PageManager' import { extractBookMetadata } from '@/lib/bookstr-parser' import { dTagToTitleCase } from '@/lib/event-metadata' import Image from '@/components/Image' @@ -61,7 +61,8 @@ export default function PublicationIndex({ isNested?: boolean parentImageUrl?: string }) { - const { push } = useSecondaryPage() + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) // Parse publication metadata from event tags const metadata = useMemo(() => { const meta: PublicationMetadata = { tags: [] } diff --git a/src/components/Note/RelayReview.tsx b/src/components/Note/RelayReview.tsx index fc9e2279..d5685704 100644 --- a/src/components/Note/RelayReview.tsx +++ b/src/components/Note/RelayReview.tsx @@ -1,7 +1,7 @@ import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { toRelay } from '@/lib/link' import { simplifyUrl } from '@/lib/url' -import { useSmartRelayNavigation } from '@/PageManager' +import { useSmartRelayNavigationOptional } from '@/PageManager' import { Link2 } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' @@ -9,7 +9,7 @@ import Content from '../Content' import Stars from '../Stars' export default function RelayReview({ event, className }: { event: Event; className?: string }) { - const { navigateToRelay } = useSmartRelayNavigation() + const { navigateToRelay } = useSmartRelayNavigationOptional() const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event]) const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event]) diff --git a/src/components/Note/WikiCard.tsx b/src/components/Note/WikiCard.tsx index ab5ce7ba..1ac5f999 100644 --- a/src/components/Note/WikiCard.tsx +++ b/src/components/Note/WikiCard.tsx @@ -1,8 +1,8 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' -import { useSecondaryPage } from '@/PageManager' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useSecondaryPageOptional } from '@/PageManager' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import Image from '../Image' @@ -14,9 +14,12 @@ export default function WikiCard({ event: Event className?: string }) { - const { isSmallScreen } = useScreenSize() - const { push } = useSecondaryPage() - const { autoLoadMedia } = useContentPolicy() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const handleCardClick = (e: React.MouseEvent) => { diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index bb1862b5..4504ff17 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -8,7 +8,7 @@ import { Zap as ZapIcon } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' +import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager' import Username from '../Username' import UserAvatar from '../UserAvatar' @@ -26,8 +26,9 @@ export default function Zap({ event, className }: { event: Event; className?: st return null } const { t } = useTranslation() - const { navigateToNote } = useSmartNoteNavigation() - const { push } = useSecondaryPage() + const { navigateToNote } = useSmartNoteNavigationOptional() + const secondaryPage = useSecondaryPageOptional() + const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { return ( diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index aeaf8677..4e10ca80 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -1,13 +1,13 @@ -import { useSmartNoteNavigation } from '@/PageManager' +import { useSmartNoteNavigationOptional } from '@/PageManager' import { ExtendedKind } from '@/constants' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event' import { toNote } from '@/lib/link' import logger from '@/lib/logger' import client from '@/services/client.service' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { useMuteList } from '@/contexts/mute-list-context' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' +import { useMuteListOptional } from '@/contexts/mute-list-context' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' import { useCallback, useMemo, useState } from 'react' @@ -71,15 +71,18 @@ export default function Note({ fullCalendarInvite?: { event: Event; naddr: string } }) { const { t } = useTranslation() - const { navigateToNote } = useSmartNoteNavigation() - const { isSmallScreen } = useScreenSize() + const { navigateToNote } = useSmartNoteNavigationOptional() + const screenSize = useScreenSizeOptional() + const isSmallScreen = screenSize?.isSmallScreen ?? false const parentEventId = useMemo( () => (hideParentNotePreview ? undefined : getParentBech32Id(event)), [event, hideParentNotePreview] ) - const { defaultShowNsfw } = useContentPolicy() + const contentPolicy = useContentPolicyOptional() + const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true const [showNsfw, setShowNsfw] = useState(false) - const { mutePubkeySet } = useMuteList() + const muteList = useMuteListOptional() + const mutePubkeySet = muteList?.mutePubkeySet ?? new Set() const [showMuted, setShowMuted] = useState(false) const [highlightData, setHighlightData] = useState(undefined) const [highlightDefaultContent, setHighlightDefaultContent] = useState('') diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 3bb4a5da..c977ff90 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -1,6 +1,6 @@ import { Separator } from '@/components/ui/separator' import { toNote } from '@/lib/link' -import { useSmartNoteNavigation } from '@/PageManager' +import { useSmartNoteNavigationOptional } from '@/PageManager' import client from '@/services/client.service' import { Pin } from 'lucide-react' import { Event } from 'nostr-tools' @@ -27,7 +27,7 @@ export default function MainNoteCard({ pinned?: boolean }) { const { t } = useTranslation() - const { navigateToNote } = useSmartNoteNavigation() + const { navigateToNote } = useSmartNoteNavigationOptional() return ( { diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index dd691249..7cf7e341 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -3,7 +3,7 @@ import { useFetchProfile } from '@/hooks' import { toProfile } from '@/lib/link' import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { cn } from '@/lib/utils' -import { useSmartProfileNavigation } from '@/PageManager' +import { useSmartProfileNavigationOptional } from '@/PageManager' import { useMemo } from 'react' export default function Username({ @@ -22,7 +22,7 @@ export default function Username({ style?: React.CSSProperties }) { const { profile, isFetching } = useFetchProfile(userId) - const { navigateToProfile } = useSmartProfileNavigation() + const { navigateToProfile } = useSmartProfileNavigationOptional() // Get pubkey from userId (works even if profile isn't loaded) const pubkey = useMemo(() => { diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx index a90f7812..17823062 100644 --- a/src/components/YoutubeEmbeddedPlayer/index.tsx +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import mediaManager from '@/services/media-manager.service' import { YouTubePlayer } from '@/types/youtube' import { useEffect, useMemo, useRef, useState } from 'react' @@ -17,7 +17,8 @@ export default function YoutubeEmbeddedPlayer({ mustLoad?: boolean }) { const { t } = useTranslation() - const { autoLoadMedia } = useContentPolicy() + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const [display, setDisplay] = useState(autoLoadMedia) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) const [initSuccess, setInitSuccess] = useState(false) diff --git a/src/contexts/mute-list-context.tsx b/src/contexts/mute-list-context.tsx index 57badfe4..a12e6d5e 100644 --- a/src/contexts/mute-list-context.tsx +++ b/src/contexts/mute-list-context.tsx @@ -25,3 +25,8 @@ export function useMuteList(): TMuteListContext { } return context } + +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function useMuteListOptional(): TMuteListContext | undefined { + return useContext(MuteListContext) +} diff --git a/src/contexts/note-drawer-context.tsx b/src/contexts/note-drawer-context.tsx index 51ca906b..bf087535 100644 --- a/src/contexts/note-drawer-context.tsx +++ b/src/contexts/note-drawer-context.tsx @@ -22,3 +22,8 @@ export function useNoteDrawer(): NoteDrawerContextValue { } return context } + +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function useNoteDrawerOptional(): NoteDrawerContextValue | undefined { + return useContext(NoteDrawerContext) +} diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index 60a90608..a05d4404 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -36,3 +36,8 @@ export function usePrimaryNoteView(): PrimaryNoteViewContextValue { } return context } + +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function usePrimaryNoteViewOptional(): PrimaryNoteViewContextValue | undefined { + return useContext(PrimaryNoteViewContext) +} diff --git a/src/contexts/primary-page-context.tsx b/src/contexts/primary-page-context.tsx index aa01a516..2fb6eb83 100644 --- a/src/contexts/primary-page-context.tsx +++ b/src/contexts/primary-page-context.tsx @@ -24,3 +24,8 @@ export function usePrimaryPage(): PrimaryPageContextValue { } return context } + +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function usePrimaryPageOptional(): PrimaryPageContextValue | undefined { + return useContext(PrimaryPageContext) +} diff --git a/src/contexts/secondary-page-context.tsx b/src/contexts/secondary-page-context.tsx index fff50cf3..8ad25e4a 100644 --- a/src/contexts/secondary-page-context.tsx +++ b/src/contexts/secondary-page-context.tsx @@ -23,3 +23,8 @@ export function useSecondaryPage(): SecondaryPageContextValue { } return context } + +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function useSecondaryPageOptional(): SecondaryPageContextValue | undefined { + return useContext(SecondaryPageContext) +} diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 0632fea4..67b50c14 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -1,7 +1,7 @@ import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' import { getProfileFromEvent } from '@/lib/event-metadata' import { userIdToPubkey } from '@/lib/pubkey' -import { useNostr } from '@/providers/NostrProvider' +import { useNostrOptional } from '@/providers/nostr-context' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { replaceableEventService } from '@/services/client.service' import { TProfile } from '@/types' @@ -24,7 +24,8 @@ export function useFetchProfile(id?: string, skipCache = false) { // stack: new Error().stack?.split('\n').slice(1, 4).join('\n') // }) - const { profile: currentAccountProfile } = useNostr() + const nostr = useNostrOptional() + const currentAccountProfile = nostr?.profile ?? null const noteFeed = useNoteFeedProfileContext() /** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */ const [isFetching, setIsFetching] = useState(() => { diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index fdac6d13..4cf1e1fc 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -20,6 +20,7 @@ export default { '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', 'Relay settings': 'Relay-Einstellungen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 23481ca6..c90a1ddb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -18,6 +18,7 @@ export default { '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', 'Relay settings': 'Relays and Storage Settings', diff --git a/src/lib/follow-outbox-aggregate-relays.ts b/src/lib/follow-outbox-aggregate-relays.ts index cdd5a6ee..009632c4 100644 --- a/src/lib/follow-outbox-aggregate-relays.ts +++ b/src/lib/follow-outbox-aggregate-relays.ts @@ -1,4 +1,8 @@ -import { READ_ONLY_RELAY_URLS } from '@/constants' +import { + DEFAULT_FAVORITE_RELAYS, + FAST_READ_RELAY_URLS, + READ_ONLY_RELAY_URLS +} from '@/constants' import { normalizeUrl } from '@/lib/url' import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import type { TRelayList } from '@/types' @@ -11,13 +15,29 @@ function isNonPublicWsRelayUrl(normalizedUrl: string): boolean { return normalizedUrl.toLowerCase().startsWith('ws://') } +function addLayer( + out: string[], + seen: Set, + blocked: Set, + urls: readonly string[] +): void { + for (const u of urls) { + const n = normalizeUrl(u) || u + if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue + seen.add(n) + out.push(n) + } +} + /** - * Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS}: - * normalized, blocked-stripped, deduped (first occurrence wins). + * Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS}, + * {@link FAST_READ_RELAY_URLS}, and user favorites: normalized, blocked-stripped, + * deduped (first occurrence wins). */ export function buildFollowOutboxAggregateReadUrls( relayLists: readonly TRelayList[], - blockedRelays: readonly string[] + blockedRelays: readonly string[], + favoriteRelays: readonly string[] = [] ): string[] { const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean)) const seen = new Set() @@ -33,12 +53,9 @@ export function buildFollowOutboxAggregateReadUrls( } } - for (const u of READ_ONLY_RELAY_URLS) { - const n = normalizeUrl(u) || u - if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue - seen.add(n) - out.push(n) - } + addLayer(out, seen, blocked, READ_ONLY_RELAY_URLS) + addLayer(out, seen, blocked, FAST_READ_RELAY_URLS) + addLayer(out, seen, blocked, favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS) return out } diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index 79f7c926..48623a85 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -11,7 +11,9 @@ import { BookOpen } from 'lucide-react' import { Button } from '@/components/ui/button' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -const SearchPage = forwardRef((_, ref) => { +type SearchPageProps = { expandFollows?: boolean } +const SearchPage = forwardRef((props: SearchPageProps, ref) => { + const { expandFollows } = props ?? {} const { current, display } = usePrimaryPage() const { pubkey, relayList } = useNostr() const [input, setInput] = useState('') @@ -88,11 +90,11 @@ const SearchPage = forwardRef((_, ref) => { {searchParams ? ( - ) : ( + ) : pubkey ? ( - + - )} + ) : null} diff --git a/src/providers/BookmarksProvider.tsx b/src/providers/BookmarksProvider.tsx index 9962efc5..86f3f2d6 100644 --- a/src/providers/BookmarksProvider.tsx +++ b/src/providers/BookmarksProvider.tsx @@ -25,6 +25,9 @@ export const useBookmarks = () => { return context } +/** Returns undefined when outside BookmarksProvider (e.g. embedded notes in createRoot trees). */ +export const useBookmarksOptional = () => useContext(BookmarksContext) + export function BookmarksProvider({ children }: { children: React.ReactNode }) { const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() diff --git a/src/providers/ContentPolicyProvider.tsx b/src/providers/ContentPolicyProvider.tsx index a51a4a36..ccf0bdd6 100644 --- a/src/providers/ContentPolicyProvider.tsx +++ b/src/providers/ContentPolicyProvider.tsx @@ -28,6 +28,11 @@ export const useContentPolicy = () => { return context } +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function useContentPolicyOptional(): TContentPolicyContext | undefined { + return useContext(ContentPolicyContext) +} + export function ContentPolicyProvider({ children }: { children: React.ReactNode }) { const [autoplay, setAutoplay] = useState(storage.getAutoplay()) const [defaultShowNsfw, setDefaultShowNsfw] = useState(storage.getDefaultShowNsfw()) diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index f5c8c24e..d2662b5d 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -116,11 +116,12 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R const fetchRef = useRef(fetchActive) fetchRef.current = fetchActive - /** Favorite relay set changed after initial hydration — refresh snapshot (not the hourly cadence). */ + /** Initial fetch on mount and when relay set changes (refresh snapshot, not hourly cadence). */ const prevRelayKeyRef = useRef(undefined) useEffect(() => { if (prevRelayKeyRef.current === undefined) { prevRelayKeyRef.current = relayKey + void fetchRef.current() return } if (prevRelayKeyRef.current === relayKey) return diff --git a/src/providers/ScreenSizeProvider.tsx b/src/providers/ScreenSizeProvider.tsx index b96f0e26..a0477a8d 100644 --- a/src/providers/ScreenSizeProvider.tsx +++ b/src/providers/ScreenSizeProvider.tsx @@ -15,6 +15,11 @@ export const useScreenSize = () => { return context } +/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */ +export function useScreenSizeOptional(): TScreenSizeContext | undefined { + return useContext(ScreenSizeContext) +} + export function ScreenSizeProvider({ children }: { children: React.ReactNode }) { const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768) const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280) diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx index e14819b3..bd938608 100644 --- a/src/providers/nostr-context.tsx +++ b/src/providers/nostr-context.tsx @@ -75,3 +75,8 @@ export function useNostr(): TNostrContext { } return context } + +/** Returns undefined when outside NostrProvider (e.g. embedded notes in createRoot trees). */ +export function useNostrOptional(): TNostrContext | undefined { + return useContext(NostrContext) +}
{t('Relay pulse')}
+ {t('Relay pulse')} +
{t('Relay pulse updated', { relative: relativeLabel })}