Browse Source

keep header fixed on scroll down

imwald
Silberengel 2 weeks ago
parent
commit
6caff4767d
  1. 2
      src/App.tsx
  2. 20
      src/PageManager.tsx
  3. 3
      src/components/Embedded/EmbeddedNoteProviders.tsx
  4. 7
      src/components/NoteCard/RepostNoteCard.tsx
  5. 7
      src/components/NoteCard/index.tsx
  6. 7
      src/components/ReplyNote/index.tsx
  7. 7
      src/components/ReplyNoteList/index.tsx
  8. 2
      src/constants.ts
  9. 42
      src/index.css
  10. 64
      src/layouts/PrimaryPageLayout/index.tsx
  11. 32
      src/layouts/SecondaryPageLayout/index.tsx
  12. 22
      src/lib/error-suppression.ts
  13. 17
      src/lib/mobile-primary-feed-scroll.ts
  14. 72
      src/pages/primary/SpellsPage/index.tsx
  15. 5
      src/services/relay-info.service.ts

2
src/App.tsx

@ -38,7 +38,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider> <DeletedEventProvider>
<NostrProvider> <NostrProvider>
<CacheBrowserProvider> <CacheBrowserProvider>
<div className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden max-md:h-auto max-md:max-h-none max-md:min-h-dvh max-md:overflow-visible"> <div className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden max-md:h-[var(--vh)] max-md:max-h-[var(--vh)] max-md:min-h-0">
<VersionUpdateBanner /> <VersionUpdateBanner />
<StartupSessionBanner /> <StartupSessionBanner />
<SlowConnectionHint /> <SlowConnectionHint />

