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 @@ -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() { @@ -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() { @@ -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(<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
export function useSmartProfileNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
@ -437,6 +524,51 @@ export function useSmartProfileNavigation() { @@ -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(<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
export function useSmartHashtagNavigation() {
const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView()
@ -471,6 +603,31 @@ export function useSmartHashtagNavigation() { @@ -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(
<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
export function useSmartFollowingListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()

16
src/components/BookmarkButton/index.tsx

@ -1,17 +1,21 @@ @@ -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 }) { @@ -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()

5
src/components/ClientSelect/index.tsx

@ -5,7 +5,7 @@ import { Separator } from '@/components/ui/separator' @@ -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({ @@ -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()

10
src/components/ContentPreview/index.tsx

@ -1,8 +1,8 @@ @@ -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({ @@ -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<string>()
const contentPolicy = useContentPolicyOptional()
const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false
const isMuted = useMemo(
() => (event ? mutePubkeySet.has(event.pubkey) : false),
[mutePubkeySet, event]

4
src/components/Embedded/EmbeddedNote.tsx

@ -18,7 +18,7 @@ import { Search } from 'lucide-react' @@ -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({ @@ -532,7 +532,7 @@ function EmbeddedNoteNotFound({
function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) {
const [parsedContent, setParsedContent] = useState<string | null>(null)
const bookMetadata = extractBookMetadata(event)
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToNote } = useSmartNoteNavigationOptional()
useEffect(() => {
const parseContent = async () => {

24
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

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

170
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -1,18 +1,22 @@ @@ -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({ @@ -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 = (
<div
className={cn(
'flex flex-row items-center pl-0.5',
scrollableRow && 'w-max max-w-none'
)}
>
<div className="flex w-full min-w-0 max-w-full flex-row flex-wrap items-center gap-y-1 pl-0.5">
{slice.map((pk, i) => (
<HoverCard key={pk} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
@ -97,26 +93,8 @@ function OverlappingAvatars({ @@ -97,26 +93,8 @@ function OverlappingAvatars({
</div>
)
if (scrollableRow) {
return (
<div
className={cn(
'w-full min-w-0 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]',
rowClassName
)}
>
{row}
</div>
)
}
return (
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-end sm:justify-start',
rowClassName
)}
>
<div className={cn('flex w-full min-w-0 max-w-full flex-1 items-start', rowClassName)}>
{row}
</div>
)
@ -132,7 +110,8 @@ function ActiveAvatarGroups({ @@ -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({ @@ -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({ @@ -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 = (
<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 (
<div className={cn('flex min-w-0 flex-col gap-2', stackClassName)}>
{followCount > 0 ? (
<div className={groupRowClass}>
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}>
{t('Relay pulse follows', { count: followCount })}
</span>
<div
className={
mobileBar ? groupRowClass : sidebarSectionClass
}
>
{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
pubkeys={followPubkeysForAvatars}
max={maxFollow}
avatarSize={avatarSize}
scrollableRow={mobileBar}
rowClassName={mobileBar ? undefined : 'min-[380px]:justify-start'}
rowClassName={mobileBar ? undefined : 'justify-start'}
/>
</div>
) : null}
{otherCount > 0 ? (
<div className={groupRowClass}>
<div className={mobileBar ? groupRowClass : sidebarSectionClass}>
<span className={cn('min-w-0 shrink-0 tabular-nums', labelClassName)}>
{t('Relay pulse others', { count: otherCount })}
</span>
@ -178,8 +202,7 @@ function ActiveAvatarGroups({ @@ -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'}
/>
</div>
) : null}
@ -190,6 +213,8 @@ function ActiveAvatarGroups({ @@ -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?: @@ -221,7 +246,16 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
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) {
@ -276,6 +310,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -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}
/>
</div>
</div>
@ -285,6 +320,8 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -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 @@ -316,7 +353,19 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
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) {
@ -350,15 +399,41 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -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">
{t('Relay pulse')}
</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>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="max-xl:hidden mb-1.5 px-1 text-[0.6rem] text-muted-foreground tabular-nums">
{t('Relay pulse updated', { relative: relativeLabel })}
</p>
) : null}
<div className="mb-1 flex justify-center xl:hidden">
<div className="mb-1 flex justify-center gap-0.5 xl:hidden">
<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 className="max-xl:flex max-xl:justify-center">
<ActiveAvatarGroups
@ -371,6 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -371,6 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
avatarSize="xSmall"
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined}
/>
</div>
</div>

18
src/components/LatestFromFollowsSection/index.tsx

@ -85,11 +85,11 @@ function recommendedCuratorHexPubkey(): string | null { @@ -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() { @@ -105,7 +105,7 @@ export default function LatestFromFollowsSection() {
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => 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() { @@ -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() { @@ -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(() => {

9
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1,4 +1,4 @@ @@ -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({ @@ -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

5
src/components/Note/CommunityDefinition.tsx

@ -1,5 +1,5 @@ @@ -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({ @@ -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 = (

5
src/components/Note/GroupMetadata.tsx

@ -1,5 +1,5 @@ @@ -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({ @@ -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 = (

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

@ -5,7 +5,7 @@ import logger from '@/lib/logger' @@ -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({ @@ -61,7 +61,7 @@ function HighlightAuthorCard({
eventId?: string
onClick?: () => void
}) {
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToNote } = useSmartNoteNavigationOptional()
const handleNoteClick = (e: React.MouseEvent) => {
e.stopPropagation()

11
src/components/Note/LiveEvent.tsx

@ -1,16 +1,17 @@ @@ -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 =

15
src/components/Note/LongFormArticlePreview.tsx

@ -1,9 +1,9 @@ @@ -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({ @@ -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) => {

9
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,4 +1,4 @@ @@ -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({ @@ -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(

7
src/components/Note/Poll.tsx

@ -4,7 +4,7 @@ import { useFetchPollResults } from '@/hooks/useFetchPollResults' @@ -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 @@ -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<string[]>([])
const pollResults = useFetchPollResults(event.id)

15
src/components/Note/PublicationCard.tsx

@ -1,8 +1,8 @@ @@ -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({ @@ -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

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

@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -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({ @@ -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<PublicationMetadata>(() => {
const meta: PublicationMetadata = { tags: [] }

4
src/components/Note/RelayReview.tsx

@ -1,7 +1,7 @@ @@ -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' @@ -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])

15
src/components/Note/WikiCard.tsx

@ -1,8 +1,8 @@ @@ -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({ @@ -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) => {

7
src/components/Note/Zap.tsx

@ -8,7 +8,7 @@ import { Zap as ZapIcon } from 'lucide-react' @@ -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 @@ -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 (

19
src/components/Note/index.tsx

@ -1,13 +1,13 @@ @@ -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({ @@ -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<string>()
const [showMuted, setShowMuted] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined)
const [highlightDefaultContent, setHighlightDefaultContent] = useState<string>('')

4
src/components/NoteCard/MainNoteCard.tsx

@ -1,6 +1,6 @@ @@ -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({ @@ -27,7 +27,7 @@ export default function MainNoteCard({
pinned?: boolean
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToNote } = useSmartNoteNavigationOptional()
return (
<div

4
src/components/UserAvatar/index.tsx

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

4
src/components/Username/index.tsx

@ -3,7 +3,7 @@ import { useFetchProfile } from '@/hooks' @@ -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({ @@ -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(() => {

5
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,5 +1,5 @@ @@ -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({ @@ -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)

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

@ -25,3 +25,8 @@ export function useMuteList(): TMuteListContext { @@ -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)
}

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

@ -22,3 +22,8 @@ export function useNoteDrawer(): NoteDrawerContextValue { @@ -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)
}

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

@ -36,3 +36,8 @@ export function usePrimaryNoteView(): PrimaryNoteViewContextValue { @@ -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)
}

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

@ -24,3 +24,8 @@ export function usePrimaryPage(): PrimaryPageContextValue { @@ -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)
}

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

@ -23,3 +23,8 @@ export function useSecondaryPage(): SecondaryPageContextValue { @@ -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)
}

5
src/hooks/useFetchProfile.tsx

@ -1,7 +1,7 @@ @@ -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) { @@ -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(() => {

1
src/i18n/locales/de.ts

@ -20,6 +20,7 @@ export default { @@ -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',

1
src/i18n/locales/en.ts

@ -18,6 +18,7 @@ export default { @@ -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',

37
src/lib/follow-outbox-aggregate-relays.ts

@ -1,4 +1,8 @@ @@ -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 { @@ -11,13 +15,29 @@ function isNonPublicWsRelayUrl(normalizedUrl: string): boolean {
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}:
* 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<string>()
@ -33,12 +53,9 @@ export function buildFollowOutboxAggregateReadUrls( @@ -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
}

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

@ -11,7 +11,9 @@ import { BookOpen } from 'lucide-react' @@ -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<TPageRef>((_, ref) => {
type SearchPageProps = { expandFollows?: boolean }
const SearchPage = forwardRef<TPageRef>((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<TPageRef>((_, ref) => { @@ -88,11 +90,11 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => {
<div key={resultRefreshKey} className="min-w-0">
{searchParams ? (
<SearchResult searchParams={searchParams} />
) : (
) : pubkey ? (
<div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection />
<LatestFromFollowsSection defaultOpen={expandFollows} />
</div>
)}
) : null}
</div>
</div>
</PrimaryPageLayout>

3
src/providers/BookmarksProvider.tsx

@ -25,6 +25,9 @@ export const useBookmarks = () => { @@ -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()

5
src/providers/ContentPolicyProvider.tsx

@ -28,6 +28,11 @@ export const useContentPolicy = () => { @@ -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())

3
src/providers/FavoriteRelaysActivityProvider.tsx

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

5
src/providers/ScreenSizeProvider.tsx

@ -15,6 +15,11 @@ export const useScreenSize = () => { @@ -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)

5
src/providers/nostr-context.tsx

@ -75,3 +75,8 @@ export function useNostr(): TNostrContext { @@ -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)
}

Loading…
Cancel
Save