refactor: replace @react-navigation and expo-router (#3151)

* init

* init

* refactor(mobile): enhance tab navigation and structure

- Added `TabScreenModule` and `TabScreenView` to support dynamic tab management.
- Replaced `FOTabBarController` with a more streamlined `UITabBarController` implementation.
- Updated `TabBarModule` to utilize new tab switching logic and event handling.
- Modified `TabBarRootView` to manage tab views more effectively and handle tab index changes.
- Adjusted main application file to integrate new tab components and improve navigation flow.

These changes aim to improve the tab navigation experience and maintainability of the codebase.

* tabbar

* refactor(mobile): streamline navigation and tab management

- Refactored main application structure to enhance tab navigation, integrating `BottomTabs` and `TabRoot` components for improved tab management.
- Updated `NavigationHeader` and `SafeNavigationScrollView` to better handle header visibility and scrolling behavior.
- Introduced `ScreenItemContext` for managing screen state and scroll position across navigation.
- Cleaned up unused imports and optimized component structure for better maintainability.

These changes aim to improve the overall navigation experience and code organization within the mobile application.

* update

* feat(mobile): implement BottomTabProvider for enhanced tab management

- Introduced `BottomTabProvider` component to manage tab visibility and scroll view context.
- Refactored `BottomTabs` to utilize context for background opacity and tab visibility, simplifying the component structure.
- Removed the deprecated `TabClient` component to streamline the tab implementation.
- Added a null check in `Tabbar` to prevent rendering when there are no tab screens.

These changes aim to improve the organization and functionality of the tab navigation system within the mobile application.

Signed-off-by: Innei <tukon479@gmail.com>

* update

* update

* update

* update

* update

* feat(mobile): integrate ErrorBoundary and enhance navigation context

- Added `ErrorBoundary` component to handle errors gracefully within the application.
- Integrated `BottomTabProvider` to manage tab visibility and scroll view context more effectively.
- Refactored navigation components to utilize new context for improved state management.
- Updated `SafeNavigationScrollView` and `Settings` components to streamline scrolling behavior and context usage.
- Removed deprecated code and optimized component structure for better maintainability.

These changes enhance the user experience by providing error handling and improving navigation management.

* feat(mobile): enhance navigation and layout components

- Registered new screens (`TermsScreen`, `LoginScreen`, `SignUpScreen`, `ForgetPasswordScreen`, `TwoFactorAuthScreen`) with the `NavigationSitemapRegistry` for improved navigation management.
- Adjusted layout properties in `HeaderElements` and `NavigationHeader` for better alignment and responsiveness.
- Refactored `Tabbar` to remove unnecessary animated reactions, optimizing performance.
- Exported `ActionGroup` component for better reusability in `TimelineSelectorProvider`.
- Updated `DebugScreen` to utilize the new navigation methods for component presentation.

These changes improve the overall navigation experience and component structure within the mobile application.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(mobile): implement new architecture support and refactor TabBar components

- Added support for the new architecture in the iOS podspec by conditionally defining `OTHER_SWIFT_FLAGS`.
- Refactored `TabBarPortalModule`, `TabBarRootView`, and `TabScreenView` to improve structure and maintainability.
- Replaced native view components with standard React Native views in `TabBarPortal`, `TabRoot`, and `TabScreen` for better compatibility and performance.
- Updated import paths for `TabScreenProps` to ensure correct type usage.

These changes enhance the navigation experience and prepare the codebase for future architecture improvements.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(mobile): refactor navigation and onboarding components

- Removed outdated navigation dependencies from `package.json` to streamline the project.
- Enhanced `TabBarRootView` to improve tab switching animations and manage view transitions more effectively.
- Updated `App` component structure for better readability and integration of onboarding logic.
- Introduced `OnboardingScreen` with improved layout and navigation handling.
- Refactored navigation context types to include additional screen options for better flexibility.

These changes enhance the navigation experience and prepare the application for future onboarding features.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(mobile): enhance intent handling with navigation integration

- Added navigation support in `useIntentHandler` to handle incoming URLs more effectively.
- Integrated `FollowScreen` for presenting user-specific content based on URL parameters.
- Updated effect dependencies to include navigation for improved functionality.

These changes improve the handling of deep links and enhance the user experience when navigating to the follow screen.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: clean up pnpm-lock.yaml by removing unused navigation dependencies

- Removed outdated navigation packages including `@react-navigation/bottom-tabs`, `@react-navigation/drawer`, and `@react-navigation/native` to streamline the lock file.
- Cleaned up unnecessary entries and peer dependencies to improve maintainability and reduce clutter.

These changes help in optimizing the dependency management for the project.

Signed-off-by: Innei <tukon479@gmail.com>

* lint

Signed-off-by: Innei <tukon479@gmail.com>

* chore: auto-fix linting and formatting issues

* fix

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update PickerIos component and remove FeedDrawer

- Added import for Portal in PickerIos component.
- Removed the FeedDrawer component entirely, cleaning up unused code.
- Replaced router.dismiss() with navigation.dismiss() in list screen options for better navigation handling.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-03-19 22:35:49 +08:00 committed by GitHub
parent 853d32afa4
commit f21b9e7bf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
152 changed files with 3382 additions and 2756 deletions

View File

@ -41,6 +41,8 @@ updates:
# electron 35
- dependency-name: electron
versions: [">=35.0.0"]
- dependency-name: react-native-sheet-transitions
versions: [">0.1.2"]
open-pull-requests-limit: 100
groups:
minor:

View File

@ -53,11 +53,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
googleServicesFile: "./build/google-services.json",
},
web: {
bundler: "metro",
output: "static",
favicon: iconPath,
},
// web: {
// bundler: "metro",
// output: "static",
// favicon: iconPath,
// },
plugins: [
[
"expo-document-picker",
@ -66,12 +66,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
],
"expo-localization",
[
"expo-router",
{
root: "./src/screens",
},
],
[
"expo-splash-screen",
{

View File

@ -7,7 +7,10 @@
"ToasterModule",
"AppleIntelligenceGlowEffectModule",
"FollowNative.GaleriaModule",
"GaleriaAccessoryModule"
"GaleriaAccessoryModule",
"TabBarModule",
"TabScreenModule",
"TabBarPortalModule"
]
},
"android": {

View File

@ -1,4 +1,5 @@
require 'json'
new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
@ -25,6 +26,7 @@ Pod::Spec.new do |s|
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'OTHER_SWIFT_FLAGS' => "$(inherited) #{new_arch_enabled ? '-DRCT_NEW_ARCH_ENABLED' : ''}",
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp,js}"

View File

@ -0,0 +1,37 @@
//
// TabBarModule.swift
// FollowNative
//
// Created by Innei on 2025/3/16.
//
import ExpoModulesCore
public class TabBarModule: Module {
public func definition() -> ModuleDefinition {
Name("TabBarRoot")
Function("switchTab") { [weak self] (reactTag: Int, index: Int) in
DispatchQueue.main.async {
guard let bridge = self?.appContext?.reactBridge else {
return
}
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) as! TabBarRootView?
{
sourceView.switchToTab(index: index)
}
}
}
View(TabBarRootView.self) {
Prop("selectedIndex") { (view, index: Int) in
view.switchToTab(index: index)
}
Events("onTabIndexChange")
}
}
}

View File

@ -0,0 +1,21 @@
//
// TabBarPortalModule.swift
// FollowNative
//
// Created by Innei on 2025/3/17.
//
import ExpoModulesCore
import UIKit
public class TabBarPortalModule: Module {
public func definition() -> ModuleDefinition {
Name("TabBarPortal")
View(TabBarPortalView.self) {}
}
}
class TabBarPortalView: ExpoView {
}

View File

@ -0,0 +1,126 @@
//
// TabBarRootView.swift
// FollowNative
//
// Created by Innei on 2025/3/16.
//
import ExpoModulesCore
import Foundation
import SnapKit
class TabBarRootView: ExpoView {
private let tabBarController = UITabBarController()
private let vc = UIViewController()
private var tabViewControllers: [UIViewController] = []
private let onTabIndexChange = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
tabBarController.tabBar.isHidden = true
tabBarController.delegate = self
addSubview(vc.view)
vc.addChild(tabBarController)
vc.view!.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
vc.view.addSubview(tabBarController.view)
tabBarController.didMove(toParent: vc)
}
#if RCT_NEW_ARCH_ENABLED
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
self.insertSubview(childComponentView, at: index)
}
override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
self.willRemoveSubview(childComponentView)
// gestureRecognizers?.removeAll()
}
#endif
public func switchToTab(index: Int) {
let beforeIndex = tabBarController.selectedIndex
if beforeIndex == index {
return
}
if tabViewControllers.count < beforeIndex || tabViewControllers.count < index {
return
}
if let fromView = tabViewControllers[beforeIndex].view,
let toView = tabViewControllers[index].view
{
if fromView != toView {
UIView.transition(
from: fromView,
to: toView,
duration: 0.1,
options: [.transitionCrossDissolve, .preferredFramesPerSecond60],
completion: nil)
}
}
tabBarController.selectedIndex = index
if let selectedViewController = tabBarController.selectedViewController {
tabBarController(tabBarController, didSelect: selectedViewController)
}
}
override func insertSubview(_ subview: UIView, at atIndex: Int) {
if let tabScreenView = subview as? TabScreenView {
let screenVC = UIViewController()
screenVC.view = tabScreenView
tabViewControllers.append(screenVC)
tabBarController.viewControllers = tabViewControllers
tabBarController.didMove(toParent: vc)
}
if let tabBarPortalView = subview as? TabBarPortalView {
let tabBarView = self.tabBarController.view!
tabBarView.addSubview(tabBarPortalView)
}
}
override func willRemoveSubview(_ subview: UIView) {
if let tabScreenView = subview as? TabScreenView {
tabViewControllers.removeAll { viewController in
return viewController.view == tabScreenView
}
tabBarController.viewControllers = tabViewControllers
tabBarController.didMove(toParent: vc)
}
}
}
// MARK: - UITabBarControllerDelegate
extension TabBarRootView: UITabBarControllerDelegate {
func tabBarController(
_ tabBarController: UITabBarController, didSelect viewController: UIViewController
) {
onTabIndexChange([
"index": tabBarController.selectedIndex
])
}
// func tabBarController(
// _ tabBarController: UITabBarController,
// animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController
// ) -> (any UIViewControllerAnimatedTransitioning)? {
// return nil
// }
}

View File

@ -0,0 +1,16 @@
//
// TabScreenModule.swift
// FollowNative
//
// Created by Innei on 2025/3/16.
//
import ExpoModulesCore
public class TabScreenModule: Module {
public func definition() -> ModuleDefinition {
Name("TabScreen")
View(TabScreenView.self) {}
}
}

View File

@ -0,0 +1,16 @@
//
// TabScreenView.swift
// FollowNative
//
// Created by Innei on 2025/3/16.
//
import ExpoModulesCore
import UIKit
class TabScreenView: ExpoView {
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
}
}

View File

@ -11,6 +11,7 @@
"ios:device": "expo run:ios --device",
"lint": "expo lint",
"start": "expo start --dev-client",
"start:clean": "expo start --dev-client --clear",
"typecheck": "tsc --noEmit",
"web": "expo start --web"
},
@ -24,6 +25,7 @@
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@gorhom/portal": "1.0.14",
"@hookform/resolvers": "4.1.3",
"@react-native-community/image-editor": "4.3.0",
"@react-native-firebase/analytics": "21.12.0",
@ -31,9 +33,6 @@
"@react-native-firebase/app-check": "21.12.0",
"@react-native-firebase/crashlytics": "21.12.0",
"@react-native-picker/picker": "2.11.0",
"@react-navigation/bottom-tabs": "7.2.1",
"@react-navigation/drawer": "7.1.2",
"@react-navigation/native": "7.0.15",
"@shopify/flash-list": "1.7.3",
"@tanstack/query-sync-storage-persister": "5.68.0",
"@tanstack/react-query": "5.68.0",
@ -62,7 +61,6 @@
"expo-linking": "7.0.5",
"expo-localization": "16.0.1",
"expo-media-library": "17.0.6",
"expo-router": "4.0.18",
"expo-secure-store": "14.0.1",
"expo-sharing": "13.0.1",
"expo-splash-screen": "0.29.22",
@ -82,6 +80,7 @@
"qrcode": "1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-error-boundary": "5.0.0",
"react-hook-form": "7.54.2",
"react-native": "0.77.1",
"react-native-awesome-slider": "2.9.0",
@ -101,7 +100,7 @@
"react-native-sheet-transitions": "0.1.2",
"react-native-svg": "15.11.2",
"react-native-track-player": "4.1.1",
"react-native-uikit-colors": "0.3.8",
"react-native-uikit-colors": "0.3.9",
"react-native-video": "6.10.2",
"react-native-volume-manager": "2.0.8",
"react-native-web": "0.19.13",

View File

@ -0,0 +1,23 @@
import analytics from "@react-native-firebase/analytics"
import { useEffect } from "react"
import { Navigation } from "./lib/navigation/Navigation"
export const Analytics = () => {
useEffect(() => {
return Navigation.rootNavigation.on("didAppear", ({ screenId }) => {
const logScreenView = async () => {
try {
await analytics().logScreenView({
screen_name: screenId,
screen_class: screenId,
})
} catch (err: any) {
console.warn(`[Error] logScreenView: ${err}`)
}
}
logScreenView()
})
}, [])
return null
}

41
apps/mobile/src/App.tsx Normal file
View File

@ -0,0 +1,41 @@
import { View } from "react-native"
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated"
import { useSheet } from "react-native-sheet-transitions"
import { FullWindowOverlay } from "./components/common/FullWindowOverlay"
import { useIntentHandler } from "./hooks/useIntentHandler"
import { DebugButton, EnvProfileIndicator } from "./modules/debug"
import { useOnboarding, usePrefetchSessionUser } from "./store/user/hooks"
export function App({ children }: { children: React.ReactNode }) {
useIntentHandler()
useOnboarding()
const { scale } = useSheet()
const style = useAnimatedStyle(() => ({
borderRadius: interpolate(scale.value, [0.8, 0.99, 1], [0, 50, 0]),
transform: [
{
scale: scale.value,
},
],
}))
return (
<View className="flex-1 bg-black">
<Session />
<Animated.View className="flex-1 overflow-hidden" style={style}>
{children}
</Animated.View>
{__DEV__ && <DebugButton />}
<FullWindowOverlay>
<EnvProfileIndicator />
</FullWindowOverlay>
</View>
)
}
const Session = () => {
usePrefetchSessionUser()
return null
}

View File

@ -0,0 +1,18 @@
import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"
import { Text, View } from "react-native"
const ErrorBoundaryFallback = ({ error }: { error: Error }) => {
return (
<View>
<Text>{error.message}</Text>
</View>
)
}
export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => {
return (
<ReactErrorBoundary fallbackRender={({ error }) => <ErrorBoundaryFallback error={error} />}>
{children}
</ReactErrorBoundary>
)
}

View File

