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. 9
      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. 66
      src/layouts/PrimaryPageLayout/index.tsx
  11. 34
      src/layouts/SecondaryPageLayout/index.tsx
  12. 22
      src/lib/error-suppression.ts
  13. 17
      src/lib/mobile-primary-feed-scroll.ts
  14. 336
      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 { @@ -38,7 +38,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider>
<NostrProvider>
<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 />
<StartupSessionBanner />
<SlowConnectionHint />

20
src/PageManager.tsx

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import {
captureMobilePrimaryFeedScrollFromWindow,
captureMobilePrimaryFeedScroll,
peekMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
@ -2054,7 +2054,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2054,7 +2054,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage)
captureMobilePrimaryFeedScroll(currentPrimaryPage)
}
// 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 }) { @@ -2393,13 +2393,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}}
>
<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" />
{primaryNoteView ? (
// Show primary note view with back button on mobile
<div
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 />
<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 }) { @@ -2423,15 +2423,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div>
<RefreshButton onClick={triggerPrimaryPanelRefresh} />
</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}
</div>
</div>
) : (
<>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div
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'
)}
aria-hidden={secondaryStack.length > 0}
@ -2441,12 +2441,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2441,12 +2441,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{secondaryStack.length > 0 ? (
<div
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]!} />
</div>
) : null}
</>
</div>
)}
</div>
<Suspense fallback={null}>
@ -2675,7 +2675,7 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean @@ -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. */
function TopSecondaryStackPane({
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
className?: string

9
src/components/Embedded/EmbeddedNoteProviders.tsx

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

7
src/components/NoteCard/RepostNoteCard.tsx

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

7
src/components/NoteCard/index.tsx

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

7
src/components/ReplyNote/index.tsx

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

7
src/components/ReplyNoteList/index.tsx

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

2
src/constants.ts

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

42
src/index.css

@ -25,6 +25,20 @@ @@ -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,
textarea,
button {
@ -96,27 +110,51 @@ @@ -96,27 +110,51 @@
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 {
overflow-y: scroll;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.page-scroll-y::-webkit-scrollbar,
.popover-scroll-y::-webkit-scrollbar {
width: 10px;
}
.page-scroll-y::-webkit-scrollbar-thumb,
.popover-scroll-y::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: hsl(var(--muted-foreground) / 0.35);
}
.page-scroll-y::-webkit-scrollbar-thumb:hover,
.popover-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
.page-scroll-y::-webkit-scrollbar-track,
.popover-scroll-y::-webkit-scrollbar-track {
border-radius: 9999px;
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.
* That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select).

66
src/layouts/PrimaryPageLayout/index.tsx

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

34
src/layouts/SecondaryPageLayout/index.tsx

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

22
src/lib/error-suppression.ts

@ -115,6 +115,15 @@ function isExpectedDevAppNoise(message: string): boolean { @@ -115,6 +115,15 @@ function isExpectedDevAppNoise(message: string): boolean {
) {
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 (
message.includes('localhost:4869') ||
message.includes('127.0.0.1:4869') ||
@ -126,11 +135,22 @@ function isExpectedDevAppNoise(message: string): boolean { @@ -126,11 +135,22 @@ function isExpectedDevAppNoise(message: string): boolean {
message.includes('[RelayOp]') ||
message.includes('connection failed') ||
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
}
}
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')) {
return true
}

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

@ -1,8 +1,15 @@ @@ -1,8 +1,15 @@
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>()
/** 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 {
if (!Number.isFinite(top) || top < 0) return
scrollByPage.set(page, top)
@ -12,6 +19,12 @@ export function peekMobilePrimaryFeedScroll(page: TPrimaryPageName): number { @@ -12,6 +19,12 @@ export function peekMobilePrimaryFeedScroll(page: TPrimaryPageName): number {
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 {
saveMobilePrimaryFeedScroll(page, window.scrollY)
captureMobilePrimaryFeedScroll(page)
}

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

@ -813,6 +813,173 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -813,6 +813,173 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</Button>
)
const spellsSubHeader = (
<div className="flex flex-col gap-2 px-4 py-2.5 sm:px-4">
{selectedFauxSpell ? (
<div className="flex shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5 -ml-2 h-9 text-muted-foreground hover:text-foreground"
onClick={clearSpellSelection}
>
<ChevronLeft className="size-4 shrink-0" aria-hidden />
<span>{t('Spells')}</span>
</Button>
</div>
) : (
<>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{isSmallScreen ? (
<>
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Drawer open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DrawerContent className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 p-0 sm:max-h-[75vh]">
<DrawerHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<DrawerTitle className="text-base">{t('Select a spell…')}</DrawerTitle>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
>
{spellPickerPanel}
</div>
</DrawerContent>
</Drawer>
</>
) : (
<DropdownMenu open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuTrigger asChild aria-haspopup="menu">
{spellPickerTriggerButton}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
showScrollButtons
className="max-h-[min(75vh,40rem)] w-[var(--radix-dropdown-menu-trigger-width)] max-w-md p-0"
>
<div className="sticky top-0 z-10 border-b bg-popover px-3 py-2 text-left text-sm font-semibold">
{t('Select a spell…')}
</div>
<div className="px-1 py-2" role="listbox" aria-label={t('Select a spell…')}>
{spellPickerPanel}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
className="justify-start gap-2"
variant="outline"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
{selectedSpell && (
<>
<Button
variant="outline"
size="icon"
className="shrink-0"
title={
favoriteSpellSet.has(selectedSpell.id.toLowerCase())
? t('Spell star remove title')
: t('Spell star add title')
}
onClick={() => void toggleFavoriteSpell(selectedSpell)}
>
<Star
className={`size-4 ${favoriteSpellSet.has(selectedSpell.id.toLowerCase()) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{selectedSpellCanEditOrDelete ? (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToClone(null)
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Pencil className="size-4" />
{t('Edit spell')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(selectedSpell)
setCreateOpen(true)
}}
>
<Copy className="size-4" />
{t('Clone spell')}
</DropdownMenuItem>
)}
<DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" />
{t('View definition')}
</DropdownMenuItem>
{selectedSpellCanEditOrDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{spellsCatalogSyncing ? (
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null}
{spellsForSelect.length === 0 && !spellsCatalogSyncing && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
</>
)}
</div>
)
return (
<PrimaryPageLayout
ref={layoutRef}
@ -842,175 +1009,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -842,175 +1009,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</div>
</div>
}
subHeader={spellsSubHeader}
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{selectedFauxSpell ? (
<div className="flex shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5 -ml-2 h-9 text-muted-foreground hover:text-foreground"
onClick={clearSpellSelection}
>
<ChevronLeft className="size-4 shrink-0" aria-hidden />
<span>{t('Spells')}</span>
</Button>
</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">
<>
{isSmallScreen ? (
<>
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={selectedSpell ? spellMenuLabel(selectedSpell) : undefined}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedSpell ? spellMenuLabel(selectedSpell) : t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Drawer open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DrawerContent className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 p-0 sm:max-h-[75vh]">
<DrawerHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<DrawerTitle className="text-base">{t('Select a spell…')}</DrawerTitle>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
>
{spellPickerPanel}
</div>
</DrawerContent>
</Drawer>
</>
) : (
<DropdownMenu open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuTrigger asChild aria-haspopup="menu">
{spellPickerTriggerButton}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
showScrollButtons
className="max-h-[min(75vh,40rem)] w-[var(--radix-dropdown-menu-trigger-width)] max-w-md p-0"
>
<div className="sticky top-0 z-10 border-b bg-popover px-3 py-2 text-left text-sm font-semibold">
{t('Select a spell…')}
</div>
<div className="px-1 py-2" role="listbox" aria-label={t('Select a spell…')}>
{spellPickerPanel}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
className="justify-start gap-2"
variant="outline"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
</Button>
{selectedSpell && (
<>
<Button
variant="outline"
size="icon"
className="shrink-0"
title={
favoriteSpellSet.has(selectedSpell.id.toLowerCase())
? t('Spell star remove title')
: t('Spell star add title')
}
onClick={() => void toggleFavoriteSpell(selectedSpell)}
>
<Star
className={`size-4 ${favoriteSpellSet.has(selectedSpell.id.toLowerCase()) ? 'fill-amber-400 text-amber-500' : ''}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" title={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{selectedSpellCanEditOrDelete ? (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToClone(null)
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Pencil className="size-4" />
{t('Edit spell')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(selectedSpell)
setCreateOpen(true)
}}
>
<Copy className="size-4" />
{t('Clone spell')}
</DropdownMenuItem>
)}
<DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" />
{t('View definition')}
</DropdownMenuItem>
{selectedSpellCanEditOrDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
{t('Delete')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
{spellsCatalogSyncing ? (
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null}
{spellsForSelect.length === 0 && !spellsCatalogSyncing && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
</>
)}
<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 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedFauxSpell === 'notifications' && !pubkey ? (

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { isViewerRelayBlocked } from '@/lib/viewer-blocked-relays'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import {
devProxyCorsProblematicHttpsIndexRelayBase,
devProxyLoopbackHttpRelayBase,
@ -169,6 +170,10 @@ class RelayInfoService { @@ -169,6 +170,10 @@ class RelayInfoService {
}
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 {
const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate

Loading…
Cancel
Save