scaffold notifications v2 pager
This commit is contained in:
+1
-1
@@ -63,7 +63,6 @@ import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccount
|
|||||||
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
||||||
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
||||||
import {NotFoundScreen} from '#/view/screens/NotFound'
|
import {NotFoundScreen} from '#/view/screens/NotFound'
|
||||||
import {NotificationsScreen} from '#/view/screens/Notifications'
|
|
||||||
import {PostThreadScreen} from '#/view/screens/PostThread'
|
import {PostThreadScreen} from '#/view/screens/PostThread'
|
||||||
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||||
import {ProfileScreen} from '#/view/screens/Profile'
|
import {ProfileScreen} from '#/view/screens/Profile'
|
||||||
@@ -89,6 +88,7 @@ import {ModerationScreen} from '#/screens/Moderation'
|
|||||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||||
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
|
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
|
||||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||||
|
import {NotificationsScreen} from '#/screens/Notifications'
|
||||||
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
||||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||||
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
|
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export enum Features {
|
|||||||
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
||||||
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
||||||
ModerationInboxEnable = 'moderation_inbox:enable',
|
ModerationInboxEnable = 'moderation_inbox:enable',
|
||||||
|
NotificationsV2Enable = 'notifications_v2:enable',
|
||||||
|
|
||||||
// values
|
// values
|
||||||
TrendingDiscoverValues = 'trending_discover:values',
|
TrendingDiscoverValues = 'trending_discover:values',
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
const ITEMS = Array.from({length: 50}, (_, index) => String(index + 1))
|
||||||
|
|
||||||
|
export function PageList({
|
||||||
|
pageIndex,
|
||||||
|
headerOffset,
|
||||||
|
}: {
|
||||||
|
pageIndex: number
|
||||||
|
headerOffset: number
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainScrollProvider>
|
||||||
|
<List
|
||||||
|
style={a.flex_1}
|
||||||
|
headerOffset={headerOffset}
|
||||||
|
{...(IS_WEB ? {disableFullWindowScroll: true} : {})}
|
||||||
|
data={ITEMS}
|
||||||
|
keyExtractor={item => item}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<View style={[a.p_md, a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
<Text>{`${pageIndex + 1}.${item}`}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</MainScrollProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Children,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||||
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||||
|
import NativePagerView from 'react-native-pager-view'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetDrawerSwipeDisabled} from '#/state/shell'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {usePagerContext} from './context'
|
||||||
|
|
||||||
|
export function Content({
|
||||||
|
children,
|
||||||
|
manageDrawerGesture = false,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
manageDrawerGesture?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
testID?: string
|
||||||
|
}) {
|
||||||
|
const {initialPage, selectedPage, onPageSelected, onPageScrollStateChanged} =
|
||||||
|
usePagerContext()
|
||||||
|
const pagerRef = useRef<NativePagerView>(null)
|
||||||
|
const currentPage = useRef(initialPage)
|
||||||
|
const [isIdle, setIsIdle] = useState(true)
|
||||||
|
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!manageDrawerGesture) return
|
||||||
|
|
||||||
|
const canSwipeDrawer = selectedPage === 0 && isIdle
|
||||||
|
setDrawerSwipeDisabled(!canSwipeDrawer)
|
||||||
|
return () => setDrawerSwipeDisabled(false)
|
||||||
|
}, [manageDrawerGesture, setDrawerSwipeDisabled, selectedPage, isIdle]),
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPage !== currentPage.current) {
|
||||||
|
currentPage.current = selectedPage
|
||||||
|
pagerRef.current?.setPage(selectedPage)
|
||||||
|
}
|
||||||
|
}, [selectedPage])
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<NativePagerView
|
||||||
|
ref={pagerRef}
|
||||||
|
testID={testID}
|
||||||
|
style={[a.flex_1, style]}
|
||||||
|
initialPage={initialPage}
|
||||||
|
onPageSelected={event => {
|
||||||
|
const page = event.nativeEvent.position
|
||||||
|
currentPage.current = page
|
||||||
|
onPageSelected(page)
|
||||||
|
}}
|
||||||
|
onPageScrollStateChanged={event => {
|
||||||
|
const state = event.nativeEvent.pageScrollState
|
||||||
|
setIsIdle(state === 'idle')
|
||||||
|
onPageScrollStateChanged(state)
|
||||||
|
}}>
|
||||||
|
{Children.map(children, child => (
|
||||||
|
<View collapsable={false} style={a.flex_1}>
|
||||||
|
{child}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</NativePagerView>
|
||||||
|
)
|
||||||
|
|
||||||
|
return manageDrawerGesture ? (
|
||||||
|
<DrawerGestureRequireFail>{content}</DrawerGestureRequireFail>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
|
||||||
|
const drawerGesture = useContext(DrawerGestureContext)
|
||||||
|
const pagerGesture = useMemo(() => {
|
||||||
|
const gesture = Gesture.Native()
|
||||||
|
if (drawerGesture) {
|
||||||
|
gesture.requireExternalGestureToFail(drawerGesture)
|
||||||
|
}
|
||||||
|
return gesture
|
||||||
|
}, [drawerGesture])
|
||||||
|
|
||||||
|
return <GestureDetector gesture={pagerGesture}>{children}</GestureDetector>
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {Activity, Children, useEffect, useId, useRef, useState} from 'react'
|
||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {usePagerContext} from './context'
|
||||||
|
|
||||||
|
export function Content({
|
||||||
|
children,
|
||||||
|
manageDrawerGesture: _manageDrawerGesture,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
manageDrawerGesture?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
testID?: string
|
||||||
|
}) {
|
||||||
|
const {selectedPage, onPageSelected} = usePagerContext()
|
||||||
|
const pages = Children.toArray(children)
|
||||||
|
const activityName = useId()
|
||||||
|
const previousPage = useRef(selectedPage)
|
||||||
|
const [visitedPages, setVisitedPages] = useState(
|
||||||
|
() => new Set([selectedPage]),
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPage !== previousPage.current) {
|
||||||
|
previousPage.current = selectedPage
|
||||||
|
onPageSelected(selectedPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisitedPages(current => {
|
||||||
|
if (current.has(selectedPage)) return current
|
||||||
|
return new Set([...current, selectedPage])
|
||||||
|
})
|
||||||
|
}, [selectedPage, onPageSelected])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={[a.flex_1, style]}>
|
||||||
|
{pages.map((page, pageIndex) => {
|
||||||
|
if (!visitedPages.has(pageIndex) && selectedPage !== pageIndex) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Activity
|
||||||
|
key={pageIndex}
|
||||||
|
name={`${activityName}-${pageIndex}`}
|
||||||
|
mode={selectedPage === pageIndex ? 'visible' : 'hidden'}>
|
||||||
|
{page}
|
||||||
|
</Activity>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
|
export type PagerScrollState = 'idle' | 'dragging' | 'settling'
|
||||||
|
|
||||||
|
export type PagerRenderProps = {
|
||||||
|
selectedPage: number
|
||||||
|
selectPage: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagerContextValue = PagerRenderProps & {
|
||||||
|
initialPage: number
|
||||||
|
onPageSelected: (page: number) => void
|
||||||
|
onPageScrollStateChanged: (state: PagerScrollState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PagerContext = createContext<PagerContextValue | null>(null)
|
||||||
|
|
||||||
|
export function Root({
|
||||||
|
children,
|
||||||
|
initialPage = 0,
|
||||||
|
onPageSelected,
|
||||||
|
onTabPressed,
|
||||||
|
onPageScrollStateChanged,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
initialPage?: number
|
||||||
|
onPageSelected?: (page: number) => void
|
||||||
|
onTabPressed?: (page: number) => void
|
||||||
|
onPageScrollStateChanged?: (state: PagerScrollState) => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
testID?: string
|
||||||
|
}) {
|
||||||
|
const [selectedPage, setSelectedPage] = useState(initialPage)
|
||||||
|
const selectedPageRef = useRef(initialPage)
|
||||||
|
|
||||||
|
const handlePageSelected = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
if (page !== selectedPageRef.current) {
|
||||||
|
selectedPageRef.current = page
|
||||||
|
setSelectedPage(page)
|
||||||
|
}
|
||||||
|
onPageSelected?.(page)
|
||||||
|
},
|
||||||
|
[onPageSelected],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectPage = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
onTabPressed?.(page)
|
||||||
|
if (page !== selectedPageRef.current) {
|
||||||
|
selectedPageRef.current = page
|
||||||
|
setSelectedPage(page)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTabPressed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
initialPage,
|
||||||
|
selectedPage,
|
||||||
|
selectPage,
|
||||||
|
onPageSelected: handlePageSelected,
|
||||||
|
onPageScrollStateChanged: (state: PagerScrollState) =>
|
||||||
|
onPageScrollStateChanged?.(state),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
initialPage,
|
||||||
|
selectedPage,
|
||||||
|
selectPage,
|
||||||
|
handlePageSelected,
|
||||||
|
onPageScrollStateChanged,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PagerContext.Provider value={value}>
|
||||||
|
<View testID={testID} style={[a.flex_1, style]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</PagerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: (props: PagerRenderProps) => ReactNode
|
||||||
|
}) {
|
||||||
|
const {selectedPage, selectPage} = usePager()
|
||||||
|
return children({selectedPage, selectPage})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePager(): PagerRenderProps {
|
||||||
|
const {selectedPage, selectPage} = usePagerContext()
|
||||||
|
return {selectedPage, selectPage}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagerContext() {
|
||||||
|
const context = useContext(PagerContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Pager components must be rendered within Pager.Root')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {Content} from './Content'
|
||||||
|
export {
|
||||||
|
type PagerRenderProps,
|
||||||
|
type PagerScrollState,
|
||||||
|
Root,
|
||||||
|
TabBar,
|
||||||
|
usePager,
|
||||||
|
} from './context'
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react'
|
||||||
|
import {
|
||||||
|
type ScrollView,
|
||||||
|
type StyleProp,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {atoms as a, tokens, useTheme, utils, web} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
|
||||||
|
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
|
||||||
|
} from '#/components/icons/Arrow'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
export type TabPillItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPills({
|
||||||
|
tabs,
|
||||||
|
selectedTab,
|
||||||
|
onSelectTab,
|
||||||
|
contentContainerStyle,
|
||||||
|
gutterWidth = tokens.space.lg,
|
||||||
|
}: {
|
||||||
|
tabs: TabPillItem[]
|
||||||
|
selectedTab: string
|
||||||
|
onSelectTab: (tab: string) => void
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
gutterWidth?: number
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const listRef = useRef<ScrollView>(null)
|
||||||
|
const [totalWidth, setTotalWidth] = useState(0)
|
||||||
|
const [scrollX, setScrollX] = useState(0)
|
||||||
|
const [contentWidth, setContentWidth] = useState(0)
|
||||||
|
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
||||||
|
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
||||||
|
|
||||||
|
const onInitialLayout = useNonReactiveCallback(() => {
|
||||||
|
scrollIntoViewIfNeeded(tabs.findIndex(tab => tab.key === selectedTab))
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabOffsets) {
|
||||||
|
onInitialLayout()
|
||||||
|
}
|
||||||
|
}, [tabOffsets, onInitialLayout])
|
||||||
|
|
||||||
|
function scrollIntoViewIfNeeded(index: number) {
|
||||||
|
const btnLayout = tabOffsets[index]
|
||||||
|
if (!btnLayout) return
|
||||||
|
listRef.current?.scrollTo({
|
||||||
|
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
||||||
|
animated: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectTab(index: number) {
|
||||||
|
const tab = tabs[index]
|
||||||
|
onSelectTab(tab.key)
|
||||||
|
scrollIntoViewIfNeeded(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabLayout(index: number, x: number, width: number) {
|
||||||
|
if (!tabOffsets.length) {
|
||||||
|
pendingTabOffsets.current[index] = {x, width}
|
||||||
|
if (
|
||||||
|
pendingTabOffsets.current.filter(offset => !!offset).length ===
|
||||||
|
tabs.length
|
||||||
|
) {
|
||||||
|
setTabOffsets(pendingTabOffsets.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canScrollLeft = scrollX > 0
|
||||||
|
const canScrollRight = Math.ceil(scrollX) < contentWidth - totalWidth
|
||||||
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
const isContinuouslyScrollingRef = useRef(false)
|
||||||
|
|
||||||
|
function scrollLeft() {
|
||||||
|
if (isContinuouslyScrollingRef.current) return
|
||||||
|
if (listRef.current && canScrollLeft) {
|
||||||
|
listRef.current.scrollTo({x: Math.max(0, scrollX - 200), animated: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRight() {
|
||||||
|
if (isContinuouslyScrollingRef.current) return
|
||||||
|
if (listRef.current && canScrollRight) {
|
||||||
|
const maxScroll = contentWidth - totalWidth
|
||||||
|
listRef.current.scrollTo({
|
||||||
|
x: Math.min(maxScroll, scrollX + 200),
|
||||||
|
animated: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startContinuousScroll(direction: 'left' | 'right') {
|
||||||
|
cleanupRef.current?.()
|
||||||
|
|
||||||
|
let holdTimeout: NodeJS.Timeout | null = null
|
||||||
|
let animationFrame: number | null = null
|
||||||
|
let isActive = true
|
||||||
|
isContinuouslyScrollingRef.current = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
isActive = false
|
||||||
|
if (holdTimeout) clearTimeout(holdTimeout)
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
cleanupRef.current = null
|
||||||
|
setTimeout(() => {
|
||||||
|
isContinuouslyScrollingRef.current = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRef.current = cleanup
|
||||||
|
holdTimeout = setTimeout(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
|
||||||
|
isContinuouslyScrollingRef.current = true
|
||||||
|
let currentScrollPosition = scrollX
|
||||||
|
|
||||||
|
const scroll = () => {
|
||||||
|
if (!isActive || !listRef.current) return
|
||||||
|
|
||||||
|
const scrollAmount = 3
|
||||||
|
const maxScroll = contentWidth - totalWidth
|
||||||
|
let newScrollX: number
|
||||||
|
let canContinue = false
|
||||||
|
|
||||||
|
if (direction === 'left' && currentScrollPosition > 0) {
|
||||||
|
newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
|
||||||
|
canContinue = newScrollX > 0
|
||||||
|
} else if (direction === 'right' && currentScrollPosition < maxScroll) {
|
||||||
|
newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
|
||||||
|
canContinue = newScrollX < maxScroll
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentScrollPosition = newScrollX
|
||||||
|
listRef.current.scrollTo({x: newScrollX, animated: false})
|
||||||
|
|
||||||
|
if (canContinue && isActive) {
|
||||||
|
animationFrame = requestAnimationFrame(scroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopContinuousScroll() {
|
||||||
|
cleanupRef.current?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => cleanupRef.current?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.flex_row]}>
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<DraggableScrollView
|
||||||
|
ref={listRef}
|
||||||
|
contentContainerStyle={[
|
||||||
|
a.gap_sm,
|
||||||
|
{paddingHorizontal: gutterWidth},
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToOffsets={
|
||||||
|
tabOffsets.filter(offset => !!offset).length === tabs.length
|
||||||
|
? tabOffsets.map(offset => offset.x - tokens.space.xl)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onLayout={event => setTotalWidth(event.nativeEvent.layout.width)}
|
||||||
|
onContentSizeChange={width => setContentWidth(width)}
|
||||||
|
onScroll={event => {
|
||||||
|
setScrollX(event.nativeEvent.contentOffset.x)
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}>
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<TabPill
|
||||||
|
key={tab.key}
|
||||||
|
tab={tab}
|
||||||
|
index={index}
|
||||||
|
active={tab.key === selectedTab}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
onLayout={handleTabLayout}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DraggableScrollView>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
|
||||||
|
{IS_WEB && canScrollLeft && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.top_0,
|
||||||
|
a.left_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.justify_center,
|
||||||
|
{paddingLeft: gutterWidth},
|
||||||
|
a.pr_md,
|
||||||
|
a.z_10,
|
||||||
|
web({
|
||||||
|
background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${utils.alpha(t.atoms.bg.backgroundColor, 0)} 100%)`,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={l`Scroll left`}
|
||||||
|
onPress={scrollLeft}
|
||||||
|
onPressIn={() => startContinuousScroll('left')}
|
||||||
|
onPressOut={stopContinuousScroll}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.h_full,
|
||||||
|
a.aspect_square,
|
||||||
|
a.rounded_full,
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ArrowLeft} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{IS_WEB && canScrollRight && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.top_0,
|
||||||
|
a.right_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.justify_center,
|
||||||
|
{paddingRight: gutterWidth},
|
||||||
|
a.pl_md,
|
||||||
|
a.z_10,
|
||||||
|
web({
|
||||||
|
background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${utils.alpha(t.atoms.bg.backgroundColor, 0)} 100%)`,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={l`Scroll right`}
|
||||||
|
onPress={scrollRight}
|
||||||
|
onPressIn={() => startContinuousScroll('right')}
|
||||||
|
onPressOut={stopContinuousScroll}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.h_full,
|
||||||
|
a.aspect_square,
|
||||||
|
a.rounded_full,
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ArrowRight} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPill({
|
||||||
|
tab,
|
||||||
|
active,
|
||||||
|
index,
|
||||||
|
onSelectTab,
|
||||||
|
onLayout,
|
||||||
|
}: {
|
||||||
|
tab: TabPillItem
|
||||||
|
active: boolean
|
||||||
|
index: number
|
||||||
|
onSelectTab: (index: number) => void
|
||||||
|
onLayout: (index: number, x: number, width: number) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
onLayout={event =>
|
||||||
|
onLayout(
|
||||||
|
index,
|
||||||
|
event.nativeEvent.layout.x,
|
||||||
|
event.nativeEvent.layout.width,
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
active ? l`${tab.label} tab, selected` : l`Select ${tab.label} tab`
|
||||||
|
}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{selected: active}}
|
||||||
|
onPress={() => onSelectTab(index)}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_sm,
|
||||||
|
{borderWidth: 1},
|
||||||
|
active
|
||||||
|
? [t.atoms.bg_contrast_50, {borderColor: t.palette.contrast_50}]
|
||||||
|
: [a.bg_transparent, t.atoms.border_contrast_low],
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_medium,
|
||||||
|
active ? t.atoms.text : t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {useCallback, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
Reanimated3DefaultSpringConfig,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
type NotificationsTabNavigatorParams,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
|
import {
|
||||||
|
HomeHeaderModeProvider,
|
||||||
|
useHomeHeaderMode,
|
||||||
|
} from '#/view/com/util/MainScrollProvider'
|
||||||
|
import {NotificationsScreen as LegacyNotificationsScreen} from '#/view/screens/Notifications'
|
||||||
|
import {PageList} from '#/screens/Notifications/components/PageList'
|
||||||
|
import * as Pager from '#/screens/Notifications/components/PagerView'
|
||||||
|
import {TabPills} from '#/screens/Notifications/components/TabPills'
|
||||||
|
import {atoms as a, useTheme, utils} from '#/alf'
|
||||||
|
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_LIQUID_GLASS} from '#/env'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
NotificationsTabNavigatorParams,
|
||||||
|
'Notifications'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function NotificationsScreen(props: Props) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const isNewNotificationsEnabled = ax.features.enabled(
|
||||||
|
ax.features.NotificationsV2Enable,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isNewNotificationsEnabled) {
|
||||||
|
return <NewNotificationsScreen {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LegacyNotificationsScreen {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewNotificationsScreen({}: Props) {
|
||||||
|
return (
|
||||||
|
<Layout.Screen testID="newNotificationsScreen" noInsetTop={IS_LIQUID_GLASS}>
|
||||||
|
<HomeHeaderModeProvider>
|
||||||
|
<NewNotificationsScreenInner />
|
||||||
|
</HomeHeaderModeProvider>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewNotificationsScreenInner() {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const headerMode = useHomeHeaderMode()
|
||||||
|
const initialHeaderOffset = useHeaderOffset()
|
||||||
|
const [headerOffset, setHeaderOffset] = useState(initialHeaderOffset)
|
||||||
|
const tabs = [
|
||||||
|
{key: 'all', label: l`All`},
|
||||||
|
{key: 'people-i-follow', label: l`People I follow`},
|
||||||
|
{key: 'follows', label: l`Follows`},
|
||||||
|
{key: 'replies', label: l`Replies`},
|
||||||
|
{key: 'activity', label: l`Activity`},
|
||||||
|
{key: 'atmosphere', label: l`Atmosphere`},
|
||||||
|
]
|
||||||
|
|
||||||
|
const showHeader = useCallback(() => {
|
||||||
|
'worklet'
|
||||||
|
headerMode.set(
|
||||||
|
withSpring(0, {
|
||||||
|
...Reanimated3DefaultSpringConfig,
|
||||||
|
overshootClamping: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [headerMode])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
return () => showHeader()
|
||||||
|
}, [showHeader]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pager.Root
|
||||||
|
onTabPressed={showHeader}
|
||||||
|
onPageScrollStateChanged={state => {
|
||||||
|
'worklet'
|
||||||
|
if (state === 'dragging') {
|
||||||
|
showHeader()
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<NotificationsHeader onHeightChange={setHeaderOffset}>
|
||||||
|
<Pager.TabBar>
|
||||||
|
{({selectedPage, selectPage}) => (
|
||||||
|
<TabPills
|
||||||
|
tabs={tabs}
|
||||||
|
selectedTab={tabs[selectedPage].key}
|
||||||
|
onSelectTab={tab =>
|
||||||
|
selectPage(tabs.findIndex(candidate => candidate.key === tab))
|
||||||
|
}
|
||||||
|
contentContainerStyle={a.pb_xs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pager.TabBar>
|
||||||
|
</NotificationsHeader>
|
||||||
|
<Pager.Content manageDrawerGesture testID="notificationsPagerView">
|
||||||
|
{tabs.map((tab, pageIndex) => (
|
||||||
|
<PageList
|
||||||
|
key={tab.key}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
headerOffset={headerOffset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pager.Content>
|
||||||
|
</Pager.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsHeader({
|
||||||
|
children,
|
||||||
|
onHeightChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onHeightChange: (height: number) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const headerMode = useHomeHeaderMode()
|
||||||
|
const {headerHeight} = useShellLayout()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const headerPinnedHeight = IS_LIQUID_GLASS ? insets.top : 0
|
||||||
|
|
||||||
|
const titleStyle = useAnimatedStyle(() => {
|
||||||
|
const mode = headerMode.get()
|
||||||
|
return {
|
||||||
|
opacity: Math.pow(1 - mode, 2),
|
||||||
|
pointerEvents: mode === 0 ? 'auto' : 'none',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pillsStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
headerMode.get(),
|
||||||
|
[0, 1],
|
||||||
|
[0, headerPinnedHeight - headerHeight.get()],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={[a.fixed, a.z_10, a.top_0, a.left_0, a.right_0]}
|
||||||
|
onLayout={event => onHeightChange(event.nativeEvent.layout.height)}>
|
||||||
|
{IS_LIQUID_GLASS ? (
|
||||||
|
<LinearGradient
|
||||||
|
key={t.name}
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
start={[0.5, 0]}
|
||||||
|
end={[0.5, 1]}
|
||||||
|
colors={[
|
||||||
|
t.atoms.bg.backgroundColor,
|
||||||
|
utils.alpha(t.atoms.bg.backgroundColor, 0.8),
|
||||||
|
utils.alpha(t.atoms.bg.backgroundColor, 0),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[a.absolute, a.inset_0, t.atoms.bg]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Animated.View
|
||||||
|
style={[IS_LIQUID_GLASS && {paddingTop: insets.top}, titleStyle]}
|
||||||
|
onLayout={event => {
|
||||||
|
headerHeight.set(event.nativeEvent.layout.height)
|
||||||
|
}}>
|
||||||
|
<Layout.Header.Outer noBottomBorder sticky={false}>
|
||||||
|
<Layout.Header.MenuButton />
|
||||||
|
<Layout.Header.Content>
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
<Trans>Notifications</Trans>
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
<Layout.Header.Slot />
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={pillsStyle}>
|
||||||
|
<Layout.Center>{children}</Layout.Center>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user