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:
parent
853d32afa4
commit
f21b9e7bf0
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
"ToasterModule",
|
||||
"AppleIntelligenceGlowEffectModule",
|
||||
"FollowNative.GaleriaModule",
|
||||
"GaleriaAccessoryModule"
|
||||
"GaleriaAccessoryModule",
|
||||
"TabBarModule",
|
||||
"TabScreenModule",
|
||||
"TabBarPortalModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 },
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { createContext } from "react"
|
||||
import type { Animated } from "react-native"
|
||||
|
||||
export const NavigationContext = createContext<{
|
||||
scrollY: Animated.Value
|
||||
} | null>(null)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }[]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
import type { IPortalMethods } from "./type"
|
||||
|
||||
export default React.createContext<IPortalMethods | null>(null)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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],
|
||||
)
|
||||
})
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Portal } from "./Portal"
|
||||
export { PortalHost } from "./PortalHost"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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!)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
import type { Route } from "./ChainNavigationContext"
|
||||
|
||||
export const GroupedNavigationRouteContext = createContext<Route[][]>(null!)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
import type { Navigation } from "./Navigation"
|
||||
|
||||
export const NavigationInstanceContext = createContext<Navigation>(null!)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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!)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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!)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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!)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { ScreenStackHeaderConfigProps } from "react-native-screens"
|
||||
|
||||
export const defaultHeaderConfig: ScreenStackHeaderConfigProps = {
|
||||
hidden: true,
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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!} />}
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue