diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index c03e65af69..de5c975b02 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -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'
diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index 4d3cf5e197..83dbfc47f4 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -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',
diff --git a/src/screens/Notifications/components/PageList.tsx b/src/screens/Notifications/components/PageList.tsx
new file mode 100644
index 0000000000..e32989378a
--- /dev/null
+++ b/src/screens/Notifications/components/PageList.tsx
@@ -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 (
+
+ item}
+ renderItem={({item}) => (
+
+ {`${pageIndex + 1}.${item}`}
+
+ )}
+ />
+
+ )
+}
diff --git a/src/screens/Notifications/components/PagerView/Content.tsx b/src/screens/Notifications/components/PagerView/Content.tsx
new file mode 100644
index 0000000000..40d9598926
--- /dev/null
+++ b/src/screens/Notifications/components/PagerView/Content.tsx
@@ -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
+ testID?: string
+}) {
+ const {initialPage, selectedPage, onPageSelected, onPageScrollStateChanged} =
+ usePagerContext()
+ const pagerRef = useRef(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 = (
+ {
+ 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 => (
+
+ {child}
+
+ ))}
+
+ )
+
+ return manageDrawerGesture ? (
+ {content}
+ ) : (
+ 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 {children}
+}
diff --git a/src/screens/Notifications/components/PagerView/Content.web.tsx b/src/screens/Notifications/components/PagerView/Content.web.tsx
new file mode 100644
index 0000000000..424d373be6
--- /dev/null
+++ b/src/screens/Notifications/components/PagerView/Content.web.tsx
@@ -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
+ 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 (
+
+ {pages.map((page, pageIndex) => {
+ if (!visitedPages.has(pageIndex) && selectedPage !== pageIndex) {
+ return null
+ }
+
+ return (
+
+ {page}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/screens/Notifications/components/PagerView/context.tsx b/src/screens/Notifications/components/PagerView/context.tsx
new file mode 100644
index 0000000000..0b2b246ec8
--- /dev/null
+++ b/src/screens/Notifications/components/PagerView/context.tsx
@@ -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(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
+ 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 (
+
+
+ {children}
+
+
+ )
+}
+
+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
+}
diff --git a/src/screens/Notifications/components/PagerView/index.ts b/src/screens/Notifications/components/PagerView/index.ts
new file mode 100644
index 0000000000..a1f7ee7c4b
--- /dev/null
+++ b/src/screens/Notifications/components/PagerView/index.ts
@@ -0,0 +1,8 @@
+export {Content} from './Content'
+export {
+ type PagerRenderProps,
+ type PagerScrollState,
+ Root,
+ TabBar,
+ usePager,
+} from './context'
diff --git a/src/screens/Notifications/components/TabPills.tsx b/src/screens/Notifications/components/TabPills.tsx
new file mode 100644
index 0000000000..a1f76331b4
--- /dev/null
+++ b/src/screens/Notifications/components/TabPills.tsx
@@ -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
+ gutterWidth?: number
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const listRef = useRef(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 (
+
+
+ !!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) => (
+
+ ))}
+
+
+
+ {IS_WEB && canScrollLeft && (
+
+
+
+ )}
+
+ {IS_WEB && canScrollRight && (
+
+
+
+ )}
+
+ )
+}
+
+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 (
+
+ onLayout(
+ index,
+ event.nativeEvent.layout.x,
+ event.nativeEvent.layout.width,
+ )
+ }>
+
+
+ )
+}
diff --git a/src/screens/Notifications/index.tsx b/src/screens/Notifications/index.tsx
new file mode 100644
index 0000000000..9831110ef6
--- /dev/null
+++ b/src/screens/Notifications/index.tsx
@@ -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
+ }
+
+ return
+}
+
+export function NewNotificationsScreen({}: Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+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 (
+ {
+ 'worklet'
+ if (state === 'dragging') {
+ showHeader()
+ }
+ }}>
+
+
+ {({selectedPage, selectPage}) => (
+
+ selectPage(tabs.findIndex(candidate => candidate.key === tab))
+ }
+ contentContainerStyle={a.pb_xs}
+ />
+ )}
+
+
+
+ {tabs.map((tab, pageIndex) => (
+
+ ))}
+
+
+ )
+}
+
+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 (
+ onHeightChange(event.nativeEvent.layout.height)}>
+ {IS_LIQUID_GLASS ? (
+
+ ) : (
+
+ )}
+ {
+ headerHeight.set(event.nativeEvent.layout.height)
+ }}>
+
+
+
+
+ Notifications
+
+
+
+
+
+
+ {children}
+
+
+ )
+}