scaffold notifications v2 pager

This commit is contained in:
Samuel Newman
2026-08-25 00:47:22 +01:00
parent 5b88335bb1
commit c3807ad37d
9 changed files with 857 additions and 1 deletions
+1 -1
View File
@@ -63,7 +63,6 @@ import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccount
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
@@ -89,6 +88,7 @@ import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsScreen} from '#/screens/Notifications'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
+1
View File
@@ -24,6 +24,7 @@ export enum Features {
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
ModerationInboxEnable = 'moderation_inbox:enable',
NotificationsV2Enable = 'notifications_v2:enable',
// 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>
)
}
+206
View File
@@ -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>
)
}