@ -1,49 +0,0 @@
import { cn } from "@follow/utils"
import { useTheme } from "@react-navigation/native"
import type { StyleProp, TextProps, TextStyle } from "react-native"
import { Animated, StyleSheet, Text, View } from "react-native"
type Props = Omit<TextProps, "style"> & {
tintColor?: string
children?: string
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>
subText?: string
subTextStyle?: StyleProp<TextStyle>
subTextClassName?: string
}
export function HeaderTitleExtra({
tintColor,
style,
subText,
subTextStyle,
subTextClassName,
...rest
}: Props) {
const { fonts } = useTheme()
return (
<View>
<Animated.Text
accessibilityRole="header"
aria-level="1"
numberOfLines={1}
{...rest}
className={"text-label"}
style={[fonts.bold, styles.title, style]}
/>
{!!subText && (
<Text
className={cn("text-text/50 text-center text-xs", subTextClassName)}
style={subTextStyle}
>
{subText}
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
title: { fontSize: 17 },
})

View File

@ -0,0 +1,24 @@
import type { PropsWithChildren } from "react"
import type { TextProps } from "react-native"
import { Linking, Text } from "react-native"
export const Link = ({
href,
children,
...props
}: PropsWithChildren<{ href: string } & TextProps>) => {
return (
<Text
className="text-accent"
onPress={async () => {
const canOpen = await Linking.canOpenURL(href)
if (canOpen) {
Linking.openURL(href)
}
}}
{...props}
>
{children}
</Text>
)
}

View File

@ -0,0 +1,27 @@
import type { StyleProp, TextProps, TextStyle } from "react-native"
import { Animated, StyleSheet } from "react-native"
type Props = Omit<TextProps, "style"> & {
tintColor?: string
children?: string
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>
}
export function FakeNativeHeaderTitle({ style, ...rest }: Props) {
return (
<Animated.Text
accessibilityRole="header"
aria-level="1"
numberOfLines={1}
className={"text-label font-bold"}
{...rest}
style={[styles.title, style]}
/>
)
}
const styles = StyleSheet.create({
title: {
fontSize: 17,
},
})

View File

@ -1,38 +1,39 @@
import { withOpacity } from "@follow/utils"
import { router } from "expo-router"
import { useCallback, useMemo } from "react"
import { TouchableOpacity } from "react-native"
import { useCallback } from "react"
import { TouchableOpacity, View } from "react-native"
import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne"
import { CheckLineIcon } from "@/src/icons/check_line"
import { CloseCuteReIcon } from "@/src/icons/close_cute_re"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
import {
useCanDismiss,
useIsSingleRouteInGroup,
useNavigation,
useScreenIsInSheetModal,
} from "@/src/lib/navigation/hooks"
import { StackScreenHeaderPortal } from "@/src/lib/navigation/StackScreenHeaderPortal"
import { useColor } from "@/src/theme/colors"
import { RotateableLoading } from "./RotateableLoading"
import { RotateableLoading } from "../../common/RotateableLoading"
export const ModalHeaderCloseButton = () => {
return <ModalHeaderCloseButtonImpl />
}
const ModalHeaderCloseButtonImpl = () => {
export const HeaderCloseButton = () => {
const label = useColor("label")
const routeOnlyOne = useIsRouteOnlyOne()
const memoedRouteOnlyOne = useMemo(() => routeOnlyOne, [])
const navigation = useNavigation()
const canDismiss = useCanDismiss()
const isInModal = useScreenIsInSheetModal()
const isSingleRouteInGroup = useIsSingleRouteInGroup()
const handlePress = useCallback(() => {
if (router.canDismiss()) {
router.dismiss()
if (canDismiss) {
navigation.dismiss()
} else {
// If we can't dismiss, redirect to the root route
router.replace("/")
navigation.back()
}
}, [])
}, [canDismiss, navigation])
return (
<TouchableOpacity onPress={handlePress}>
{memoedRouteOnlyOne ? (
{isInModal && isSingleRouteInGroup ? (
<CloseCuteReIcon height={20} width={20} color={label} />
) : (
<MingcuteLeftLineIcon height={20} width={20} color={label} />
@ -46,15 +47,8 @@ export interface ModalHeaderSubmitButtonProps {
onPress: () => void
isLoading?: boolean
}
export const ModalHeaderSubmitButton = ({
isValid,
onPress,
isLoading,
}: ModalHeaderSubmitButtonProps) => {
return <ModalHeaderShubmitButtonImpl isValid={isValid} onPress={onPress} isLoading={isLoading} />
}
const ModalHeaderShubmitButtonImpl = ({
export const HeaderSubmitButton = ({
isValid,
onPress,
isLoading,
@ -71,3 +65,13 @@ const ModalHeaderShubmitButtonImpl = ({
</TouchableOpacity>
)
}
export const HeaderCloseOnly = () => {
return (
<StackScreenHeaderPortal>
<View className="absolute left-4 top-4">
<HeaderCloseButton />
</View>
</StackScreenHeaderPortal>
)
}

View File

@ -1,105 +0,0 @@
import { useTypeScriptHappyCallback } from "@follow/hooks/exports"
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"
import { Stack } from "expo-router"
import type { FC } from "react"
import { memo } from "react"
import { StyleSheet, View } from "react-native"
import type { SharedValue } from "react-native-reanimated"
import Animated, { useAnimatedStyle } from "react-native-reanimated"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import { HeaderTitleExtra } from "../../common/HeaderTitleExtra"
import { ModalHeaderCloseButton } from "../../common/ModalSharedComponents"
import { ThemedBlurView } from "../../common/ThemedBlurView"
import { useModalScrollViewContext } from "../contexts/ModalScrollViewContext"
import { getDefaultHeaderHeight } from "../utils"
interface ModalHeaderProps
extends Omit<NativeStackNavigationOptions, "headerLeft" | "headerRight" | "headerTitle"> {
headerLeft?: React.ReactNode
headerRight?: React.ReactNode
headerTitle?: string
headerSubtitle?: string
}
export const ModalHeader: FC<ModalHeaderProps> = (props) => {
const { animatedY } = useModalScrollViewContext()
return (
<Stack.Screen
options={{
...props,
headerTransparent: true,
headerShown: true,
headerLeft: () => props.headerLeft ?? <ModalHeaderCloseButton />,
headerRight: () => props.headerRight,
header: useTypeScriptHappyCallback(
({ options }) => (
<Header
headerTitle={props.headerTitle}
headerLeft={options.headerLeft?.({} as any)}
headerRight={options.headerRight?.({} as any)}
animatedY={animatedY}
headerSubtitle={props.headerSubtitle}
/>
),
[animatedY, props.headerSubtitle, props.headerTitle],
),
}}
/>
)
}
interface HeaderProps {
headerTitle?: string
headerLeft?: React.ReactNode
headerRight?: React.ReactNode
animatedY: SharedValue<number>
headerSubtitle?: string
}
const InternalBlurEffectWithBottomBorder = (props: { animatedY: SharedValue<number> }) => {
const border = useColor("opaqueSeparator")
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: Math.max(0, Math.min(1, props.animatedY.value / 10)),
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: border,
}
})
return (
<Animated.View className={"absolute inset-0 overflow-hidden"} style={animatedStyle}>
<ThemedBlurView style={StyleSheet.absoluteFillObject} />
</Animated.View>
)
}
const Header: FC<HeaderProps> = memo(
({ headerTitle, headerLeft, headerRight, headerSubtitle, animatedY }) => {
const frame = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const height = getDefaultHeaderHeight(frame, true, insets.top)
return (
<View style={{ height }} className="items-center justify-center">
<InternalBlurEffectWithBottomBorder animatedY={animatedY} />
{/* Grid */}
<View className="mx-5 flex-row items-center">
{/* Left actions */}
<View className="flex-1 flex-row items-center justify-start gap-2">{headerLeft}</View>
{/* Title */}
<View className="mx-8 flex-row items-center justify-center text-center">
{/* <Text>Title</Text> */}
<HeaderTitleExtra subText={headerSubtitle}>{headerTitle}</HeaderTitleExtra>
</View>
{/* Right actions */}
<View className="flex-1 flex-row items-center justify-end gap-2">{headerRight}</View>
</View>
</View>
)
},
)

View File

@ -1,12 +1,11 @@
import { cn } from "@follow/utils"
import { HeaderTitle } from "@react-navigation/elements"
import { router, Stack, useNavigation } from "expo-router"
import type { FC, PropsWithChildren, ReactNode } from "react"
import { createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
import type { LayoutChangeEvent } from "react-native"
import { StyleSheet, TouchableOpacity, View } from "react-native"
import type { AnimatedProps } from "react-native-reanimated"
import Animated, {
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
@ -14,26 +13,34 @@ import Animated, {
} from "react-native-reanimated"
import type { DefaultStyle } from "react-native-reanimated/lib/typescript/hook/commonTypes"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import type { NativeStackNavigationOptions } from "react-native-screens/lib/typescript/native-stack/types"
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"
import { useColor } from "react-native-uikit-colors"
import { CloseCuteReIcon } from "@/src/icons/close_cute_re"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
import {
useCanBack,
useCanDismiss,
useNavigation,
useScreenIsInSheetModal,
} from "@/src/lib/navigation/hooks"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { ThemedBlurView } from "../../common/ThemedBlurView"
import { getDefaultHeaderHeight } from "../utils"
import { NavigationContext } from "../views/NavigationContext"
import { SetNavigationHeaderHeightContext } from "../views/NavigationHeaderContext"
import { FakeNativeHeaderTitle } from "./FakeNativeHeaderTitle"
interface NavigationHeaderButtonProps {
canGoBack: boolean
canDismiss: boolean
modal?: boolean
}
export interface NavigationHeaderRawProps {
headerLeft?: FC<{
canGoBack: boolean
}>
headerTitle?: FC<React.ComponentProps<typeof HeaderTitle>> | ReactNode
headerLeft?: FC<NavigationHeaderButtonProps>
headerRight?: FC<NavigationHeaderButtonProps>
headerTitle?: FC<React.ComponentProps<typeof FakeNativeHeaderTitle>> | ReactNode
headerTitleAbsolute?: boolean
headerRight?: FC<{
canGoBack: boolean
}>
title?: string
@ -63,9 +70,10 @@ const useHideableBottom = (
})
}, [hideableBottomRef, largeHeaderHeight, originalDefaultHeaderHeight])
const { scrollY } = useContext(NavigationContext)!
useEffect(() => {
const id = scrollY.addListener(({ value }) => {
const { reAnimatedScrollY } = useContext(ScreenItemContext)!
useAnimatedReaction(
() => reAnimatedScrollY.value,
(value) => {
if (!enable) {
return
}
@ -80,12 +88,8 @@ const useHideableBottom = (
largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight)
}
lastScrollY.current = value
})
return () => {
scrollY.removeListener(id)
}
}, [enable, largeHeaderHeight, originalDefaultHeaderHeight, scrollY])
},
)
const layoutHeightOnceRef = useRef(false)
const onLayout = useCallback(
@ -115,10 +119,26 @@ const useHideableBottom = (
onLayout,
}
}
interface NavigationHeaderProps
export interface InternalNavigationHeaderProps
extends Omit<AnimatedProps<ViewProps>, "children">,
NavigationHeaderRawProps,
PropsWithChildren {}
PropsWithChildren {
headerLeft?:
| FC<{
canGoBack: boolean
}>
| ReactNode
headerRight?:
| FC<{
canGoBack: boolean
}>
| ReactNode
title?: string
hideableBottom?: ReactNode
hideableBottomHeight?: number
headerTitleAbsolute?: boolean
headerTitle?: FC<React.ComponentProps<typeof FakeNativeHeaderTitle>> | ReactNode
}
const blurThreshold = 0
const titlebarPaddingHorizontal = 8
@ -130,36 +150,41 @@ export const InternalNavigationHeader = ({
headerRight,
title,
headerTitle: customHeaderTitle,
modal = false,
hideableBottom,
hideableBottomHeight,
headerTitleAbsolute,
...rest
}: NavigationHeaderProps) => {
}: InternalNavigationHeaderProps) => {
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
const sheetModal = useScreenIsInSheetModal()
const defaultHeight = useMemo(
() => getDefaultHeaderHeight(frame, modal, modal ? 0 : insets.top),
[frame, insets.top, modal],
() => getDefaultHeaderHeight(frame, sheetModal, sheetModal ? 0 : insets.top),
[frame, insets.top, sheetModal],
)
const border = useColor("opaqueSeparator")
const opacityAnimated = useSharedValue(0)
const { scrollY } = useContext(NavigationContext)!
const { reAnimatedScrollY } = useContext(ScreenItemContext)!
const setHeaderHeight = useContext(SetNavigationHeaderHeightContext)
useEffect(() => {
const handler = ({ value }: { value: number }) => {
useAnimatedReaction(
() => reAnimatedScrollY.value,
(value) => {
opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10))
}
const id = scrollY.addListener(handler)
},
)
handler({ value: (scrollY as any)._value })
return () => {
scrollY.removeListener(id)
}
}, [opacityAnimated, scrollY])
const canBack = useCanBack()
const canDismiss = useCanDismiss()
useEffect(() => {
const { value } = reAnimatedScrollY
opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10))
}, [opacityAnimated, reAnimatedScrollY])
const blurStyle = useAnimatedStyle(() => ({
opacity: opacityAnimated.value,
@ -175,7 +200,7 @@ export const InternalNavigationHeader = ({
)
const rootTitleBarStyle = useAnimatedStyle(() => {
const styles = {
paddingTop: modal ? 0 : insets.top,
paddingTop: sheetModal ? 0 : insets.top,
position: "relative",
overflow: "hidden",
} satisfies DefaultStyle
@ -185,24 +210,16 @@ export const InternalNavigationHeader = ({
return styles
})
const navigation = useNavigation()
const canBack = navigation.canGoBack()
useEffect(() => {
if (title) {
navigation.setOptions({ title })
}
}, [navigation, title])
const HeaderLeft = headerLeft ?? DefaultHeaderBackButton
const renderTitle = customHeaderTitle ?? HeaderTitle
const renderTitle = customHeaderTitle ?? FakeNativeHeaderTitle
const headerTitle =
typeof renderTitle !== "function"
? renderTitle
: createElement(renderTitle, {
children: title,
})
const RightButton = headerRight ?? Noop
const RightButton = headerRight ?? (Noop as FC<NavigationHeaderButtonProps>)
const animatedRef = useAnimatedRef<Animated.View>()
useEffect(() => {
@ -235,22 +252,23 @@ export const InternalNavigationHeader = ({
style={{
marginLeft: insets.left,
marginRight: insets.right,
height: !modal ? defaultHeight - insets.top : defaultHeight,
height: !sheetModal ? defaultHeight - insets.top : defaultHeight,
paddingHorizontal: titlebarPaddingHorizontal,
}}
pointerEvents={"box-none"}
>
{/* Left */}
<View
className="min-w-6 flex-1 flex-row items-center justify-start"
pointerEvents={"box-none"}
>
<HeaderLeft canGoBack={canBack} />
<View className="flex-1 flex-row items-center justify-start" pointerEvents={"box-none"}>
{typeof HeaderLeft === "function" ? (
<HeaderLeft canGoBack={canBack} canDismiss={canDismiss} modal={sheetModal} />
) : (
HeaderLeft
)}
</View>
{/* Center */}
<Animated.View
className="items-center justify-center"
className="flex min-w-0 shrink items-center justify-center"
pointerEvents={"box-none"}
style={{
marginHorizontal: titleMarginHorizontal,
@ -261,10 +279,14 @@ export const InternalNavigationHeader = ({
{/* Right */}
<View
className="min-w-6 flex-1 flex-row items-center justify-end"
className="flex flex-1 shrink-0 flex-row items-center justify-end"
pointerEvents={"box-none"}
>
<RightButton canGoBack={canBack} />
{typeof RightButton === "function" ? (
<RightButton canGoBack={canBack} canDismiss={canDismiss} modal={sheetModal} />
) : (
RightButton
)}
</View>
<View
className="absolute inset-0 flex-row items-center justify-center"
@ -283,12 +305,25 @@ export const InternalNavigationHeader = ({
)
}
export const DefaultHeaderBackButton = ({ canGoBack }: { canGoBack: boolean }) => {
export const DefaultHeaderBackButton = ({ canGoBack, canDismiss }: NavigationHeaderButtonProps) => {
const label = useColor("label")
if (!canGoBack) return null
const navigation = useNavigation()
if (!canGoBack && !canDismiss) return null
return (
<UINavigationHeaderActionButton onPress={() => router.back()}>
<MingcuteLeftLineIcon height={20} width={20} color={label} />
<UINavigationHeaderActionButton
onPress={() => {
if (canGoBack) {
navigation.back()
} else if (canDismiss) {
navigation.dismiss()
}
}}
>
{canGoBack ? (
<MingcuteLeftLineIcon height={20} width={20} color={label} />
) : (
<CloseCuteReIcon height={20} width={20} color={label} />
)}
</UINavigationHeaderActionButton>
)
}
@ -316,24 +351,3 @@ export const UINavigationHeaderActionButton = ({
)
}
const Noop = () => null
/**
* NativeNavigationHeader wrapped react navigation native-stack is universal in modal and stack, but there are significant limitations in UI customization.
*/
export interface NativeNavigationHeaderProps
extends Pick<NativeStackNavigationOptions, "headerLeft" | "headerRight" | "headerTitle"> {}
export const NativeNavigationHeader: FC<NativeNavigationHeaderProps> = (props) => {
const navigation = useNavigation()
return (
<Stack.Screen
options={{
headerShown: true,
headerLeft: () => <DefaultHeaderBackButton canGoBack={navigation.canGoBack()} />,
headerTransparent: true,
headerBlurEffect: "systemChromeMaterial",
...props,
}}
/>
)
}

View File

@ -0,0 +1,24 @@
import { useMemo, useState } from "react"
import { useSharedValue } from "react-native-reanimated"
import { BottomTabHeightProvider } from "./BottomTabHeightProvider"
import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext"
import {
BottomTabBarVisibleContext,
SetBottomTabBarVisibleContext,
} from "./contexts/BottomTabBarVisibleContext"
export const BottomTabProvider = ({ children }: { children: React.ReactNode }) => {
const opacity = useSharedValue(1)
const [tabBarVisible, setTabBarVisible] = useState(true)
return (
<BottomTabBarBackgroundContext.Provider value={useMemo(() => ({ opacity }), [opacity])}>
<SetBottomTabBarVisibleContext.Provider value={setTabBarVisible}>
<BottomTabBarVisibleContext.Provider value={tabBarVisible}>
<BottomTabHeightProvider>{children}</BottomTabHeightProvider>
</BottomTabBarVisibleContext.Provider>
</SetBottomTabBarVisibleContext.Provider>
</BottomTabBarBackgroundContext.Provider>
)
}

View File

@ -1,77 +1,25 @@
import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"
import type { ForwardRefExoticComponent } from "react"
import { forwardRef, useMemo, useRef, useState } from "react"
import type { ScrollView } from "react-native"
import { useSharedValue } from "react-native-reanimated"
import { useContext, useRef } from "react"
import { BottomTabHeightProvider } from "./BottomTabHeightProvider"
import {
AttachNavigationScrollViewContext,
SetAttachNavigationScrollViewContext,
} from "./contexts/AttachNavigationScrollViewContext"
import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext"
import {
BottomTabBarVisibleContext,
SetBottomTabBarVisibleContext,
} from "./contexts/BottomTabBarVisibleContext"
import { useNavigationScrollToTop } from "./hooks"
import { Tabbar } from "./Tabbar"
import { Tabs } from "./TabClient"
type ExtractReactForwardRefExoticComponent<T> =
T extends React.ForwardRefExoticComponent<infer P> ? P : never
export const BottomTabs: ForwardRefExoticComponent<
Omit<ExtractReactForwardRefExoticComponent<typeof Tabs>, "tabBar">
> = forwardRef((props, ref) => {
const opacity = useSharedValue(1)
const [tabBarVisible, setTabBarVisible] = useState(true)
const [attachNavigationScrollViewRef, setAttachNavigationScrollViewRef] =
useState<React.RefObject<ScrollView> | null>(null)
const currentTarget = useRef<string | undefined>(undefined)
const scrollToTop = useNavigationScrollToTop(attachNavigationScrollViewRef)
export const BottomTabs = () => {
const currentIndex = useRef<number | undefined>(undefined)
const scrollToTop = useNavigationScrollToTop()
const { opacity } = useContext(BottomTabBarBackgroundContext)
return (
<AttachNavigationScrollViewContext.Provider value={attachNavigationScrollViewRef}>
<SetAttachNavigationScrollViewContext.Provider value={setAttachNavigationScrollViewRef}>
<BottomTabBarBackgroundContext.Provider value={useMemo(() => ({ opacity }), [opacity])}>
<SetBottomTabBarVisibleContext.Provider value={setTabBarVisible}>
<BottomTabBarVisibleContext.Provider value={tabBarVisible}>
<BottomTabHeightProvider>
<Tabs
{...props}
tabBar={TabBar}
screenListeners={{
...props.screenListeners,
tabPress: (e) => {
if (props.screenListeners && "tabPress" in props.screenListeners) {
props.screenListeners.tabPress!(e)
}
<Tabbar
onPress={(index) => {
opacity.value = 1
if (currentTarget.current === e.target) {
scrollToTop()
return
}
if (currentIndex.current === index) {
scrollToTop()
return
}
opacity.value = 1
currentTarget.current = e.target
},
transitionStart: (e) => {
if (props.screenListeners && "transitionStart" in props.screenListeners) {
props.screenListeners.transitionStart!(e)
}
currentTarget.current = e.target
opacity.value = 1
},
}}
ref={ref}
/>
</BottomTabHeightProvider>
</BottomTabBarVisibleContext.Provider>
</SetBottomTabBarVisibleContext.Provider>
</BottomTabBarBackgroundContext.Provider>
</SetAttachNavigationScrollViewContext.Provider>
</AttachNavigationScrollViewContext.Provider>
currentIndex.current = index
}}
/>
)
})
const TabBar = (props: BottomTabBarProps) => <Tabbar {...props} />
}

View File

@ -1,55 +0,0 @@
// @see https://github.com/expo/expo/blob/main/packages/expo-router/src/layouts/TabsClient.tsx
import type {
BottomTabNavigationEventMap,
BottomTabNavigationOptions,
} from "@react-navigation/bottom-tabs"
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import type { ParamListBase, TabNavigationState } from "@react-navigation/native"
import type { Href } from "expo-router"
import { Link, withLayoutContext } from "expo-router"
import * as React from "react"
// This is the only way to access the navigator.
const BottomTabNavigator = createBottomTabNavigator().Navigator
type TabsProps = BottomTabNavigationOptions & { href?: Href | null }
export const Tabs = withLayoutContext<
TabsProps,
typeof BottomTabNavigator,
TabNavigationState<ParamListBase>,
BottomTabNavigationEventMap
>(BottomTabNavigator, (screens) => {
// Support the `href` shortcut prop.
return screens.map((screen) => {
if (typeof screen.options !== "function" && screen.options?.href !== undefined) {
const { href, ...options } = screen.options
return {
...screen,
options: {
...options,
tabBarItemStyle: options.tabBarItemStyle,
tabBarButton: (props) => {
if (href == null) {
return null
}
// TODO: React Navigation types these props as Animated.WithAnimatedValue<StyleProp<ViewStyle>>
// While Link expects a TextStyle. We need to reconcile these types.
return (
<Link
{...(props as any)}
className="flex"
href={href}
asChild
children={props.children}
/>
)
},
},
}
}
return screen
})
})

View File

@ -1,8 +1,6 @@
import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"
import { getLabel } from "@react-navigation/elements"
import { CommonActions, NavigationContext, NavigationRouteContext } from "@react-navigation/native"
import { useAtom, useAtomValue } from "jotai"
import type { FC } from "react"
import { Fragment, memo, useContext, useEffect } from "react"
import { memo, useContext, useEffect } from "react"
import type { StyleProp, TextStyle } from "react-native"
import { Platform, Pressable, StyleSheet, Text, View } from "react-native"
import Animated, {
@ -15,6 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
import { SetBottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext"
import { gentleSpringPreset, quickSpringPreset, softSpringPreset } from "@/src/constants/spring"
import { BottomTabContext } from "@/src/lib/navigation/bottom-tab/BottomTabContext"
import type { TabbarIconProps } from "@/src/lib/navigation/bottom-tab/types"
import { PlayerTabBar } from "@/src/modules/player/PlayerTabBar"
import { accentColor } from "@/src/theme/colors"
@ -23,15 +23,19 @@ import { Grid } from "../../ui/grid"
import { BottomTabBarBackgroundContext } from "./contexts/BottomTabBarBackgroundContext"
import { BottomTabBarVisibleContext } from "./contexts/BottomTabBarVisibleContext"
export const Tabbar: FC<BottomTabBarProps> = (props) => {
const { state, navigation, descriptors } = props
const { routes } = state
export const Tabbar: FC<{
onPress?: (index: number) => void
}> = ({ onPress: onPressProp }) => {
const setTabBarHeight = useContext(SetBottomTabBarHeightContext)
const insets = useSafeAreaInsets()
const tabBarVisible = useContext(BottomTabBarVisibleContext)
const tabContext = useContext(BottomTabContext)
const tabScreens = useAtomValue(tabContext.tabScreensAtom)
const [selectedIndex, setSelectedIndex] = useAtom(tabContext.currentIndexAtom)
const translateY = useSharedValue(0)
useEffect(() => {
cancelAnimation(translateY)
translateY.value = withSpring(
@ -39,6 +43,7 @@ export const Tabbar: FC<BottomTabBarProps> = (props) => {
!tabBarVisible ? quickSpringPreset : softSpringPreset,
)
}, [tabBarVisible, translateY])
if (tabScreens.length === 0) return null
return (
<Animated.View
accessibilityRole="tablist"
@ -53,38 +58,22 @@ export const Tabbar: FC<BottomTabBarProps> = (props) => {
>
<TabBarBackground />
<PlayerTabBar />
<Grid columns={routes.length} gap={10} className="mt-[7]">
{routes.map((route, index) => {
const focused = index === state.index
const { options } = descriptors[route.key]!
<Grid columns={tabScreens.length} gap={10} className="mt-[7]">
{tabScreens.map((route, index) => {
const focused = index === selectedIndex
const inactiveTintColor = "#999"
const onPress = () => {
const event = navigation.emit({
type: "tabPress",
target: route.key,
canPreventDefault: true,
})
if (!focused && !event.defaultPrevented) {
navigation.dispatch({
...CommonActions.navigate(route),
target: state.key,
})
}
setSelectedIndex(index)
onPressProp?.(index)
}
const tabButton = options?.tabBarButton
const label =
typeof options?.tabBarLabel === "function"
? options?.tabBarLabel
: getLabel({ label: options?.tabBarLabel, title: options?.title }, route.name)
const label = route.title
const accessibilityLabel =
options?.tabBarAccessibilityLabel !== undefined
? options?.tabBarAccessibilityLabel
: typeof label === "string" && Platform.OS === "ios"
? `${label}, tab, ${index + 1} of ${routes.length}`
: undefined
typeof label === "string" && Platform.OS === "ios"
? `${label}, tab, ${index + 1} of ${tabScreens.length}`
: undefined
const renderIcon = ({ focused }: { focused: boolean }) => {
const iconSize = ICON_SIZE_ROUND
@ -93,31 +82,12 @@ export const Tabbar: FC<BottomTabBarProps> = (props) => {
focused={focused}
iconSize={iconSize}
inactiveTintColor={inactiveTintColor}
renderIcon={options.tabBarIcon!}
renderIcon={route.renderIcon! || noop}
/>
)
}
const renderLabel = ({ focused }: { focused: boolean }) => {
const color = focused ? accentColor : accentColor
if (typeof label !== "string") {
const labelString = getLabel(
{
label: typeof options?.tabBarLabel === "string" ? options.tabBarLabel : undefined,
title: options?.title,
},
route.name,
)
return label({
focused,
color,
position: "beside-icon",
children: labelString,
})
}
return (
<TextLabel
focused={focused}
@ -129,19 +99,16 @@ export const Tabbar: FC<BottomTabBarProps> = (props) => {
)
}
const TabButton = tabButton ?? Fragment
return (
<TabButton key={route.key}>
<TabItem
route={route}
focused={focused}
descriptors={descriptors}
onPress={onPress}
originalRenderIcon={renderIcon}
originalRenderLabel={renderLabel}
accessibilityLabel={accessibilityLabel}
/>
</TabButton>
<TabItem
key={route.tabScreenIndex}
route={route}
focused={focused}
onPress={onPress}
originalRenderIcon={renderIcon}
originalRenderLabel={renderLabel}
accessibilityLabel={accessibilityLabel}
/>
)
})}
</Grid>
@ -178,7 +145,7 @@ const TabIcon = ({
focused: boolean
iconSize: number
inactiveTintColor: string
renderIcon: (options: { focused: boolean; size: number; color: string }) => React.ReactNode
renderIcon: (options: TabbarIconProps) => React.ReactNode
}) => {
const activeOpacity = focused ? 1 : 0
const inactiveOpacity = focused ? 0 : 1
@ -260,7 +227,6 @@ const styles = StyleSheet.create({
},
})
const AnimatedThemedBlurView = Animated.createAnimatedComponent(ThemedBlurView)
const TabBarBackground = () => {
const { opacity } = useContext(BottomTabBarBackgroundContext)
@ -273,7 +239,9 @@ const TabBarBackground = () => {
}))
return (
<View style={styles.blurEffect}>
<AnimatedThemedBlurView style={[styles.blurEffect, animatedStyle]} />
<Animated.View style={[styles.blurEffect, animatedStyle]}>
<ThemedBlurView style={styles.blurEffect} />
</Animated.View>
<Animated.View
className="bg-opaque-separator absolute top-0 w-full"
style={[
@ -291,7 +259,7 @@ const TabItem = memo(
({
route,
focused,
descriptors,
onPress,
originalRenderIcon,
originalRenderLabel,
@ -299,7 +267,7 @@ const TabItem = memo(
}: {
route: any
focused: boolean
descriptors: any
onPress: () => void
originalRenderIcon: (scene: { route: any; focused: boolean }) => React.ReactNode
originalRenderLabel: (scene: { route: any; focused: boolean }) => React.ReactNode
@ -316,26 +284,24 @@ const TabItem = memo(
const scene = { route, focused }
return (
<NavigationContext.Provider value={descriptors[route.key]?.navigation}>
<NavigationRouteContext.Provider value={route}>
<Pressable
onPress={onPress}
onPressIn={() => {
pressed.value = withSpring(1, gentleSpringPreset)
}}
onPressOut={() => {
pressed.value = withSpring(0, gentleSpringPreset)
}}
className="flex-1 flex-col items-center justify-center"
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
>
<Animated.View style={animatedStyle}>{originalRenderIcon(scene)}</Animated.View>
{originalRenderLabel(scene)}
</Pressable>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
<Pressable
onPress={onPress}
onPressIn={() => {
pressed.value = withSpring(1, gentleSpringPreset)
}}
onPressOut={() => {
pressed.value = withSpring(0, gentleSpringPreset)
}}
className="flex-1 flex-col items-center justify-center"
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
>
<Animated.View style={animatedStyle}>{originalRenderIcon(scene)}</Animated.View>
{originalRenderLabel(scene)}
</Pressable>
)
},
)
const noop = () => null

View File

@ -1,15 +1,15 @@
import { useNavigation } from "expo-router"
import type { RefObject } from "react"
import { useCallback, useContext, useEffect, useRef } from "react"
import type { FlatList, ScrollView } from "react-native"
import { findNodeHandle, Platform } from "react-native"
import { performNativeScrollToTop } from "@/src/lib/native"
import {
SetAttachNavigationScrollViewContext,
useAttachNavigationScrollView,
} from "./contexts/AttachNavigationScrollViewContext"
} from "@/src/lib/navigation/AttachNavigationScrollViewContext"
import { useScreenIsAppeared } from "@/src/lib/navigation/bottom-tab/hooks"
import { BottomTabBarHeightContext } from "./contexts/BottomTabBarHeightContext"
export const useBottomTabBarHeight = () => {
@ -55,17 +55,15 @@ export const useNavigationScrollToTop = (
export const useRegisterNavigationScrollView = <T = unknown>(active = true) => {
const scrollViewRef = useRef<T>(null)
const navigation = useNavigation()
const tabScreenIsFocused = useScreenIsAppeared()
const setAttachNavigationScrollViewRef = useContext(SetAttachNavigationScrollViewContext)
useEffect(() => {
if (!active) return
if (!setAttachNavigationScrollViewRef) return
if (!tabScreenIsFocused) return
setAttachNavigationScrollViewRef(scrollViewRef as unknown as RefObject<ScrollView>)
const unsubscribe = navigation.addListener("focus", () => {
setAttachNavigationScrollViewRef(scrollViewRef as unknown as RefObject<ScrollView>)
})
return unsubscribe
}, [setAttachNavigationScrollViewRef, scrollViewRef, active, navigation])
}, [setAttachNavigationScrollViewRef, scrollViewRef, active, tabScreenIsFocused])
return scrollViewRef
}

View File

@ -1,6 +0,0 @@
import { createContext } from "react"
import type { Animated } from "react-native"
export const NavigationContext = createContext<{
scrollY: Animated.Value
} | null>(null)

View File

@ -1,36 +1,33 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"
import { Stack } from "expo-router"
import { useSetAtom, useStore } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect, useMemo, useRef, useState } from "react"
import type {
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
ScrollViewProps,
} from "react-native"
import { Animated as RNAnimated, useAnimatedValue, View } from "react-native"
import { useContext, useEffect, useRef, useState } from "react"
import type { ScrollView, ScrollViewProps, StyleProp, ViewStyle } from "react-native"
import { View } from "react-native"
import type { SharedValue } from "react-native-reanimated"
import { useAnimatedScrollHandler } from "react-native-reanimated"
import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import {
AttachNavigationScrollViewContext,
SetAttachNavigationScrollViewContext,
} from "@/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
} from "@/src/lib/navigation/AttachNavigationScrollViewContext"
import { useScreenIsAppeared } from "@/src/lib/navigation/bottom-tab/hooks"
import { useScreenIsInSheetModal } from "@/src/lib/navigation/hooks"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { AnimatedScrollView } from "../../common/AnimatedComponents"
import { ReAnimatedScrollView } from "../../common/AnimatedComponents"
import type { InternalNavigationHeaderProps } from "../header/NavigationHeader"
import { InternalNavigationHeader } from "../header/NavigationHeader"
import { getDefaultHeaderHeight } from "../utils"
import { NavigationContext } from "./NavigationContext"
import {
NavigationHeaderHeightContext,
SetNavigationHeaderHeightContext,
} from "./NavigationHeaderContext"
type SafeNavigationScrollViewProps = Omit<ScrollViewProps, "onScroll"> & {
withHeaderBlur?: boolean
onScroll?: (e: ReanimatedScrollEvent) => void
// For scroll view content adjustment behavior
@ -39,70 +36,91 @@ type SafeNavigationScrollViewProps = Omit<ScrollViewProps, "onScroll"> & {
// to sharedValue
reanimatedScrollY?: SharedValue<number>
contentViewStyle?: StyleProp<ViewStyle>
contentViewClassName?: string
} & PropsWithChildren
export const SafeNavigationScrollView: FC<SafeNavigationScrollViewProps> = ({
children,
withHeaderBlur = true,
onScroll,
withBottomInset = false,
withTopInset = false,
reanimatedScrollY,
contentViewClassName,
contentViewStyle,
...props
}) => {
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const scrollY = useAnimatedValue(0)
const scrollViewRef = useRef<ScrollView>(null)
const setAttachNavigationScrollViewRef = useContext(SetAttachNavigationScrollViewContext)
const isFocused = useScreenIsAppeared()
useEffect(() => {
if (setAttachNavigationScrollViewRef) {
if (setAttachNavigationScrollViewRef && isFocused) {
setAttachNavigationScrollViewRef(scrollViewRef)
}
}, [setAttachNavigationScrollViewRef, scrollViewRef])
}, [setAttachNavigationScrollViewRef, scrollViewRef, isFocused])
const frame = useSafeAreaFrame()
const sheetModal = useScreenIsInSheetModal()
const [headerHeight, setHeaderHeight] = useState(() =>
getDefaultHeaderHeight(frame, false, insets.top),
getDefaultHeaderHeight(frame, sheetModal, insets.top),
)
const screenCtxValue = useContext(ScreenItemContext)
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
if (reanimatedScrollY) {
reanimatedScrollY.value = event.contentOffset.y
}
screenCtxValue.reAnimatedScrollY.value = event.contentOffset.y
},
})
return (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<NavigationHeaderHeightContext.Provider value={headerHeight}>
<SetNavigationHeaderHeightContext.Provider value={setHeaderHeight}>
{withHeaderBlur && <NavigationBlurEffectHeader />}
<AnimatedScrollView
ref={scrollViewRef}
onScroll={RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
listener(event: NativeSyntheticEvent<NativeScrollEvent>) {
if (reanimatedScrollY) {
reanimatedScrollY.value = event.nativeEvent.contentOffset.y
}
},
})}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
scrollIndicatorInsets={{
top: headerHeight,
bottom: tabBarHeight,
}}
{...props}
>
<View style={{ height: headerHeight - (withTopInset ? insets.top : 0) }} />
<AttachNavigationScrollViewContext.Provider value={scrollViewRef}>
<View>{children}</View>
</AttachNavigationScrollViewContext.Provider>
<View style={{ height: tabBarHeight - (withBottomInset ? insets.bottom : 0) }} />
</AnimatedScrollView>
</SetNavigationHeaderHeightContext.Provider>
</NavigationHeaderHeightContext.Provider>
</NavigationContext.Provider>
<NavigationHeaderHeightContext.Provider value={headerHeight}>
<SetNavigationHeaderHeightContext.Provider value={setHeaderHeight}>
<ReAnimatedScrollView
ref={scrollViewRef}
onScroll={scrollHandler}
onContentSizeChange={useTypeScriptHappyCallback(
(w, h) => {
screenCtxValue.scrollViewContentHeight.value = h
},
[screenCtxValue.scrollViewContentHeight],
)}
onLayout={useTypeScriptHappyCallback(
(e) => {
screenCtxValue.scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight
},
[screenCtxValue.scrollViewHeight, headerHeight],
)}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
scrollIndicatorInsets={{
top: headerHeight,
bottom: tabBarHeight,
}}
{...props}
>
<View style={{ height: headerHeight - (withTopInset ? insets.top : 0) }} />
<AttachNavigationScrollViewContext.Provider value={scrollViewRef}>
<View style={contentViewStyle} className={contentViewClassName}>
{children}
</View>
</AttachNavigationScrollViewContext.Provider>
<View style={{ height: tabBarHeight - (withBottomInset ? insets.bottom : 0) }} />
</ReAnimatedScrollView>
</SetNavigationHeaderHeightContext.Provider>
</NavigationHeaderHeightContext.Provider>
)
}
@ -111,51 +129,49 @@ export const NavigationBlurEffectHeader = ({
headerHideableBottomHeight,
headerTitleAbsolute,
...props
}: NativeStackNavigationOptions & {
}: InternalNavigationHeaderProps & {
blurThreshold?: number
headerHideableBottomHeight?: number
headerHideableBottom?: () => React.ReactNode
headerTitleAbsolute?: boolean
}) => {
const navigationContext = useContext(NavigationContext)!
const setHeaderHeight = useContext(SetNavigationHeaderHeightContext)
const hideableBottom = headerHideableBottom?.()
const { headerLeft, ...rest } = props
const screenCtxValue = useContext(ScreenItemContext)
return (
<Stack.Screen
options={{
headerTransparent: true,
const setSlot = useSetAtom(screenCtxValue.Slot)
const store = useStore()
useEffect(() => {
setSlot({
...store.get(screenCtxValue.Slot),
header: (
<SetNavigationHeaderHeightContext.Provider value={setHeaderHeight}>
<InternalNavigationHeader
title={props.title}
headerRight={props.headerRight}
headerLeft={props.headerLeft}
hideableBottom={hideableBottom}
hideableBottomHeight={headerHideableBottomHeight}
headerTitleAbsolute={headerTitleAbsolute}
headerTitle={props.headerTitle}
/>
</SetNavigationHeaderHeightContext.Provider>
),
})
}, [
screenCtxValue.Slot,
headerHideableBottomHeight,
headerTitleAbsolute,
hideableBottom,
props.headerLeft,
props.headerRight,
props.title,
setHeaderHeight,
setSlot,
store,
props.headerTitle,
])
headerShown: true,
headerLeft,
header: useTypeScriptHappyCallback(
({ options }) => (
<NavigationContext.Provider value={navigationContext}>
<SetNavigationHeaderHeightContext.Provider value={setHeaderHeight}>
<InternalNavigationHeader
{...options}
modal={options.presentation === "modal" || options.presentation === "formSheet"}
title={options.title}
headerRight={options.headerRight}
headerLeft={options.headerLeft}
hideableBottom={hideableBottom}
hideableBottomHeight={headerHideableBottomHeight}
headerTitleAbsolute={headerTitleAbsolute}
// @ts-expect-error
headerTitle={options.headerTitle}
/>
</SetNavigationHeaderHeightContext.Provider>
</NavigationContext.Provider>
),
[hideableBottom, navigationContext],
),
...rest,
}}
/>
)
return null
}

View File

@ -1,4 +1,5 @@
import { clsx } from "@follow/utils"
import { Portal } from "@gorhom/portal"
import { requireNativeView } from "expo"
import { useAtom } from "jotai"
import * as React from "react"
@ -10,7 +11,6 @@ import { useUISettingKey } from "@/src/atoms/settings/ui"
import { BugCuteReIcon } from "@/src/icons/bug_cute_re"
import type { EntryModel } from "@/src/store/entry/types"
import { Portal } from "../../ui/portal"
import { sharedWebViewHeightAtom } from "./atom"
import { htmlUrl } from "./constants"
import { prepareEntryRenderWebView, SharedWebViewModule } from "./index"

View File

@ -1,5 +1,6 @@
/* eslint-disable @eslint-react/no-array-index-key */
import { cn } from "@follow/utils"
import { Portal } from "@gorhom/portal"
import { Picker } from "@react-native-picker/picker"
import { useMemo, useState } from "react"
import type { StyleProp, ViewStyle } from "react-native"
@ -11,7 +12,6 @@ import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line"
import { useColor } from "@/src/theme/colors"
import { BlurEffect } from "../../common/BlurEffect"
import { Portal } from "../portal"
interface PickerIosProps<T> {
options: { label: string; value: T }[]

View File

@ -1,47 +0,0 @@
import * as React from "react"
import { useEffect, useMemo, useRef } from "react"
import PortalContext from "./PortalContext"
import type { IPortalMethods, PortalConsumerProps } from "./type"
const PortalConsumer: React.FC<PortalConsumerProps> = (props) => {
const id = React.useId()
const key = useRef<string>(id)
const once = useRef<boolean>(false)
useEffect(
() => () => {
const { manager } = props
manager?.unmount?.(key.current)
},
[],
)
useEffect(() => {
const { manager, children } = props
if (!once.current) {
once.current = true
key.current = manager?.mount?.(children) ?? ""
} else {
manager?.update?.(key.current, children)
}
}, [props])
return null
}
export function Portal({ children }: React.PropsWithChildren) {
return useMemo(
() => (
<PortalContext.Consumer>
{(manager: IPortalMethods | null): React.ReactNode => (
<PortalConsumer manager={manager}>{children}</PortalConsumer>
)}
</PortalContext.Consumer>
),
[children],
)
}
export { PortalConsumerProps, PortalProps } from "./type"

View File

@ -1,5 +0,0 @@
import * as React from "react"
import type { IPortalMethods } from "./type"
export default React.createContext<IPortalMethods | null>(null)

View File

@ -1,87 +0,0 @@
import * as React from "react"
import { useCallback, useMemo, useRef } from "react"
import PortalContext from "./PortalContext"
import PortalManager from "./PortalManager"
import type { IPortalManager, Operation, PortalKey } from "./type"
interface PortalHostProps {
children: React.ReactNode
}
export function PortalHost(props: PortalHostProps) {
const id = React.useId()
const nextKey = useRef(0)
const queue = useRef<Operation[]>([])
const manager = useRef<IPortalManager>()
const mount = useCallback((children: React.ReactNode, _key?: PortalKey) => {
let key = _key
if (!key) {
nextKey.current++
key = `${id}-${nextKey.current}`
}
if (manager.current) {
manager.current.mount(key, children)
} else {
queue.current.push({ type: "mount", key, children })
}
return key
}, [])
const update = useCallback((key: PortalKey, children: React.ReactNode) => {
if (manager.current) {
manager.current.update(key, children)
} else {
const op: Operation = { type: "mount", key, children }
const index = queue.current.findIndex(
(o) => o.type === "mount" || (o.type === "update" && o.key === key),
)
if (index !== -1) {
queue.current[index] = op
} else {
queue.current.push(op)
}
}
}, [])
const unmount = useCallback((key: PortalKey) => {
if (manager.current) {
manager.current.unmount(key)
} else {
queue.current.push({ type: "unmount", key })
}
}, [])
const { children } = props
const ctxValue = useMemo(
() => ({
mount,
update,
unmount,
}),
[mount, update, unmount],
)
return useMemo(
() => (
<>
<PortalContext.Provider value={ctxValue}>
<>
{/* placeholder */}
{children}
{/* portal container */}
<PortalManager ref={manager} />
</>
</PortalContext.Provider>
</>
),
[ctxValue, children],
)
}
PortalHost.displayName = "Portal.host"

View File

@ -1,46 +0,0 @@
import * as React from "react"
import { useCallback, useImperativeHandle, useMemo, useState } from "react"
import { StyleSheet, View } from "react-native"
export default React.forwardRef((_, ref: any) => {
const [portals, setPortals] = useState<{ key: number; children: React.ReactNode }[]>([])
const mount = useCallback((key: number, children: React.ReactNode) => {
setPortals((oldPortals) => [...oldPortals, { key, children }])
}, [])
const update = useCallback((key: number, children: React.ReactNode) => {
setPortals((oldPortals) =>
oldPortals.map((item) => {
if (item.key === key) {
return { ...item, children }
}
return { ...item }
}),
)
}, [])
const unmount = useCallback(
(key: number) => setPortals((oldPortals) => oldPortals.filter((item) => item.key !== key)),
[],
)
useImperativeHandle(ref, () => ({
mount,
update,
unmount,
}))
return useMemo(
() => (
<>
{portals.map(({ key, children }) => (
<View key={key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
{children}
</View>
))}
</>
),
[portals],
)
})

View File

@ -1,2 +0,0 @@
export { Portal } from "./Portal"
export { PortalHost } from "./PortalHost"

View File

@ -1,44 +0,0 @@
import type * as React from "react"
export type PortalKey = string
export interface IPortalManager {
mount: (key: PortalKey, children: React.ReactNode) => void
update: (key: PortalKey, children: React.ReactNode) => void
unmount: (key: PortalKey) => void
}
export interface IPortalMethods {
mount: (children: React.ReactNode) => PortalKey
update: (key: PortalKey, children: React.ReactNode) => void
unmount: (key: PortalKey) => void
}
export type Operation =
| {
type: "mount"
key: PortalKey
children: React.ReactNode
}
| {
type: "update"
key: PortalKey
children: React.ReactNode
}
| {
type: "unmount"
key: PortalKey
}
export interface IStaticContainerProps {
shouldUpdate: boolean
children: React.ReactNode
}
export interface PortalProps {
children?: React.ReactNode
}
export interface PortalConsumerProps {
manager: IPortalMethods | null
children: React.ReactNode
}

View File

@ -1,7 +1,9 @@
import * as Linking from "expo-linking"
import { router } from "expo-router"
import { useEffect } from "react"
import { useNavigation } from "../lib/navigation/hooks"
import { FollowScreen } from "../screens/(modal)/follow"
// This needs to stay outside of react to persist between account switches
let previousIntentUrl = ""
export const resetIntentUrl = () => {
@ -11,6 +13,8 @@ export const resetIntentUrl = () => {
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const navigation = useNavigation()
useEffect(() => {
if (incomingUrl && incomingUrl !== previousIntentUrl) {
previousIntentUrl = incomingUrl
@ -21,9 +25,13 @@ export function useIntentHandler() {
return
}
router.push(`/follow?${searchParams.toString()}`)
navigation.presentControllerView(FollowScreen, {
id: searchParams.get("id") ?? undefined,
type: (searchParams.get("type") as "url" | "feed" | "list") ?? undefined,
url: searchParams.get("url") ?? undefined,
})
}
}, [incomingUrl])
}, [incomingUrl, navigation])
}
// follow://add?id=41147805276726272

View File

@ -1,10 +0,0 @@
import { useNavigation } from "expo-router"
export const useIsRouteOnlyOne = () => {
const navigation = useNavigation()
const state = navigation.getState()
const routeOnlyOne = state?.routes.length === 1
return routeOnlyOne
}

View File

@ -0,0 +1,23 @@
import type { PrimitiveAtom } from "jotai"
import { createContext } from "react"
import type {
NavigationControllerView,
NavigationControllerViewExtraProps,
NavigationControllerViewType,
} from "./types"
export interface Route {
id: string
Component?: NavigationControllerView<any>
element?: React.ReactElement
type: NavigationControllerViewType
props?: unknown
screenOptions?: NavigationControllerViewExtraProps
}
export type ChainNavigationContextType = {
routesAtom: PrimitiveAtom<Route[]>
}
export const ChainNavigationContext = createContext<ChainNavigationContextType>(null!)

View File

@ -0,0 +1,5 @@
import { createContext } from "react"
import type { Route } from "./ChainNavigationContext"
export const GroupedNavigationRouteContext = createContext<Route[][]>(null!)

View File

@ -0,0 +1,180 @@
import { jotaiStore } from "@follow/utils"
import { EventEmitter } from "expo"
import { atom } from "jotai"
import type { ChainNavigationContextType, Route } from "./ChainNavigationContext"
import type {
NavigationControllerView,
NavigationControllerViewExtraProps,
NavigationControllerViewType,
} from "./types"
export class Navigation {
private ctxValue: ChainNavigationContextType
constructor(ctxValue: ChainNavigationContextType) {
this.ctxValue = ctxValue
this.pushControllerView = this.pushControllerView.bind(this)
this.presentControllerView = this.presentControllerView.bind(this)
this.dismiss = this.dismiss.bind(this)
this.back = this.back.bind(this)
this.popTo = this.popTo.bind(this)
this.popToRoot = this.popToRoot.bind(this)
}
__internal_getCtxValue() {
return this.ctxValue
}
private viewIdCounter = 0
static readonly rootNavigation: Navigation = new Navigation({
routesAtom: atom<Route[]>([]),
})
private __push(route: Route) {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const hasRoute = routes.some((r) => r.id === route.id)
if (hasRoute && routes.at(-1)?.id === route.id) {
console.warn(`Top of stack is already ${route.id}`)
return
} else if (hasRoute) {
route.id = `${route.id}-${this.viewIdCounter++}`
}
jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])
}
private resolveScreenOptions<T>(
view: NavigationControllerView<T>,
): Required<NavigationControllerViewExtraProps> {
return {
transparent: view.transparent ?? false,
id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,
title: view.title ?? "",
// Form Sheet
sheetAllowedDetents: view.sheetAllowedDetents ?? "fitToContents",
sheetCornerRadius: view.sheetCornerRadius ?? 16,
sheetExpandsWhenScrolledToEdge: view.sheetExpandsWhenScrolledToEdge ?? true,
sheetElevation: view.sheetElevation ?? 24,
sheetGrabberVisible: view.sheetGrabberVisible ?? true,
sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,
sheetLargestUndimmedDetentIndex: view.sheetLargestUndimmedDetentIndex ?? "medium",
}
}
pushControllerView<T>(view: NavigationControllerView<T>, props?: T) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type: "push",
Component: view,
props,
screenOptions,
})
}
presentControllerView<T>(
view: NavigationControllerView<T>,
props?: T,
type: Exclude<NavigationControllerViewType, "push"> = "modal",
) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type,
Component: view,
props,
screenOptions,
})
}
private __pop() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastRoute = routes.at(-1)
if (!lastRoute) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))
}
/**
* Dismiss the current modal.
*/
dismiss() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastModalIndex = routes.findLastIndex((r) => r.type !== "push")
if (lastModalIndex === -1) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
}
back() {
return this.__pop()
}
__internal_dismiss(routeId: string) {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastModalIndex = routes.findLastIndex((r) => r.id === routeId)
if (lastModalIndex === -1) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
}
// eslint-disable-next-line unicorn/prefer-event-target
private bus = new EventEmitter<{
willAppear: (payload: LifecycleEventPayload) => void
didAppear: (payload: LifecycleEventPayload) => void
willDisappear: (payload: LifecycleEventPayload) => void
didDisappear: (payload: LifecycleEventPayload) => void
}>()
on(event: "willAppear", callback: (payload: LifecycleEventPayload) => void): Disposer
on(event: "didAppear", callback: (payload: LifecycleEventPayload) => void): Disposer
on(event: "willDisappear", callback: (payload: LifecycleEventPayload) => void): Disposer
on(event: "didDisappear", callback: (payload: LifecycleEventPayload) => void): Disposer
on(event: "screenChange", callback: (payload: ScreenChangeEventPayload) => void): Disposer
on(event: string, callback: (payload: any) => void): Disposer {
const subscription = this.bus.addListener(event as any, callback)
return () => {
subscription.remove()
}
}
emit(event: "willAppear", payload: LifecycleEventPayload): void
emit(event: "didAppear", payload: LifecycleEventPayload): void
emit(event: "willDisappear", payload: LifecycleEventPayload): void
emit(event: "didDisappear", payload: LifecycleEventPayload): void
emit(event: "screenChange", payload: ScreenChangeEventPayload): void
emit(event: string, payload: LifecycleEventPayload): void {
this.bus.emit(event as any, payload)
}
canGoBack() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
return routes.length > 0
}
popTo(routeId: string) {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const index = routes.findIndex((r) => r.id === routeId)
if (index === -1) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, index + 1))
}
popToRoot() {
jotaiStore.set(this.ctxValue.routesAtom, [])
}
}
type Disposer = () => void
type LifecycleEventPayload = {
screenId: string
}
type ScreenChangeEventPayload = {
screenId: string
type: "appear" | "disappear"
}

View File

@ -0,0 +1,5 @@
import { createContext } from "react"
import type { Navigation } from "./Navigation"
export const NavigationInstanceContext = createContext<Navigation>(null!)

View File

@ -0,0 +1,39 @@
import { forwardRef } from "react"
import type { TextProps } from "react-native"
import { Text } from "react-native"
import type { StackPresentationTypes } from "react-native-screens"
import { useNavigation } from "./hooks"
import type { NavigationControllerView } from "./types"
interface NavigationLinkProps<T> extends TextProps {
destination: NavigationControllerView<T>
stackPresentation?: StackPresentationTypes
props?: T
}
function NavigationLinkImpl<T>(
{ destination, children, stackPresentation = "push", props, ...rest }: NavigationLinkProps<T>,
ref: React.Ref<Text>,
) {
const navigation = useNavigation()
return (
<Text
onPress={() => {
if (stackPresentation === "push") {
navigation.pushControllerView(destination, props)
} else {
navigation.presentControllerView(destination, props, stackPresentation)
}
}}
{...rest}
ref={ref}
>
{children}
</Text>
)
}
export const NavigationLink = forwardRef(NavigationLinkImpl) as <T>(
props: NavigationLinkProps<T> & { ref?: React.Ref<Text> },
) => ReturnType<typeof NavigationLinkImpl>

View File

@ -0,0 +1,22 @@
import type { PrimitiveAtom } from "jotai"
import type { ReactNode } from "react"
import { createContext } from "react"
import type { SharedValue } from "react-native-reanimated"
export interface ScreenItemContextType {
screenId: string
isFocusedAtom: PrimitiveAtom<boolean>
isAppearedAtom: PrimitiveAtom<boolean>
isDisappearedAtom: PrimitiveAtom<boolean>
// For Layout ScrollView
reAnimatedScrollY: SharedValue<number>
scrollViewHeight: SharedValue<number>
scrollViewContentHeight: SharedValue<number>
Slot: PrimitiveAtom<{
header: ReactNode
}>
}
export const ScreenItemContext = createContext<ScreenItemContextType>(null!)

View File

@ -0,0 +1,13 @@
import type { PrimitiveAtom } from "jotai"
import { useAtomValue } from "jotai"
import { createContext, useContext } from "react"
export const ScreenNameContext = createContext<PrimitiveAtom<string>>(null!)
export const useScreenName = () => {
const name = useContext(ScreenNameContext)
if (!name) {
throw new Error("ScreenNameContext not mounted")
}
return useAtomValue(name)
}

View File

@ -0,0 +1,46 @@
import type { PrimitiveAtom } from "jotai"
import { useStore } from "jotai"
import { createContext, useCallback, useContext } from "react"
export interface ScreenOptionsContextType {
gestureEnabled?: boolean
preventNativeDismiss?: boolean
}
export const ScreenOptionsContext = createContext<PrimitiveAtom<ScreenOptionsContextType>>(null!)
export const useSetScreenOptions = () => {
const ctx = useContext(ScreenOptionsContext)
if (!ctx) {
throw new Error("ScreenOptionsContext not found")
}
const store = useStore()
return useCallback(
(options: ScreenOptionsContextType) => {
const prev = store.get(ctx)
store.set(ctx, { ...prev, ...options })
},
[store, ctx],
)
}
export const ModalScreenItemOptionsContext = createContext<PrimitiveAtom<ScreenOptionsContextType>>(
null!,
)
export const useSetModalScreenOptions = () => {
const ctx = useContext(ModalScreenItemOptionsContext)
const store = useStore()
return useCallback(
(options: ScreenOptionsContextType) => {
if (!ctx) {
return
}
const prev = store.get(ctx)
store.set(ctx, { ...prev, ...options })
},
[store, ctx],
)
}

View File

@ -0,0 +1,201 @@
import type { PrimitiveAtom } from "jotai"
import { atom, useAtomValue } from "jotai"
import type { FC } from "react"
import { memo, useContext, useMemo, useState } from "react"
import type { ScrollView } from "react-native"
import { StyleSheet } from "react-native"
import { SafeAreaProvider } from "react-native-safe-area-context"
import type { ScreenStackHeaderConfigProps } from "react-native-screens"
import { ScreenStack } from "react-native-screens"
import {
AttachNavigationScrollViewContext,
SetAttachNavigationScrollViewContext,
} from "./AttachNavigationScrollViewContext"
import type { Route } from "./ChainNavigationContext"
import { ChainNavigationContext } from "./ChainNavigationContext"
import { GroupedNavigationRouteContext } from "./GroupedNavigationRouteContext"
import { Navigation } from "./Navigation"
import { NavigationInstanceContext } from "./NavigationInstanceContext"
import { ScreenNameContext } from "./ScreenNameContext"
import type { ScreenOptionsContextType } from "./ScreenOptionsContext"
import { ModalScreenItemOptionsContext } from "./ScreenOptionsContext"
import type { NavigationControllerView } from "./types"
import { WrappedScreenItem } from "./WrappedScreenItem"
interface RootStackNavigationProps {
children: React.ReactNode
headerConfig?: ScreenStackHeaderConfigProps
}
export const RootStackNavigation = ({ children, headerConfig }: RootStackNavigationProps) => {
const [attachNavigationScrollViewRef, setAttachNavigationScrollViewRef] =
useState<React.RefObject<ScrollView> | null>(null)
return (
<SafeAreaProvider>
<AttachNavigationScrollViewContext.Provider value={attachNavigationScrollViewRef}>
<SetAttachNavigationScrollViewContext.Provider value={setAttachNavigationScrollViewRef}>
<ScreenNameContext.Provider value={useMemo(() => atom(""), [])}>
<ChainNavigationContext.Provider
value={Navigation.rootNavigation.__internal_getCtxValue()}
>
<NavigationInstanceContext.Provider value={Navigation.rootNavigation}>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem headerConfig={headerConfig} screenId="root">
{children}
</WrappedScreenItem>
<ScreenItemsMapper />
</ScreenStack>
</NavigationInstanceContext.Provider>
</ChainNavigationContext.Provider>
</ScreenNameContext.Provider>
</SetAttachNavigationScrollViewContext.Provider>
</AttachNavigationScrollViewContext.Provider>
</SafeAreaProvider>
)
}
const ScreenItemsMapper = () => {
const chainCtxValue = useContext(ChainNavigationContext)
const routes = useAtomValue(chainCtxValue.routesAtom)
const routeGroups = useMemo(() => {
const groups: Route[][] = []
let currentGroup: Route[] = []
routes.forEach((route, index) => {
// Start a new group if this is the first route or if it's a modal (non-push)
if (index === 0 || route.type !== "push") {
// Save the previous group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
// Start a new group with this route
currentGroup = [route]
} else {
// Add to the current group if it's a push route
currentGroup.push(route)
}
})
// Add the last group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
return groups
}, [routes])
return (
<GroupedNavigationRouteContext.Provider value={routeGroups}>
{routeGroups.map((group) => {
const isPushGroup = group.at(0)?.type === "push"
if (!isPushGroup) {
return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />
}
return <MapScreenStackItems key={group.at(0)?.id} routes={group} />
})}
</GroupedNavigationRouteContext.Provider>
)
}
const MapScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
return routes.map((route) => {
return (
<WrappedScreenItem
stackPresentation={"push"}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView comp={route.Component} element={route.element} props={route.props} />
</WrappedScreenItem>
)
})
})
const ModalScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
const rootModalRoute = routes.at(0)
const modalScreenOptionsCtxValue = useMemo<PrimitiveAtom<ScreenOptionsContextType>>(
() => atom({}),
[],
)
const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)
if (!rootModalRoute) {
return null
}
const isStackModal = rootModalRoute.type !== "formSheet"
if (isStackModal) {
return (
<ModalScreenItemOptionsContext.Provider value={modalScreenOptionsCtxValue}>
<WrappedScreenItem
stackPresentation={rootModalRoute?.type}
key={rootModalRoute.id}
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
{...modalScreenOptions}
>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
>
<ResolveView
comp={rootModalRoute.Component}
element={rootModalRoute.element}
props={rootModalRoute.props}
/>
</WrappedScreenItem>
{routes.slice(1).map((route) => {
return (
<WrappedScreenItem
stackPresentation={"push"}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView comp={route.Component} element={route.element} props={route.props} />
</WrappedScreenItem>
)
})}
</ScreenStack>
</WrappedScreenItem>
</ModalScreenItemOptionsContext.Provider>
)
}
return routes.map((route) => {
return (
<WrappedScreenItem
key={route.id}
screenId={route.id}
stackPresentation={route.type}
screenOptions={route.screenOptions}
>
<ResolveView comp={route.Component} element={route.element} props={route.props} />
</WrappedScreenItem>
)
})
})
const ResolveView: FC<{
comp?: NavigationControllerView<any>
element?: React.ReactElement
props?: unknown
}> = ({ comp: Component, element, props }) => {
if (Component && typeof Component === "function") {
return <Component {...(props as any)} />
}
if (element) {
return element
}
throw new Error("No component or element provided")
}

View File

@ -0,0 +1,19 @@
import { jotaiStore } from "@follow/utils"
import { useStore } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect } from "react"
import { ScreenItemContext } from "./ScreenItemContext"
export const StackScreenHeaderPortal: FC<PropsWithChildren> = ({ children }) => {
const ctxValue = useContext(ScreenItemContext)
const store = useStore()
useEffect(() => {
jotaiStore.set(ctxValue.Slot, {
...store.get(ctxValue.Slot),
header: children,
})
}, [ctxValue, children, store])
return null
}

View File

@ -0,0 +1,143 @@
import type { PrimitiveAtom } from "jotai"
import { atom, useAtomValue, useSetAtom } from "jotai"
import type { FC, ReactNode } from "react"
import { memo, useContext, useMemo } from "react"
import { StyleSheet, View } from "react-native"
import { useSharedValue } from "react-native-reanimated"
import type { ScreenStackHeaderConfigProps, StackPresentationTypes } from "react-native-screens"
import { ScreenStackItem } from "react-native-screens"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { useColor } from "@/src/theme/colors"
import { useCombinedLifecycleEvents } from "./__internal/hooks"
import { defaultHeaderConfig } from "./config"
import type { ScreenItemContextType } from "./ScreenItemContext"
import { ScreenItemContext } from "./ScreenItemContext"
import type { ScreenOptionsContextType } from "./ScreenOptionsContext"
import { ScreenOptionsContext } from "./ScreenOptionsContext"
import type { NavigationControllerViewExtraProps } from "./types"
export const WrappedScreenItem: FC<
{
screenId: string
children: React.ReactNode
stackPresentation?: StackPresentationTypes
headerConfig?: ScreenStackHeaderConfigProps
screenOptions?: NavigationControllerViewExtraProps
} & ScreenOptionsContextType
> = memo(
({
screenId,
children,
stackPresentation,
headerConfig,
screenOptions: screenOptionsProp,
...rest
}) => {
const navigation = useNavigation()
const reAnimatedScrollY = useSharedValue(0)
const scrollViewHeight = useSharedValue(0)
const scrollViewContentHeight = useSharedValue(0)
const ctxValue = useMemo<ScreenItemContextType>(
() => ({
screenId,
isFocusedAtom: atom(false),
isAppearedAtom: atom(false),
isDisappearedAtom: atom(false),
reAnimatedScrollY,
Slot: atom<{
header: ReactNode
}>({
header: null,
}),
scrollViewHeight,
scrollViewContentHeight,
}),
[screenId, reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight],
)
const setIsFocused = useSetAtom(ctxValue.isFocusedAtom)
const setIsAppeared = useSetAtom(ctxValue.isAppearedAtom)
const setIsDisappeared = useSetAtom(ctxValue.isDisappearedAtom)
const combinedLifecycleEvents = useCombinedLifecycleEvents(ctxValue.screenId, {
onAppear: () => {
setIsFocused(true)
setIsAppeared(true)
setIsDisappeared(false)
},
onDisappear: () => {
setIsFocused(false)
setIsAppeared(false)
setIsDisappeared(true)
},
onWillAppear: () => {
setIsFocused(false)
setIsAppeared(true)
setIsDisappeared(false)
},
onWillDisappear: () => {
setIsFocused(false)
setIsAppeared(false)
setIsDisappeared(true)
},
})
const screenOptionsCtxValue = useMemo<PrimitiveAtom<ScreenOptionsContextType>>(
() => atom({}),
[],
)
const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)
const backgroundColor = useColor("systemBackground")
// Priority: Ctx > Define on Component
const mergedScreenOptions = {
...screenOptionsProp,
...screenOptionsFromCtx,
}
return (
<ScreenItemContext.Provider value={ctxValue}>
<ScreenOptionsContext.Provider value={screenOptionsCtxValue}>
<ScreenStackItem
{...combinedLifecycleEvents}
headerConfig={{
...defaultHeaderConfig,
...headerConfig,
}}
key={screenId}
screenId={screenId}
stackPresentation={stackPresentation}
style={[
StyleSheet.absoluteFill,
{ backgroundColor: screenOptionsProp?.transparent ? undefined : backgroundColor },
]}
onDismissed={() => {
navigation.__internal_dismiss(screenId)
}}
{...rest}
{...mergedScreenOptions}
>
<Header />
{children}
</ScreenStackItem>
</ScreenOptionsContext.Provider>
</ScreenItemContext.Provider>
)
},
)
const Header = () => {
const ctxValue = useContext(ScreenItemContext)
const Slot = useAtomValue(ctxValue.Slot)
if (!Slot.header) {
return null
}
return <View className="absolute inset-x-0 top-0 z-[99]">{Slot.header}</View>
}
WrappedScreenItem.displayName = "WrappedScreenItem"

View File

@ -0,0 +1,55 @@
import { useEffect, useMemo, useRef } from "react"
import { useNavigation } from "../hooks"
export const useCombinedLifecycleEvents = (
screenId: string,
{
onAppear,
onDisappear,
onWillAppear,
onWillDisappear,
}: {
onAppear?: () => void
onDisappear?: () => void
onWillAppear?: () => void
onWillDisappear?: () => void
} = {},
) => {
const navigation = useNavigation()
const stableRef = useRef({
onAppear,
onDisappear,
onWillAppear,
onWillDisappear,
})
useEffect(() => {
stableRef.current = {
onAppear,
onDisappear,
onWillAppear,
onWillDisappear,
}
}, [onAppear, onDisappear, onWillAppear, onWillDisappear])
return useMemo(() => {
return {
onAppear: () => {
navigation.emit("didAppear", { screenId })
stableRef.current.onAppear?.()
},
onDisappear: () => {
navigation.emit("didDisappear", { screenId })
stableRef.current.onDisappear?.()
},
onWillAppear: () => {
navigation.emit("willAppear", { screenId })
stableRef.current.onWillAppear?.()
},
onWillDisappear: () => {
navigation.emit("willDisappear", { screenId })
stableRef.current.onWillDisappear?.()
},
}
}, [navigation, screenId])
}

View File

@ -0,0 +1,14 @@
import type { PrimitiveAtom } from "jotai"
import { createContext } from "react"
import type { TabScreenProps } from "./TabScreen"
export interface BottomTabContextType {
currentIndexAtom: PrimitiveAtom<number>
loadedableIndexAtom: PrimitiveAtom<Set<number>>
tabScreensAtom: PrimitiveAtom<TabScreenProps[]>
tabHeightAtom: PrimitiveAtom<number>
}
export const BottomTabContext = createContext<BottomTabContextType>(null!)

View File

@ -0,0 +1,41 @@
import { useContext } from "react"
import { useAnimatedReaction } from "react-native-reanimated"
import { BottomTabBarBackgroundContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarBackgroundContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { ScreenItemContext } from "../ScreenItemContext"
// FIXME
export const CalculateTabBarOpacity = () => {
const { scrollViewContentHeight, scrollViewHeight, reAnimatedScrollY } =
useContext(ScreenItemContext)
const { opacity } = useContext(BottomTabBarBackgroundContext)
const tabbarHeight = useBottomTabBarHeight()
useAnimatedReaction(
() => {
// Calculate how close we are to the bottom of the content
const distanceFromBottom =
scrollViewContentHeight.value -
scrollViewHeight.value -
reAnimatedScrollY.value -
tabbarHeight
// Define a threshold for when to start fading (in pixels)
const fadeThreshold = 50
// If we're within the threshold distance from the bottom, calculate opacity
if (distanceFromBottom <= fadeThreshold) {
// Linear interpolation: 0 at bottom, 1 at threshold
return Math.max(0, distanceFromBottom / fadeThreshold)
}
// Otherwise, keep the tab bar fully visible
return 1
},
(opacityValue) => {
opacity.value = opacityValue
},
)
return null
}

View File

@ -0,0 +1,34 @@
import { requireNativeView } from "expo"
import { useSetAtom } from "jotai"
import { useContext } from "react"
import type { ViewProps } from "react-native"
import { StyleSheet, View } from "react-native"
import { BottomTabContext } from "./BottomTabContext"
const TabBarPortalNative = requireNativeView<ViewProps>("TabBarPortal")
export const TabBarPortal = ({ children }: { children: React.ReactNode }) => {
const { tabHeightAtom } = useContext(BottomTabContext)
const setTabHeight = useSetAtom(tabHeightAtom)
return (
<TabBarPortalNative style={styles.container}>
<View
onLayout={(e) => {
setTabHeight(e.nativeEvent.layout.height)
}}
>
{children}
</View>
</TabBarPortalNative>
)
}
const styles = StyleSheet.create({
container: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
})

View File

@ -0,0 +1,30 @@
import { useSetAtom } from "jotai"
import { useContext } from "react"
import { StyleSheet, View } from "react-native"
import { BottomTabContext } from "./BottomTabContext"
export const TabBarPortal = ({ children }: { children: React.ReactNode }) => {
const { tabHeightAtom } = useContext(BottomTabContext)
const setTabHeight = useSetAtom(tabHeightAtom)
return (
<View style={styles.container}>
<View
onLayout={(e) => {
setTabHeight(e.nativeEvent.layout.height)
}}
>
{children}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
})

View File

@ -0,0 +1,69 @@
import { requireNativeView } from "expo"
import { atom, useAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import * as React from "react"
import { useCallback, useMemo, useState } from "react"
import type { NativeSyntheticEvent, ViewProps } from "react-native"
import { StyleSheet } from "react-native"
import type { BottomTabContextType } from "./BottomTabContext"
import { BottomTabContext } from "./BottomTabContext"
import type { TabScreenProps } from "./TabScreen.ios"
import { TabScreen } from "./TabScreen.ios"
interface TabRootProps {
initialTabIndex?: number
}
const TabBarRoot = requireNativeView<
{
onTabIndexChange: (e: NativeSyntheticEvent<{ index: number }>) => void
selectedIndex: number
} & ViewProps
>("TabBarRoot")
export const TabRoot: FC<TabRootProps & PropsWithChildren> = ({
children,
initialTabIndex = 0,
}) => {
const [tabIndexAtom] = useState(() => atom(initialTabIndex))
const [tabIndex, setTabIndex] = useAtom(tabIndexAtom)
const ctxValue = useMemo<BottomTabContextType>(
() => ({
currentIndexAtom: tabIndexAtom,
loadedableIndexAtom: atom(new Set<number>()),
tabScreensAtom: atom<TabScreenProps[]>([]),
tabHeightAtom: atom(0),
}),
[tabIndexAtom],
)
const MapChildren = useMemo(() => {
let cnt = 0
return React.Children.map(children, (child) => {
if (typeof child === "object" && child && "type" in child && child.type === TabScreen) {
return React.cloneElement(child, {
tabScreenIndex: cnt++,
})
}
return child
})
}, [children])
return (
<BottomTabContext.Provider value={ctxValue}>
<TabBarRoot
style={StyleSheet.absoluteFill}
onTabIndexChange={useCallback(
(e) => {
setTabIndex(e.nativeEvent.index)
},
[setTabIndex],
)}
selectedIndex={tabIndex}
>
{MapChildren}
</TabBarRoot>
</BottomTabContext.Provider>
)
}

View File

@ -0,0 +1,48 @@
import { atom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import * as React from "react"
import { useMemo, useState } from "react"
import { StyleSheet, View } from "react-native"
import type { BottomTabContextType } from "./BottomTabContext"
import { BottomTabContext } from "./BottomTabContext"
import type { TabScreenProps } from "./TabScreen"
import { TabScreen } from "./TabScreen"
interface TabRootProps {
initialTabIndex?: number
}
export const TabRoot: FC<TabRootProps & PropsWithChildren> = ({
children,
initialTabIndex = 0,
}) => {
const [tabIndexAtom] = useState(() => atom(initialTabIndex))
const ctxValue = useMemo<BottomTabContextType>(
() => ({
currentIndexAtom: tabIndexAtom,
loadedableIndexAtom: atom(new Set<number>()),
tabScreensAtom: atom<TabScreenProps[]>([]),
tabHeightAtom: atom(0),
}),
[tabIndexAtom],
)
const MapChildren = useMemo(() => {
let cnt = 0
return React.Children.map(children, (child) => {
if (typeof child === "object" && child && "type" in child && child.type === TabScreen) {
return React.cloneElement(child, {
tabScreenIndex: cnt++,
})
}
return child
})
}, [children])
return (
<BottomTabContext.Provider value={ctxValue}>
<View style={StyleSheet.absoluteFill}>{MapChildren}</View>
</BottomTabContext.Provider>
)
}

View File

@ -0,0 +1,106 @@
import { requireNativeView } from "expo"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect, useMemo } from "react"
import type { ViewProps } from "react-native"
import { StyleSheet } from "react-native"
import { WrappedScreenItem } from "../WrappedScreenItem"
import { BottomTabContext } from "./BottomTabContext"
import { LifecycleEvents, ScreenNameRegister } from "./shared"
import type { TabScreenContextType } from "./TabScreenContext"
import { TabScreenContext } from "./TabScreenContext"
import type { TabbarIconProps, TabScreenComponent } from "./types"
const TabScreenNative = requireNativeView<ViewProps>("TabScreen")
export interface TabScreenProps {
title: string
tabScreenIndex: number
renderIcon?: (props: TabbarIconProps) => React.ReactNode
}
export const TabScreen: FC<PropsWithChildren<Omit<TabScreenProps, "tabScreenIndex">>> = ({
children,
...props
}) => {
const { tabScreenIndex } = props as any as TabScreenProps
const {
loadedableIndexAtom,
currentIndexAtom,
tabScreensAtom: tabScreens,
} = useContext(BottomTabContext)
const setTabScreens = useSetAtom(tabScreens)
useEffect(() => {
const propsFromChildren: Partial<TabScreenProps> = {}
if (children && typeof children === "object") {
const childType = (children as any).type as TabScreenComponent
if ("tabBarIcon" in childType) {
propsFromChildren.renderIcon = childType.tabBarIcon
}
if ("title" in childType) {
propsFromChildren.title = childType.title
}
}
setTabScreens((prev) => [
...prev,
{
...propsFromChildren,
...props,
tabScreenIndex,
},
])
return () => {
setTabScreens((prev) =>
prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex),
)
}
}, [setTabScreens, props, tabScreenIndex, children])
const currentSelectedIndex = useAtomValue(currentIndexAtom)
const isSelected = useMemo(
() => currentSelectedIndex === tabScreenIndex,
[currentSelectedIndex, tabScreenIndex],
)
const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom)
const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex)
useEffect(() => {
if (isSelected) {
setLoadedableIndex((prev) => {
prev.add(tabScreenIndex)
return new Set(prev)
})
}
}, [setLoadedableIndex, tabScreenIndex, isSelected])
const ctxValue = useMemo<TabScreenContextType>(
() => ({
tabScreenIndex,
titleAtom: atom(props.title),
}),
[tabScreenIndex, props.title],
)
const shouldLoadReact = isSelected || isLoadedBefore
return (
<TabScreenNative style={StyleSheet.absoluteFill}>
<TabScreenContext.Provider value={ctxValue}>
{shouldLoadReact && (
<WrappedScreenItem screenId={`tab-screen-${tabScreenIndex}`}>
{children}
<ScreenNameRegister />
<LifecycleEvents isSelected={isSelected} />
{/* <CalculateTabBarOpacity /> */}
</WrappedScreenItem>
)}
</TabScreenContext.Provider>
</TabScreenNative>
)
}

View File

@ -0,0 +1,102 @@
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect, useMemo } from "react"
import { StyleSheet, View } from "react-native"
import { WrappedScreenItem } from "../WrappedScreenItem"
import { BottomTabContext } from "./BottomTabContext"
import { LifecycleEvents, ScreenNameRegister } from "./shared"
import type { TabScreenContextType } from "./TabScreenContext"
import { TabScreenContext } from "./TabScreenContext"
import type { TabbarIconProps, TabScreenComponent } from "./types"
export interface TabScreenProps {
title: string
tabScreenIndex: number
renderIcon?: (props: TabbarIconProps) => React.ReactNode
}
export const TabScreen: FC<PropsWithChildren<Omit<TabScreenProps, "tabScreenIndex">>> = ({
children,
...props
}) => {
const { tabScreenIndex } = props as any as TabScreenProps
const {
loadedableIndexAtom,
currentIndexAtom,
tabScreensAtom: tabScreens,
} = useContext(BottomTabContext)
const setTabScreens = useSetAtom(tabScreens)
useEffect(() => {
const propsFromChildren: Partial<TabScreenProps> = {}
if (children && typeof children === "object") {
const childType = (children as any).type as TabScreenComponent
if ("tabBarIcon" in childType) {
propsFromChildren.renderIcon = childType.tabBarIcon
}
if ("title" in childType) {
propsFromChildren.title = childType.title
}
}
setTabScreens((prev) => [
...prev,
{
...propsFromChildren,
...props,
tabScreenIndex,
},
])
return () => {
setTabScreens((prev) =>
prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex),
)
}
}, [setTabScreens, props, tabScreenIndex, children])
const currentSelectedIndex = useAtomValue(currentIndexAtom)
const isSelected = useMemo(
() => currentSelectedIndex === tabScreenIndex,
[currentSelectedIndex, tabScreenIndex],
)
const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom)
const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex)
useEffect(() => {
if (isSelected) {
setLoadedableIndex((prev) => {
prev.add(tabScreenIndex)
return new Set(prev)
})
}
}, [setLoadedableIndex, tabScreenIndex, isSelected])
const ctxValue = useMemo<TabScreenContextType>(
() => ({
tabScreenIndex,
titleAtom: atom(props.title),
}),
[tabScreenIndex, props.title],
)
const shouldLoadReact = isSelected || isLoadedBefore
return (
<View className={isSelected ? "flex-1" : "hidden"} style={StyleSheet.absoluteFill}>
<TabScreenContext.Provider value={ctxValue}>
{shouldLoadReact && (
<WrappedScreenItem screenId={`tab-screen-${tabScreenIndex}`}>
{children}
<ScreenNameRegister />
<LifecycleEvents isSelected={isSelected} />
{/* <CalculateTabBarOpacity /> */}
</WrappedScreenItem>
)}
</TabScreenContext.Provider>
</View>
)
}

View File

@ -0,0 +1,9 @@
import type { PrimitiveAtom } from "jotai"
import { createContext } from "react"
export interface TabScreenContextType {
tabScreenIndex: number
titleAtom: PrimitiveAtom<string>
}
export const TabScreenContext = createContext<TabScreenContextType>(null!)

View File

@ -0,0 +1,38 @@
import { useAtomValue, useSetAtom } from "jotai"
import { useCallback, useContext } from "react"
import { ScreenItemContext } from "../ScreenItemContext"
import { BottomTabContext } from "./BottomTabContext"
import { TabScreenContext } from "./TabScreenContext"
export const useScreenIsAppeared = () => {
const { isAppearedAtom } = useContext(ScreenItemContext)
return useAtomValue(isAppearedAtom)
}
export const useTabScreenIsFocused = () => {
const { currentIndexAtom } = useContext(BottomTabContext)
const currentIndex = useAtomValue(currentIndexAtom)
const { isFocusedAtom } = useContext(ScreenItemContext)
const isFocused = useAtomValue(isFocusedAtom)
const { tabScreenIndex } = useContext(TabScreenContext)
return currentIndex === tabScreenIndex && isFocused
}
export const useSwitchTab = () => {
const { currentIndexAtom } = useContext(BottomTabContext)
const setCurrentIndex = useSetAtom(currentIndexAtom)
return useCallback(
(index: number) => {
setCurrentIndex(index)
},
[setCurrentIndex],
)
}
export const useBottomTabHeight = () => {
const { tabHeightAtom } = useContext(BottomTabContext)
return useAtomValue(tabHeightAtom)
}

View File

@ -0,0 +1,40 @@
import { useAtomValue, useSetAtom, useStore } from "jotai"
import { useContext, useEffect } from "react"
import { ScreenItemContext } from "../ScreenItemContext"
import { ScreenNameContext } from "../ScreenNameContext"
import { useTabScreenIsFocused } from "./hooks"
import { TabScreenContext } from "./TabScreenContext"
export const ScreenNameRegister = () => {
const nameAtom = useContext(ScreenNameContext)
const { titleAtom } = useContext(TabScreenContext)
const isFocused = useTabScreenIsFocused()
const title = useAtomValue(titleAtom)
const store = useStore()
useEffect(() => {
if (isFocused) {
store.set(nameAtom, title)
}
}, [isFocused, title, nameAtom, store])
return null
}
export const LifecycleEvents = ({ isSelected }: { isSelected: boolean }) => {
const { isFocusedAtom, isAppearedAtom, isDisappearedAtom } = useContext(ScreenItemContext)
const setIsFocused = useSetAtom(isFocusedAtom)
const setIsAppeared = useSetAtom(isAppearedAtom)
const setIsDisappeared = useSetAtom(isDisappearedAtom)
useEffect(() => {
if (isSelected) {
setIsFocused(true)
setIsAppeared(true)
setIsDisappeared(false)
} else {
setIsFocused(false)
setIsAppeared(false)
setIsDisappeared(true)
}
}, [isSelected, setIsAppeared, setIsDisappeared, setIsFocused])
return null
}

View File

@ -0,0 +1,11 @@
import type { FC } from "react"
export type TabbarIconProps = {
focused: boolean
size: number
color: string
}
export type TabScreenComponent = FC & {
tabBarIcon?: (props: TabbarIconProps) => React.ReactNode
title?: string
}

View File

@ -0,0 +1,5 @@
import type { ScreenStackHeaderConfigProps } from "react-native-screens"
export const defaultHeaderConfig: ScreenStackHeaderConfigProps = {
hidden: true,
}

View File

@ -0,0 +1,159 @@
/* eslint-disable no-console */
import { useEffect, useRef } from "react"
import { Button, SafeAreaView, ScrollView, Text, TouchableOpacity, View } from "react-native"
import { FullWindowOverlay } from "@/src/components/common/FullWindowOverlay"
import { CloseCuteReIcon } from "@/src/icons/close_cute_re"
import { useNavigation } from "../hooks"
import { Navigation } from "../Navigation"
import type { NavigationControllerView } from "../types"
export const DebugButtonGroup = () => {
useEffect(() => {
const disposers: (() => void)[] = []
disposers.push(
Navigation.rootNavigation.on("willAppear", (payload) => {
console.log("willAppear", payload)
}),
Navigation.rootNavigation.on("didAppear", (payload) => {
console.log("didAppear", payload)
}),
Navigation.rootNavigation.on("willDisappear", (payload) => {
console.log("willDisappear", payload)
}),
Navigation.rootNavigation.on("didDisappear", (payload) => {
console.log("didDisappear", payload)
}),
)
return () => {
disposers.forEach((disposer) => disposer())
}
}, [])
const cntRef = useRef(0)
return (
<View className="flex-1 bg-gray-50">
<SafeAreaView>
<Text>
Ad eveniet laboriosam hic voluptas non facilis sint. Laborum rem et provident blanditiis
iure rem. Porro voluptate ipsa explicabo voluptatem cumque est architecto. Sit
voluptatibus exercitationem recusandae cupiditate tenetur inventore amet repellendus
ratione. Nobis nulla harum soluta aliquam iure unde saepe. Modi ipsam harum aspernatur
aperiam quod pariatur nisi corporis. Doloribus molestiae a dolore. Veniam commodi nesciunt
beatae itaque aliquid nemo. Ut labore rem voluptates. Reprehenderit recusandae voluptate
earum consectetur tempora corrupti. Nulla ducimus enim sit ipsam eum esse debitis saepe.
Nobis ut voluptas. Id sapiente voluptate soluta ipsa esse corrupti facere nemo recusandae.
Dignissimos eum mollitia hic corrupti. Reiciendis voluptates et provident sed laborum
consequuntur. Quod nemo nesciunt dignissimos doloribus veniam odio.
</Text>
</SafeAreaView>
<FullWindowOverlay>
<View className="absolute inset-x-0 bottom-24">
<Button
title="Push"
onPress={() => {
Navigation.rootNavigation.pushControllerView(
cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,
)
}}
/>
<Button
title="formSheet"
onPress={() => {
Navigation.rootNavigation.presentControllerView(
cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,
void 0,
"formSheet",
)
}}
/>
<Button
title="Modal"
onPress={() => {
Navigation.rootNavigation.presentControllerView(
cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,
)
}}
/>
<Button
title="Transparent Modal"
onPress={() => {
Navigation.rootNavigation.presentControllerView(
cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,
void 0,
"transparentModal",
)
}}
/>
<Button
title="Full Screen Modal"
onPress={() => {
Navigation.rootNavigation.presentControllerView(
cntRef.current++ % 2 === 0 ? TestScreen : TestScreen3,
void 0,
"fullScreenModal",
)
}}
/>
</View>
</FullWindowOverlay>
</View>
)
}
const TestScreen3: NavigationControllerView = () => {
const navigation = useNavigation()
return (
<View className="flex-1 bg-white">
<TouchableOpacity
className="absolute right-5 top-12 z-10 p-4"
onPress={() => {
navigation.back()
}}
>
<CloseCuteReIcon height={20} width={20} color="black" />
</TouchableOpacity>
<Text>TestScreen3</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
<Text>Hello2</Text>
</View>
)
}
TestScreen3.sheetAllowedDetents = [0.7, 1]
TestScreen3.sheetCornerRadius = 0
const TestScreen: NavigationControllerView = () => {
const navigation = useNavigation()
return (
<View className="flex-1">
<TouchableOpacity
className="absolute right-5 top-12 z-10 p-4"
onPress={() => {
navigation.back()
}}
>
<CloseCuteReIcon height={20} width={20} color="black" />
</TouchableOpacity>
<ScrollView className="flex-1 bg-white">
{Array.from({ length: 100 }).map((_, index) => {
return (
<Text key={index} className="text-black">
TestScreen
</Text>
)
})}
</ScrollView>
</View>
)
}
TestScreen.sheetAllowedDetents = [0.5, 1]

View File

@ -0,0 +1,74 @@
import { useContext } from "react"
import type { StackPresentationTypes } from "react-native-screens"
import { GroupedNavigationRouteContext } from "./GroupedNavigationRouteContext"
import { NavigationInstanceContext } from "./NavigationInstanceContext"
import { ScreenItemContext } from "./ScreenItemContext"
export const useCanBack = () => {
const { screenId } = useContext(ScreenItemContext)
const routeGroups = useContext(GroupedNavigationRouteContext)
if (!routeGroups) return false
const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))
if (!routeGroup) return false
if (routeGroup.length === 0) return false
// If routeGroup is M, P, P and current route is P, then we can back
const firstIsModal = routeGroup.at(0)?.type !== "push"
const onlyOne = routeGroup.length === 1
if (firstIsModal && onlyOne) return false
return true
}
/**
* If screen present as a modal, then we can dismiss it.
*/
export const useCanDismiss = () => {
const { screenId } = useContext(ScreenItemContext)
const routeGroups = useContext(GroupedNavigationRouteContext)
if (!routeGroups) return false
const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))
if (!routeGroup || routeGroup.length === 0) return false
return routeGroup.at(0)?.type !== "push"
}
export const useNavigation = () => {
const navigation = useContext(NavigationInstanceContext)
if (!navigation) {
throw new Error("Navigation not found")
}
return navigation
}
export const useScreenIsInModal = useCanDismiss
const sheetTypes = new Set<StackPresentationTypes>(["formSheet", "modal"])
export const useScreenIsInSheetModal = () => {
const { screenId } = useContext(ScreenItemContext)
const routeGroups = useContext(GroupedNavigationRouteContext)
if (!routeGroups) return false
const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))
if (!routeGroup || routeGroup.length === 0) return false
const first = routeGroup.at(0)
if (!first) return false
return sheetTypes.has(first.type)
}
export const useIsSingleRouteInGroup = () => {
const { screenId } = useContext(ScreenItemContext)
const routeGroups = useContext(GroupedNavigationRouteContext)
if (!routeGroups) return false
const routeGroup = routeGroups.find((group) => group.some((r) => r.id === screenId))
if (!routeGroup || routeGroup.length === 0) return false
return routeGroup.length === 1
}