20
src/PageManager.tsx

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
captureMobilePrimaryFeedScrollFromWindow, captureMobilePrimaryFeedScroll,
peekMobilePrimaryFeedScroll peekMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll' } from '@/lib/mobile-primary-feed-scroll'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back' import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
@ -2054,7 +2054,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if (isSmallScreen && currentPrimaryPage) { if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage) captureMobilePrimaryFeedScroll(currentPrimaryPage)
} }
// Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows. // Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows.
@ -2393,13 +2393,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}} }}
> >
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-content-canvas min-h-[var(--vh)]"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-content-canvas">
<LiveActivitiesStrip placement="mobile" /> <LiveActivitiesStrip placement="mobile" />
{primaryNoteView ? ( {primaryNoteView ? (
// Show primary note view with back button on mobile // Show primary note view with back button on mobile
<div <div
ref={setMobilePrimarySwipeRoot} ref={setMobilePrimarySwipeRoot}
className="flex min-h-0 flex-1 flex-col h-full w-full touch-pan-y" className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden touch-pan-y"
> >
<ImwaldBrandBar /> <ImwaldBrandBar />
<div className="flex gap-1 border-b border-border p-1 items-center justify-between font-semibold"> <div className="flex gap-1 border-b border-border p-1 items-center justify-between font-semibold">
@ -2423,15 +2423,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
<RefreshButton onClick={triggerPrimaryPanelRefresh} /> <RefreshButton onClick={triggerPrimaryPanelRefresh} />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="page-scroll-y min-h-0 flex-1 basis-0 overflow-y-scroll overscroll-y-contain touch-pan-y">
{primaryNoteView} {primaryNoteView}
</div> </div>
</div> </div>
) : ( ) : (
<> <div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div <div
className={cn( className={cn(
'block h-full min-h-0 min-w-0', 'flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden',
secondaryStack.length > 0 && 'hidden' secondaryStack.length > 0 && 'hidden'
)} )}
aria-hidden={secondaryStack.length > 0} aria-hidden={secondaryStack.length > 0}
@ -2441,12 +2441,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
<div <div
ref={setMobileSecondarySwipeRoot} ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y bg-background" className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden touch-pan-y bg-background"
> >
<TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} /> <TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
</div> </div>
) : null} ) : null}
</> </div>
)} )}
</div> </div>
<Suspense fallback={null}> <Suspense fallback={null}>
@ -2675,7 +2675,7 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean
/** Mount only the top secondary frame so Back unmounts feeds/relays under the previous page. */ /** Mount only the top secondary frame so Back unmounts feeds/relays under the previous page. */
function TopSecondaryStackPane({ function TopSecondaryStackPane({
item, item,
className = 'block h-full min-h-0 min-w-0' className = 'flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
}: { }: {
item: TStackItem item: TStackItem
className?: string className?: string

3
src/components/Embedded/EmbeddedNoteProviders.tsx

@ -1,11 +1,14 @@
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { ReplyProvider } from '@/providers/ReplyProvider' import { ReplyProvider } from '@/providers/ReplyProvider'
/** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */ /** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */
export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) { export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) {
return ( return (
<ContentPolicyProvider>
<DeletedEventProvider> <DeletedEventProvider>
<ReplyProvider>{children}</ReplyProvider> <ReplyProvider>{children}</ReplyProvider>
</DeletedEventProvider> </DeletedEventProvider>
</ContentPolicyProvider>
) )
} }

7
src/components/NoteCard/RepostNoteCard.tsx

@ -1,7 +1,8 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -28,7 +29,9 @@ export default function RepostNoteCard({
seenOnAllowlist?: readonly string[] seenOnAllowlist?: readonly string[]
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const [targetEvent, setTargetEvent] = useState<Event | null>(null) const [targetEvent, setTargetEvent] = useState<Event | null>(null)
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (!targetEvent) return true if (!targetEvent) return true

7
src/components/NoteCard/index.tsx

@ -1,7 +1,8 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers, isNip18RepostKind, isNip56ReportEvent } from '@/lib/event' import { isMentioningMutedUsers, isNip18RepostKind, isNip56ReportEvent } from '@/lib/event'
import ReportCard from '@/components/ReportCard' import ReportCard from '@/components/ReportCard'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -37,7 +38,9 @@ const NoteCard = memo(function NoteCard({
showPaymentAttestationAction?: boolean showPaymentAttestationAction?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (filterMutedNotes && muteSetHas(mutePubkeySet, event.pubkey)) { if (filterMutedNotes && muteSetHas(mutePubkeySet, event.pubkey)) {
return true return true

7
src/components/ReplyNote/index.tsx

@ -18,7 +18,8 @@ import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -59,7 +60,9 @@ export default function ReplyNote({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
const reactionDisplay = useNotificationReactionDisplay(event) const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo( const webReactionParentUrl = useMemo(

7
src/components/ReplyNoteList/index.tsx

@ -20,7 +20,8 @@ import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
@ -109,7 +110,9 @@ function ReplyNoteList({
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()

2
src/constants.ts

@ -557,7 +557,7 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://profiles.nostrver.se/', 'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://indexer.coracle.social/' 'wss://indexer.coracle.social/'
] ]

42
src/index.css

@ -25,6 +25,20 @@
} }
} }
/* Mobile: lock document scroll; feeds and note panels scroll inside .page-scroll-y regions. */
@media (max-width: 768px) {
html,
body {
height: 100%;
overflow: hidden;
}
#root {
height: 100%;
min-height: 0;
overflow: hidden;
}
}
input, input,
textarea, textarea,
button { button {
@ -96,27 +110,51 @@
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
/* Popover / select lists: keep a visible vertical scrollbar (not overlay-only). */ /*
* Primary/secondary pages, popovers, selects: visible vertical scrollbar (not overlay-only).
* Pair with Tailwind overflow-y-scroll on the same element.
*/
.page-scroll-y,
.popover-scroll-y { .popover-scroll-y {
overflow-y: scroll;
scrollbar-gutter: stable; scrollbar-gutter: stable;
scrollbar-width: thin; scrollbar-width: thin;
} }
.page-scroll-y::-webkit-scrollbar,
.popover-scroll-y::-webkit-scrollbar { .popover-scroll-y::-webkit-scrollbar {
width: 10px; width: 10px;
} }
.page-scroll-y::-webkit-scrollbar-thumb,
.popover-scroll-y::-webkit-scrollbar-thumb { .popover-scroll-y::-webkit-scrollbar-thumb {
border-radius: 9999px; border-radius: 9999px;
background-color: hsl(var(--muted-foreground) / 0.35); background-color: hsl(var(--muted-foreground) / 0.35);
} }
.page-scroll-y::-webkit-scrollbar-thumb:hover,
.popover-scroll-y::-webkit-scrollbar-thumb:hover { .popover-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5); background-color: hsl(var(--muted-foreground) / 0.5);
} }
.page-scroll-y::-webkit-scrollbar-track,
.popover-scroll-y::-webkit-scrollbar-track { .popover-scroll-y::-webkit-scrollbar-track {
border-radius: 9999px; border-radius: 9999px;
background-color: hsl(var(--muted) / 0.45); background-color: hsl(var(--muted) / 0.45);
} }
/* Narrow viewports: stronger scroll affordance (feed + note panels use inner scroll). */
@media (max-width: 768px) {
.page-scroll-y {
-webkit-overflow-scrolling: touch;
scrollbar-width: auto;
}
.page-scroll-y::-webkit-scrollbar {
width: 12px;
}
.page-scroll-y::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.55);
}
.page-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.7);
}
}
/* /*
* Radix Select injects a sibling <style> that hides scrollbars on the viewport. * Radix Select injects a sibling <style> that hides scrollbars on the viewport.
* That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select). * That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select).

64
src/layouts/PrimaryPageLayout/index.tsx

@ -13,10 +13,11 @@ import {
} from '@/lib/keyboard-shortcuts' } from '@/lib/keyboard-shortcuts'
import { import {
peekMobilePrimaryFeedScroll, peekMobilePrimaryFeedScroll,
registerMobilePrimaryFeedScrollElement,
saveMobilePrimaryFeedScroll saveMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll' } from '@/lib/mobile-primary-feed-scroll'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@ -39,7 +40,6 @@ const PrimaryPageLayout = forwardRef(
ref ref
) => { ) => {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const smallScreenScrollAreaRef = useRef<HTMLDivElement>(null)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current, display, frozen } = usePrimaryPage() const { current, display, frozen } = usePrimaryPage()
const savedScrollTopRef = useRef(0) const savedScrollTopRef = useRef(0)
@ -50,34 +50,45 @@ const PrimaryPageLayout = forwardRef(
() => ({ () => ({
scrollToTop: (behavior: ScrollBehavior = 'smooth') => { scrollToTop: (behavior: ScrollBehavior = 'smooth') => {
setTimeout(() => { setTimeout(() => {
if (scrollAreaRef.current) { scrollAreaRef.current?.scrollTo({ top: 0, behavior })
return scrollAreaRef.current.scrollTo({ top: 0, behavior })
}
window.scrollTo({ top: 0, behavior })
}, 10) }, 10)
} }
}), }),
[] []
) )
useLayoutEffect(() => {
if (!isSmallScreen) {
registerMobilePrimaryFeedScrollElement(null)
return
}
registerMobilePrimaryFeedScrollElement(scrollAreaRef.current)
return () => registerMobilePrimaryFeedScrollElement(null)
}, [isSmallScreen, display, current, pageName])
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName || frozen) return if (!isSmallScreen || current !== pageName) return
const el = scrollAreaRef.current
if (!el) return
const handleScroll = () => { const handleScroll = () => {
saveMobilePrimaryFeedScroll(pageName, window.scrollY) saveMobilePrimaryFeedScroll(pageName, el.scrollTop)
} }
window.addEventListener('scroll', handleScroll, { passive: true }) el.addEventListener('scroll', handleScroll, { passive: true })
return () => { return () => {
handleScroll() handleScroll()
window.removeEventListener('scroll', handleScroll) el.removeEventListener('scroll', handleScroll)
} }
}, [current, frozen, isSmallScreen, pageName]) }, [current, isSmallScreen, pageName])
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName || !display) return if (!isSmallScreen || current !== pageName || !display) return
const top = peekMobilePrimaryFeedScroll(pageName) const top = peekMobilePrimaryFeedScroll(pageName)
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' }) if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = top
}
}) })
}, [current, display, isSmallScreen, pageName]) }, [current, display, isSmallScreen, pageName])
@ -125,25 +136,32 @@ const PrimaryPageLayout = forwardRef(
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DeepBrowsingProvider active={current === pageName && display && !frozen}> <DeepBrowsingProvider
<div active={current === pageName && display && !frozen}
ref={smallScreenScrollAreaRef} scrollAreaRef={scrollAreaRef}
className="min-w-0 w-full overflow-x-hidden"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
> >
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{hasTitlebarRow ? ( {hasTitlebarRow ? (
<PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}> <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
{titlebar} {titlebar}
</PrimaryPageTitlebar> </PrimaryPageTitlebar>
) : null} ) : null}
{subHeader && <div className="shrink-0 w-full min-w-0 bg-background">{subHeader}</div>} {subHeader ? (
<div className="min-w-0 w-full"> <div className="min-w-0 shrink-0 border-b border-border/80 bg-background">
{subHeader}
</div>
) : null}
<div
ref={scrollAreaRef}
className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-hidden overscroll-y-contain touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{children} {children}
</div> </div>
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton />} {displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider> </DeepBrowsingProvider>
) )
} }
@ -165,7 +183,7 @@ const PrimaryPageLayout = forwardRef(
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto" className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-auto overscroll-y-contain"
> >
{children} {children}
<div className="h-4" /> <div className="h-4" />

32
src/layouts/SecondaryPageLayout/index.tsx

@ -62,11 +62,9 @@ const SecondaryPageLayout = forwardRef(
) )
useEffect(() => { useEffect(() => {
if (isSmallScreen) { if (!isSmallScreen) return
setTimeout(() => window.scrollTo({ top: 0 }), 10) setTimeout(() => scrollAreaRef.current?.scrollTo({ top: 0 }), 10)
return }, [isSmallScreen])
}
}, [])
useEffect(() => { useEffect(() => {
if (isSmallScreen) return if (isSmallScreen) return
@ -88,13 +86,8 @@ const SecondaryPageLayout = forwardRef(
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DeepBrowsingProvider active={currentIndex === index}> <DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{shouldRenderTitlebar ? ( {shouldRenderTitlebar ? (
<SecondaryPageTitlebar <SecondaryPageTitlebar
title={title} title={title}
@ -102,19 +95,26 @@ const SecondaryPageLayout = forwardRef(
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
hideBottomBorder={hideTitlebarBottomBorder} hideBottomBorder={hideTitlebarBottomBorder}
titlebar={titlebar} titlebar={titlebar}
sticky={isSmallScreen}
/> />
) : null} ) : null}
<div
ref={scrollAreaRef}
className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-hidden overscroll-y-contain touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{children} {children}
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton />} </div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider> </DeepBrowsingProvider>
) )
} }
return ( return (
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}> <DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div className="flex h-full min-h-0 min-w-0 flex-col"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{shouldRenderTitlebar ? ( {shouldRenderTitlebar ? (
<SecondaryPageTitlebar <SecondaryPageTitlebar
title={title} title={title}
@ -127,7 +127,7 @@ const SecondaryPageLayout = forwardRef(
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto" className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-auto overscroll-y-contain"
> >
{children} {children}
<div className="h-12" /> <div className="h-12" />

22
src/lib/error-suppression.ts

@ -115,6 +115,15 @@ function isExpectedDevAppNoise(message: string): boolean {
) { ) {
return true return true
} }
if (
message.includes('feeds.nostrarchives.com') &&
(message.includes('CORS') ||
message.includes('Gleiche-Quelle') ||
message.includes('Cross-Origin') ||
message.includes('Access-Control-Allow-Origin'))
) {
return true
}
if ( if (
message.includes('localhost:4869') || message.includes('localhost:4869') ||
message.includes('127.0.0.1:4869') || message.includes('127.0.0.1:4869') ||
@ -126,11 +135,22 @@ function isExpectedDevAppNoise(message: string): boolean {
message.includes('[RelayOp]') || message.includes('[RelayOp]') ||
message.includes('connection failed') || message.includes('connection failed') ||
message.includes('connection timed out') || message.includes('connection timed out') ||
message.includes('Local relay connection timeout') message.includes('Local relay connection timeout') ||
message.includes('kann keine Verbindung') ||
message.includes('can\'t establish a connection') ||
message.includes("can't establish a connection")
) { ) {
return true return true
} }
} }
if (
message.includes('profiles.nostrver.se') &&
(message.includes('kann keine Verbindung') ||
message.includes('can\'t establish a connection') ||
message.includes("can't establish a connection"))
) {
return true
}
if (message.includes('[FetchRelayLists] Network relay-list fetch exceeded budget')) { if (message.includes('[FetchRelayLists] Network relay-list fetch exceeded budget')) {
return true return true
} }

17
src/lib/mobile-primary-feed-scroll.ts

@ -1,8 +1,15 @@
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
/** Persist primary feed window scroll across mobile secondary unmount (PageManager hides the feed while a panel is open). */ /** Persist primary feed scroll across mobile secondary navigation. */
const scrollByPage = new Map<TPrimaryPageName, number>() const scrollByPage = new Map<TPrimaryPageName, number>()
/** Primary feed scroll container when using inner scroll (not window). */
let registeredScrollElement: HTMLElement | null = null
export function registerMobilePrimaryFeedScrollElement(el: HTMLElement | null): void {
registeredScrollElement = el
}
export function saveMobilePrimaryFeedScroll(page: TPrimaryPageName, top: number): void { export function saveMobilePrimaryFeedScroll(page: TPrimaryPageName, top: number): void {
if (!Number.isFinite(top) || top < 0) return if (!Number.isFinite(top) || top < 0) return
scrollByPage.set(page, top) scrollByPage.set(page, top)
@ -12,6 +19,12 @@ export function peekMobilePrimaryFeedScroll(page: TPrimaryPageName): number {
return scrollByPage.get(page) ?? 0 return scrollByPage.get(page) ?? 0
} }
export function captureMobilePrimaryFeedScroll(page: TPrimaryPageName): void {
const top = registeredScrollElement?.scrollTop ?? window.scrollY
saveMobilePrimaryFeedScroll(page, top)
}
/** @deprecated Use captureMobilePrimaryFeedScroll */
export function captureMobilePrimaryFeedScrollFromWindow(page: TPrimaryPageName): void { export function captureMobilePrimaryFeedScrollFromWindow(page: TPrimaryPageName): void {
saveMobilePrimaryFeedScroll(page, window.scrollY) captureMobilePrimaryFeedScroll(page)
} }

72
src/pages/primary/SpellsPage/index.tsx

@ -813,38 +813,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</Button> </Button>
) )
return ( const spellsSubHeader = (
<PrimaryPageLayout <div className="flex flex-col gap-2 px-4 py-2.5 sm:px-4">
ref={layoutRef}
pageName="spells"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div
className="app-chrome-title min-w-0 flex-1 truncate pl-3"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
</div>
}
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{selectedFauxSpell ? ( {selectedFauxSpell ? (
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Button <Button
@ -860,9 +830,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</div> </div>
) : ( ) : (
<> <>
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<>
{isSmallScreen ? ( {isSmallScreen ? (
<> <>
<Button <Button
@ -914,7 +882,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</>
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<Button <Button
@ -1010,7 +977,42 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
)} )}
</> </>
)} )}
</div>
)
return (
<PrimaryPageLayout
ref={layoutRef}
pageName="spells"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div
className="app-chrome-title min-w-0 flex-1 truncate pl-3"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
</div>
}
subHeader={spellsSubHeader}
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 pb-4 pt-2">
{/* Feed — faux spells and kind-777 spells all use NoteList */} {/* Feed — faux spells and kind-777 spells all use NoteList */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedFauxSpell === 'notifications' && !pubkey ? ( {selectedFauxSpell === 'notifications' && !pubkey ? (

5
src/services/relay-info.service.ts

@ -1,4 +1,5 @@
import { isViewerRelayBlocked } from '@/lib/viewer-blocked-relays' import { isViewerRelayBlocked } from '@/lib/viewer-blocked-relays'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { import {
devProxyCorsProblematicHttpsIndexRelayBase, devProxyCorsProblematicHttpsIndexRelayBase,
devProxyLoopbackHttpRelayBase, devProxyLoopbackHttpRelayBase,
@ -169,6 +170,10 @@ class RelayInfoService {
} }
private async fetchRelayNip11(url: string) { private async fetchRelayNip11(url: string) {
// Path-based WS trending feed — no NIP-11 document at the derived https URL (avoids CORS noise).
if (isWispTrendingNotesRelayUrl(url)) {
return undefined
}
try { try {
const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate

Loading…
Cancel
Save