feat: sticky list mode switcher

This commit is contained in:
codytseng
2025-01-24 16:53:01 +08:00
parent ee21e19625
commit 1df975dfc6
7 changed files with 175 additions and 183 deletions

View File

@@ -1,15 +1,18 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import AccountButton from './AccountButton'
import HomeButton from './HomeButton' import HomeButton from './HomeButton'
import NotificationsButton from './NotificationsButton' import NotificationsButton from './NotificationsButton'
import PostButton from './PostButton' import PostButton from './PostButton'
import AccountButton from './AccountButton'
export default function BottomNavigationBar({ visible = true }: { visible?: boolean }) { export default function BottomNavigationBar() {
const { deepBrowsing } = useDeepBrowsing()
return ( return (
<div <div
className={cn( className={cn(
'fixed bottom-0 w-full z-20 bg-background/80 backdrop-blur-xl duration-700 transition-transform flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0', 'fixed bottom-0 w-full z-20 bg-background/80 backdrop-blur-xl duration-700 transition-transform flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0',
visible ? '' : 'translate-y-full' deepBrowsing ? 'translate-y-full' : ''
)} )}
style={{ style={{
height: 'calc(3rem + env(safe-area-inset-bottom))', height: 'calc(3rem + env(safe-area-inset-bottom))',

View File

@@ -3,6 +3,7 @@ import { PICTURE_EVENT_KIND } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event' import { isReplyNoteEvent } from '@/lib/event'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -236,9 +237,15 @@ function ListModeSwitch({
setListMode: (listMode: TNoteListMode) => void setListMode: (listMode: TNoteListMode) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { deepBrowsing } = useDeepBrowsing()
return ( return (
<div> <div
className={cn(
'sticky top-12 bg-background z-10 duration-700 transition-transform',
deepBrowsing ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
<div className="flex"> <div className="flex">
<div <div
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'posts' ? '' : 'text-muted-foreground'}`} className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'posts' ? '' : 'text-muted-foreground'}`}

View File

@@ -1,21 +1,22 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ChevronUp } from 'lucide-react' import { ChevronUp } from 'lucide-react'
export default function ScrollToTopButton({ export default function ScrollToTopButton({
scrollAreaRef, scrollAreaRef,
className, className
visible = true
}: { }: {
scrollAreaRef: React.RefObject<HTMLDivElement> scrollAreaRef?: React.RefObject<HTMLDivElement>
className?: string className?: string
visible?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
const visible = !deepBrowsing && lastScrollTop > 800
const handleScrollToTop = () => { const handleScrollToTop = () => {
if (isSmallScreen) { if (!scrollAreaRef) {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
return return
} }

View File

@@ -1,19 +1,22 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
export function Titlebar({ export function Titlebar({
children, children,
className, className
visible = true
}: { }: {
children?: React.ReactNode children?: React.ReactNode
className?: string className?: string
visible?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize()
const { deepBrowsing } = useDeepBrowsing()
return ( return (
<div <div
className={cn( className={cn(
'fixed sm:sticky top-0 w-full z-20 bg-background duration-700 transition-transform [&_svg]:size-4 [&_svg]:shrink-0', 'sticky top-0 w-full z-20 bg-background duration-700 transition-transform [&_svg]:size-4 [&_svg]:shrink-0',
visible ? '' : '-translate-y-full', isSmallScreen && deepBrowsing ? '-translate-y-full' : '',
className className
)} )}
> >

View File

@@ -3,8 +3,9 @@ import ScrollToTopButton from '@/components/ScrollToTopButton'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager' import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@@ -22,8 +23,6 @@ const PrimaryPageLayout = forwardRef(
ref ref
) => { ) => {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(true)
const [lastScrollTop, setLastScrollTop] = useState(0)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current } = usePrimaryPage() const { current } = usePrimaryPage()
@@ -44,77 +43,39 @@ const PrimaryPageLayout = forwardRef(
useEffect(() => { useEffect(() => {
if (isSmallScreen) { if (isSmallScreen) {
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0 })
setVisible(true)
return return
} }
}, [current]) }, [current])
useEffect(() => {
if (current !== pageName) return
const handleScroll = () => {
const atBottom = isSmallScreen
? window.innerHeight + window.scrollY >= document.body.offsetHeight - 20
: scrollAreaRef.current
? scrollAreaRef.current?.clientHeight + scrollAreaRef.current?.scrollTop >=
scrollAreaRef.current?.scrollHeight - 20
: false
if (atBottom) {
setVisible(true)
return
}
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
const diff = scrollTop - lastScrollTop
if (scrollTop <= 800) {
setVisible(true)
setLastScrollTop(scrollTop)
return
}
if (diff > 20) {
setVisible(false)
setLastScrollTop(scrollTop)
} else if (diff < -20) {
setVisible(true)
setLastScrollTop(scrollTop)
}
}
if (isSmallScreen) { if (isSmallScreen) {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
return () => {
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
}
}, [lastScrollTop, isSmallScreen, current])
return ( return (
<ScrollArea <DeepBrowsingProvider active={current === pageName}>
className="sm:h-screen sm:overflow-auto pt-12 sm:pt-0" <div
scrollBarClassName="sm:z-50"
ref={scrollAreaRef}
style={{ style={{
paddingBottom: isSmallScreen ? 'calc(env(safe-area-inset-bottom) + 3rem)' : '' paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}} }}
> >
{titlebar && ( {titlebar && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
<PrimaryPageTitlebar visible={!isSmallScreen || visible}>{titlebar}</PrimaryPageTitlebar> {children}
)} {displayScrollToTopButton && <ScrollToTopButton />}
<div className="overflow-x-hidden">{children}</div> <BottomNavigationBar />
{displayScrollToTopButton && ( </div>
<ScrollToTopButton </DeepBrowsingProvider>
scrollAreaRef={scrollAreaRef} )
visible={visible && lastScrollTop > 800} }
/>
)} return (
{isSmallScreen && <BottomNavigationBar visible={visible} />} <DeepBrowsingProvider active={current === pageName} scrollAreaRef={scrollAreaRef}>
<ScrollArea
className="h-screen overflow-auto"
scrollBarClassName="z-50"
ref={scrollAreaRef}
>
{titlebar && <PrimaryPageTitlebar>{titlebar}</PrimaryPageTitlebar>}
{children}
</ScrollArea> </ScrollArea>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider>
) )
} }
) )
@@ -125,16 +86,6 @@ export type TPrimaryPageLayoutRef = {
scrollToTop: () => void scrollToTop: () => void
} }
function PrimaryPageTitlebar({ function PrimaryPageTitlebar({ children }: { children?: React.ReactNode }) {
children, return <Titlebar className="h-12 p-1">{children}</Titlebar>
visible = true
}: {
children?: React.ReactNode
visible?: boolean
}) {
return (
<Titlebar className="h-12 p-1" visible={visible}>
{children}
</Titlebar>
)
} }

View File

@@ -4,8 +4,9 @@ import ScrollToTopButton from '@/components/ScrollToTopButton'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
export default function SecondaryPageLayout({ export default function SecondaryPageLayout({
children, children,
@@ -23,119 +24,65 @@ export default function SecondaryPageLayout({
displayScrollToTopButton?: boolean displayScrollToTopButton?: boolean
}): JSX.Element { }): JSX.Element {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(true)
const [lastScrollTop, setLastScrollTop] = useState(0)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { currentIndex } = useSecondaryPage() const { currentIndex } = useSecondaryPage()
useEffect(() => { useEffect(() => {
if (isSmallScreen) { if (isSmallScreen) {
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0 })
setVisible(true)
return return
} }
}, []) }, [])
useEffect(() => {
if (currentIndex !== index) return
const handleScroll = () => {
const atBottom = isSmallScreen
? window.innerHeight + window.scrollY >= document.body.offsetHeight - 20
: scrollAreaRef.current
? scrollAreaRef.current?.clientHeight + scrollAreaRef.current?.scrollTop >=
scrollAreaRef.current?.scrollHeight - 20
: false
if (atBottom) {
setVisible(true)
return
}
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
const diff = scrollTop - lastScrollTop
if (scrollTop <= 800) {
setVisible(true)
setLastScrollTop(scrollTop)
return
}
if (diff > 20) {
setVisible(false)
setLastScrollTop(scrollTop)
} else if (diff < -20) {
setVisible(true)
setLastScrollTop(scrollTop)
}
}
if (isSmallScreen) { if (isSmallScreen) {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
return () => {
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
}
}, [lastScrollTop, isSmallScreen, currentIndex])
return ( return (
<ScrollArea <DeepBrowsingProvider active={currentIndex === index}>
className="sm:h-screen sm:overflow-auto pt-12 sm:pt-0" <div
scrollBarClassName="sm:z-50"
ref={scrollAreaRef}
style={{ style={{
paddingBottom: isSmallScreen ? 'calc(env(safe-area-inset-bottom) + 3rem)' : '' paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}} }}
> >
<SecondaryPageTitlebar <SecondaryPageTitlebar
title={title} title={title}
controls={controls} controls={controls}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
visible={visible}
/> />
<div className="pb-4 mt-2">{children}</div> <div className="pb-4 mt-2">{children}</div>
{displayScrollToTopButton && ( {displayScrollToTopButton && <ScrollToTopButton />}
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 800} /> <BottomNavigationBar />
)} </div>
{isSmallScreen && <BottomNavigationBar visible={visible} />} </DeepBrowsingProvider>
)
}
return (
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<ScrollArea
className="h-screen overflow-auto"
scrollBarClassName="sm:z-50"
ref={scrollAreaRef}
>
<SecondaryPageTitlebar title={title} controls={controls} hideBackButton={hideBackButton} />
<div className="pb-4 mt-2">{children}</div>
</ScrollArea> </ScrollArea>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider>
) )
} }
export function SecondaryPageTitlebar({ export function SecondaryPageTitlebar({
title, title,
controls, controls,
hideBackButton = false, hideBackButton = false
visible = true
}: { }: {
title?: React.ReactNode title?: React.ReactNode
controls?: React.ReactNode controls?: React.ReactNode
hideBackButton?: boolean hideBackButton?: boolean
visible?: boolean
}): JSX.Element { }): JSX.Element {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return ( return (
<Titlebar <Titlebar className="h-12 flex gap-1 p-1 items-center justify-between font-semibold">
className="h-12 flex gap-1 p-1 items-center justify-between font-semibold"
visible={visible}
>
<BackButton hide={hideBackButton}>{title}</BackButton> <BackButton hide={hideBackButton}>{title}</BackButton>
<div className="flex-shrink-0">{controls}</div> <div className="flex-shrink-0">{controls}</div>
</Titlebar> </Titlebar>
) )
}
return (
<Titlebar className="h-12 flex gap-1 p-1 justify-between items-center font-semibold">
<div className="flex items-center gap-1 flex-1 w-0">
<BackButton hide={hideBackButton}>{title}</BackButton>
</div>
<div className="flex-shrink-0">{controls}</div>
</Titlebar>
)
} }

View File

@@ -0,0 +1,80 @@
import { createContext, useContext, useEffect, useState } from 'react'
type TDeepBrowsingContext = {
deepBrowsing: boolean
lastScrollTop: number
}
const DeepBrowsingContext = createContext<TDeepBrowsingContext | undefined>(undefined)
export const useDeepBrowsing = () => {
const context = useContext(DeepBrowsingContext)
if (!context) {
throw new Error('useDeepBrowsing must be used within a DeepBrowsingProvider')
}
return context
}
export function DeepBrowsingProvider({
children,
active,
scrollAreaRef
}: {
children: React.ReactNode
active: boolean
scrollAreaRef?: React.RefObject<HTMLDivElement>
}) {
const [deepBrowsing, setDeepBrowsing] = useState(false)
const [lastScrollTop, setLastScrollTop] = useState(0)
useEffect(() => {
if (!active) return
const handleScroll = () => {
const atBottom = !scrollAreaRef
? window.innerHeight + window.scrollY >= document.body.offsetHeight - 20
: scrollAreaRef.current
? scrollAreaRef.current?.clientHeight + scrollAreaRef.current?.scrollTop >=
scrollAreaRef.current?.scrollHeight - 20
: false
if (atBottom) {
setDeepBrowsing(false)
return
}
const scrollTop = (!scrollAreaRef ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
const diff = scrollTop - lastScrollTop
if (scrollTop <= 800) {
setDeepBrowsing(false)
setLastScrollTop(scrollTop)
return
}
if (diff > 20) {
setDeepBrowsing(true)
setLastScrollTop(scrollTop)
} else if (diff < -20) {
setDeepBrowsing(false)
setLastScrollTop(scrollTop)
}
}
if (!scrollAreaRef) {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
return () => {
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
}
}, [lastScrollTop, active])
return (
<DeepBrowsingContext.Provider value={{ deepBrowsing, lastScrollTop }}>
{children}
</DeepBrowsingContext.Provider>
)
}