View File

@ -0,0 +1,41 @@
import type { NavigationControllerView, NavigationControllerViewType } from "../types"
type RegisterOptions = {
stackPresentation: NavigationControllerViewType
}
type Registry = RegisterOptions & {
Component: NavigationControllerView<any>
props: any
}
class NavigationSitemapRegistryStatic {
private map = new Map<string, Registry>()
registerByComponent<T>(
controllerView: NavigationControllerView<T>,
props?: T,
options?: Partial<RegisterOptions>,
) {
const title = controllerView.name || controllerView.displayName
if (!title) {
if (__DEV__) {
console.error("registerByComponent: no name, ignore")
}
return
}
this.map.set(title, {
Component: controllerView,
props,
stackPresentation: "push",
...options,
})
}
[Symbol.iterator]() {
return this.map.entries()
}
entries() {
return [...this.map.entries()]
}
}
export const NavigationSitemapRegistry = new NavigationSitemapRegistryStatic()

View File

@ -0,0 +1,36 @@
import type { FC } from "react"
import type { ScreenProps, StackPresentationTypes } from "react-native-screens"
export interface NavigationPushOptions<T> {
Component?: NavigationControllerView<T>
element?: React.ReactElement
}
export type NavigationControllerViewExtraProps = {
/**
* Unique identifier for the view.
*/
id?: string
/**
* Title for the view.
*/
title?: string
/**
* Whether the view is transparent.
*/
transparent?: boolean
} & Pick<
ScreenProps,
| "sheetAllowedDetents"
| "sheetCornerRadius"
| "sheetExpandsWhenScrolledToEdge"
| "sheetElevation"
| "sheetGrabberVisible"
| "sheetInitialDetentIndex"
| "sheetLargestUndimmedDetentIndex"
>
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type NavigationControllerView<P = {}> = FC<P> & NavigationControllerViewExtraProps
export type NavigationControllerViewType = StackPresentationTypes

