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 {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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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