bsky-app/src/Navigation.tsx
Hailey d02e0884c4
[🐴] Block Info (#4068)
* get the damn thing in there 😮‍💨

* more cleanup and little fixes

another nit

nit

small annoyance

add a comment

only use `scrollTo` when necessary

remove now unnecessary styles

* move padding out

* add unblock function

* rm need for moderationpts

* ?

* ??

* extract leaveconvoprompt

* move `setHasScrolled` to `onContentSizeChanged`

* account for block footer

* wrap up

nit

make sure recipient is loaded before showing

refactor to hide chat input

typo squigglie

add report dialog

finalize delete

implement custom animation

add configurable replace animation

add leave convo to block options

* correct functionality for report

* moev component to another file

* maybe...

* fix chat item

* improve

* remove unused gtmobile

* nit

* more cleanup

* more cleanup

* fix merge

* fix header

* few more changes

* nit

* remove old
2024-05-17 16:21:15 -05:00

766 lines
24 KiB
TypeScript

import * as React from 'react'
import {JSX} from 'react/jsx-runtime'
import {i18n, MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs'
import {
CommonActions,
createNavigationContainerRef,
DarkTheme,
DefaultTheme,
NavigationContainer,
StackActions,
} from '@react-navigation/native'
import {timeout} from 'lib/async/timeout'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {usePalette} from 'lib/hooks/usePalette'
import {buildStateObject} from 'lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
FeedsTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative} from 'platform/detection'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
setEmailConfirmationRequested,
shouldRequestEmailConfirmation,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {DebugModScreen} from './view/screens/DebugMod'
import {FeedsScreen} from './view/screens/Feeds'
import {HomeScreen} from './view/screens/Home'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ListsScreen} from './view/screens/Lists'
import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
import {ProfileScreen} from './view/screens/Profile'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {ProfileListScreen} from './view/screens/ProfileList'
import {SearchScreen} from './view/screens/Search'
import {SettingsScreen} from './view/screens/Settings'
import {Storybook} from './view/screens/Storybook'
import {SupportScreen} from './view/screens/Support'
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const MessagesTab =
createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
* These "common screens" are reused across stacks.
*/
function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
const title = (page: MessageDescriptor) =>
bskyTitle(i18n._(page), unreadCountLabel)
return (
<>
<Stack.Screen
name="NotFound"
getComponent={() => NotFoundScreen}
options={{title: title(msg`Not Found`)}}
/>
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title(msg`Lists`), requireAuth: true}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title(msg`Moderation`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title(msg`Moderation Lists`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title(msg`Muted Accounts`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title(msg`Settings`), requireAuth: true}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title(msg`Language Settings`), requireAuth: true}}
/>
<Stack.Screen
name="Profile"
getComponent={() => ProfileScreen}
options={({route}) => ({
title: bskyTitle(`@${route.params.name}`, unreadCountLabel),
})}
/>
<Stack.Screen
name="ProfileFollowers"
getComponent={() => ProfileFollowersScreen}
options={({route}) => ({
title: title(msg`People following @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFollows"
getComponent={() => ProfileFollowsScreen}
options={({route}) => ({
title: title(msg`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title(msg`List`), requireAuth: true}}
/>
<Stack.Screen
name="PostThread"
getComponent={() => PostThreadScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostLikedBy"
getComponent={() => PostLikedByScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostRepostedBy"
getComponent={() => PostRepostedByScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
options={{title: title(msg`Feed`)}}
/>
<Stack.Screen
name="ProfileFeedLikedBy"
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="ProfileLabelerLikedBy"
getComponent={() => ProfileLabelerLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="Debug"
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
name="DebugMod"
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title(msg`Log`), requireAuth: true}}
/>
<Stack.Screen
name="Support"
getComponent={() => SupportScreen}
options={{title: title(msg`Support`)}}
/>
<Stack.Screen
name="PrivacyPolicy"
getComponent={() => PrivacyPolicyScreen}
options={{title: title(msg`Privacy Policy`)}}
/>
<Stack.Screen
name="TermsOfService"
getComponent={() => TermsOfServiceScreen}
options={{title: title(msg`Terms of Service`)}}
/>
<Stack.Screen
name="CommunityGuidelines"
getComponent={() => CommunityGuidelinesScreen}
options={{title: title(msg`Community Guidelines`)}}
/>
<Stack.Screen
name="CopyrightPolicy"
getComponent={() => CopyrightPolicyScreen}
options={{title: title(msg`Copyright Policy`)}}
/>
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title(msg`App Passwords`), requireAuth: true}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesFollowingFeed"
getComponent={() => PreferencesFollowingFeed}
options={{
title: title(msg`Following Feed Preferences`),
requireAuth: true,
}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title(msg`Threads Preferences`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesExternalEmbeds"
getComponent={() => PreferencesExternalEmbeds}
options={{
title: title(msg`External Media Preferences`),
requireAuth: true,
}}
/>
<Stack.Screen
name="AccessibilitySettings"
getComponent={() => AccessibilitySettingsScreen}
options={{
title: title(msg`Accessibility Settings`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
<Stack.Screen
name="MessagesConversation"
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
</>
)
}
/**
* The TabsNavigator is used by native mobile to represent the routes
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator() {
const tabBar = React.useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
),
[],
)
return (
<Tab.Navigator
initialRouteName="HomeTab"
backBehavior="initialRoute"
screenOptions={{headerShown: false, lazy: true}}
tabBar={tabBar}>
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
<Tab.Screen
name="NotificationsTab"
getComponent={() => NotificationsTabNavigator}
/>
<Tab.Screen
name="MyProfileTab"
getComponent={() => MyProfileTabNavigator}
/>
<Tab.Screen
name="MessagesTab"
getComponent={() => MessagesTabNavigator}
/>
</Tab.Navigator>
)
}
function HomeTabNavigator() {
const pal = usePalette('default')
return (
<HomeTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
}
function SearchTabNavigator() {
const pal = usePalette('default')
return (
<SearchTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
{commonScreens(SearchTab as typeof HomeTab)}
</SearchTab.Navigator>
)
}
function FeedsTabNavigator() {
const pal = usePalette('default')
return (
<FeedsTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
{commonScreens(FeedsTab as typeof HomeTab)}
</FeedsTab.Navigator>
)
}
function NotificationsTabNavigator() {
const pal = usePalette('default')
return (
<NotificationsTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
)
}
function MyProfileTabNavigator() {
const pal = usePalette('default')
return (
<MyProfileTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
}}
/>
{commonScreens(MyProfileTab as typeof HomeTab)}
</MyProfileTab.Navigator>
)
}
function MessagesTabNavigator() {
const pal = usePalette('default')
return (
<MessagesTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={({route}) => ({
requireAuth: true,
animationTypeForReplace: route.params?.animation ?? 'push',
})}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
</MessagesTab.Navigator>
)
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = () => {
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
return (
<Flat.Navigator
screenListeners={screenListeners}
screenOptions={{
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<Flat.Screen
name="Home"
getComponent={() => HomeScreen}
options={{title: title(msg`Home`)}}
/>
<Flat.Screen
name="Search"
getComponent={() => SearchScreen}
options={{title: title(msg`Search`)}}
/>
<Flat.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{title: title(msg`Feeds`)}}
/>
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
}
/**
* The RoutesContainer should wrap all components which need access
* to the navigation context.
*/
const LINKING = {
// TODO figure out what we are going to use
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) {
// find the current node in the navigation tree
let node = state.routes[state.index || 0]
while (node.state?.routes && typeof node.state?.index === 'number') {
node = node.state?.routes[node.state?.index]
}
// build the path
const route = router.matchName(node.name)
if (typeof route === 'undefined') {
return '/' // default to home
}
return route.build((node.params || {}) as RouteParams)
},
getStateFromPath(path: string) {
const [name, params] = router.matchPath(path)
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
// intent
// On web, there is no route state that's created by default, so we should initialize it as the home route. On
// native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
// since it will be created by react-navigation.
if (path.includes('intent/')) {
if (isNative) return
return buildStateObject('Flat', 'Home', params)
}
if (isNative) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
}
if (name === 'Notifications') {
return buildStateObject('NotificationsTab', 'Notifications', params)
}
if (name === 'Home') {
return buildStateObject('HomeTab', 'Home', params)
}
if (name === 'Messages') {
return buildStateObject('MessagesTab', 'Messages', params)
}
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
return buildStateObject('HomeTab', name, params, [
{
name: 'Home',
params: {},
},
])
} else {
const res = buildStateObject('Flat', name, params)
return res
}
},
}
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
initAnalytics(currentAccount)
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true})
setEmailConfirmationRequested()
}
}
return (
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
logEvent('router:navigate', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logEvent('router:navigate', {})
}}>
{children}
</NavigationContainer>
)
}
function getCurrentRouteName() {
if (navigationRef.isReady()) {
return navigationRef.getCurrentRoute()?.name
} else {
return undefined
}
}
/**
* These helpers can be used from outside of the RoutesContainer
* (eg in the state models).
*/
function navigate<K extends keyof AllNavigatorParams>(
name: K,
params?: AllNavigatorParams[K],
) {
if (navigationRef.isReady()) {
return Promise.race([
new Promise<void>(resolve => {
const handler = () => {
resolve()
navigationRef.removeListener('state', handler)
}
navigationRef.addListener('state', handler)
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
navigationRef.navigate(name, params)
}),
timeout(1e3),
])
}
return Promise.resolve()
}
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
if (navigationRef.isReady()) {
navigate(tabName)
if (navigationRef.canGoBack()) {
navigationRef.dispatch(StackActions.popToTop()) //we need to check .canGoBack() before calling it
}
}
}
// returns a promise that resolves after the state reset is complete
function reset(): Promise<void> {
if (navigationRef.isReady()) {
navigationRef.dispatch(
CommonActions.reset({
index: 0,
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
}),
)
return Promise.race([
timeout(1e3),
new Promise<void>(resolve => {
const handler = () => {
resolve()
navigationRef.removeListener('state', handler)
}
navigationRef.addListener('state', handler)
}),
])
} else {
return Promise.resolve()
}
}
function handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
const [name, params] = router.matchPath(path)
if (isNative) {
if (name === 'Search') {
resetToTab('SearchTab')
} else if (name === 'Notifications') {
resetToTab('NotificationsTab')
} else {
resetToTab('HomeTab')
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
} else {
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
}
let didInit = false
function logModuleInitTime() {
if (didInit) {
return
}
didInit = true
const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
)
console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', {
initMs,
})
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
// Relies on our patch to polyfill.js in metro-runtime
const initLogs = (global as any).__INIT_LOGS__
if (shouldLog && Array.isArray(initLogs)) {
console.log(initLogs.join('\n'))
}
}
}
export {
FlatNavigator,
handleLink,
navigate,
reset,
resetToTab,
RoutesContainer,
TabsNavigator,
}