View File

@ -1,14 +1,87 @@
import "./global.css"
import { registerRootComponent } from "expo"
import { Image } from "expo-image"
import { App } from "expo-router/build/qualified-entry"
import { cssInterop } from "nativewind"
import { enableFreeze } from "react-native-screens"
import { Analytics } from "./Analytics"
import { App } from "./App"
import { BottomTabProvider } from "./components/layouts/tabbar/BottomTabProvider"
import { BottomTabs } from "./components/layouts/tabbar/BottomTabs"
import { initializeApp } from "./initialize"
import { TabBarPortal } from "./lib/navigation/bottom-tab/TabBarPortal"
import { TabRoot } from "./lib/navigation/bottom-tab/TabRoot"
import { TabScreen } from "./lib/navigation/bottom-tab/TabScreen"
import { NavigationSitemapRegistry } from "./lib/navigation/sitemap/registry"
import { RootStackNavigation } from "./lib/navigation/StackNavigation"
import { RootProviders } from "./providers"
import { TermsScreen } from "./screens/(headless)/terms"
import { TwoFactorAuthScreen } from "./screens/(modal)/2fa"
import { ForgetPasswordScreen } from "./screens/(modal)/forget-password"
import { LoginScreen } from "./screens/(modal)/login"
import { SignUpScreen } from "./screens/(modal)/sign-up"
import { IndexTabScreen } from "./screens/(stack)/(tabs)"
import { DiscoverTabScreen } from "./screens/(stack)/(tabs)/discover"
import { SettingsTabScreen } from "./screens/(stack)/(tabs)/settings"
import { SubscriptionsTabScreen } from "./screens/(stack)/(tabs)/subscriptions"
import { OnboardingScreen } from "./screens/onboarding"
enableFreeze(true)
cssInterop(Image, { className: "style" })
initializeApp()
registerRootComponent(App)
registerRootComponent(() => <App4 />)
const App4 = () => {
return (
<RootProviders>
<BottomTabProvider>
<RootStackNavigation
headerConfig={{
hidden: true,
}}
>
<App>
<TabRoot>
<TabScreen title="Home">
<IndexTabScreen />
</TabScreen>
<TabScreen title="Subscriptions">
<SubscriptionsTabScreen />
</TabScreen>
<TabScreen title="Discover">
<DiscoverTabScreen />
</TabScreen>
<TabScreen title="Settings">
<SettingsTabScreen />
</TabScreen>
<TabBarPortal>
<BottomTabs />
</TabBarPortal>
</TabRoot>
</App>
<Analytics />
</RootStackNavigation>
</BottomTabProvider>
</RootProviders>
)
}
;[TermsScreen].forEach((Component) => {
NavigationSitemapRegistry.registerByComponent(Component)
})
;[LoginScreen, SignUpScreen, ForgetPasswordScreen, TwoFactorAuthScreen].forEach((Component) => {
NavigationSitemapRegistry.registerByComponent(Component, void 0, {
stackPresentation: "modal",
})
})
;[OnboardingScreen].forEach((Component) => {
NavigationSitemapRegistry.registerByComponent(Component, void 0, {
stackPresentation: "transparentModal",
})
})

