Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
4614254a72
  1. 165
      src/PageManager.tsx
  2. 16
      src/components/BookmarkButton/index.tsx
  3. 5
      src/components/ClientSelect/index.tsx
  4. 10
      src/components/ContentPreview/index.tsx
  5. 4
      src/components/Embedded/EmbeddedNote.tsx
  6. 24
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  7. 170
      src/components/FavoriteRelaysActiveStrip/index.tsx
  8. 18
      src/components/LatestFromFollowsSection/index.tsx
  9. 9
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  10. 5
      src/components/Note/CommunityDefinition.tsx
  11. 5
      src/components/Note/GroupMetadata.tsx
  12. 4
      src/components/Note/Highlight/index.tsx
  13. 11
      src/components/Note/LiveEvent.tsx
  14. 15
      src/components/Note/LongFormArticlePreview.tsx
  15. 9
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  16. 7
      src/components/Note/Poll.tsx
  17. 15
      src/components/Note/PublicationCard.tsx
  18. 5
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  19. 4
      src/components/Note/RelayReview.tsx
  20. 15
      src/components/Note/WikiCard.tsx
  21. 7
      src/components/Note/Zap.tsx
  22. 19
      src/components/Note/index.tsx
  23. 4
      src/components/NoteCard/MainNoteCard.tsx
  24. 4
      src/components/UserAvatar/index.tsx
  25. 4
      src/components/Username/index.tsx
  26. 5
      src/components/YoutubeEmbeddedPlayer/index.tsx
  27. 5
      src/contexts/mute-list-context.tsx
  28. 5
      src/contexts/note-drawer-context.tsx
  29. 5
      src/contexts/primary-note-view-context.tsx
  30. 5
      src/contexts/primary-page-context.tsx
  31. 5
      src/contexts/secondary-page-context.tsx
  32. 5
      src/hooks/useFetchProfile.tsx
  33. 1
      src/i18n/locales/de.ts
  34. 1
      src/i18n/locales/en.ts
  35. 37
      src/lib/follow-outbox-aggregate-relays.ts
  36. 10
      src/pages/primary/SearchPage/index.tsx
  37. 3
      src/providers/BookmarksProvider.tsx
  38. 5
      src/providers/ContentPolicyProvider.tsx
  39. 3
      src/providers/FavoriteRelaysActivityProvider.tsx
  40. 5
      src/providers/ScreenSizeProvider.tsx
  41. 5
      src/providers/nostr-context.tsx

165
src/PageManager.tsx

@ -39,16 +39,21 @@ import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHel
import { import {
PrimaryPageContext, PrimaryPageContext,
usePrimaryPage, usePrimaryPage,
usePrimaryPageOptional,
type PrimaryPageContextValue type PrimaryPageContextValue
} from '@/contexts/primary-page-context' } from '@/contexts/primary-page-context'
import { normalizeUrl } from './lib/url' import { normalizeUrl } from './lib/url'
import modalManager from './services/modal-manager.service' import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
import { routes } from './routes' import { routes } from './routes'
import { useScreenSize } from './providers/ScreenSizeProvider' import { useScreenSize, useScreenSizeOptional } from './providers/ScreenSizeProvider'
import { NoteDrawerContext, useNoteDrawer } from '@/contexts/note-drawer-context' import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/contexts/note-drawer-context'
import { PrimaryNoteViewContext, usePrimaryNoteView } from '@/contexts/primary-note-view-context' import {
import { SecondaryPageContext, useSecondaryPage } from '@/contexts/secondary-page-context' 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). */ /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage')) const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
@ -366,6 +371,59 @@ export function useSmartNoteNavigation() {
return { navigateToNote } 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 // 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() { export function useSmartRelayNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -396,6 +454,35 @@ export function useSmartRelayNavigation() {
return { navigateToRelay } 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(<SecondaryRelayPage url={relayUrl} index={0} hideTitlebar={true} />, 'relay')
} else {
pushSecondaryPage(contextualUrl)
}
}
return { navigateToRelay }
}
// Fixed: Profile navigation now uses primary note view on mobile, secondary routing on desktop // Fixed: Profile navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartProfileNavigation() { export function useSmartProfileNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -437,6 +524,51 @@ export function useSmartProfileNavigation() {
return { navigateToProfile } 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(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
pushSecondaryPage(url)
}
}, 400)
} else {
if (isSmallScreen) {
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
pushSecondaryPage(url)
}
}
}
return { navigateToProfile }
}
// Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled // Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled
export function useSmartHashtagNavigation() { export function useSmartHashtagNavigation() {
const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView() const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView()
@ -471,6 +603,31 @@ export function useSmartHashtagNavigation() {
return { navigateToHashtag } 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(
<Suspense fallback={primaryPageLazyFallback}>
<SecondaryNoteListPageLazy key={key} hideTitlebar={true} />
</Suspense>,
'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 // Fixed: Following list navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartFollowingListNavigation() { export function useSmartFollowingListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()

16
src/components/BookmarkButton/index.tsx

@ -1,17 +1,21 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useBookmarks } from '@/providers/BookmarksProvider' import { NostrContext } from '@/providers/nostr-context'
import { useNostr } from '@/providers/NostrProvider' import { useBookmarksOptional } from '@/providers/BookmarksProvider'
import { BookmarkIcon } from 'lucide-react' import { BookmarkIcon } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useContext, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
export default function BookmarkButton({ event }: { event: Event }) { export default function BookmarkButton({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr() const nostrContext = useContext(NostrContext)
const { addBookmark, removeBookmark } = useBookmarks() 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 [updating, setUpdating] = useState(false)
const isBookmarked = useMemo(() => { const isBookmarked = useMemo(() => {
const isReplaceable = isReplaceableEvent(event.kind) const isReplaceable = isReplaceableEvent(event.kind)
@ -22,7 +26,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
) )
}, [bookmarkListEvent, event]) }, [bookmarkListEvent, event])
if (!accountPubkey) return null if (!bookmarksContext || !accountPubkey) return null
const handleBookmark = async (e: React.MouseEvent) => { const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()

5
src/components/ClientSelect/index.tsx

@ -5,7 +5,7 @@ import { Separator } from '@/components/ui/separator'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event' import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event'
import { toChachiChat } from '@/lib/link' import { toChachiChat } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import clientService from '@/services/client.service' import clientService from '@/services/client.service'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
@ -85,7 +85,8 @@ export default function ClientSelect({
event?: Event event?: Event
originalNoteId?: string originalNoteId?: string
}) { }) {
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()

10
src/components/ContentPreview/index.tsx

@ -1,8 +1,8 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -29,8 +29,10 @@ export default function ContentPreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { mutePubkeySet } = useMuteList() const muteList = useMuteListOptional()
const { hideContentMentioningMutedUsers } = useContentPolicy() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
const contentPolicy = useContentPolicyOptional()
const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false
const isMuted = useMemo( const isMuted = useMemo(
() => (event ? mutePubkeySet.has(event.pubkey) : false), () => (event ? mutePubkeySet.has(event.pubkey) : false),
[mutePubkeySet, event] [mutePubkeySet, event]

4
src/components/Embedded/EmbeddedNote.tsx

@ -18,7 +18,7 @@ import { Search } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { contentParserService } from '@/services/content-parser.service' import { contentParserService } from '@/services/content-parser.service'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { import {
type EmbeddedNoteIdValidation, type EmbeddedNoteIdValidation,
@ -532,7 +532,7 @@ function EmbeddedNoteNotFound({
function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) { function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) {
const [parsedContent, setParsedContent] = useState<string | null>(null) const [parsedContent, setParsedContent] = useState<string | null>(null)
const bookMetadata = extractBookMetadata(event) const bookMetadata = extractBookMetadata(event)
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigationOptional()
useEffect(() => { useEffect(() => {
const parseContent = async () => { const parseContent = async () => {

24
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -8,6 +8,7 @@ import {
SheetTitle SheetTitle
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { import {
collectAggregatedNip05sFromKind0, collectAggregatedNip05sFromKind0,
@ -73,20 +74,35 @@ export function RelayPulseActiveNpubsOpenButton({
if (totalCount === 0) return null if (totalCount === 0) return null
const countLabel = (
<span className="tabular-nums font-medium">
{totalCount > 99 ? '99+' : totalCount}
</span>
)
return ( return (
<Button <Button
type="button" type="button"
variant={variant} variant={variant}
size={size} size={size}
className={className} className={cn(className, 'relative')}
aria-label={t('Relay pulse active npubs')} aria-label={t('Relay pulse active npubs')}
title={t('Relay pulse active npubs')} title={t('Relay pulse active npubs')}
onClick={() => setActiveNpubsDrawerOpen(true)} onClick={() => setActiveNpubsDrawerOpen(true)}
> >
<Users className={size === 'icon' ? 'size-4' : 'size-3.5 shrink-0'} /> <Users className={size === 'icon' ? 'size-4' : 'size-3.5 shrink-0'} />
{size !== 'icon' ? ( {size === 'icon' ? (
<span className="ml-1.5 text-xs font-medium">{t('Relay pulse active npubs')}</span> <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[0.6rem] font-medium text-primary-foreground">
) : null} {countLabel}
</span>
) : (
<>
<span className="ml-1.5 text-xs font-medium">{countLabel}</span>
<span className="ml-1 text-xs text-muted-foreground">
{t('Relay pulse active npubs')}
</span>
</>
)}
</Button> </Button>
) )
} }

170
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,18 +1,22 @@
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username' import { SimpleUsername } from '@/components/Username'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { cn } from '@/lib/utils' 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 { useMuteList } from '@/contexts/mute-list-context'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet'
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import { FileText } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const MOBILE_MAX_FOLLOW = 8 const MOBILE_MAX_FOLLOW = 30
const MOBILE_MAX_OTHER = 8 const MOBILE_MAX_OTHER = 30
const SIDEBAR_MAX_FOLLOW = 5 const SIDEBAR_MAX_FOLLOW = 50
const SIDEBAR_MAX_OTHER = 5 const SIDEBAR_MAX_OTHER = 50
/** Slight overlap so faces stay recognizable */ /** Slight overlap so faces stay recognizable */
const AVATAR_OVERLAP = '-ml-1' const AVATAR_OVERLAP = '-ml-1'
@ -45,26 +49,18 @@ function OverlappingAvatars({
pubkeys, pubkeys,
max, max,
avatarSize, avatarSize,
rowClassName, rowClassName
scrollableRow = false
}: { }: {
pubkeys: string[] pubkeys: string[]
max: number max: number
avatarSize: 'small' | 'xSmall' | 'tiny' avatarSize: 'small' | 'xSmall' | 'tiny'
rowClassName?: string rowClassName?: string
/** Narrow screens: horizontal scroll inside the viewport instead of overflowing the page */
scrollableRow?: boolean
}) { }) {
const slice = pubkeys.slice(0, max) const slice = pubkeys.slice(0, max)
const extra = pubkeys.length - slice.length const extra = pubkeys.length - slice.length
const row = ( const row = (
<div <div className="flex w-full min-w-0 max-w-full flex-row flex-wrap items-center gap-y-1 pl-0.5">
className={cn(
'flex flex-row items-center pl-0.5',
scrollableRow && 'w-max max-w-none'
)}
>
{slice.map((pk, i) => ( {slice.map((pk, i) => (
<HoverCard key={pk} openDelay={180} closeDelay={80}> <HoverCard key={pk} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
@ -97,26 +93,8 @@ function OverlappingAvatars({
</div> </div>
) )
if (scrollableRow) {
return (
<div
className={cn(
'w-full min-w-0 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]',
rowClassName
)}
>
{row}
</div>
)
}
return ( return (
<div <div className={cn('flex w-full min-w-0 max-w-full flex-1 items-start', rowClassName)}>
className={cn(
'flex min-w-0 flex-1 items-center justify-end sm:justify-start',
rowClassName
)}
>
{row} {row}
</div> </div>
) )
@ -132,7 +110,8 @@ function ActiveAvatarGroups({
avatarSize, avatarSize,
labelClassName, labelClassName,
stackClassName, stackClassName,
variant = 'default' variant = 'default',
onOpenFollowsNotes
}: { }: {
/** Subset with kind 0 only (shown as circles); counts use full totals */ /** Subset with kind 0 only (shown as circles); counts use full totals */
followPubkeysForAvatars: string[] followPubkeysForAvatars: string[]
@ -146,6 +125,8 @@ function ActiveAvatarGroups({
stackClassName?: string stackClassName?: string
/** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */ /** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */
variant?: 'default' | 'mobileBar' variant?: 'default' | 'mobileBar'
/** Opens search page and expands the notes-from-follows section */
onOpenFollowsNotes?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const mobileBar = variant === 'mobileBar' const mobileBar = variant === 'mobileBar'
@ -153,24 +134,67 @@ function ActiveAvatarGroups({
? 'flex w-full min-w-0 flex-col gap-1.5' ? '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' : 'flex min-w-0 flex-col gap-1 min-[380px]:flex-row min-[380px]:items-center min-[380px]:gap-2'
const followsLabelBlock = (
<div className="flex shrink-0 flex-col gap-1">
<span className={cn('tabular-nums', labelClassName)}>
{t('Relay pulse follows', { count: followCount })}
</span>
{onOpenFollowsNotes && mobileBar ? (
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 self-start"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={onOpenFollowsNotes}
>
<FileText className="size-3.5" />
</Button>
) : null}
</div>
)
const sidebarSectionClass = 'flex min-w-0 flex-col gap-1'
return ( return (
<div className={cn('flex min-w-0 flex-col gap-2', stackClassName)}> <div className={cn('flex min-w-0 flex-col gap-2', stackClassName)}>
{followCount > 0 ? ( {followCount > 0 ? (
<div className={groupRowClass}> <div
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}> className={
{t('Relay pulse follows', { count: followCount })} mobileBar ? groupRowClass : sidebarSectionClass
</span> }
>
{mobileBar ? (
<span className="flex min-w-0 shrink-0 items-center gap-1">
<span className={cn('tabular-nums', labelClassName)}>
{t('Relay pulse follows', { count: followCount })}
</span>
{onOpenFollowsNotes ? (
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={onOpenFollowsNotes}
>
<FileText className="size-3.5" />
</Button>
) : null}
</span>
) : (
followsLabelBlock
)}
<OverlappingAvatars <OverlappingAvatars
pubkeys={followPubkeysForAvatars} pubkeys={followPubkeysForAvatars}
max={maxFollow} max={maxFollow}
avatarSize={avatarSize} avatarSize={avatarSize}
scrollableRow={mobileBar} rowClassName={mobileBar ? undefined : 'justify-start'}
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'}
/> />
</div> </div>
) : null} ) : null}
{otherCount > 0 ? ( {otherCount > 0 ? (
<div className={groupRowClass}> <div className={mobileBar ? groupRowClass : sidebarSectionClass}>
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}> <span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}>
{t('Relay pulse others', { count: otherCount })} {t('Relay pulse others', { count: otherCount })}
</span> </span>
@ -178,8 +202,7 @@ function ActiveAvatarGroups({
pubkeys={otherPubkeysForAvatars} pubkeys={otherPubkeysForAvatars}
max={maxOther} max={maxOther}
avatarSize={avatarSize} avatarSize={avatarSize}
scrollableRow={mobileBar} rowClassName={mobileBar ? undefined : 'justify-start'}
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'}
/> />
</div> </div>
) : null} ) : null}
@ -190,6 +213,8 @@ function ActiveAvatarGroups({
/** Home feed / mobile: full label above the page title */ /** Home feed / mobile: full label above the page title */
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { const {
followPubkeys, followPubkeys,
@ -221,7 +246,16 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) { if (!relayActivityReady && !loading) {
return null return (
<div
className={cn(
'w-full min-w-0 max-w-full border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4 animate-pulse',
className
)}
>
<p className="text-xs font-medium text-foreground">{t('Relay pulse')}</p>
</div>
)
} }
if (relayActivityReady && !loading && totalCount === 0) { if (relayActivityReady && !loading && totalCount === 0) {
@ -276,6 +310,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
avatarSize="small" avatarSize="small"
labelClassName="text-[0.7rem] font-medium text-muted-foreground" labelClassName="text-[0.7rem] font-medium text-muted-foreground"
stackClassName="w-full min-w-0 max-w-full" stackClassName="w-full min-w-0 max-w-full"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined}
/> />
</div> </div>
</div> </div>
@ -285,6 +320,8 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
/** Desktop sidebar: compact row under nav */ /** Desktop sidebar: compact row under nav */
export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { const {
followPubkeys, followPubkeys,
@ -316,7 +353,19 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
if (!relayActivityReady && !loading) { if (!relayActivityReady && !loading) {
return null return (
<div
className={cn(
'px-1 py-2 xl:px-0 animate-pulse',
className
)}
>
<p className="text-[0.65rem] font-medium leading-snug text-foreground">
{t('Relay pulse')}
</p>
<div className="mt-0.5 h-4 w-16 rounded bg-muted/50" aria-hidden />
</div>
)
} }
if (relayActivityReady && !loading && totalCount === 0) { if (relayActivityReady && !loading && totalCount === 0) {
@ -350,15 +399,41 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
<p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground"> <p className="min-w-0 flex-1 text-[0.65rem] font-medium leading-snug text-foreground">
{t('Relay pulse')} {t('Relay pulse')}
</p> </p>
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" /> <div className="flex shrink-0 items-center gap-0.5">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-7 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('search', { expandFollows: true })}
>
<FileText className="size-3.5" />
</Button>
) : null}
</div>
</div> </div>
{lastFetchedAtMs != null && relativeLabel ? ( {lastFetchedAtMs != null && relativeLabel ? (
<p className="max-xl:hidden mb-1.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums"> <p className="max-xl:hidden mb-1.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })} {t('Relay pulse updated', { relative: relativeLabel })}
</p> </p>
) : null} ) : null}
<div className="mb-1 flex justify-center xl:hidden"> <div className="mb-1 flex justify-center gap-0.5 xl:hidden">
<RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" /> <RelayPulseActiveNpubsOpenButton size="icon" variant="ghost" className="size-8 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('search', { expandFollows: true })}
>
<FileText className="size-4" />
</Button>
) : null}
</div> </div>
<div className="max-xl:flex max-xl:justify-center"> <div className="max-xl:flex max-xl:justify-center">
<ActiveAvatarGroups <ActiveAvatarGroups
@ -371,6 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
avatarSize="xSmall" avatarSize="xSmall"
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1" labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center" stackClassName="w-full max-xl:items-center"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined}
/> />
</div> </div>
</div> </div>

18
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 { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, followListEvent, isInitialized } = useNostr() const { pubkey, followListEvent, isInitialized } = useNostr()
const { blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
@ -105,7 +105,7 @@ export default function LatestFromFollowsSection() {
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map()) const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map())
const [batchBusy, setBatchBusy] = useState(false) const [batchBusy, setBatchBusy] = useState(false)
/** Search page: start collapsed so the bar doesn’t push the search field; data still prefetches in the background. */ /** 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 abortedRef = useRef(false)
const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys
@ -195,12 +195,18 @@ export default function LatestFromFollowsSection() {
allLists.push(...lists) allLists.push(...lists)
} }
if (cancelled) return if (cancelled) return
const urls = buildFollowOutboxAggregateReadUrls(allLists, blockedRelays) const urls = buildFollowOutboxAggregateReadUrls(
allLists,
blockedRelays,
favoriteRelays
)
setAggregateRelayUrls(urls) setAggregateRelayUrls(urls)
} catch (err) { } catch (err) {
logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err)
if (!cancelled) { if (!cancelled) {
setAggregateRelayUrls(buildFollowOutboxAggregateReadUrls([], blockedRelays)) setAggregateRelayUrls(
buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays)
)
} }
} finally { } finally {
if (!cancelled) setAggregateRelaysReady(true) if (!cancelled) setAggregateRelaysReady(true)
@ -210,7 +216,7 @@ export default function LatestFromFollowsSection() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [followPubkeys, blockedRelays, isInitialized, loadingFollowList]) }, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList])
// Batch-fetch posts per slice of authors against the aggregate relay set. // Batch-fetch posts per slice of authors against the aggregate relay set.
useEffect(() => { useEffect(() => {

9
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 Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
@ -344,9 +344,10 @@ export default function AsciidocArticle({
hideImagesAndInfo?: boolean hideImagesAndInfo?: boolean
parentImageUrl?: string parentImageUrl?: string
}) { }) {
const { push } = useSecondaryPage() const secondaryPage = useSecondaryPageOptional()
const { navigateToHashtag } = useSmartHashtagNavigation() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToHashtag } = useSmartHashtagNavigationOptional()
const { navigateToRelay } = useSmartRelayNavigationOptional()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book

5
src/components/Note/CommunityDefinition.tsx

@ -1,5 +1,5 @@
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata' import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
@ -12,7 +12,8 @@ export default function CommunityDefinition({
event: Event event: Event
className?: string className?: string
}) { }) {
const { autoLoadMedia } = useContentPolicy() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event]) const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
const communityNameComponent = ( const communityNameComponent = (

5
src/components/Note/GroupMetadata.tsx

@ -1,5 +1,5 @@
import { getGroupMetadataFromEvent } from '@/lib/event-metadata' import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
@ -14,7 +14,8 @@ export default function GroupMetadata({
originalNoteId?: string originalNoteId?: string
className?: string className?: string
}) { }) {
const { autoLoadMedia } = useContentPolicy() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event]) const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
const groupNameComponent = ( const groupNameComponent = (

4
src/components/Note/Highlight/index.tsx

@ -5,7 +5,7 @@ import logger from '@/lib/logger'
import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview' import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
@ -61,7 +61,7 @@ function HighlightAuthorCard({
eventId?: string eventId?: string
onClick?: () => void onClick?: () => void
}) { }) {
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigationOptional()
const handleNoteClick = (e: React.MouseEvent) => { const handleNoteClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()

11
src/components/Note/LiveEvent.tsx

@ -1,16 +1,17 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
import Image from '../Image' import Image from '../Image'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) { export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const { autoLoadMedia } = useContentPolicy() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const liveStatusComponent = const liveStatusComponent =

15
src/components/Note/LongFormArticlePreview.tsx

@ -1,9 +1,9 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
@ -15,9 +15,12 @@ export default function LongFormArticlePreview({
event: Event event: Event
className?: string className?: string
}) { }) {
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const { push } = useSecondaryPage() const isSmallScreen = screenSize?.isSmallScreen ?? false
const { autoLoadMedia } = useContentPolicy() 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 metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {

9
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 Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink' 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 */ /** When viewing a kind-24 invite, render full calendar card with RSVP in place of the naddr embed */
fullCalendarInvite?: { naddr: string; event: Event } fullCalendarInvite?: { naddr: string; event: Event }
}) { }) {
const { push } = useSecondaryPage() const secondaryPage = useSecondaryPageOptional()
const { navigateToHashtag } = useSmartHashtagNavigation() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToHashtag } = useSmartHashtagNavigationOptional()
const { navigateToRelay } = useSmartRelayNavigationOptional()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event]) const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event])
const iArticleCleaned = useMemo( const iArticleCleaned = useMemo(

7
src/components/Note/Poll.tsx

@ -4,7 +4,7 @@ import { useFetchPollResults } from '@/hooks/useFetchPollResults'
import { createPollResponseDraftEvent } from '@/lib/draft-event' import { createPollResponseDraftEvent } from '@/lib/draft-event'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { cn, isPartiallyInViewport } from '@/lib/utils' 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 pollResultsService from '@/services/poll-results.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Skeleton } from '@/components/ui/skeleton' 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 }) { export default function Poll({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() 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 [isVoting, setIsVoting] = useState(false)
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([]) const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
const pollResults = useFetchPollResults(event.id) const pollResults = useFetchPollResults(event.id)

15
src/components/Note/PublicationCard.tsx

@ -1,8 +1,8 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
@ -16,9 +16,12 @@ export default function PublicationCard({
event: Event event: Event
className?: string className?: string
}) { }) {
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const { push } = useSecondaryPage() const isSmallScreen = screenSize?.isSmallScreen ?? false
const { autoLoadMedia } = useContentPolicy() 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 metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book

5
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { RefreshCw, ArrowUp } from 'lucide-react' import { RefreshCw, ArrowUp } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { dTagToTitleCase } from '@/lib/event-metadata' import { dTagToTitleCase } from '@/lib/event-metadata'
import Image from '@/components/Image' import Image from '@/components/Image'
@ -61,7 +61,8 @@ export default function PublicationIndex({
isNested?: boolean isNested?: boolean
parentImageUrl?: string parentImageUrl?: string
}) { }) {
const { push } = useSecondaryPage() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
// Parse publication metadata from event tags // Parse publication metadata from event tags
const metadata = useMemo<PublicationMetadata>(() => { const metadata = useMemo<PublicationMetadata>(() => {
const meta: PublicationMetadata = { tags: [] } const meta: PublicationMetadata = { tags: [] }

4
src/components/Note/RelayReview.tsx

@ -1,7 +1,7 @@
import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigationOptional } from '@/PageManager'
import { Link2 } from 'lucide-react' import { Link2 } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -9,7 +9,7 @@ import Content from '../Content'
import Stars from '../Stars' import Stars from '../Stars'
export default function RelayReview({ event, className }: { event: Event; className?: string }) { export default function RelayReview({ event, className }: { event: Event; className?: string }) {
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToRelay } = useSmartRelayNavigationOptional()
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event]) const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event]) const relayUrl = useMemo(() => getRelayUrlFromRelayReviewEvent(event), [event])

15
src/components/Note/WikiCard.tsx

@ -1,8 +1,8 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
@ -14,9 +14,12 @@ export default function WikiCard({
event: Event event: Event
className?: string className?: string
}) { }) {
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const { push } = useSecondaryPage() const isSmallScreen = screenSize?.isSmallScreen ?? false
const { autoLoadMedia } = useContentPolicy() 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 metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {

7
src/components/Note/Zap.tsx

@ -8,7 +8,7 @@ import { Zap as ZapIcon } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username' import Username from '../Username'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@ -26,8 +26,9 @@ export default function Zap({ event, className }: { event: Event; className?: st
return null return null
} }
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigationOptional()
const { push } = useSecondaryPage() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
return ( return (

19
src/components/Note/index.tsx

@ -1,13 +1,13 @@
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event' import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
@ -71,15 +71,18 @@ export default function Note({
fullCalendarInvite?: { event: Event; naddr: string } fullCalendarInvite?: { event: Event; naddr: string }
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigationOptional()
const { isSmallScreen } = useScreenSize() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const parentEventId = useMemo( const parentEventId = useMemo(
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)), () => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
[event, hideParentNotePreview] [event, hideParentNotePreview]
) )
const { defaultShowNsfw } = useContentPolicy() const contentPolicy = useContentPolicyOptional()
const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true
const [showNsfw, setShowNsfw] = useState(false) const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList() const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined) const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined)
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('') const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')

4
src/components/NoteCard/MainNoteCard.tsx

@ -1,6 +1,6 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -27,7 +27,7 @@ export default function MainNoteCard({
pinned?: boolean pinned?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigationOptional()
return ( return (
<div <div

4
src/components/UserAvatar/index.tsx

@ -3,7 +3,7 @@ import { useFetchProfile } from '@/hooks'
import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigation } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
const UserAvatarSizeCnMap = { const UserAvatarSizeCnMap = {
@ -27,7 +27,7 @@ export default function UserAvatar({
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny' size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
}) { }) {
const { profile } = useFetchProfile(userId) const { profile } = useFetchProfile(userId)
const { navigateToProfile } = useSmartProfileNavigation() const { navigateToProfile } = useSmartProfileNavigationOptional()
// Extract pubkey from userId if it's npub/nprofile format // Extract pubkey from userId if it's npub/nprofile format
const pubkey = useMemo(() => { const pubkey = useMemo(() => {

4
src/components/Username/index.tsx

@ -3,7 +3,7 @@ import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigation } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
import { useMemo } from 'react' import { useMemo } from 'react'
export default function Username({ export default function Username({
@ -22,7 +22,7 @@ export default function Username({
style?: React.CSSProperties style?: React.CSSProperties
}) { }) {
const { profile, isFetching } = useFetchProfile(userId) const { profile, isFetching } = useFetchProfile(userId)
const { navigateToProfile } = useSmartProfileNavigation() const { navigateToProfile } = useSmartProfileNavigationOptional()
// Get pubkey from userId (works even if profile isn't loaded) // Get pubkey from userId (works even if profile isn't loaded)
const pubkey = useMemo(() => { const pubkey = useMemo(() => {

5
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,5 +1,5 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service' import mediaManager from '@/services/media-manager.service'
import { YouTubePlayer } from '@/types/youtube' import { YouTubePlayer } from '@/types/youtube'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@ -17,7 +17,8 @@ export default function YoutubeEmbeddedPlayer({
mustLoad?: boolean mustLoad?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [display, setDisplay] = useState(autoLoadMedia) const [display, setDisplay] = useState(autoLoadMedia)
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
const [initSuccess, setInitSuccess] = useState(false) const [initSuccess, setInitSuccess] = useState(false)

5
src/contexts/mute-list-context.tsx

@ -25,3 +25,8 @@ export function useMuteList(): TMuteListContext {
} }
return context return context
} }
/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */
export function useMuteListOptional(): TMuteListContext | undefined {
return useContext(MuteListContext)
}

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

@ -22,3 +22,8 @@ export function useNoteDrawer(): NoteDrawerContextValue {
} }
return context return context
} }
/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */
export function useNoteDrawerOptional(): NoteDrawerContextValue | undefined {
return useContext(NoteDrawerContext)
}

5
src/contexts/primary-note-view-context.tsx

@ -36,3 +36,8 @@ export function usePrimaryNoteView(): PrimaryNoteViewContextValue {
} }
return context return context
} }
/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */
export function usePrimaryNoteViewOptional(): PrimaryNoteViewContextValue | undefined {
return useContext(PrimaryNoteViewContext)
}

5
src/contexts/primary-page-context.tsx

@ -24,3 +24,8 @@ export function usePrimaryPage(): PrimaryPageContextValue {
} }
return context return context
} }
/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */
export function usePrimaryPageOptional(): PrimaryPageContextValue | undefined {
return useContext(PrimaryPageContext)
}

5
src/contexts/secondary-page-context.tsx

@ -23,3 +23,8 @@ export function useSecondaryPage(): SecondaryPageContextValue {
} }
return context return context
} }
/** Returns undefined when outside provider (e.g. embedded notes in createRoot trees). */
export function useSecondaryPageOptional(): SecondaryPageContextValue | undefined {
return useContext(SecondaryPageContext)
}

5
src/hooks/useFetchProfile.tsx

@ -1,7 +1,7 @@
import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider' import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { replaceableEventService } from '@/services/client.service' import { replaceableEventService } from '@/services/client.service'
import { TProfile } from '@/types' 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') // 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() const noteFeed = useNoteFeedProfileContext()
/** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */ /** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */
const [isFetching, setIsFetching] = useState(() => { const [isFetching, setIsFetching] = useState(() => {

1
src/i18n/locales/de.ts

@ -20,6 +20,7 @@ export default {
'Relay pulse drawer following': 'Folge ich', 'Relay pulse drawer following': 'Folge ich',
'Relay pulse drawer others': 'Andere', 'Relay pulse drawer others': 'Andere',
'Relay pulse drawer no profiles': 'Für diese Stichprobe wurden noch keine Kind-0-Profile geladen.', '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', 'All favorite relays': 'Alle Lieblingsrelais',
'Pinned note': 'Angehefteter Beitrag', 'Pinned note': 'Angehefteter Beitrag',
'Relay settings': 'Relay-Einstellungen', 'Relay settings': 'Relay-Einstellungen',

1
src/i18n/locales/en.ts

@ -18,6 +18,7 @@ export default {
'Relay pulse drawer following': 'Following', 'Relay pulse drawer following': 'Following',
'Relay pulse drawer others': 'Others', 'Relay pulse drawer others': 'Others',
'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', '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', 'All favorite relays': 'All favorite relays',
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings', 'Relay settings': 'Relays and Storage Settings',

37
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 { normalizeUrl } from '@/lib/url'
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
import type { TRelayList } from '@/types' import type { TRelayList } from '@/types'
@ -11,13 +15,29 @@ function isNonPublicWsRelayUrl(normalizedUrl: string): boolean {
return normalizedUrl.toLowerCase().startsWith('ws://') return normalizedUrl.toLowerCase().startsWith('ws://')
} }
function addLayer(
out: string[],
seen: Set<string>,
blocked: Set<string>,
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}: * Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS},
* normalized, blocked-stripped, deduped (first occurrence wins). * {@link FAST_READ_RELAY_URLS}, and user favorites: normalized, blocked-stripped,
* deduped (first occurrence wins).
*/ */
export function buildFollowOutboxAggregateReadUrls( export function buildFollowOutboxAggregateReadUrls(
relayLists: readonly TRelayList[], relayLists: readonly TRelayList[],
blockedRelays: readonly string[] blockedRelays: readonly string[],
favoriteRelays: readonly string[] = []
): string[] { ): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean)) const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean))
const seen = new Set<string>() const seen = new Set<string>()
@ -33,12 +53,9 @@ export function buildFollowOutboxAggregateReadUrls(
} }
} }
for (const u of READ_ONLY_RELAY_URLS) { addLayer(out, seen, blocked, READ_ONLY_RELAY_URLS)
const n = normalizeUrl(u) || u addLayer(out, seen, blocked, FAST_READ_RELAY_URLS)
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue addLayer(out, seen, blocked, favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS)
seen.add(n)
out.push(n)
}
return out return out
} }

10
src/pages/primary/SearchPage/index.tsx

@ -11,7 +11,9 @@ import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
const SearchPage = forwardRef<TPageRef>((_, ref) => { type SearchPageProps = { expandFollows?: boolean }
const SearchPage = forwardRef<TPageRef>((props: SearchPageProps, ref) => {
const { expandFollows } = props ?? {}
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -88,11 +90,11 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => {
<div key={resultRefreshKey} className="min-w-0"> <div key={resultRefreshKey} className="min-w-0">
{searchParams ? ( {searchParams ? (
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />
) : ( ) : pubkey ? (
<div className="mb-4 min-w-0 space-y-2"> <div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection /> <LatestFromFollowsSection defaultOpen={expandFollows} />
</div> </div>
)} ) : null}
</div> </div>
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>

3
src/providers/BookmarksProvider.tsx

@ -25,6 +25,9 @@ export const useBookmarks = () => {
return context 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 }) { export function BookmarksProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()

5
src/providers/ContentPolicyProvider.tsx

@ -28,6 +28,11 @@ export const useContentPolicy = () => {
return context 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 }) { export function ContentPolicyProvider({ children }: { children: React.ReactNode }) {
const [autoplay, setAutoplay] = useState(storage.getAutoplay()) const [autoplay, setAutoplay] = useState(storage.getAutoplay())
const [defaultShowNsfw, setDefaultShowNsfw] = useState(storage.getDefaultShowNsfw()) const [defaultShowNsfw, setDefaultShowNsfw] = useState(storage.getDefaultShowNsfw())

3
src/providers/FavoriteRelaysActivityProvider.tsx

@ -116,11 +116,12 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
const fetchRef = useRef(fetchActive) const fetchRef = useRef(fetchActive)
fetchRef.current = 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<string | undefined>(undefined) const prevRelayKeyRef = useRef<string | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (prevRelayKeyRef.current === undefined) { if (prevRelayKeyRef.current === undefined) {
prevRelayKeyRef.current = relayKey prevRelayKeyRef.current = relayKey
void fetchRef.current()
return return
} }
if (prevRelayKeyRef.current === relayKey) return if (prevRelayKeyRef.current === relayKey) return

5
src/providers/ScreenSizeProvider.tsx

@ -15,6 +15,11 @@ export const useScreenSize = () => {
return context 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 }) { export function ScreenSizeProvider({ children }: { children: React.ReactNode }) {
const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768) const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768)
const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280) const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280)

5
src/providers/nostr-context.tsx

@ -75,3 +75,8 @@ export function useNostr(): TNostrContext {
} }
return context return context
} }
/** Returns undefined when outside NostrProvider (e.g. embedded notes in createRoot trees). */
export function useNostrOptional(): TNostrContext | undefined {
return useContext(NostrContext)
}

Loading…
Cancel
Save