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} + + + ) +}