View File

@ -1,4 +1,4 @@
import { router } from "expo-router"
import { PortalProvider } from "@gorhom/portal"
import type { PropsWithChildren } from "react"
import { useCallback } from "react"
import { Share, Text, View } from "react-native"
@ -8,10 +8,11 @@ import {
preloadWebViewEntry,
} from "@/src/components/native/webview/EntryContentWebView"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { PortalHost } from "@/src/components/ui/portal"
import { openLink } from "@/src/lib/native"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { getHorizontalScrolling, useSelectedView } from "@/src/modules/screen/atoms"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]"
import { useIsEntryStarred } from "@/src/store/collection/hooks"
import { collectionSyncService } from "@/src/store/collection/store"
import { useEntry } from "@/src/store/entry/hooks"
@ -23,13 +24,17 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
const view = useSelectedView()
const isEntryStarred = useIsEntryStarred(id)
const navigation = useNavigation()
const handlePressPreview = useCallback(() => {
const isHorizontalScrolling = getHorizontalScrolling()
if (entry && !isHorizontalScrolling) {
preloadWebViewEntry(entry)
router.push(`/entries/${id}`)
navigation.pushControllerView(EntryDetailScreen, {
entryId: id,
view: view!,
})
}
}, [entry, id])
}, [entry, id, navigation, view])
if (!entry) return null
@ -40,14 +45,14 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
<ContextMenu.Content>
<ContextMenu.Preview size="STRETCH" onPress={handlePressPreview}>
{() => (
<PortalHost>
<PortalProvider>
<View className="bg-system-background flex-1">
<Text className="text-label mt-5 p-4 text-2xl font-semibold" numberOfLines={2}>
{entry.title?.trim()}
</Text>
<EntryContentWebView entry={entry} />
</View>
</PortalHost>
</PortalProvider>
)}
</ContextMenu.Preview>

View File

@ -1,5 +1,4 @@
import { FeedViewType } from "@follow/constants"
import { router } from "expo-router"
import type { FC, PropsWithChildren } from "react"
import { useCallback, useMemo } from "react"
import type { ListRenderItemInfo } from "react-native"
@ -7,7 +6,9 @@ import { Alert, Clipboard, FlatList, View } from "react-native"
import { ContextMenu } from "@/src/components/ui/context-menu"
import { views } from "@/src/constants/views"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]"
import { useEntryIdsByFeedId } from "@/src/store/entry/hooks"
import { getFeed } from "@/src/store/feed/getter"
import { getSubscription } from "@/src/store/subscription/getter"
@ -25,7 +26,7 @@ export const SubscriptionFeedItemContextMenu: FC<
}
> = ({ id, children, view }) => {
const allCategories = useSubscriptionCategory(view)
const navigation = useNavigation()
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
@ -35,7 +36,9 @@ export const SubscriptionFeedItemContextMenu: FC<
<ContextMenu.Preview
size="STRETCH"
onPress={() => {
router.push(`/feeds/${id}`)
navigation.pushControllerView(FeedScreen, {
feedId: id,
})
}}
>
{() => <PreviewFeeds id={id} view={view!} />}

View File

@ -1,4 +1,3 @@
import { router } from "expo-router"
import { useAtom } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { useMemo } from "react"
@ -10,7 +9,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
import { ReAnimatedTouchableOpacity } from "@/src/components/common/AnimatedComponents"
import { BugCuteReIcon } from "@/src/icons/bug_cute_re"
import { JotaiPersistSyncStorage } from "@/src/lib/jotai"
import { Navigation } from "@/src/lib/navigation/Navigation"
import { useEnvProfile } from "@/src/lib/proxy-env"
import { DebugScreen } from "@/src/screens/(headless)/debug"
export const DebugButton = () => {
const cachedPositionAtom = useMemo(
@ -40,7 +41,8 @@ export const DebugButton = () => {
})
.onEnd((event) => {
if (Math.abs(event.translationX) < 5 && Math.abs(event.translationY) < 5) {
runOnJS(router.push)("/debug")
// @ts-expect-error
runOnJS(Navigation.rootNavigation.pushControllerView)(DebugScreen)
return
}
@ -66,7 +68,7 @@ export const DebugButton = () => {
<GestureDetector gesture={gestureEvent}>
<ReAnimatedTouchableOpacity
onPress={() => {
runOnJS(router.push)("/debug")
Navigation.rootNavigation.pushControllerView(DebugScreen)
}}
style={[
{

View File

@ -1,10 +1,12 @@
import { router } from "expo-router"
import { Text, TextInput, View } from "react-native"
import { useColor } from "react-native-uikit-colors"
import { LinkCuteReIcon } from "@/src/icons/link_cute_re"
import type { DialogComponent } from "@/src/lib/dialog"
import { Dialog } from "@/src/lib/dialog"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { Navigation } from "@/src/lib/navigation/Navigation"
import { FollowScreen } from "@/src/screens/(modal)/follow"
import { accentColor } from "@/src/theme/colors"
export const AddFeedDialog: DialogComponent<{
@ -12,17 +14,15 @@ export const AddFeedDialog: DialogComponent<{
}> = ({ ctx }) => {
const label = useColor("label")
const navigation = useNavigation()
const { dismiss } = Dialog.useDialogContext()!
const handleAdd = () => {
dismiss()
const value = ctx.url
if (!value) return
router.push({
pathname: "/follow",
params: {
url: value,
type: "url",
},
navigation.pushControllerView(FollowScreen, {
url: value,
type: "url",
})
}
@ -56,12 +56,9 @@ AddFeedDialog.onConfirm = (ctx) => {
ctx.dismiss()
setTimeout(() => {
router.push({
pathname: "/follow",
params: {
url: value,
type: "url",
},
Navigation.rootNavigation.pushControllerView(FollowScreen, {
url: value,
type: "url",
})
}, 16)
}

View File

@ -1,6 +1,5 @@
import type { RSSHubCategories } from "@follow/constants"
import type { RSSHubRouteDeclaration } from "@follow/models/src/rsshub"
import { router } from "expo-router"
import type { FC } from "react"
import { memo, useMemo } from "react"
import { Clipboard, Text, TouchableOpacity, View } from "react-native"
@ -10,7 +9,9 @@ import * as ContextMenu from "zeego/context-menu"
import { Grid } from "@/src/components/ui/grid"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { openLink } from "@/src/lib/native"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { RsshubFormScreen } from "@/src/screens/(modal)/rsshub-form"
import { RSSHubCategoryCopyMap } from "./copy"
@ -37,6 +38,8 @@ export const RecommendationListItem: FC<{
}
}, [data])
const navigation = useNavigation()
return (
<View className="flex-row items-center p-4 px-6">
<View className="mt-1.5 flex-row self-start overflow-hidden rounded-lg">
@ -114,13 +117,10 @@ export const RecommendationListItem: FC<{
<View className="relative" key={route}>
<TouchableOpacity
onPress={() => {
router.push({
pathname: "/rsshub-form",
params: {
routePrefix,
route: JSON.stringify(data.routes[route]),
name: data.name,
},
navigation.presentControllerView(RsshubFormScreen, {
routePrefix,
route: data.routes[route]!,
name: data.name,
})
}}
className="bg-gray-6 h-10 flex-row items-center justify-center overflow-hidden rounded-xl px-2"

View File

@ -17,7 +17,6 @@ import {
} from "react-native"
import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler"
import { PanGestureHandler } from "react-native-gesture-handler"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { AnimatedScrollView } from "@/src/components/common/AnimatedComponents"
import {
@ -192,8 +191,6 @@ const Tab: TabComponent = ({ tab, isSelected, ...rest }) => {
const { headerHeightAtom } = useContext(DiscoverContext)
const headerHeight = useAtomValue(headerHeightAtom)
const insets = useSafeAreaInsets()
const scrollOffsetRef = useRef(0)
const { animatedY } = useContext(DiscoverContext)
@ -202,6 +199,7 @@ const Tab: TabComponent = ({ tab, isSelected, ...rest }) => {
animatedY.value = scrollOffsetRef.current
}
}, [animatedY, isSelected])
if (isLoading) {
return <ActivityIndicator className="flex-1 items-center justify-center" />
}
@ -220,10 +218,11 @@ const Tab: TabComponent = ({ tab, isSelected, ...rest }) => {
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={ItemRenderer}
automaticallyAdjustsScrollIndicatorInsets={false}
scrollIndicatorInsets={{
right: -2,
top: headerHeight - insets.top,
bottom: tabHeight - insets.bottom,
top: headerHeight,
bottom: tabHeight,
}}
contentContainerStyle={{ paddingBottom: tabHeight, paddingTop: headerHeight }}
removeClippedSubviews

View File

@ -1,6 +1,5 @@
import { FeedViewType } from "@follow/constants"
import { useQuery } from "@tanstack/react-query"
import { router } from "expo-router"
import { useAtomValue } from "jotai"
import type { FC } from "react"
import type { ListRenderItem, ListRenderItemInfo } from "react-native"
@ -12,6 +11,8 @@ import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { Image } from "@/src/components/ui/image/Image"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { apiClient } from "@/src/lib/api-fetch"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FollowScreen } from "@/src/screens/(modal)/follow"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
import { useSearchPageContext } from "../ctx"
@ -64,13 +65,17 @@ const renderItem: ListRenderItem<SearchResultItem> = (props) => (
const SearchFeedItem: FC<ListRenderItemInfo<SearchResultItem>> = ({ item }) => {
const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "")
const navigation = useNavigation()
return (
<Animated.View entering={FadeInUp}>
<ItemPressable
className="py-4"
onPress={() => {
if (item.feed?.id) {
router.push(`/follow?id=${item.feed.id}`)
navigation.pushControllerView(FollowScreen, {
id: item.feed.id,
type: "feed",
})
}
}}
>

View File

@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"
import { router } from "expo-router"
import { useAtomValue } from "jotai"
import { memo } from "react"
import { Text, View } from "react-native"
@ -8,6 +7,8 @@ import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon"
import { Image } from "@/src/components/ui/image/Image"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { apiClient } from "@/src/lib/api-fetch"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FollowScreen } from "@/src/screens/(modal)/follow"
import { useSubscriptionByListId } from "@/src/store/subscription/hooks"
import { useSearchPageContext } from "../ctx"
@ -58,12 +59,16 @@ const renderItem = ({ item }: { item: SearchResultItem }) => (
const SearchListCard = memo(({ item }: { item: SearchResultItem }) => {
const isSubscribed = useSubscriptionByListId(item.list?.id ?? "")
const navigation = useNavigation()
return (
<ItemPressable
className="py-2"
onPress={() => {
if (item.list?.id) {
router.push(`/follow?id=${item.list.id}&type=list`)
navigation.pushControllerView(FollowScreen, {
id: item.list.id,
type: "list",
})
}
}}
>

View File

@ -1,5 +1,4 @@
import { RSSHubCategories } from "@follow/constants"
import { router } from "expo-router"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import type { FC } from "react"
import { useContext, useEffect, useRef, useState } from "react"
@ -17,6 +16,9 @@ import { BlurEffect } from "@/src/components/common/BlurEffect"
import { getDefaultHeaderHeight } from "@/src/components/layouts/utils"
import { TabBar } from "@/src/components/ui/tabview/TabBar"
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
import { useScreenIsInSheetModal } from "@/src/lib/navigation/hooks"
import { Navigation } from "@/src/lib/navigation/Navigation"
import SearchScreen from "@/src/screens/(headless)/search"
import { accentColor, useColor } from "@/src/theme/colors"
import { AddFeedButton } from "../screen/action"
@ -31,7 +33,9 @@ export const SearchHeader: FC<{
}> = ({ animatedX, onLayout }) => {
const frame = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)
const sheetModal = useScreenIsInSheetModal()
const headerHeight = getDefaultHeaderHeight(frame, sheetModal, insets.top)
return (
<View
@ -66,7 +70,8 @@ export const DiscoverHeader = () => {
const DiscoverHeaderImpl = () => {
const frame = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)
const sheetModal = useScreenIsInSheetModal()
const headerHeight = getDefaultHeaderHeight(frame, sheetModal, insets.top)
const { animatedX, currentTabAtom, headerHeightAtom } = useContext(DiscoverContext)
const setCurrentTab = useSetAtom(currentTabAtom)
const setHeaderHeight = useSetAtom(headerHeightAtom)
@ -112,7 +117,7 @@ const PlaceholerSearchBar = () => {
style={styles.searchbar}
className="bg-tertiary-system-fill"
onPress={() => {
router.push("/search")
Navigation.rootNavigation.pushControllerView(SearchScreen)
}}
>
<View
@ -142,8 +147,8 @@ const ComposeSearchBar = () => {
setIsFocused(false)
setSearchValue("")
if (router.canGoBack()) {
router.back()
if (Navigation.rootNavigation.canGoBack()) {
Navigation.rootNavigation.back()
}
}}
>

View File

@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query"
import { router } from "expo-router"
import { Pressable, View } from "react-native"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { apiClient } from "@/src/lib/api-fetch"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { ProfileScreen } from "@/src/screens/(modal)/profile"
import { useEntry } from "@/src/store/entry/hooks"
import { isInboxEntry } from "@/src/store/entry/utils"
import { userActions } from "@/src/store/user/store"
@ -25,6 +26,7 @@ export const EntryReadHistory = ({ entryId }: { entryId: string }) => {
staleTime: 1000 * 60 * 5,
enabled: !isInboxEntry(entry),
})
const navigation = useNavigation()
if (!data?.data.entryReadHistories) return null
return (
<View className="flex-row items-center justify-center">
@ -45,7 +47,9 @@ export const EntryReadHistory = ({ entryId }: { entryId: string }) => {
},
])
router.push(`/profile?userId=${user.id}`)
navigation.presentControllerView(ProfileScreen, {
userId: user.id,
})
}}
className="border-system-background bg-tertiary-system-background overflow-hidden rounded-full border-2"
key={userId}

View File

@ -1,10 +1,11 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { useHeaderHeight } from "@react-navigation/elements"
import { useCallback, useContext, useEffect, useState } from "react"
import { useCallback, useContext, useState } from "react"
import { Text, useWindowDimensions, View } from "react-native"
import type { SharedValue } from "react-native-reanimated"
import Animated, {
interpolate,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
@ -12,20 +13,20 @@ import Animated, {
import { useUISettingKey } from "@/src/atoms/settings/ui"
import { DefaultHeaderBackButton } from "@/src/components/layouts/header/NavigationHeader"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { EntryContentContext, useEntryContentContext } from "@/src/modules/entry-content/ctx"
import { EntryContentHeaderRightActions } from "@/src/modules/entry-content/EntryContentHeaderRightActions"
import { useEntry } from "@/src/store/entry/hooks"
import { useFeed } from "@/src/store/feed/hooks"
import { useHeaderHeight } from "../screen/hooks/useHeaderHeight"
import { EntryReadHistory } from "./EntryReadHistory"
export const EntryTitle = ({ title, entryId }: { title: string; entryId: string }) => {
const { scrollY } = useContext(NavigationContext)!
const reanimatedScrollY = useContext(ScreenItemContext).reAnimatedScrollY
const [titleHeight, setTitleHeight] = useState(0)
const opacityAnimatedValue = useSharedValue(0)
@ -34,30 +35,25 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
const [isHeaderTitleVisible, setIsHeaderTitleVisible] = useState(true)
useEffect(() => {
const id = scrollY.addListener((value) => {
if (value.value > titleHeight + headerHeight) {
useAnimatedReaction(
() => reanimatedScrollY.value,
(value) => {
if (value > titleHeight + headerHeight) {
opacityAnimatedValue.value = withTiming(1, { duration: 100 })
setIsHeaderTitleVisible(true)
runOnJS(setIsHeaderTitleVisible)(true)
} else {
opacityAnimatedValue.value = withTiming(0, { duration: 100 })
setIsHeaderTitleVisible(false)
runOnJS(setIsHeaderTitleVisible)(false)
}
})
return () => {
scrollY.removeListener(id)
}
}, [scrollY, title, titleHeight, headerHeight, opacityAnimatedValue])
},
)
const ctxValue = useEntryContentContext()
const headerBarWidth = useWindowDimensions().width
return (
<>
<NavigationBlurEffectHeader
headerShown
headerTitleAbsolute
title={title}
headerLeft={useTypeScriptHappyCallback(
({ canGoBack }) => (
<EntryLeftGroup
@ -68,36 +64,30 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
),
[entryId],
)}
headerRight={useCallback(
() => (
<EntryContentContext.Provider value={ctxValue}>
<EntryContentHeaderRightActions
entryId={entryId}
titleOpacityShareValue={opacityAnimatedValue}
isHeaderTitleVisible={isHeaderTitleVisible}
/>
</EntryContentContext.Provider>
),
[ctxValue, entryId, opacityAnimatedValue, isHeaderTitleVisible],
)}
headerTitle={useCallback(
() => (
<View
className="flex-row items-center justify-center"
pointerEvents="none"
style={{ width: headerBarWidth - 80 }}
headerRight={
<EntryContentContext.Provider value={ctxValue}>
<EntryContentHeaderRightActions
entryId={entryId}
titleOpacityShareValue={opacityAnimatedValue}
isHeaderTitleVisible={isHeaderTitleVisible}
/>
</EntryContentContext.Provider>
}
headerTitle={
<View
className="flex-row items-center justify-center"
pointerEvents="none"
style={{ width: headerBarWidth - 80 }}
>
<Animated.Text
className={"text-label text-center text-[17px] font-semibold"}
numberOfLines={1}
style={{ opacity: opacityAnimatedValue }}
>
<Animated.Text
className={"text-label text-center text-[17px] font-semibold"}
numberOfLines={1}
style={{ opacity: opacityAnimatedValue }}
>
{title}
</Animated.Text>
</View>
),
[headerBarWidth, opacityAnimatedValue, title],
)}
{title}
</Animated.Text>
</View>
}
/>
<View
onLayout={() => {
@ -111,7 +101,7 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
}
export const EntrySocialTitle = ({ entryId }: { entryId: string }) => {
const { scrollY } = useContext(NavigationContext)!
const reanimatedScrollY = useContext(ScreenItemContext).reAnimatedScrollY
const opacityAnimatedValue = useSharedValue(0)
@ -130,28 +120,24 @@ export const EntrySocialTitle = ({ entryId }: { entryId: string }) => {
const titleHeight = -60
const [isHeaderTitleVisible, setIsHeaderTitleVisible] = useState(true)
useEffect(() => {
const id = scrollY.addListener((value) => {
if (value.value > titleHeight + headerHeight) {
useAnimatedReaction(
() => reanimatedScrollY.value,
(value) => {
if (value > titleHeight + headerHeight) {
opacityAnimatedValue.value = withTiming(1, { duration: 100 })
setIsHeaderTitleVisible(true)
runOnJS(setIsHeaderTitleVisible)(true)
} else {
opacityAnimatedValue.value = withTiming(0, { duration: 100 })
setIsHeaderTitleVisible(false)
runOnJS(setIsHeaderTitleVisible)(false)
}
})
return () => {
scrollY.removeListener(id)
}
}, [scrollY, titleHeight, headerHeight, opacityAnimatedValue])
},
)
const ctxValue = useEntryContentContext()
return (
<>
<NavigationBlurEffectHeader
title="Post"
headerShown
headerRight={useCallback(
() => (
<EntryContentContext.Provider value={ctxValue}>
@ -203,7 +189,7 @@ const EntryLeftGroup = ({ canGoBack, entryId, titleOpacityShareValue }: EntryLef
})
return (
<View className="flex-row items-center justify-center">
<DefaultHeaderBackButton canGoBack={canGoBack} />
<DefaultHeaderBackButton canGoBack={canGoBack} canDismiss={false} />
{!hideRecentReader && (
<Animated.View style={animatedOpacity} className="absolute left-[32px] z-10 flex-row gap-2">

View File

@ -1,6 +1,5 @@
import { FeedViewType } from "@follow/constants"
import { formatEstimatedMins } from "@follow/utils"
import { router } from "expo-router"
import { useCallback, useEffect } from "react"
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import ReAnimated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
@ -15,8 +14,10 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { PauseCuteFiIcon } from "@/src/icons/pause_cute_fi"
import { PlayCuteFiIcon } from "@/src/icons/play_cute_fi"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { getAttachmentState, player } from "@/src/lib/player"
import { getHorizontalScrolling } from "@/src/modules/screen/atoms"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]"
import { useEntry } from "@/src/store/entry/hooks"
import { getInboxFrom } from "@/src/store/entry/utils"
import { useFeed } from "@/src/store/feed/hooks"
@ -30,14 +31,17 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra
const from = getInboxFrom(entry)
const feed = useFeed(entry?.feedId as string)
const view = useEntryListContextView()
const navigation = useNavigation()
const handlePress = useCallback(() => {
const isHorizontalScrolling = getHorizontalScrolling()
if (entry && !isHorizontalScrolling) {
preloadWebViewEntry(entry)
router.push(`/entries/${entryId}`)
navigation.pushControllerView(EntryDetailScreen, {
entryId,
view,
})
}
}, [entryId, entry])
}, [entryId, entry, navigation, view])
const unreadZoomSharedValue = useSharedValue(entry?.read ? 0 : 1)

View File

@ -1,5 +1,4 @@
import { FeedViewType } from "@follow/constants"
import { router } from "expo-router"
import { useCallback, useEffect, useMemo } from "react"
import { Pressable, Text, View } from "react-native"
import ReAnimated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
@ -13,7 +12,10 @@ import { Image } from "@/src/components/ui/image/Image"
import { ItemPressableStyle } from "@/src/components/ui/pressable/enum"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { getHorizontalScrolling } from "@/src/modules/screen/atoms"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]"
import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]"
import { useEntry } from "@/src/store/entry/hooks"
import { useFeed } from "@/src/store/feed/hooks"
import { unreadSyncService } from "@/src/store/unread/store"
@ -26,13 +28,17 @@ export function EntrySocialItem({ entryId }: { entryId: string }) {
const feed = useFeed(entry?.feedId || "")
const navigation = useNavigation()
const handlePress = useCallback(() => {
const isHorizontalScrolling = getHorizontalScrolling()
if (!isHorizontalScrolling) {
unreadSyncService.markEntryAsRead(entryId)
router.push(`/entries/${entryId}?view=${FeedViewType.SocialMedia}`)
navigation.pushControllerView(EntryDetailScreen, {
entryId,
view: FeedViewType.SocialMedia,
})
}
}, [entryId])
}, [entryId, navigation])
const unreadZoomSharedValue = useSharedValue(entry?.read ? 0 : 1)
@ -61,6 +67,7 @@ export function EntrySocialItem({ entryId }: { entryId: string }) {
const memoedMediaUrlList = useMemo(() => {
return entry?.media?.map((i) => i.url) ?? []
}, [entry])
if (!entry) return <EntryItemSkeleton />
const { description, publishedAt, media } = entry
@ -81,7 +88,10 @@ export function EntrySocialItem({ entryId }: { entryId: string }) {
<Pressable
hitSlop={10}
onPress={() => {
router.push(`/feeds/${entry.feedId}`)
if (!entry.feedId) return
navigation.pushControllerView(FeedScreen, {
feedId: entry.feedId,
})
}}
>
{entry.authorAvatar ? (

View File

@ -1,71 +0,0 @@
import type { PropsWithChildren } from "react"
import { useCallback } from "react"
import { useWindowDimensions, View } from "react-native"
import { Drawer } from "react-native-drawer-layout"
import type { PanGestureType } from "react-native-gesture-handler/lib/typescript/handlers/gestures/panGesture"
import { isIOS } from "@/src/lib/platform"
import { useFeedDrawer, useIsDrawerSwipeDisabled } from "./atoms"
import { CollectionPanel } from "./collection-panel"
import { FeedPanel } from "./feed-panel"
export const FeedDrawer = ({ children }: PropsWithChildren) => {
const { isDrawerOpen, openDrawer, closeDrawer } = useFeedDrawer()
const winDim = useWindowDimensions()
const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
const renderDrawerContent = useCallback(() => <DrawerContent />, [])
const configureGestureHandler = useCallback(
(handler: PanGestureType) => {
const swipeEnabled = !isDrawerSwipeDisabled
if (swipeEnabled) {
if (isDrawerOpen) {
return handler.activeOffsetX([-1, 1])
} else {
return (
handler
// Any movement to the left is a pager swipe
// so fail the drawer gesture immediately.
.failOffsetX(-1)
// Don't rush declaring that a movement to the right
// is a drawer swipe. It could be a vertical scroll.
.activeOffsetX(5)
)
}
} else {
// Fail the gesture immediately.
// This seems more reliable than the `swipeEnabled` prop.
// With `swipeEnabled` alone, the gesture may freeze after toggling off/on.
return handler.failOffsetX([0, 0]).failOffsetY([0, 0])
}
},
[isDrawerOpen, isDrawerSwipeDisabled],
)
return (
<Drawer
open={isDrawerOpen}
drawerStyle={{ width: winDim.width }}
onOpen={openDrawer}
onClose={closeDrawer}
renderDrawerContent={renderDrawerContent}
configureGestureHandler={configureGestureHandler}
swipeEdgeWidth={winDim.width}
swipeMinVelocity={100}
swipeMinDistance={10}
drawerType={isIOS ? "slide" : "front"}
>
{children}
</Drawer>
)
}
const DrawerContent = () => {
return (
<View className="bg-system-background flex-1 flex-row overflow-hidden">
<CollectionPanel />
<FeedPanel />
</View>
)
}

View File

@ -1,6 +1,4 @@
import { cn } from "@follow/utils"
import { HeaderHeightContext } from "@react-navigation/elements"
import { router } from "expo-router"
import type { FC } from "react"
import { createContext, memo, useContext, useState } from "react"
import {
@ -18,10 +16,13 @@ import { useSharedValue } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { BottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext"
import { NavigationHeaderHeightContext } from "@/src/components/layouts/views/NavigationHeaderContext"
import { AccordionItem } from "@/src/components/ui/accordion/AccordionItem"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]"
import { useFeed, usePrefetchFeed } from "@/src/store/feed/hooks"
import { useList } from "@/src/store/list/hooks"
import { useSortedUngroupedSubscription, useSubscription } from "@/src/store/subscription/hooks"
@ -53,7 +54,7 @@ export const FeedPanel = () => {
}}
/>
<HeaderHeightContext.Provider value={headerHeight}>
<NavigationHeaderHeightContext.Provider value={headerHeight}>
<BottomTabBarHeightContext.Provider value={insets.bottom}>
<ViewPageCurrentViewProvider
key={selectedCollection.viewId}
@ -62,7 +63,7 @@ export const FeedPanel = () => {
<SubscriptionList view={selectedCollection.viewId} />
</ViewPageCurrentViewProvider>
</BottomTabBarHeightContext.Provider>
</HeaderHeightContext.Provider>
</NavigationHeaderHeightContext.Provider>
</View>
)
}
@ -141,10 +142,12 @@ const CategoryGrouped = memo(
const rotateValue = useAnimatedValue(1)
const selectedCollection = useSelectedCollection()
const view = selectedCollection.type === "view" ? selectedCollection.viewId : undefined
const navigation = useNavigation()
if (view === undefined) {
console.warn("view is undefined", selectedCollection)
return null
}
return (
<SubscriptionFeedCategoryContextMenu
view={view}
@ -158,7 +161,9 @@ const CategoryGrouped = memo(
categoryName: category,
})
closeDrawer()
router.push(`/feeds/${category}`)
navigation.pushControllerView(FeedScreen, {
feedId: category,
})
}}
className="h-12 flex-row items-center px-3"
>

View File

@ -1,23 +1,24 @@
import { FeedViewType } from "@follow/constants"
import { zodResolver } from "@hookform/resolvers/zod"
import { StackActions } from "@react-navigation/native"
import { router, useNavigation } from "expo-router"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { ActivityIndicator, Text, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { z } from "zod"
import { ModalHeaderSubmitButton } from "@/src/components/common/ModalSharedComponents"
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
import { HeaderSubmitButton } from "@/src/components/layouts/header/HeaderElements"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import { FormProvider } from "@/src/components/ui/form/FormProvider"
import { FormLabel } from "@/src/components/ui/form/Label"
import { FormSwitch } from "@/src/components/ui/form/Switch"
import { TextField } from "@/src/components/ui/form/TextField"
import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne"
import { useCanDismiss, useNavigation } from "@/src/lib/navigation/hooks"
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
import { useFeed, usePrefetchFeed, usePrefetchFeedByUrl } from "@/src/store/feed/hooks"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
@ -79,9 +80,10 @@ function FollowImpl(props: { feedId: string }) {
})
const [isLoading, setIsLoading] = useState(false)
const routeOnlyOne = useIsRouteOnlyOne()
const navigate = useNavigation()
const parentRoute = navigate.getParent()
const canDismiss = useCanDismiss()
const submit = async () => {
setIsLoading(true)
const values = form.getValues()
@ -98,42 +100,38 @@ function FollowImpl(props: { feedId: string }) {
setIsLoading(false)
})
if (router.canDismiss()) {
router.dismissAll()
if (!routeOnlyOne) {
parentRoute?.dispatch(StackActions.popToTop())
}
if (canDismiss) {
navigate.dismiss()
} else {
// If we can't dismiss, redirect to the root route
router.replace("/")
navigate.back()
}
}
const insets = useSafeAreaInsets()
const { isValid, isDirty } = form.formState
const navigation = useNavigation()
const setScreenOptions = useSetModalScreenOptions()
useEffect(() => {
navigation.setOptions({
gestureEnabled: !isDirty,
setScreenOptions({
preventNativeDismiss: isDirty,
})
}, [isDirty, navigation])
}, [isDirty, setScreenOptions])
if (!feed?.id) {
return <Text className="text-label">Feed ({id}) not found</Text>
}
return (
<SafeModalScrollView
<SafeNavigationScrollView
className="bg-system-grouped-background"
contentContainerClassName="gap-y-4 mt-2"
contentViewClassName="gap-y-4 mt-2"
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<ModalHeader
headerTitle={`${isSubscribed ? "Edit" : "Follow"} - ${feed?.title}`}
<NavigationBlurEffectHeader
title={`${isSubscribed ? "Edit" : "Follow"} - ${feed?.title}`}
headerRight={
<ModalHeaderSubmitButton
<HeaderSubmitButton
isValid={isValid}
onPress={form.handleSubmit(submit)}
isLoading={isLoading}
@ -217,6 +215,6 @@ function FollowImpl(props: { feedId: string }) {
</View>
</FormProvider>
</GroupedInsetListCard>
</SafeModalScrollView>
</SafeNavigationScrollView>
)
}

View File

@ -1,15 +1,14 @@
import { FeedViewType } from "@follow/constants"
import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery } from "@tanstack/react-query"
import { router, useNavigation } from "expo-router"
import { useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import { ActivityIndicator, Alert, StyleSheet, Text, View } from "react-native"
import { z } from "zod"
import { ModalHeaderSubmitButton } from "@/src/components/common/ModalSharedComponents"
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
import { HeaderSubmitButton } from "@/src/components/layouts/header/HeaderElements"
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { FormProvider } from "@/src/components/ui/form/FormProvider"
import { FormLabel } from "@/src/components/ui/form/Label"
import { FormSwitch } from "@/src/components/ui/form/Switch"
@ -18,6 +17,8 @@ import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
import { IconWithFallback } from "@/src/components/ui/icon/fallback-icon"
import { PowerIcon } from "@/src/icons/power"
import { apiClient } from "@/src/lib/api-fetch"
import { useNavigation, useScreenIsInSheetModal } from "@/src/lib/navigation/hooks"
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
import { toast } from "@/src/lib/toast"
import { useList } from "@/src/store/list/hooks"
import { listSyncServices } from "@/src/store/list/store"
@ -65,6 +66,8 @@ const Impl = (props: { id: string }) => {
})
const { isValid, isDirty } = form.formState
const isModal = useScreenIsInSheetModal()
const navigation = useNavigation()
const submit = async () => {
const payload = form.getValues()
@ -81,7 +84,11 @@ const Impl = (props: { id: string }) => {
await $method({
json: body,
})
router.dismiss()
if (isModal) {
navigation.dismiss()
} else {
navigation.back()
}
toast.success(isSubscribed ? "List updated" : "List followed")
}
if (list.fee && !isSubscribed) {
@ -109,21 +116,21 @@ const Impl = (props: { id: string }) => {
const isLoading = false
const navigation = useNavigation()
const setModalOptions = useSetModalScreenOptions()
useEffect(() => {
navigation.setOptions({
setModalOptions({
gestureEnabled: !isDirty,
})
}, [isDirty, navigation])
}, [isDirty, setModalOptions])
return (
<SafeModalScrollView
className="bg-system-grouped-background"
contentContainerClassName="gap-y-4 mt-2"
>
<ModalHeader
headerTitle={`${isSubscribed ? "Edit" : "Follow"} - ${list?.title}`}
<NavigationBlurEffectHeader
title={`${isSubscribed ? "Edit" : "Follow"} - ${list?.title}`}
headerRight={
<ModalHeaderSubmitButton
<HeaderSubmitButton
isValid={isValid}
onPress={form.handleSubmit(submit)}
isLoading={isLoading}

View File

@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useCallback, useRef } from "react"
import { Alert, Text, TouchableOpacity, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
@ -8,7 +7,12 @@ import { z } from "zod"
import { SubmitButton } from "@/src/components/common/SubmitButton"
import { PlainTextField } from "@/src/components/ui/form/TextField"
import { signIn } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { Navigation } from "@/src/lib/navigation/Navigation"
import { getTokenHeaders } from "@/src/lib/token"
import { TwoFactorAuthScreen } from "@/src/screens/(modal)/2fa"
import { ForgetPasswordScreen } from "@/src/screens/(modal)/forget-password"
import { SignUpScreen } from "@/src/screens/(modal)/sign-up"
import { accentColor } from "@/src/theme/colors"
const formSchema = z.object({
@ -42,7 +46,7 @@ async function onSubmit(values: FormValue) {
}
// @ts-expect-error
if (res.data.twoFactorRedirect) {
router.push("/2fa")
Navigation.rootNavigation.presentControllerView(TwoFactorAuthScreen)
}
})
.catch((error) => {
@ -65,6 +69,7 @@ export function EmailLogin() {
})
}, [submitMutation])
const navigation = useNavigation()
return (
<View className="mx-auto flex w-full max-w-sm">
<View className="bg-secondary-system-background gap-4 rounded-2xl px-6 py-4">
@ -113,10 +118,16 @@ export function EmailLogin() {
title="Continue"
className="mt-8"
/>
<TouchableOpacity className="mx-auto mt-10" onPress={() => router.push("/sign-up")}>
<TouchableOpacity
className="mx-auto mt-10"
onPress={() => navigation.presentControllerView(SignUpScreen)}
>
<Text className="text-accent m-1 text-[15px]">Don't have an account?</Text>
</TouchableOpacity>
<TouchableOpacity className="mx-auto mt-4" onPress={() => router.push("/forget-password")}>
<TouchableOpacity
className="mx-auto mt-4"
onPress={() => navigation.presentControllerView(ForgetPasswordScreen)}
>
<Text className="text-secondary-label m-[6] text-[15px]">Forgot password?</Text>
</TouchableOpacity>
</View>

View File

@ -1,4 +1,3 @@
import { Link, router } from "expo-router"
import { useCallback } from "react"
import { Text, TouchableWithoutFeedback, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
@ -6,8 +5,10 @@ import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanima
import * as ContextMenu from "zeego/context-menu"
import { Logo } from "@/src/components/ui/logo"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { NavigationLink } from "@/src/lib/navigation/NavigationLink"
import { useScaleHeight } from "@/src/lib/responsive"
import { TermsMarkdown } from "@/src/screens/(headless)/terms"
import { TermsMarkdown, TermsScreen } from "@/src/screens/(headless)/terms"
import { EmailLogin } from "./email"
import { SocialLogin } from "./social"
@ -78,13 +79,18 @@ const TermsCheckBox = () => {
}
const TermsText = () => {
const navigation = useNavigation()
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="w-full overflow-hidden rounded-full">
<Text className="text-secondary-label text-sm">
<Link href="/terms" suppressHighlighting className="text-primary-label">
<NavigationLink
destination={TermsScreen}
suppressHighlighting
className="text-primary-label"
>
Terms of Service
</Link>
</NavigationLink>
</Text>
</ContextMenu.Trigger>
@ -92,8 +98,8 @@ const TermsText = () => {
<ContextMenu.Preview
size="STRETCH"
onPress={useCallback(() => {
router.push("/terms")
}, [])}
navigation.pushControllerView(TermsScreen)
}, [navigation])}
>
{() => (
<View className="bg-system-background flex-1">

View File

@ -1,4 +1,3 @@
import { router } from "expo-router"
import type { PropsWithChildren } from "react"
import { ActivityIndicator, Pressable, Text, TouchableOpacity, View } from "react-native"
import { useColor } from "react-native-uikit-colors"
@ -10,15 +9,19 @@ import { FileImportCuteReIcon } from "@/src/icons/file_import_cute_re"
import { ListCheck2CuteReIcon } from "@/src/icons/list_check_2_cute_re"
import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
import { Settings1CuteReIcon } from "@/src/icons/settings_1_cute_re"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { SelectReadingModeScreen } from "@/src/screens/(modal)/onboarding/select-reading-mode"
import { useWhoami } from "@/src/store/user/hooks"
import { accentColor } from "@/src/theme/colors"
import { EditProfileScreen } from "../settings/routes/EditProfile"
import { importOpml, setAvatar } from "../settings/utils"
import { useReadingBehavior } from "./hooks/use-reading-behavior"
export const StepPreferences = () => {
const { behavior } = useReadingBehavior()
const navigation = useNavigation()
return (
<View className="mt-[10vh] flex-1 p-4">
<View className="mb-10 flex items-center gap-4">
@ -41,7 +44,7 @@ export const StepPreferences = () => {
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
router.push("/onboarding/edit-profile")
navigation.pushControllerView(EditProfileScreen)
}}
>
<Text className="text-secondary-label text-sm">
@ -58,7 +61,7 @@ export const StepPreferences = () => {
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
router.push("/onboarding/select-reading-mode")
navigation.pushControllerView(SelectReadingModeScreen)
}}
>
{behavior === "radical" && (

View File

@ -1,5 +1,4 @@
import { cn } from "@follow/utils"
import { router, usePathname } from "expo-router"
import { useEffect } from "react"
import { Pressable, Text, View } from "react-native"
import Animated, {
@ -10,21 +9,25 @@ import Animated, {
} from "react-native-reanimated"
import { Image } from "@/src/components/ui/image/Image"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { useScreenName } from "@/src/lib/navigation/ScreenNameContext"
import { useActiveTrack } from "@/src/lib/player"
import { PlayerScreen } from "@/src/screens/player"
import { usePrefetchImageColors } from "@/src/store/image/hooks"
import { PlayPauseButton, SeekButton } from "./control"
const allowedRoutes = new Set(["/", "/subscriptions", "/player"])
const allowedScreenNames = new Set(["Home", "Subscriptions", "Player"])
export function PlayerTabBar({ className }: { className?: string }) {
const activeTrack = useActiveTrack()
const pathname = usePathname()
const isVisible = !!activeTrack && allowedRoutes.has(pathname)
const screenName = useScreenName()
const isVisible = !!activeTrack && allowedScreenNames.has(screenName)
const isVisibleSV = useSharedValue(isVisible ? 1 : 0)
useEffect(() => {
isVisibleSV.value = withTiming(isVisible ? 1 : 0)
}, [pathname, isVisible])
}, [isVisible, isVisibleSV])
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: isVisibleSV.value,
@ -34,6 +37,7 @@ export function PlayerTabBar({ className }: { className?: string }) {
})
usePrefetchImageColors(activeTrack?.artwork)
const navigation = useNavigation()
return (
<Animated.View
@ -42,7 +46,8 @@ export function PlayerTabBar({ className }: { className?: string }) {
>
<Pressable
onPress={() => {
router.push("/player")
navigation.presentControllerView(PlayerScreen, void 0, "transparentModal")
// TODO
}}
>
<View className="flex flex-row items-center gap-4 overflow-hidden rounded-2xl p-2">

View File

@ -1,5 +1,4 @@
import { cn } from "@follow/utils"
import { router } from "expo-router"
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"
import { Slider } from "react-native-awesome-slider"
import { FadeOut, useDerivedValue, useSharedValue, ZoomIn } from "react-native-reanimated"
@ -15,6 +14,7 @@ import { RewindForward30CuteReIcon } from "@/src/icons/rewind_forward_30_cute_re
import { StopCircleCuteFiIcon } from "@/src/icons/stop_circle_cute_fi"
import { VolumeCuteReIcon } from "@/src/icons/volume_cute_re"
import { VolumeOffCuteReIcon } from "@/src/icons/volume_off_cute_re"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { allowedRate, player, useIsPlaying, useProgress, useRate } from "@/src/lib/player"
import { useVolume } from "@/src/lib/volume"
import { useColor } from "@/src/theme/colors"
@ -113,12 +113,13 @@ export function RateSelector() {
export function StopButton({ size = 24, className, color }: ControlButtonProps) {
const label = useColor("label")
const navigation = useNavigation()
return (
<TouchableOpacity
className={className}
onPress={() => {
player.reset()
router.back()
navigation.back()
}}
>
<StopCircleCuteFiIcon color={color ?? label} width={size} height={size} />

View File

@ -1,3 +1,4 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import type {
FlashListProps,
MasonryFlashListProps,
@ -13,7 +14,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { usePrefetchSubscription } from "@/src/store/subscription/hooks"
import { usePrefetchUnread } from "@/src/store/unread/hooks"
@ -31,30 +32,39 @@ export const TimelineSelectorList = forwardRef<
const { refetch: subscriptionRefetch } = usePrefetchSubscription()
const headerHeight = useHeaderHeight()
const { scrollY } = useContext(NavigationContext)!
const { reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight } =
useContext(ScreenItemContext)!
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
props.onScroll?.(e)
scrollY?.setValue(e.nativeEvent.contentOffset.y)
reAnimatedScrollY.value = e.nativeEvent.contentOffset.y
},
[scrollY, props.onScroll],
[props, reAnimatedScrollY],
)
const tabBarHeight = useBottomTabBarHeight()
const systemFill = useColor("secondaryLabel")
// const listRef = useRef<FlashList<any>>(null)
// useImperativeHandle(ref, () => listRef.current!)
return (
<FlashList
automaticallyAdjustsScrollIndicatorInsets={false}
automaticallyAdjustContentInsets={false}
ref={ref}
onLayout={useTypeScriptHappyCallback(
(e) => {
scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight - tabBarHeight
},
[scrollViewHeight],
)}
onContentSizeChange={useTypeScriptHappyCallback(
(w, h) => {
scrollViewContentHeight.value = h
},
[scrollViewContentHeight],
)}
refreshControl={
<RefreshControl
progressViewOffset={headerHeight}
@ -93,14 +103,15 @@ export const TimelineSelectorMasonryList = forwardRef<
const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight()
const scrollY = useContext(NavigationContext)?.scrollY
const { reAnimatedScrollY } = useContext(ScreenItemContext)!
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
props.onScroll?.(e)
scrollY?.setValue(e.nativeEvent.contentOffset.y)
reAnimatedScrollY.value = e.nativeEvent.contentOffset.y
},
[scrollY, props.onScroll],
[props, reAnimatedScrollY],
)
const tabBarHeight = useBottomTabBarHeight()

View File

@ -1,16 +1,15 @@
import { env } from "@follow/shared/src/env"
import { useLocalSearchParams } from "expo-router"
import { useMemo } from "react"
import { Share, useAnimatedValue, View } from "react-native"
import { Share, View } from "react-native"
import { useColor } from "react-native-uikit-colors"
import { DefaultHeaderBackButton } from "@/src/components/layouts/header/NavigationHeader"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { UIBarButton } from "@/src/components/ui/button/UIBarButton"
import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui"
import { Share3CuteReIcon } from "@/src/icons/share_3_cute_re"
import {
ActionGroup,
HomeLeftAction,
HomeSharedRightAction,
UnreadOnlyActionButton,
@ -20,25 +19,29 @@ import { getFeed } from "@/src/store/feed/getter"
import { useEntryListContext, useSelectedFeedTitle } from "./atoms"
export function TimelineSelectorProvider({ children }: { children: React.ReactNode }) {
const scrollY = useAnimatedValue(0)
export function TimelineSelectorProvider({
children,
feedId,
}: {
children: React.ReactNode
feedId?: string
}) {
const viewTitle = useSelectedFeedTitle()
const screenType = useEntryListContext().type
const params = useLocalSearchParams()
const isFeed = screenType === "feed"
const isTimeline = screenType === "timeline"
const isSubscriptions = screenType === "subscriptions"
return (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<>
<NavigationBlurEffectHeader
headerShown
title={viewTitle}
headerLeft={useMemo(
() =>
isTimeline || isSubscriptions
? () => <HomeLeftAction />
: () => <DefaultHeaderBackButton canGoBack={true} />,
: () => <DefaultHeaderBackButton canDismiss={false} canGoBack={true} />,
[isTimeline, isSubscriptions],
)}
headerRight={useMemo(() => {
@ -53,28 +56,27 @@ export function TimelineSelectorProvider({ children }: { children: React.ReactNo
if (isSubscriptions) return () => <HomeSharedRightAction />
if (isFeed)
return () => (
<View className="-mr-2 flex-row gap-2">
<ActionGroup>
<UnreadOnlyActionButton variant={buttonVariant} />
<FeedShareAction params={params} />
</View>
<FeedShareAction feedId={feedId} />
</ActionGroup>
)
})()
if (Component)
return () => <View className="flex-row items-center justify-end">{Component()}</View>
return
}, [isFeed, isTimeline, isSubscriptions, params])}
}, [isFeed, isTimeline, isSubscriptions, feedId])}
headerHideableBottom={isTimeline || isSubscriptions ? TimelineViewSelector : undefined}
headerHideableBottomHeight={TIMELINE_VIEW_SELECTOR_HEIGHT}
/>
{children}
</NavigationContext.Provider>
</>
)
}
function FeedShareAction({ params }: { params: any }) {
function FeedShareAction({ feedId }: { feedId?: string }) {
const label = useColor("label")
const { feedId } = params
if (!feedId) return null
return (

View File

@ -1,6 +1,5 @@
import { cn } from "@follow/utils"
import * as Haptics from "expo-haptics"
import { router } from "expo-router"
import type { PropsWithChildren } from "react"
import { useCallback } from "react"
import { TouchableOpacity, View } from "react-native"
@ -13,27 +12,31 @@ import { CheckCircleCuteReIcon } from "@/src/icons/check_circle_cute_re"
import { RoundCuteFiIcon } from "@/src/icons/round_cute_fi"
import { RoundCuteReIcon } from "@/src/icons/round_cute_re"
import { Dialog } from "@/src/lib/dialog"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { LoginScreen } from "@/src/screens/(modal)/login"
import { ProfileScreen } from "@/src/screens/(modal)/profile"
import { useWhoami } from "@/src/store/user/hooks"
import { accentColor, useColor } from "@/src/theme/colors"
import { AddFeedDialog } from "../dialogs/AddFeedDialog"
import { MarkAllAsReadDialog } from "../dialogs/MarkAllAsReadDialog"
const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
export const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return <View className={cn("flex flex-row items-center gap-2", className)}>{children}</View>
}
export function HomeLeftAction() {
const user = useWhoami()
const navigation = useNavigation()
const handlePress = useCallback(() => {
if (user) {
router.push("/profile")
navigation.presentControllerView(ProfileScreen, { userId: user.id })
} else {
router.push("/login")
navigation.presentControllerView(LoginScreen)
}
}, [user])
}, [navigation, user])
return (
<ActionGroup className="ml-2">

View File

@ -1,13 +1,9 @@
import { useIsFocused } from "@react-navigation/native"
import * as FileSystem from "expo-file-system"
import type { FC, RefObject } from "react"
import { Fragment, useContext, useEffect, useMemo } from "react"
import { Fragment, useMemo } from "react"
import type { ScrollView } from "react-native"
import { Alert, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { SetBottomTabBarVisibleContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarVisibleContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import {
GroupedInsetListCard,
GroupedInsetListNavigationLink,
@ -26,17 +22,25 @@ import { Settings1CuteFiIcon } from "@/src/icons/settings_1_cute_fi"
import { StarCuteFiIcon } from "@/src/icons/star_cute_fi"
import { UserSettingCuteFiIcon } from "@/src/icons/user_setting_cute_fi"
import { signOut } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import type { Navigation } from "@/src/lib/navigation/Navigation"
import { useWhoami } from "@/src/store/user/hooks"
import { useSettingsNavigation } from "./hooks"
import { AboutScreen } from "./routes/About"
import { AccountScreen } from "./routes/Account"
import { ActionsScreen } from "./routes/Actions"
import { AppearanceScreen } from "./routes/Appearance"
import { DataScreen } from "./routes/Data"
import { FeedsScreen } from "./routes/Feeds"
import { GeneralScreen } from "./routes/General"
import { ListsScreen } from "./routes/Lists"
import { NotificationsScreen } from "./routes/Notifications"
import { PrivacyScreen } from "./routes/Privacy"
interface GroupNavigationLink {
label: string
icon: React.ElementType
onPress: (
navigation: ReturnType<typeof useSettingsNavigation>,
scrollRef: RefObject<ScrollView>,
) => void
onPress: (navigation: Navigation, scrollRef: RefObject<ScrollView>) => void
iconBackgroundColor: string
anonymous?: boolean
@ -47,7 +51,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
label: "General",
icon: Settings1CuteFiIcon,
onPress: (navigation) => {
navigation.navigate("General")
navigation.pushControllerView(GeneralScreen)
},
iconBackgroundColor: "#F59E0B",
},
@ -55,7 +59,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
label: "Notifications",
icon: BellRingingCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Notifications")
navigation.pushControllerView(NotificationsScreen)
},
iconBackgroundColor: "#FBBF24",
todo: true,
@ -65,7 +69,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
label: "Appearance",
icon: PaletteCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Appearance")
navigation.pushControllerView(AppearanceScreen)
},
iconBackgroundColor: "#FCD34D",
},
@ -73,7 +77,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
label: "Data",
icon: DatabaseIcon,
onPress: (navigation) => {
navigation.navigate("Data")
navigation.pushControllerView(DataScreen)
},
iconBackgroundColor: "#CBAD6D",
anonymous: false,
@ -82,7 +86,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
label: "Account",
icon: UserSettingCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Account")
navigation.pushControllerView(AccountScreen)
},
iconBackgroundColor: "#d08700",
anonymous: false,
@ -94,7 +98,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
label: "Actions",
icon: Magic2CuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Actions")
navigation.pushControllerView(ActionsScreen)
},
iconBackgroundColor: "#059669",
anonymous: false,
@ -104,7 +108,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
label: "Feeds",
icon: CertificateCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Feeds")
navigation.pushControllerView(FeedsScreen)
},
iconBackgroundColor: "#10B981",
todo: true,
@ -114,7 +118,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
label: "Lists",
icon: RadaCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("Lists")
navigation.pushControllerView(ListsScreen)
},
iconBackgroundColor: "#34D399",
// todo: true,
@ -127,7 +131,7 @@ const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [
label: "Privacy",
icon: SafeLockFilledIcon,
onPress: (navigation) => {
navigation.navigate("Privacy")
navigation.pushControllerView(PrivacyScreen)
},
iconBackgroundColor: "#EF4444",
},
@ -135,7 +139,7 @@ const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [
label: "About",
icon: StarCuteFiIcon,
onPress: (navigation) => {
navigation.navigate("About")
navigation.pushControllerView(AboutScreen)
},
iconBackgroundColor: "#F87181",
},
@ -168,31 +172,33 @@ const ActionGroupNavigationLinks: GroupNavigationLink[] = [
const NavigationLinkGroup: FC<{
links: GroupNavigationLink[]
navigation: ReturnType<typeof useSettingsNavigation>
scrollRef: RefObject<ScrollView>
}> = ({ links, navigation, scrollRef }) => (
<GroupedInsetListCard>
{links.map((link) => {
if (link.todo) {
return null
}
return (
<GroupedInsetListNavigationLink
key={link.label}
label={link.label}
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor={link.iconBackgroundColor}>
<link.icon height={18} width={18} color="#fff" />
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
link.onPress(navigation, scrollRef)
}}
/>
)
})}
</GroupedInsetListCard>
)
}> = ({ links, scrollRef }) => {
const navigation = useNavigation()
return (
<GroupedInsetListCard>
{links.map((link) => {
if (link.todo) {
return null
}
return (
<GroupedInsetListNavigationLink
key={link.label}
label={link.label}
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor={link.iconBackgroundColor}>
<link.icon height={18} width={18} color="#fff" />
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
link.onPress(navigation, scrollRef)
}}
/>
)
})}
</GroupedInsetListCard>
)
}
const navigationGroups = [
DataGroupNavigationLinks,
@ -202,19 +208,6 @@ const navigationGroups = [
] as const
export const SettingsList: FC<{ scrollRef: RefObject<ScrollView> }> = ({ scrollRef }) => {
const navigation = useSettingsNavigation()
const setTabBarVisible = useContext(SetBottomTabBarVisibleContext)
const isVisible = useIsFocused()
useEffect(() => {
if (isVisible) {
setTabBarVisible(true)
}
}, [isVisible, setTabBarVisible])
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const whoami = useWhoami()
const filteredNavigationGroups = useMemo(() => {
@ -229,18 +222,10 @@ export const SettingsList: FC<{ scrollRef: RefObject<ScrollView> }> = ({ scrollR
.filter((group) => group !== false)
}, [whoami])
return (
<View
className="bg-system-grouped-background flex-1 py-4"
style={{ paddingBottom: insets.bottom + tabBarHeight }}
>
<View className="bg-system-grouped-background flex-1 py-4">
{filteredNavigationGroups.map((group, index) => (
<Fragment key={`nav-group-${index}`}>
<NavigationLinkGroup
key={`nav-group-${index}`}
links={group}
navigation={navigation}
scrollRef={scrollRef}
/>
<NavigationLinkGroup key={`nav-group-${index}`} links={group} scrollRef={scrollRef} />
{index < filteredNavigationGroups.length - 1 && <View className="h-8" />}
</Fragment>
))}

View File

@ -1,6 +1,5 @@
import { cn, getLuminance } from "@follow/utils"
import { LinearGradient } from "expo-linear-gradient"
import { router } from "expo-router"
import { useMemo } from "react"
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"
import type { SharedValue } from "react-native-reanimated"
@ -9,6 +8,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { LoginScreen } from "@/src/screens/(modal)/login"
import { useImageColors, usePrefetchImageColors } from "@/src/store/image/hooks"
import { useUser } from "@/src/store/user/hooks"
@ -87,6 +88,8 @@ export const UserHeaderBanner = ({
}
})
const navigation = useNavigation()
return (
<View
className="relative h-[200px] items-center justify-center"
@ -147,7 +150,10 @@ export const UserHeaderBanner = ({
@{user.handle}
</Text>
) : !user ? (
<TouchableOpacity className="mx-auto" onPress={() => router.push("/login")}>
<TouchableOpacity
className="mx-auto"
onPress={() => navigation.presentControllerView(LoginScreen)}
>
<Text className="text-accent m-[6] text-[16px]">Sign in to your account</Text>
</TouchableOpacity>
) : null}

View File

@ -1,8 +1,10 @@
import type { SupportedLanguages } from "@/src/lib/language"
import type { Navigation } from "@/src/lib/navigation/Navigation"
import { actionActions } from "@/src/store/action/store"
import type { ActionId, ActionRule } from "@/src/store/action/types"
import type { SettingsNavigation } from "../hooks"
import { EditRewriteRulesScreen } from "../routes/EditRewriteRules"
import { EditWebhooksScreen } from "../routes/EditWebhooks"
import { ActionFormTranslation } from "./components"
export const filterFieldOptions = [
@ -92,7 +94,7 @@ export const availableActionList: Array<{
value: ActionId
label: string
onEnable?: (index: number) => void
onNavigate?: (router: SettingsNavigation, index: number) => void
onNavigate?: (router: Navigation, index: number) => void
component?: React.FC<{ rule: ActionRule }>
}> = [
{
@ -143,7 +145,7 @@ export const availableActionList: Array<{
})
},
onNavigate: (router, index) => {
router.navigate("EditRewriteRules", { index })
router.pushControllerView(EditRewriteRulesScreen, { index })
},
},
{
@ -153,7 +155,7 @@ export const availableActionList: Array<{
actionActions.patchRule(index, { result: { webhooks: [""] } })
},
onNavigate: (router, index) => {
router.navigate("EditWebhooks", { index })
router.pushControllerView(EditWebhooksScreen, { index })
},
},
]

View File

@ -1,10 +0,0 @@
import { useNavigation } from "@react-navigation/native"
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"
import type { SettingsStackParamList } from "./types"
export const useSettingsNavigation = () => {
return useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
}
export type SettingsNavigation = ReturnType<typeof useSettingsNavigation>

View File

@ -1,4 +1,3 @@
import type { RouteProp } from "@react-navigation/native"
import { useMutation } from "@tanstack/react-query"
import { useRef } from "react"
import { KeyboardAvoidingView, Text, View } from "react-native"
@ -11,26 +10,21 @@ import {
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import { QRCode } from "@/src/components/ui/qrcode/QRCode"
import { twoFactor } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { queryClient } from "@/src/lib/query-client"
import { toast } from "@/src/lib/toast"
import { whoamiQueryKey } from "@/src/store/user/hooks"
import { accentColor, useColor } from "@/src/theme/colors"
import { useSettingsNavigation } from "../hooks"
import type { SettingsStackParamList } from "../types"
const isAuthCodeValid = (code: string) => {
return code.length === 6 && Number(code) > 0
}
export const TwoFASetting = ({
route,
}: {
route: RouteProp<SettingsStackParamList, "TwoFASetting">
}) => {
export const TwoFASetting: NavigationControllerView<{ totpURI: string }> = ({ totpURI }) => {
const label = useColor("label")
const tertiaryLabel = useColor("tertiaryLabel")
const navigation = useSettingsNavigation()
const navigation = useNavigation()
const submitMutation = useMutation({
mutationFn: async (value: string) => {
const res = await twoFactor.verifyTotp({ code: value })
@ -43,7 +37,7 @@ export const TwoFASetting = ({
toast.error(`Failed to verify: ${error.message}`)
},
onSuccess() {
navigation.goBack()
navigation.back()
toast.success("2FA enabled!")
},
})
@ -56,7 +50,7 @@ export const TwoFASetting = ({
<View className="my-8 items-center justify-center">
<QRCode
data={route.params.totpURI}
data={totpURI}
padding={20}
pieceSize={7}
pieceBorderRadius={4}

View File

@ -1,7 +1,7 @@
import Constants from "expo-constants"
import { Link } from "expo-router"
import { Linking, Text, View } from "react-native"
import { Link } from "@/src/components/common/Link"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,

View File

@ -33,12 +33,14 @@ import {
import { Dialog } from "@/src/lib/dialog"
import { loading } from "@/src/lib/loading"
import { openLink } from "@/src/lib/native"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { toast } from "@/src/lib/toast"
import { useWhoami } from "@/src/store/user/hooks"
import { userSyncService } from "@/src/store/user/store"
import { ConfirmPasswordDialog } from "../../dialogs/ConfirmPasswordDialog"
import { useSettingsNavigation } from "../hooks"
import { TwoFASetting } from "./2FASetting"
import { ResetPassword } from "./ResetPassword"
type Account = {
id: string
@ -232,11 +234,11 @@ const AuthenticationSection = () => {
const SecuritySection = () => {
const { data: account } = useAccount()
const hasPassword = account?.data?.find((account) => account.provider === "credential")
const router = useSettingsNavigation()
const whoAmI = useWhoami()
const twoFactorEnabled = whoAmI?.twoFactorEnabled
const navigation = useNavigation()
return (
<View className="mt-6">
<GroupedInsetListSectionHeader label="Security" />
@ -254,7 +256,7 @@ const SecuritySection = () => {
forgetPassword({ email })
toast.success("We have sent you an email with instructions to reset your password.")
} else {
router.navigate("ResetPassword")
navigation.pushControllerView(ResetPassword)
}
}}
/>
@ -292,7 +294,7 @@ const SecuritySection = () => {
return
}
if (res.data && "totpURI" in res.data) {
router.navigate("TwoFASetting", {
navigation.pushControllerView(TwoFASetting, {
totpURI: res.data.totpURI,
})
} else {

View File

@ -21,6 +21,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { Switch } from "@/src/components/ui/switch/Switch"
import { CheckLineIcon } from "@/src/icons/check_line"
import { Magic2CuteFiIcon } from "@/src/icons/magic_2_cute_fi"
import { useNavigation } from "@/src/lib/navigation/hooks"
import {
useActionRules,
useIsActionDataDirty,
@ -30,7 +31,7 @@ import {
import { actionActions } from "@/src/store/action/store"
import type { ActionRule } from "@/src/store/action/types"
import { useSettingsNavigation } from "../hooks"
import { EditRuleScreen } from "./EditRule"
export const ActionsScreen = () => {
const { isLoading } = usePrefetchActions()
@ -127,7 +128,7 @@ const ListItemCell: ListRenderItem<ActionRule> = (props) => {
return <ListItemCellImpl {...props} />
}
const ListItemCellImpl: ListRenderItem<ActionRule> = ({ item: rule }) => {
const navigation = useSettingsNavigation()
const navigation = useNavigation()
const colors = useColors()
return (
<SwipeableItem
@ -143,7 +144,9 @@ const ListItemCellImpl: ListRenderItem<ActionRule> = ({ item: rule }) => {
{
label: "Edit",
onPress: () => {
navigation.navigate("EditRule", { index: rule.index })
navigation.pushControllerView(EditRuleScreen, {
index: rule.index,
})
},
backgroundColor: colors.blue,
},
@ -151,7 +154,7 @@ const ListItemCellImpl: ListRenderItem<ActionRule> = ({ item: rule }) => {
>
<ItemPressable
className="flex-row justify-between p-4"
onPress={() => navigation.navigate("EditRule", { index: rule.index })}
onPress={() => navigation.presentControllerView(EditRuleScreen, { index: rule.index })}
>
<Text className="text-label text-base">{rule.name}</Text>
<Switch

Some files were not shown because too many files have changed in this diff Show More