diff options
Diffstat (limited to 'src')
103 files changed, 3489 insertions, 3423 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 8bb204923..fcd6e787b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -2,18 +2,17 @@ import 'react-native-url-polyfill/auto' import React, {useState, useEffect} from 'react' import {Linking} from 'react-native' import {RootSiblingParent} from 'react-native-root-siblings' -import {GestureHandlerRootView} from 'react-native-gesture-handler' import SplashScreen from 'react-native-splash-screen' import {SafeAreaProvider} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import {ThemeProvider} from 'lib/ThemeContext' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' -import {MobileShell} from './view/shell/mobile' -import {s} from 'lib/styles' +import {Shell} from './view/shell' import * as notifee from 'lib/notifee' import * as analytics from 'lib/analytics' import * as Toast from './view/com/util/Toast' +import {handleLink} from './Navigation' const App = observer(() => { const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( @@ -31,11 +30,11 @@ const App = observer(() => { store.hackCheckIfUpgradeNeeded() Linking.getInitialURL().then((url: string | null) => { if (url) { - store.nav.handleLink(url) + handleLink(url) } }) Linking.addEventListener('url', ({url}) => { - store.nav.handleLink(url) + handleLink(url) }) store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') @@ -48,19 +47,17 @@ const App = observer(() => { return null } return ( - <GestureHandlerRootView style={s.h100pct}> - <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}> - <RootSiblingParent> - <analytics.Provider> - <RootStoreProvider value={rootStore}> - <SafeAreaProvider> - <MobileShell /> - </SafeAreaProvider> - </RootStoreProvider> - </analytics.Provider> - </RootSiblingParent> - </ThemeProvider> - </GestureHandlerRootView> + <ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}> + <RootSiblingParent> + <analytics.Provider> + <RootStoreProvider value={rootStore}> + <SafeAreaProvider> + <Shell /> + </SafeAreaProvider> + </RootStoreProvider> + </analytics.Provider> + </RootSiblingParent> + </ThemeProvider> ) }) diff --git a/src/App.web.tsx b/src/App.web.tsx index 84d3b6cd6..0bfa909be 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,9 +1,9 @@ import React, {useState, useEffect} from 'react' import {SafeAreaProvider} from 'react-native-safe-area-context' -import {getInitialURL} from 'platform/urls' +import {RootSiblingParent} from 'react-native-root-siblings' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' -import {WebShell} from './view/shell/web' +import {Shell} from './view/shell/index' import {ToastContainer} from './view/com/util/Toast.web' function App() { @@ -16,12 +16,6 @@ function App() { view.setup() setupState().then(store => { setRootStore(store) - store.nav.bindWebNavigation() - getInitialURL().then(url => { - if (url) { - store.nav.handleLink(url) - } - }) }) }, []) @@ -31,12 +25,14 @@ function App() { } return ( - <RootStoreProvider value={rootStore}> - <SafeAreaProvider> - <WebShell /> - </SafeAreaProvider> - <ToastContainer /> - </RootStoreProvider> + <RootSiblingParent> + <RootStoreProvider value={rootStore}> + <SafeAreaProvider> + <Shell /> + </SafeAreaProvider> + <ToastContainer /> + </RootStoreProvider> + </RootSiblingParent> ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx new file mode 100644 index 000000000..22d8d8b21 --- /dev/null +++ b/src/Navigation.tsx @@ -0,0 +1,287 @@ +import * as React from 'react' +import {StyleSheet} from 'react-native' +import { + NavigationContainer, + createNavigationContainerRef, + StackActions, +} from '@react-navigation/native' +import {createNativeStackNavigator} from '@react-navigation/native-stack' +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs' +import { + HomeTabNavigatorParams, + SearchTabNavigatorParams, + NotificationsTabNavigatorParams, + FlatNavigatorParams, + AllNavigatorParams, +} from 'lib/routes/types' +import {BottomBar} from './view/shell/BottomBar' +import {buildStateObject} from 'lib/routes/helpers' +import {State, RouteParams} from 'lib/routes/types' +import {colors} from 'lib/styles' +import {isNative} from 'platform/detection' +import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {router} from './routes' + +import {HomeScreen} from './view/screens/Home' +import {SearchScreen} from './view/screens/Search' +import {NotificationsScreen} from './view/screens/Notifications' +import {NotFoundScreen} from './view/screens/NotFound' +import {SettingsScreen} from './view/screens/Settings' +import {ProfileScreen} from './view/screens/Profile' +import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' +import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {PostThreadScreen} from './view/screens/PostThread' +import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy' +import {PostRepostedByScreen} from './view/screens/PostRepostedBy' +import {DebugScreen} from './view/screens/Debug' +import {LogScreen} from './view/screens/Log' + +const navigationRef = createNavigationContainerRef<AllNavigatorParams>() + +const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>() +const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>() +const NotificationsTab = + createNativeStackNavigator<NotificationsTabNavigatorParams>() +const Flat = createNativeStackNavigator<FlatNavigatorParams>() +const Tab = createBottomTabNavigator() + +/** + * These "common screens" are reused across stacks. + */ +function commonScreens(Stack: typeof HomeTab) { + return ( + <> + <Stack.Screen name="NotFound" component={NotFoundScreen} /> + <Stack.Screen name="Settings" component={SettingsScreen} /> + <Stack.Screen name="Profile" component={ProfileScreen} /> + <Stack.Screen + name="ProfileFollowers" + component={ProfileFollowersScreen} + /> + <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} /> + <Stack.Screen name="PostThread" component={PostThreadScreen} /> + <Stack.Screen name="PostUpvotedBy" component={PostUpvotedByScreen} /> + <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} /> + <Stack.Screen name="Debug" component={DebugScreen} /> + <Stack.Screen name="Log" component={LogScreen} /> + </> + ) +} + +/** + * 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 => <BottomBar {...props} />, []) + return ( + <Tab.Navigator + initialRouteName="HomeTab" + backBehavior="initialRoute" + screenOptions={{headerShown: false}} + tabBar={tabBar}> + <Tab.Screen name="HomeTab" component={HomeTabNavigator} /> + <Tab.Screen + name="NotificationsTab" + component={NotificationsTabNavigator} + /> + <Tab.Screen name="SearchTab" component={SearchTabNavigator} /> + </Tab.Navigator> + ) +} + +function HomeTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + <HomeTab.Navigator + screenOptions={{ + gestureEnabled: true, + fullScreenGestureEnabled: true, + headerShown: false, + animationDuration: 250, + contentStyle, + }}> + <HomeTab.Screen name="Home" component={HomeScreen} /> + {commonScreens(HomeTab)} + </HomeTab.Navigator> + ) +} + +function SearchTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + <SearchTab.Navigator + screenOptions={{ + gestureEnabled: true, + fullScreenGestureEnabled: true, + headerShown: false, + animationDuration: 250, + contentStyle, + }}> + <SearchTab.Screen name="Search" component={SearchScreen} /> + {commonScreens(SearchTab as typeof HomeTab)} + </SearchTab.Navigator> + ) +} + +function NotificationsTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + <NotificationsTab.Navigator + screenOptions={{ + gestureEnabled: true, + fullScreenGestureEnabled: true, + headerShown: false, + animationDuration: 250, + contentStyle, + }}> + <NotificationsTab.Screen + name="Notifications" + component={NotificationsScreen} + /> + {commonScreens(NotificationsTab as typeof HomeTab)} + </NotificationsTab.Navigator> + ) +} + +/** + * The FlatNavigator is used by Web to represent the routes + * in a single ("flat") stack. + */ +function FlatNavigator() { + return ( + <Flat.Navigator + screenOptions={{ + gestureEnabled: true, + fullScreenGestureEnabled: true, + headerShown: false, + animationDuration: 250, + contentStyle: {backgroundColor: 'white'}, + }}> + <Flat.Screen name="Home" component={HomeScreen} /> + <Flat.Screen name="Search" component={SearchScreen} /> + <Flat.Screen name="Notifications" component={NotificationsScreen} /> + {commonScreens(Flat as typeof HomeTab)} + </Flat.Navigator> + ) +} + +/** + * The RoutesContainer should wrap all components which need access + * to the navigation context. + */ + +const LINKING = { + prefixes: ['bsky://', '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) + if (isNative) { + if (name === 'Search') { + return buildStateObject('SearchTab', 'Search', params) + } + if (name === 'Notifications') { + return buildStateObject('NotificationsTab', 'Notifications', params) + } + return buildStateObject('HomeTab', name, params) + } else { + return buildStateObject('Flat', name, params) + } + }, +} + +function RoutesContainer({children}: React.PropsWithChildren<{}>) { + return ( + <NavigationContainer ref={navigationRef} linking={LINKING}> + {children} + </NavigationContainer> + ) +} + +/** + * 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()) { + // @ts-ignore I dont know what would make typescript happy but I have a life -prf + navigationRef.navigate(name, params) + } +} + +function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') { + if (navigationRef.isReady()) { + navigate(tabName) + navigationRef.dispatch(StackActions.popToTop()) + } +} + +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) + } +} + +const styles = StyleSheet.create({ + bgDark: { + backgroundColor: colors.black, + }, + bgLight: { + backgroundColor: colors.gray1, + }, +}) + +export { + navigate, + resetToTab, + handleLink, + TabsNavigator, + FlatNavigator, + RoutesContainer, +} diff --git a/src/app.json b/src/app.json deleted file mode 100644 index 1f9e391e0..000000000 --- a/src/app.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "xyz.blueskyweb.app", - "displayName": "Bluesky" -} \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 45a06f40a..000000000 --- a/src/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @format - */ - -import {AppRegistry} from 'react-native' -import App from './App' - -AppRegistry.registerComponent('App', () => App) - -AppRegistry.runApplication('App', { - rootTag: document.getElementById('root'), -}) diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx index 5358a8682..725dd2328 100644 --- a/src/lib/analytics.tsx +++ b/src/lib/analytics.tsx @@ -16,7 +16,7 @@ export function init(store: RootStoreModel) { // this method is a copy of segment's own lifecycle event tracking // we handle it manually to ensure that it never fires while the app is backgrounded // -prf - segmentClient.onContextLoaded(() => { + segmentClient.isReady.onChange(() => { if (AppState.currentState !== 'active') { store.log.debug('Prevented a metrics ping while the app was backgrounded') return diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 3b8af44e8..85eca4a61 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -117,7 +117,9 @@ export async function post(store: RootStoreModel, opts: PostOpts) { if (opts.extLink.localThumb) { opts.onStateChange?.('Uploading link thumbnail...') let encoding - if (opts.extLink.localThumb.path.endsWith('.png')) { + if (opts.extLink.localThumb.mime) { + encoding = opts.extLink.localThumb.mime + } else if (opts.extLink.localThumb.path.endsWith('.png')) { encoding = 'image/png' } else if ( opts.extLink.localThumb.path.endsWith('.jpeg') || diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts index d7f4a7287..d7ef9a05e 100644 --- a/src/lib/assets.native.ts +++ b/src/lib/assets.native.ts @@ -1,5 +1,5 @@ import {ImageRequireSource} from 'react-native' -export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg') -export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg') -export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png') +export const DEF_AVATAR: ImageRequireSource = require('../../assets/default-avatar.jpg') +export const TABS_EXPLAINER: ImageRequireSource = require('../../assets/tabs-explainer.jpg') +export const CLOUD_SPLASH: ImageRequireSource = require('../../assets/cloud-splash.png') diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts index 155230e5d..28b650b6f 100644 --- a/src/lib/build-flags.ts +++ b/src/lib/build-flags.ts @@ -1,2 +1 @@ export const LOGIN_INCLUDE_DEV_SERVERS = true -export const TABS_ENABLED = false diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 31947cd8f..ef4bb0f08 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -166,5 +166,3 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) { export const POST_IMG_MAX_WIDTH = 2000 export const POST_IMG_MAX_HEIGHT = 2000 export const POST_IMG_MAX_SIZE = 1000000 - -export const DESKTOP_HEADER_HEIGHT = 57 diff --git a/src/lib/hooks/useColorSchemeStyle.ts b/src/lib/hooks/useColorSchemeStyle.ts index 61e3d7cc9..18c48b961 100644 --- a/src/lib/hooks/useColorSchemeStyle.ts +++ b/src/lib/hooks/useColorSchemeStyle.ts @@ -1,6 +1,6 @@ -import {useColorScheme} from 'react-native' +import {useTheme} from 'lib/ThemeContext' export function useColorSchemeStyle(lightStyle: any, darkStyle: any) { - const colorScheme = useColorScheme() + const colorScheme = useTheme().colorScheme return colorScheme === 'dark' ? darkStyle : lightStyle } diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts new file mode 100644 index 000000000..36a92ac32 --- /dev/null +++ b/src/lib/hooks/usePermissions.ts @@ -0,0 +1,50 @@ +import {Alert} from 'react-native' +import {Camera} from 'expo-camera' +import * as MediaLibrary from 'expo-media-library' +import {Linking} from 'react-native' + +const openSettings = () => { + Linking.openURL('app-settings:') +} + +const openPermissionAlert = (perm: string) => { + Alert.alert( + 'Permission needed', + `Bluesky does not have permission to access your ${perm}.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Open Settings', onPress: () => openSettings()}, + ], + ) +} + +export function usePhotoLibraryPermission() { + const [mediaLibraryPermissions] = MediaLibrary.usePermissions() + const requestPhotoAccessIfNeeded = async () => { + if (mediaLibraryPermissions?.status === 'granted') { + return true + } else { + openPermissionAlert('photo library') + return false + } + } + return {requestPhotoAccessIfNeeded} +} + +export function useCameraPermission() { + const [cameraPermissionStatus] = Camera.useCameraPermissions() + + const requestCameraAccessIfNeeded = async () => { + if (cameraPermissionStatus?.granted) { + return true + } else { + openPermissionAlert('camera') + return false + } + } + + return {requestCameraAccessIfNeeded} +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index f82ea2602..e194e7a87 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -73,12 +73,10 @@ export function HomeIconSolid({ style, size, strokeWidth = 4, - fillOpacity = 1, }: { style?: StyleProp<ViewStyle> size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( <Svg @@ -89,11 +87,6 @@ export function HomeIconSolid({ style={style}> <Path fill="currentColor" - stroke="none" - opacity={fillOpacity} - d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" - /> - <Path strokeWidth={strokeWidth} d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" /> @@ -158,12 +151,10 @@ export function MagnifyingGlassIcon2Solid({ style, size, strokeWidth = 2, - fillOpacity = 1, }: { style?: StyleProp<ViewStyle> size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( <Svg @@ -181,7 +172,6 @@ export function MagnifyingGlassIcon2Solid({ ry="7" stroke="none" fill="currentColor" - opacity={fillOpacity} /> <Ellipse cx="12" cy="11" rx="9" ry="9" /> <Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" /> @@ -219,12 +209,10 @@ export function BellIconSolid({ style, size, strokeWidth = 1.5, - fillOpacity = 1, }: { style?: StyleProp<ViewStyle> size?: string | number strokeWidth?: number - fillOpacity?: number }) { return ( <Svg @@ -237,10 +225,7 @@ export function BellIconSolid({ <Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" fill="currentColor" - stroke="none" - opacity={fillOpacity} /> - <Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" /> <Line x1="9" y1="22" x2="15" y2="22" /> </Svg> ) @@ -278,6 +263,34 @@ export function CogIcon({ ) } +export function CogIconSolid({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={strokeWidth} + stroke="currentColor" + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M 9.594 3.94 C 9.684 3.398 10.154 3 10.704 3 L 13.297 3 C 13.847 3 14.317 3.398 14.407 3.94 L 14.62 5.221 C 14.683 5.595 14.933 5.907 15.265 6.091 C 15.339 6.131 15.412 6.174 15.485 6.218 C 15.809 6.414 16.205 6.475 16.56 6.342 L 17.777 5.886 C 18.292 5.692 18.872 5.9 19.147 6.376 L 20.443 8.623 C 20.718 9.099 20.608 9.705 20.183 10.054 L 19.18 10.881 C 18.887 11.121 18.742 11.494 18.749 11.873 C 18.751 11.958 18.751 12.043 18.749 12.128 C 18.742 12.506 18.887 12.878 19.179 13.118 L 20.184 13.946 C 20.608 14.296 20.718 14.9 20.444 15.376 L 19.146 17.623 C 18.871 18.099 18.292 18.307 17.777 18.114 L 16.56 17.658 C 16.205 17.525 15.81 17.586 15.484 17.782 C 15.412 17.826 15.338 17.869 15.264 17.91 C 14.933 18.093 14.683 18.405 14.62 18.779 L 14.407 20.059 C 14.317 20.602 13.847 21 13.297 21 L 10.703 21 C 10.153 21 9.683 20.602 9.593 20.06 L 9.38 18.779 C 9.318 18.405 9.068 18.093 8.736 17.909 C 8.662 17.868 8.589 17.826 8.516 17.782 C 8.191 17.586 7.796 17.525 7.44 17.658 L 6.223 18.114 C 5.708 18.307 5.129 18.1 4.854 17.624 L 3.557 15.377 C 3.282 14.901 3.392 14.295 3.817 13.946 L 4.821 13.119 C 5.113 12.879 5.258 12.506 5.251 12.127 C 5.249 12.042 5.249 11.957 5.251 11.872 C 5.258 11.494 5.113 11.122 4.821 10.882 L 3.817 10.054 C 3.393 9.705 3.283 9.1 3.557 8.624 L 4.854 6.377 C 5.129 5.9 5.709 5.692 6.224 5.886 L 7.44 6.342 C 7.796 6.475 8.191 6.414 8.516 6.218 C 8.588 6.174 8.662 6.131 8.736 6.09 C 9.068 5.907 9.318 5.595 9.38 5.221 Z M 13.5 9.402 C 11.5 8.247 9 9.691 9 12 C 9 13.072 9.572 14.062 10.5 14.598 C 12.5 15.753 15 14.309 15 12 C 15 10.928 14.428 9.938 13.5 9.402 Z" + fill="currentColor" + /> + </Svg> + ) +} + // Copyright (c) 2020 Refactoring UI Inc. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function MoonIcon({ @@ -336,6 +349,45 @@ export function UserIcon({ ) } +export function UserIconSolid({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={strokeWidth} + stroke="currentColor" + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + fill="currentColor" + d="M 15 9.75 C 15 12.059 12.5 13.503 10.5 12.348 C 9.572 11.812 9 10.822 9 9.75 C 9 7.441 11.5 5.997 13.5 7.152 C 14.428 7.688 15 8.678 15 9.75 Z" + /> + <Path + strokeLinecap="round" + strokeLinejoin="round" + fill="currentColor" + d="M 17.982 18.725 C 16.565 16.849 14.35 15.748 12 15.75 C 9.65 15.748 7.435 16.849 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725" + /> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M 17.981 18.725 C 23.158 14.12 21.409 5.639 14.833 3.458 C 8.257 1.277 1.786 7.033 3.185 13.818 C 3.576 15.716 4.57 17.437 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725" + /> + </Svg> + ) +} + // Copyright (c) 2020 Refactoring UI Inc. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function UserGroupIcon({ @@ -674,6 +726,7 @@ export function ComposeIcon2({ <Svg viewBox="0 0 24 24" stroke="currentColor" + fill="none" width={size || 24} height={size || 24} style={style}> diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index c9c2ed31a..0d8e8c69b 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,19 +1,20 @@ import {LikelyType, LinkMeta} from './link-meta' -import {match as matchRoute} from 'view/routes' +// import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' import {RootStoreModel} from 'state/index' import {PostThreadViewModel} from 'state/models/post-thread-view' import {ComposerOptsQuote} from 'state/models/shell-ui' -import {Home} from 'view/screens/Home' -import {Search} from 'view/screens/Search' -import {Notifications} from 'view/screens/Notifications' -import {PostThread} from 'view/screens/PostThread' -import {PostUpvotedBy} from 'view/screens/PostUpvotedBy' -import {PostRepostedBy} from 'view/screens/PostRepostedBy' -import {Profile} from 'view/screens/Profile' -import {ProfileFollowers} from 'view/screens/ProfileFollowers' -import {ProfileFollows} from 'view/screens/ProfileFollows' +// TODO +// import {Home} from 'view/screens/Home' +// import {Search} from 'view/screens/Search' +// import {Notifications} from 'view/screens/Notifications' +// import {PostThread} from 'view/screens/PostThread' +// import {PostUpvotedBy} from 'view/screens/PostUpvotedBy' +// import {PostRepostedBy} from 'view/screens/PostRepostedBy' +// import {Profile} from 'view/screens/Profile' +// import {ProfileFollowers} from 'view/screens/ProfileFollowers' +// import {ProfileFollows} from 'view/screens/ProfileFollows' // NOTE // this is a hack around the lack of hosted social metadata @@ -24,77 +25,77 @@ export async function extractBskyMeta( url: string, ): Promise<LinkMeta> { url = convertBskyAppUrlIfNeeded(url) - const route = matchRoute(url) + // const route = matchRoute(url) let meta: LinkMeta = { likelyType: LikelyType.AtpData, url, - title: route.defaultTitle, + // title: route.defaultTitle, } - if (route.Com === Home) { - meta = { - ...meta, - title: 'Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Search) { - meta = { - ...meta, - title: 'Search - Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Notifications) { - meta = { - ...meta, - title: 'Notifications - Bluesky', - description: 'A new kind of social network', - } - } else if ( - route.Com === PostThread || - route.Com === PostUpvotedBy || - route.Com === PostRepostedBy - ) { - // post and post-related screens - const threadUri = makeRecordUri( - route.params.name, - 'app.bsky.feed.post', - route.params.rkey, - ) - const threadView = new PostThreadViewModel(store, { - uri: threadUri, - depth: 0, - }) - await threadView.setup().catch(_err => undefined) - const title = [ - route.Com === PostUpvotedBy - ? 'Likes on a post by' - : route.Com === PostRepostedBy - ? 'Reposts of a post by' - : 'Post by', - threadView.thread?.post.author.displayName || - threadView.thread?.post.author.handle || - 'a bluesky user', - ].join(' ') - meta = { - ...meta, - title, - description: threadView.thread?.postRecord?.text, - } - } else if ( - route.Com === Profile || - route.Com === ProfileFollowers || - route.Com === ProfileFollows - ) { - // profile and profile-related screens - const profile = await store.profiles.getProfile(route.params.name) - if (profile?.data) { - meta = { - ...meta, - title: profile.data.displayName || profile.data.handle, - description: profile.data.description, - } - } - } + // if (route.Com === Home) { + // meta = { + // ...meta, + // title: 'Bluesky', + // description: 'A new kind of social network', + // } + // } else if (route.Com === Search) { + // meta = { + // ...meta, + // title: 'Search - Bluesky', + // description: 'A new kind of social network', + // } + // } else if (route.Com === Notifications) { + // meta = { + // ...meta, + // title: 'Notifications - Bluesky', + // description: 'A new kind of social network', + // } + // } else if ( + // route.Com === PostThread || + // route.Com === PostUpvotedBy || + // route.Com === PostRepostedBy + // ) { + // // post and post-related screens + // const threadUri = makeRecordUri( + // route.params.name, + // 'app.bsky.feed.post', + // route.params.rkey, + // ) + // const threadView = new PostThreadViewModel(store, { + // uri: threadUri, + // depth: 0, + // }) + // await threadView.setup().catch(_err => undefined) + // const title = [ + // route.Com === PostUpvotedBy + // ? 'Likes on a post by' + // : route.Com === PostRepostedBy + // ? 'Reposts of a post by' + // : 'Post by', + // threadView.thread?.post.author.displayName || + // threadView.thread?.post.author.handle || + // 'a bluesky user', + // ].join(' ') + // meta = { + // ...meta, + // title, + // description: threadView.thread?.postRecord?.text, + // } + // } else if ( + // route.Com === Profile || + // route.Com === ProfileFollowers || + // route.Com === ProfileFollows + // ) { + // // profile and profile-related screens + // const profile = await store.profiles.getProfile(route.params.name) + // if (profile?.data) { + // meta = { + // ...meta, + // title: profile.data.displayName || profile.data.handle, + // description: profile.data.description, + // } + // } + // } return meta } diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index e617d01af..cd0bb3bc9 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,5 +1,6 @@ // import {Share} from 'react-native' // import * as Toast from 'view/com/util/Toast' +import {extractDataUriMime, getDataUriSize} from './util' export interface DownloadAndResizeOpts { uri: string @@ -18,9 +19,15 @@ export interface Image { height: number } -export async function downloadAndResize(_opts: DownloadAndResizeOpts) { - // TODO - throw new Error('TODO') +export async function downloadAndResize(opts: DownloadAndResizeOpts) { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), opts.timeout || 5e3) + const res = await fetch(opts.uri) + const resBody = await res.blob() + clearTimeout(to) + + const dataUri = await blobToDataUri(resBody) + return await resize(dataUri, opts) } export interface ResizeOpts { @@ -31,11 +38,18 @@ export interface ResizeOpts { } export async function resize( - _localUri: string, + dataUri: string, _opts: ResizeOpts, ): Promise<Image> { - // TODO - throw new Error('TODO') + const dim = await getImageDim(dataUri) + // TODO -- need to resize + return { + path: dataUri, + mime: extractDataUriMime(dataUri), + size: getDataUriSize(dataUri), + width: dim.width, + height: dim.height, + } } export async function compressIfNeeded( @@ -86,3 +100,18 @@ export async function getImageDim(path: string): Promise<Dim> { await promise return {width: img.width, height: img.height} } + +function blobToDataUri(blob: Blob): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to read blob')) + } + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 746feaedd..43675074e 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -10,6 +10,7 @@ import { compressIfNeeded, moveToPremanantPath, } from 'lib/media/manip' +import {extractDataUriMime} from './util' interface PickedFile { uri: string @@ -138,7 +139,3 @@ function selectFile(opts: PickerOpts): Promise<PickedFile> { input.click() }) } - -function extractDataUriMime(uri: string): string { - return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) -} diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts new file mode 100644 index 000000000..a27c71d82 --- /dev/null +++ b/src/lib/media/util.ts @@ -0,0 +1,7 @@ +export function extractDataUriMime(uri: string): string { + return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) +} + +export function getDataUriSize(uri: string): number { + return Math.round((uri.length * 3) / 4) // very rough estimate +} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts index fb0afdd60..4baf64050 100644 --- a/src/lib/notifee.ts +++ b/src/lib/notifee.ts @@ -1,9 +1,9 @@ import notifee, {EventType} from '@notifee/react-native' import {AppBskyEmbedImages} from '@atproto/api' import {RootStoreModel} from 'state/models/root-store' -import {TabPurpose} from 'state/models/navigation' import {NotificationsViewItemModel} from 'state/models/notifications-view' import {enforceLen} from 'lib/strings/helpers' +import {resetToTab} from '../Navigation' export function init(store: RootStoreModel) { store.onUnreadNotifications(count => notifee.setBadgeCount(count)) @@ -16,7 +16,7 @@ export function init(store: RootStoreModel) { store.log.debug('Notifee foreground event', {type}) if (type === EventType.PRESS) { store.log.debug('User pressed a notifee, opening notifications') - store.nav.switchTo(TabPurpose.Notifs, true) + resetToTab('NotificationsTab') } }) notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts deleted file mode 100644 index ab2c73ca6..000000000 --- a/src/lib/permissions.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {Alert} from 'react-native' -import { - check, - openSettings, - Permission, - PermissionStatus, - PERMISSIONS, - RESULTS, -} from 'react-native-permissions' - -export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY -export const CAMERA = PERMISSIONS.IOS.CAMERA - -/** - * Returns `true` if the user has granted permission or hasn't made - * a decision yet. Returns `false` if unavailable or not granted. - */ -export async function hasAccess(perm: Permission): Promise<boolean> { - const status = await check(perm) - return isntANo(status) -} - -export async function requestAccessIfNeeded( - perm: Permission, -): Promise<boolean> { - if (await hasAccess(perm)) { - return true - } - let permDescription - if (perm === PHOTO_LIBRARY) { - permDescription = 'photo library' - } else if (perm === CAMERA) { - permDescription = 'camera' - } else { - return false - } - Alert.alert( - 'Permission needed', - `Bluesky does not have permission to access your ${permDescription}.`, - [ - { - text: 'Cancel', - style: 'cancel', - }, - {text: 'Open Settings', onPress: () => openSettings()}, - ], - ) - return false -} - -export async function requestPhotoAccessIfNeeded() { - return requestAccessIfNeeded(PHOTO_LIBRARY) -} - -export async function requestCameraAccessIfNeeded() { - return requestAccessIfNeeded(CAMERA) -} - -function isntANo(status: PermissionStatus): boolean { - return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED -} diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts deleted file mode 100644 index 5b69637ed..000000000 --- a/src/lib/permissions.web.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* -At the moment, Web doesn't have any equivalence for these. -*/ - -export const PHOTO_LIBRARY = '' -export const CAMERA = '' - -export async function hasAccess(_perm: any): Promise<boolean> { - return true -} - -export async function requestAccessIfNeeded(_perm: any): Promise<boolean> { - return true -} - -export async function requestPhotoAccessIfNeeded() { - return requestAccessIfNeeded(PHOTO_LIBRARY) -} - -export async function requestCameraAccessIfNeeded() { - return requestAccessIfNeeded(CAMERA) -} diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts new file mode 100644 index 000000000..be76b9669 --- /dev/null +++ b/src/lib/routes/helpers.ts @@ -0,0 +1,77 @@ +import {State, RouteParams} from './types' + +export function getCurrentRoute(state: State) { + let node = state.routes[state.index || 0] + while (node.state?.routes && typeof node.state?.index === 'number') { + node = node.state?.routes[node.state?.index] + } + return node +} + +export function isStateAtTabRoot(state: State | undefined) { + if (!state) { + // NOTE + // if state is not defined it's because init is occuring + // and therefore we can safely assume we're at root + // -prf + return true + } + const currentRoute = getCurrentRoute(state) + return ( + isTab(currentRoute.name, 'Home') || + isTab(currentRoute.name, 'Search') || + isTab(currentRoute.name, 'Notifications') + ) +} + +export function isTab(current: string, route: string) { + // NOTE + // our tab routes can be variously referenced by 3 different names + // this helper deals with that weirdness + // -prf + return ( + current === route || + current === `${route}Tab` || + current === `${route}Inner` + ) +} + +export enum TabState { + InsideAtRoot, + Inside, + Outside, +} +export function getTabState(state: State | undefined, tab: string): TabState { + if (!state) { + return TabState.Outside + } + const currentRoute = getCurrentRoute(state) + if (isTab(currentRoute.name, tab)) { + return TabState.InsideAtRoot + } else if (isTab(state.routes[state.index || 0].name, tab)) { + return TabState.Inside + } + return TabState.Outside +} + +export function buildStateObject( + stack: string, + route: string, + params: RouteParams, +) { + if (stack === 'Flat') { + return { + routes: [{name: route, params}], + } + } + return { + routes: [ + { + name: stack, + state: { + routes: [{name: route, params}], + }, + }, + ], + } +} diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts new file mode 100644 index 000000000..05e0a63de --- /dev/null +++ b/src/lib/routes/router.ts @@ -0,0 +1,55 @@ +import {RouteParams, Route} from './types' + +export class Router { + routes: [string, Route][] = [] + constructor(description: Record<string, string>) { + for (const [screen, pattern] of Object.entries(description)) { + this.routes.push([screen, createRoute(pattern)]) + } + } + + matchName(name: string): Route | undefined { + for (const [screenName, route] of this.routes) { + if (screenName === name) { + return route + } + } + } + + matchPath(path: string): [string, RouteParams] { + let name = 'NotFound' + let params: RouteParams = {} + for (const [screenName, route] of this.routes) { + const res = route.match(path) + if (res) { + name = screenName + params = res.params + break + } + } + return [name, params] + } +} + +function createRoute(pattern: string): Route { + let matcherReInternal = pattern.replace( + /:([\w]+)/g, + (_m, name) => `(?<${name}>[^/]+)`, + ) + const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i') + return { + match(path: string) { + const res = matcherRe.exec(path) + if (res) { + return {params: res.groups || {}} + } + return undefined + }, + build(params: Record<string, string>) { + return pattern.replace( + /:([\w]+)/g, + (_m, name) => params[name] || 'undefined', + ) + }, + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts new file mode 100644 index 000000000..e339a46bf --- /dev/null +++ b/src/lib/routes/types.ts @@ -0,0 +1,61 @@ +import {NavigationState, PartialState} from '@react-navigation/native' +import type {NativeStackNavigationProp} from '@react-navigation/native-stack' + +export type {NativeStackScreenProps} from '@react-navigation/native-stack' + +export type CommonNavigatorParams = { + NotFound: undefined + Settings: undefined + Profile: {name: string} + ProfileFollowers: {name: string} + ProfileFollows: {name: string} + PostThread: {name: string; rkey: string} + PostUpvotedBy: {name: string; rkey: string} + PostRepostedBy: {name: string; rkey: string} + Debug: undefined + Log: undefined +} + +export type HomeTabNavigatorParams = CommonNavigatorParams & { + Home: undefined +} + +export type SearchTabNavigatorParams = CommonNavigatorParams & { + Search: undefined +} + +export type NotificationsTabNavigatorParams = CommonNavigatorParams & { + Notifications: undefined +} + +export type FlatNavigatorParams = CommonNavigatorParams & { + Home: undefined + Search: undefined + Notifications: undefined +} + +export type AllNavigatorParams = CommonNavigatorParams & { + HomeTab: undefined + Home: undefined + SearchTab: undefined + Search: undefined + NotificationsTab: undefined + Notifications: undefined +} + +// NOTE +// this isn't strictly correct but it should be close enough +// a TS wizard might be able to get this 100% +// -prf +export type NavigationProp = NativeStackNavigationProp<AllNavigatorParams> + +export type State = + | NavigationState + | Omit<PartialState<NavigationState>, 'stale'> + +export type RouteParams = Record<string, string> +export type MatchResult = {params: RouteParams} +export type Route = { + match: (path: string) => MatchResult | undefined + build: (params: RouteParams) => string +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index dbce39178..328229f46 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,7 +1,5 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {Theme, TypographyVariant} from './ThemeContext' -import {isDesktopWeb} from 'platform/detection' -import {DESKTOP_HEADER_HEIGHT} from './constants' // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest export const colors = { @@ -161,9 +159,7 @@ export const s = StyleSheet.create({ // dimensions w100pct: {width: '100%'}, h100pct: {height: '100%'}, - hContentRegion: isDesktopWeb - ? {height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`} - : {height: '100%'}, + hContentRegion: {height: '100%'}, // text align textLeft: {textAlign: 'left'}, diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 000000000..5987177ef --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,16 @@ +import {Router} from 'lib/routes/router' + +export const router = new Router({ + Home: '/', + Search: '/search', + Notifications: '/notifications', + Settings: '/settings', + Profile: '/profile/:name', + ProfileFollowers: '/profile/:name/followers', + ProfileFollows: '/profile/:name/follows', + PostThread: '/profile/:name/post/:rkey', + PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by', + PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + Debug: '/sys/debug', + Log: '/sys/log', +}) diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts deleted file mode 100644 index 11af65912..000000000 --- a/src/state/models/navigation.ts +++ /dev/null @@ -1,434 +0,0 @@ -import {RootStoreModel} from './root-store' -import {makeAutoObservable} from 'mobx' -import {TABS_ENABLED} from 'lib/build-flags' -import * as analytics from 'lib/analytics' -import {isNative} from 'platform/detection' - -let __id = 0 -function genId() { - return String(++__id) -} - -// NOTE -// this model was originally built for a freeform "tabs" concept like a browser -// we've since decided to pause that idea and do something more traditional -// until we're fully sure what that is, the tabs are being repurposed into a fixed topology -// - Tab 0: The "Default" tab -// - Tab 1: The "Search" tab -// - Tab 2: The "Notifications" tab -// These tabs always retain the first item in their history. -// -prf -export enum TabPurpose { - Default = 0, - Search = 1, - Notifs = 2, -} - -export const TabPurposeMainPath: Record<TabPurpose, string> = { - [TabPurpose.Default]: '/', - [TabPurpose.Search]: '/search', - [TabPurpose.Notifs]: '/notifications', -} - -interface HistoryItem { - url: string - ts: number - title?: string - id: string -} - -export type HistoryPtr = string // `{tabId}-{historyId}` - -export class NavigationTabModel { - id = genId() - history: HistoryItem[] - index = 0 - isNewTab = false - - constructor(public fixedTabPurpose: TabPurpose) { - this.history = [ - {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()}, - ] - makeAutoObservable(this, { - serialize: false, - hydrate: false, - }) - } - // accessors - // = - - get current() { - return this.history[this.index] - } - - get canGoBack() { - return this.index > 0 - } - - get canGoForward() { - return this.index < this.history.length - 1 - } - - getBackList(n: number) { - const start = Math.max(this.index - n, 0) - const end = this.index - return this.history.slice(start, end).map((item, i) => ({ - url: item.url, - title: item.title, - index: start + i, - id: item.id, - })) - } - - get backTen() { - return this.getBackList(10) - } - - getForwardList(n: number) { - const start = Math.min(this.index + 1, this.history.length) - const end = Math.min(this.index + n + 1, this.history.length) - return this.history.slice(start, end).map((item, i) => ({ - url: item.url, - title: item.title, - index: start + i, - id: item.id, - })) - } - - get forwardTen() { - return this.getForwardList(10) - } - - // navigation - // = - - navigate(url: string, title?: string) { - try { - const path = url.split('/')[1] - analytics.track('Navigation', { - path, - }) - } catch (error) {} - - if (this.current?.url === url) { - this.refresh() - } else { - if (this.index < this.history.length - 1) { - this.history.length = this.index + 1 - } - // TEMP ensure the tab has its purpose's main view -prf - if (this.history.length < 1) { - const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose] - this.history.push({url: fixedUrl, ts: Date.now(), id: genId()}) - } - this.history.push({url, title, ts: Date.now(), id: genId()}) - this.index = this.history.length - 1 - if (!isNative) { - window.history.pushState({hindex: this.index, hurl: url}, '', url) - } - } - } - - refresh() { - this.history = [ - ...this.history.slice(0, this.index), - { - url: this.current.url, - title: this.current.title, - ts: Date.now(), - id: this.current.id, - }, - ...this.history.slice(this.index + 1), - ] - } - - goBack() { - if (this.canGoBack) { - this.index-- - if (!isNative) { - window.history.back() - } - } - } - - // TEMP - // a helper to bring the tab back to its base state - // -prf - fixedTabReset() { - this.index = 0 - } - - goForward() { - if (this.canGoForward) { - this.index++ - if (!isNative) { - window.history.forward() - } - } - } - - goToIndex(index: number) { - if (index >= 0 && index <= this.history.length - 1) { - const delta = index - this.index - this.index = index - if (!isNative) { - window.history.go(delta) - } - } - } - - setTitle(id: string, title: string) { - this.history = this.history.map(h => { - if (h.id === id) { - return {...h, title} - } - return h - }) - } - - setIsNewTab(v: boolean) { - this.isNewTab = v - } - - // browser only - // = - - resetTo(url: string) { - this.index = 0 - this.history.push({url, title: '', ts: Date.now(), id: genId()}) - this.index = this.history.length - 1 - } - - // persistence - // = - - serialize(): unknown { - return { - history: this.history, - index: this.index, - } - } - - hydrate(_v: unknown) { - // TODO fixme - // if (isObj(v)) { - // if (hasProp(v, 'history') && Array.isArray(v.history)) { - // for (const item of v.history) { - // if ( - // isObj(item) && - // hasProp(item, 'url') && - // typeof item.url === 'string' - // ) { - // let copy: HistoryItem = { - // url: item.url, - // ts: - // hasProp(item, 'ts') && typeof item.ts === 'number' - // ? item.ts - // : Date.now(), - // } - // if (hasProp(item, 'title') && typeof item.title === 'string') { - // copy.title = item.title - // } - // this.history.push(copy) - // } - // } - // } - // if (hasProp(v, 'index') && typeof v.index === 'number') { - // this.index = v.index - // } - // if (this.index >= this.history.length - 1) { - // this.index = this.history.length - 1 - // } - // } - } -} - -export class NavigationModel { - tabs: NavigationTabModel[] = isNative - ? [ - new NavigationTabModel(TabPurpose.Default), - new NavigationTabModel(TabPurpose.Search), - new NavigationTabModel(TabPurpose.Notifs), - ] - : [new NavigationTabModel(TabPurpose.Default)] - tabIndex = 0 - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - serialize: false, - hydrate: false, - }) - } - - /** - * Used only in the web build to sync with browser history state - */ - bindWebNavigation() { - if (!isNative) { - window.addEventListener('popstate', e => { - const {hindex, hurl} = e.state - if (hindex >= 0 && hindex <= this.tab.history.length - 1) { - this.tab.index = hindex - } - if (this.tab.current.url !== hurl) { - // desynced because they went back to an old tab session- - // do a reset to match that - this.tab.resetTo(hurl) - } - - // sanity check - if (this.tab.current.url !== window.location.pathname) { - // state has completely desynced, reload - window.location.reload() - } - }) - } - } - - clear() { - this.tabs = isNative - ? [ - new NavigationTabModel(TabPurpose.Default), - new NavigationTabModel(TabPurpose.Search), - new NavigationTabModel(TabPurpose.Notifs), - ] - : [new NavigationTabModel(TabPurpose.Default)] - this.tabIndex = 0 - } - - // accessors - // = - - get tab() { - return this.tabs[this.tabIndex] - } - - get tabCount() { - return this.tabs.length - } - - isCurrentScreen(tabId: string, index: number) { - return this.tab.id === tabId && this.tab.index === index - } - - // navigation - // = - - navigate(url: string, title?: string) { - this.rootStore.emitNavigation() - this.tab.navigate(url, title) - } - - refresh() { - this.tab.refresh() - } - - setTitle(ptr: HistoryPtr, title: string) { - const [tid, hid] = ptr.split('-') - this.tabs.find(t => t.id === tid)?.setTitle(hid, title) - } - - 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 - } - this.navigate(path) - } - - // tab management - // = - - // TEMP - // fixed tab helper function - // -prf - switchTo(purpose: TabPurpose, reset: boolean) { - this.rootStore.emitNavigation() - switch (purpose) { - case TabPurpose.Notifs: - this.tabIndex = 2 - break - case TabPurpose.Search: - this.tabIndex = 1 - break - default: - this.tabIndex = 0 - } - if (reset) { - this.tab.fixedTabReset() - } - } - - newTab(url: string, title?: string) { - if (!TABS_ENABLED) { - return this.navigate(url) - } - const tab = new NavigationTabModel(TabPurpose.Default) - tab.navigate(url, title) - tab.isNewTab = true - this.tabs.push(tab) - this.tabIndex = this.tabs.length - 1 - } - - setActiveTab(tabIndex: number) { - if (!TABS_ENABLED) { - return - } - this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0) - } - - closeTab(tabIndex: number) { - if (!TABS_ENABLED) { - return - } - this.tabs = [ - ...this.tabs.slice(0, tabIndex), - ...this.tabs.slice(tabIndex + 1), - ] - if (this.tabs.length === 0) { - this.newTab('/') - } else if (this.tabIndex >= this.tabs.length) { - this.tabIndex = this.tabs.length - 1 - } - } - - // persistence - // = - - serialize(): unknown { - return { - tabs: this.tabs.map(t => t.serialize()), - tabIndex: this.tabIndex, - } - } - - hydrate(_v: unknown) { - // TODO fixme - this.clear() - /*if (isObj(v)) { - if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) { - for (const tab of v.tabs) { - const copy = new NavigationTabModel() - copy.hydrate(tab) - if (copy.history.length) { - this.tabs.push(copy) - } - } - } - if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') { - this.tabIndex = v.tabIndex - } - }*/ - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 4b62f501e..203dacce8 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -11,12 +11,12 @@ import {z} from 'zod' import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' -import {NavigationModel} from './navigation' import {ShellUiModel} from './shell-ui' import {ProfilesViewModel} from './profiles-view' import {LinkMetasViewModel} from './link-metas-view' import {NotificationsViewItemModel} from './notifications-view' import {MeModel} from './me' +import {resetToTab} from '../../Navigation' export const appInfo = z.object({ build: z.string(), @@ -31,7 +31,6 @@ export class RootStoreModel { appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) - nav = new NavigationModel(this) shell = new ShellUiModel(this) me = new MeModel(this) profiles = new ProfilesViewModel(this) @@ -82,7 +81,6 @@ export class RootStoreModel { log: this.log.serialize(), session: this.session.serialize(), me: this.me.serialize(), - nav: this.nav.serialize(), shell: this.shell.serialize(), } } @@ -101,9 +99,6 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } - if (hasProp(v, 'nav')) { - this.nav.hydrate(v.nav) - } if (hasProp(v, 'session')) { this.session.hydrate(v.session) } @@ -144,7 +139,7 @@ export class RootStoreModel { */ async handleSessionDrop() { this.log.debug('RootStoreModel:handleSessionDrop') - this.nav.clear() + resetToTab('HomeTab') this.me.clear() this.emitSessionDropped() } @@ -155,7 +150,7 @@ export class RootStoreModel { clearAllSessionState() { this.log.debug('RootStoreModel:clearAllSessionState') this.session.clear() - this.nav.clear() + resetToTab('HomeTab') this.me.clear() } @@ -203,6 +198,7 @@ export class RootStoreModel { } // the current screen has changed + // TODO is this still needed? onNavigation(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('navigation', handler) } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 68d9cd3d0..8e4eed6eb 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -108,7 +108,6 @@ export interface ComposerOptsQuote { } } export interface ComposerOpts { - imagesOpen?: boolean replyTo?: ComposerOptsPostRef onPost?: () => void quote?: ComposerOptsQuote @@ -117,7 +116,7 @@ export interface ComposerOpts { export class ShellUiModel { darkMode = false minimalShellMode = false - isMainMenuOpen = false + isDrawerOpen = false isModalActive = false activeModals: Modal[] = [] isLightboxActive = false @@ -156,8 +155,12 @@ export class ShellUiModel { this.minimalShellMode = v } - setMainMenuOpen(v: boolean) { - this.isMainMenuOpen = v + openDrawer() { + this.isDrawerOpen = true + } + + closeDrawer() { + this.isDrawerOpen = false } openModal(modal: Modal) { diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts deleted file mode 100644 index b14e8a6a4..000000000 --- a/src/state/models/user-local-photos.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll' -import {makeAutoObservable, runInAction} from 'mobx' -import {CameraRoll} from '@react-native-camera-roll/camera-roll' -import {RootStoreModel} from './root-store' - -export type {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll' - -export class UserLocalPhotosModel { - // state - photos: PhotoIdentifier[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) - } - - async setup() { - const r = await CameraRoll.getPhotos({first: 20}) - runInAction(() => { - this.photos = r.edges - }) - } -} diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/Composer.tsx index f45c6340d..e9b728d73 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,74 +1,47 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react' +import React, {useEffect, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, - NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, - TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics' -import _isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' -import {Autocomplete} from './autocomplete/Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' -import {TextLink} from '../util/Link' import {UserAvatar} from '../util/UserAvatar' import {useStores} from 'state/index' import * as apilib from 'lib/api/index' import {ComposerOpts} from 'state/models/shell-ui' import {s, colors, gradients} from 'lib/styles' import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' -import {getLinkMeta} from 'lib/link-meta/link-meta' -import {getPostAsQuote} from 'lib/link-meta/bsky' -import {getImageDim, downloadAndResize} from 'lib/media/manip' -import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker' -import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker' -import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' -import {SelectedPhoto} from './SelectedPhoto' +import {SelectPhotoBtn} from './photos/SelectPhotoBtn' +import {OpenCameraBtn} from './photos/OpenCameraBtn' +import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' -import {isWeb} from 'platform/detection' import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import {useExternalLinkFetch} from './useExternalLinkFetch' const MAX_TEXT_LENGTH = 256 -const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} - -interface Selection { - start: number - end: number -} export const ComposePost = observer(function ComposePost({ replyTo, - imagesOpen, onPost, onClose, quote: initQuote, }: { replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void quote?: ComposerOpts['quote'] @@ -77,7 +50,6 @@ export const ComposePost = observer(function ComposePost({ const pal = usePalette('default') const store = useStores() const textInput = useRef<TextInputRef>(null) - const textInputSelection = useRef<Selection>({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -85,15 +57,8 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) - const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( - undefined, - ) - const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>( - new Set(), - ) - const [isSelectingPhotos, setIsSelectingPhotos] = useState( - imagesOpen || false, - ) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( @@ -106,85 +71,16 @@ export const ComposePost = observer(function ComposePost({ // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) // manually blurring before closing gets around that // -prf - const hackfixOnClose = () => { + const hackfixOnClose = React.useCallback(() => { textInput.current?.blur() onClose() - } + }, [textInput, onClose]) // initial setup useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - // external link metadata-fetch flow - useEffect(() => { - let aborted = false - const cleanup = () => { - aborted = true - } - if (!extLink) { - return cleanup - } - if (!extLink.meta) { - if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( - newQuote => { - if (aborted) { - return - } - setQuote(newQuote) - setExtLink(undefined) - }, - err => { - store.log.error('Failed to fetch post for quote embedding', {err}) - setExtLink(undefined) - }, - ) - } else { - getLinkMeta(store, extLink.uri).then(meta => { - if (aborted) { - return - } - setExtLink({ - uri: extLink.uri, - isLoading: !!meta.image, - meta, - }) - }) - } - return cleanup - } - if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { - downloadAndResize({ - uri: extLink.meta.image, - width: 2000, - height: 2000, - mode: 'contain', - maxSize: 1000000, - timeout: 15e3, - }) - .catch(() => undefined) - .then(localThumb => { - if (aborted) { - return - } - setExtLink({ - ...extLink, - isLoading: false, // done - localThumb, - }) - }) - return cleanup - } - if (extLink.isLoading) { - setExtLink({ - ...extLink, - isLoading: false, // done - }) - } - return cleanup - }, [store, extLink]) - useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view @@ -202,95 +98,36 @@ export const ComposePost = observer(function ComposePost({ } }, []) - const onPressContainer = () => { + const onPressContainer = React.useCallback(() => { textInput.current?.focus() - } - const onPressSelectPhotos = async () => { - track('ComposePost:SelectPhotos') - if (isWeb) { - if (selectedPhotos.length < 4) { - const images = await pickImagesFlow( - store, - 4 - selectedPhotos.length, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - setSelectedPhotos([...selectedPhotos, ...images]) - } - } else { - if (isSelectingPhotos) { - setIsSelectingPhotos(false) - } else if (selectedPhotos.length < 4) { - setIsSelectingPhotos(true) - } - } - } - const onSelectPhotos = (photos: string[]) => { - track('ComposePost:SelectPhotos:Done') - setSelectedPhotos(photos) - if (photos.length >= 4) { - setIsSelectingPhotos(false) - } - } - const onPressAddLinkCard = (uri: string) => { - setExtLink({uri, isLoading: true}) - } - const onChangeText = (newText: string) => { - setText(newText) + }, [textInput]) - const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) - if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) - } + const onSelectPhotos = React.useCallback( + (photos: string[]) => { + track('Composer:SelectedPhotos') + setSelectedPhotos(photos) + }, + [track, setSelectedPhotos], + ) - if (!extLink) { - const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') - const set = new Set(ents ? ents.map(e => e.value) : []) - if (!_isEqual(set, suggestedExtLinks)) { - setSuggestedExtLinks(set) - } - } - } - const onPaste = async (err: string | undefined, uris: string[]) => { - if (err) { - return setError(cleanError(err)) - } - if (selectedPhotos.length >= 4) { - return - } - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) - if (imgUri) { - let imgDim - try { - imgDim = await getImageDim(imgUri) - } catch (e) { - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + const onPressAddLinkCard = React.useCallback( + (uri: string) => { + setExtLink({uri, isLoading: true}) + }, + [setExtLink], + ) + + const onPhotoPasted = React.useCallback( + async (uri: string) => { + if (selectedPhotos.length >= 4) { + return } - const finalImgPath = await cropAndCompressFlow( - store, - imgUri, - imgDim, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, finalImgPath]) - } - } - const onSelectionChange = ( - evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>, - ) => { - // NOTE we track the input selection using a ref to avoid excessive renders -prf - textInputSelection.current = evt.nativeEvent.selection - } - const onSelectAutocompleteItem = (item: string) => { - setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) - autocompleteView.setActive(false) - } - const onPressCancel = () => hackfixOnClose() - const onPressPublish = async () => { + onSelectPhotos([...selectedPhotos, uri]) + }, + [selectedPhotos, onSelectPhotos], + ) + + const onPressPublish = React.useCallback(async () => { if (isProcessing) { return } @@ -332,7 +169,22 @@ export const ComposePost = observer(function ComposePost({ onPost?.() hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - } + }, [ + isProcessing, + text, + setError, + setIsProcessing, + replyTo, + autocompleteView.knownHandles, + extLink, + hackfixOnClose, + onPost, + quote, + selectedPhotos, + setExtLink, + store, + track, + ]) const canPost = text.length <= MAX_TEXT_LENGTH @@ -346,25 +198,6 @@ export const ComposePost = observer(function ComposePost({ ? 'Write a comment' : "What's up?" - const textDecorated = useMemo(() => { - let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { - return ( - <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} - </Text> - ) - } else { - return ( - <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} - </Text> - ) - } - }) - }, [text, pal.link, pal.text]) - return ( <KeyboardAvoidingView testID="composePostView" @@ -375,7 +208,7 @@ export const ComposePost = observer(function ComposePost({ <View style={styles.topbar}> <TouchableOpacity testID="composerCancelButton" - onPress={onPressCancel}> + onPress={hackfixOnClose}> <Text style={[pal.link, s.f18]}>Cancel</Text> </TouchableOpacity> <View style={s.flex1} /> @@ -423,19 +256,11 @@ export const ComposePost = observer(function ComposePost({ <ScrollView style={s.flex1}> {replyTo ? ( <View style={[pal.border, styles.replyToLayout]}> - <UserAvatar - handle={replyTo.author.handle} - displayName={replyTo.author.displayName} - avatar={replyTo.author.avatar} - size={50} - /> + <UserAvatar avatar={replyTo.author.avatar} size={50} /> <View style={styles.replyToPost}> - <TextLink - type="xl-medium" - href={`/profile/${replyTo.author.handle}`} - text={replyTo.author.displayName || replyTo.author.handle} - style={[pal.text]} - /> + <Text type="xl-medium" style={[pal.text]}> + {replyTo.author.displayName || replyTo.author.handle} + </Text> <Text type="post-text" style={pal.text} numberOfLines={6}> {replyTo.text} </Text> @@ -449,26 +274,18 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, selectTextInputLayout, ]}> - <UserAvatar - handle={store.me.handle || ''} - displayName={store.me.displayName} - avatar={store.me.avatar} - size={50} - /> + <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput - testID="composerTextInput" - innerRef={textInput} - onChangeText={(str: string) => onChangeText(str)} - onPaste={onPaste} - onSelectionChange={onSelectionChange} + ref={textInput} + text={text} placeholder={selectTextInputPlaceholder} - style={[ - pal.text, - styles.textInput, - styles.textInputFormatting, - ]}> - {textDecorated} - </TextInput> + suggestedLinks={suggestedLinks} + autocompleteView={autocompleteView} + onTextChanged={setText} + onPhotoPasted={onPhotoPasted} + onSuggestedLinksChanged={setSuggestedLinks} + onError={setError} + /> </View> {quote ? ( @@ -477,7 +294,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : undefined} - <SelectedPhoto + <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> @@ -488,17 +305,12 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && selectedPhotos.length < 4 ? ( - <PhotoCarouselPicker - selectedPhotos={selectedPhotos} - onSelectPhotos={onSelectPhotos} - /> - ) : !extLink && - selectedPhotos.length === 0 && - suggestedExtLinks.size > 0 && - !quote ? ( + {!extLink && + selectedPhotos.length === 0 && + suggestedLinks.size > 0 && + !quote ? ( <View style={s.mb5}> - {Array.from(suggestedExtLinks).map(url => ( + {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} style={[pal.borderDark, styles.addExtLinkBtn]} @@ -511,31 +323,19 @@ export const ComposePost = observer(function ComposePost({ </View> ) : null} <View style={[pal.border, styles.bottomBar]}> - {quote ? undefined : ( - <TouchableOpacity - testID="composerSelectPhotosButton" - onPress={onPressSelectPhotos} - style={[s.pl5]} - hitSlop={HITSLOP}> - <FontAwesomeIcon - icon={['far', 'image']} - style={ - (selectedPhotos.length < 4 - ? pal.link - : pal.textLight) as FontAwesomeIconStyle - } - size={24} - /> - </TouchableOpacity> - )} + <SelectPhotoBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> + <OpenCameraBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> <View style={s.flex1} /> <CharProgress count={text.length} /> </View> - <Autocomplete - active={autocompleteView.isActive} - items={autocompleteView.suggestions} - onSelect={onSelectAutocompleteItem} - /> </SafeAreaView> </TouchableWithoutFeedback> </KeyboardAvoidingView> @@ -597,18 +397,6 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingTop: 16, }, - textInput: { - flex: 1, - padding: 5, - marginLeft: 8, - alignSelf: 'flex-start', - }, - textInputFormatting: { - fontSize: 18, - letterSpacing: 0.2, - fontWeight: '400', - lineHeight: 23.4, // 1.3*16 - }, replyToLayout: { flexDirection: 'row', borderTopWidth: 1, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 23dcaffd5..658023330 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -75,6 +75,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 8, marginTop: 20, + marginBottom: 10, }, inner: { padding: 10, diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 88d5de2bf..301b90093 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -4,12 +4,9 @@ import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' -export function ComposePrompt({ - onPressCompose, -}: { - onPressCompose: (imagesOpen?: boolean) => void -}) { +export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { const store = useStores() const pal = usePalette('default') return ( @@ -17,13 +14,13 @@ export function ComposePrompt({ testID="replyPromptBtn" style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()}> - <UserAvatar - handle={store.me.handle} - avatar={store.me.avatar} - displayName={store.me.displayName} - size={38} - /> - <Text type="xl" style={[pal.text, styles.label]}> + <UserAvatar avatar={store.me.avatar} size={38} /> + <Text + type="xl" + style={[ + pal.text, + isDesktopWeb ? styles.labelDesktopWeb : styles.labelMobile, + ]}> Write your reply </Text> </TouchableOpacity> @@ -39,7 +36,10 @@ const styles = StyleSheet.create({ alignItems: 'center', borderTopWidth: 1, }, - label: { + labelMobile: { paddingLeft: 12, }, + labelDesktopWeb: { + paddingLeft: 20, + }, }) diff --git a/src/view/com/composer/autocomplete/Autocomplete.tsx b/src/view/com/composer/autocomplete/Autocomplete.tsx deleted file mode 100644 index 82fb239da..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, {useEffect} from 'react' -import { - Animated, - TouchableOpacity, - StyleSheet, - useWindowDimensions, -} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - const winDim = useWindowDimensions() - const positionInterp = useAnimatedValue(0) - - useEffect(() => { - Animated.timing(positionInterp, { - toValue: active ? 1 : 0, - duration: 200, - useNativeDriver: false, - }).start() - }, [positionInterp, active]) - - const topAnimStyle = { - top: positionInterp.interpolate({ - inputRange: [0, 1], - outputRange: [winDim.height, winDim.height / 4], - }), - } - return ( - <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </Animated.View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - borderTopWidth: 1, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/autocomplete/Autocomplete.web.tsx b/src/view/com/composer/autocomplete/Autocomplete.web.tsx deleted file mode 100644 index b6be1c21e..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.web.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - - if (!active) { - return <View /> - } - return ( - <View style={[styles.outer, pal.view, pal.border]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderWidth: 1, - borderRadius: 8, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx new file mode 100644 index 000000000..cf4a4c7d1 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openCamera} from 'lib/media/picker' +import {compressIfNeeded} from 'lib/media/manip' +import {useCameraPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function OpenCameraBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestCameraAccessIfNeeded} = useCameraPermission() + + const onPressTakePicture = React.useCallback(async () => { + track('Composer:CameraOpened') + if (!enabled) { + return + } + try { + if (!(await requestCameraAccessIfNeeded())) { + return + } + const cameraRes = await openCamera(store, { + mediaType: 'photo', + width: POST_IMG_MAX_WIDTH, + height: POST_IMG_MAX_HEIGHT, + freeStyleCropEnabled: true, + }) + const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) + onSelectPhotos([...selectedPhotos, img.path]) + } catch (err: any) { + // ignore + store.log.warn('Error using camera', err) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestCameraAccessIfNeeded, + ]) + + if (isDesktopWeb) { + return <></> + } + + return ( + <TouchableOpacity + testID="openCameraButton" + onPress={onPressTakePicture} + style={[s.pl5]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon="camera" + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx deleted file mode 100644 index 580e9746e..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics' -import { - openPicker, - openCamera, - cropAndCompressFlow, -} from '../../../../lib/media/picker' -import { - UserLocalPhotosModel, - PhotoIdentifier, -} from 'state/models/user-local-photos' -import {compressIfNeeded} from 'lib/media/manip' -import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' -import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' - -export const PhotoCarouselPicker = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const [isSetup, setIsSetup] = React.useState<boolean>(false) - - const localPhotos = React.useMemo<UserLocalPhotosModel>( - () => new UserLocalPhotosModel(store), - [store], - ) - - React.useEffect(() => { - // initial setup - localPhotos.setup().then(() => { - setIsSetup(true) - }) - }, [localPhotos]) - - const handleOpenCamera = useCallback(async () => { - try { - if (!(await requestCameraAccessIfNeeded())) { - return - } - const cameraRes = await openCamera(store, { - mediaType: 'photo', - width: POST_IMG_MAX_WIDTH, - height: POST_IMG_MAX_HEIGHT, - freeStyleCropEnabled: true, - }) - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) - onSelectPhotos([...selectedPhotos, img.path]) - } catch (err: any) { - // ignore - store.log.warn('Error using camera', err) - } - }, [store, selectedPhotos, onSelectPhotos]) - - const handleSelectPhoto = useCallback( - async (item: PhotoIdentifier) => { - track('PhotoCarouselPicker:PhotoSelected') - try { - const imgPath = await cropAndCompressFlow( - store, - item.node.image.uri, - { - width: item.node.image.width, - height: item.node.image.height, - }, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, imgPath]) - } catch (err: any) { - // ignore - store.log.warn('Error selecting photo', err) - } - }, - [track, store, onSelectPhotos, selectedPhotos], - ) - - const handleOpenGallery = useCallback(async () => { - track('PhotoCarouselPicker:GalleryOpened') - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker(store, { - multiple: true, - maxFiles: 4 - selectedPhotos.length, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow( - store, - image.path, - image, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ), - ) - } - onSelectPhotos([...selectedPhotos, ...result]) - }, [track, store, selectedPhotos, onSelectPhotos]) - - return ( - <ScrollView - testID="photoCarouselPickerView" - horizontal - style={[pal.view, styles.photosContainer]} - keyboardShouldPersistTaps="always" - showsHorizontalScrollIndicator={false}> - <TouchableOpacity - testID="openCameraButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenCamera}> - <FontAwesomeIcon - icon="camera" - size={24} - style={pal.link as FontAwesomeIconStyle} - /> - </TouchableOpacity> - <TouchableOpacity - testID="openGalleryButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenGallery}> - <FontAwesomeIcon - icon="image" - style={pal.link as FontAwesomeIconStyle} - size={24} - /> - </TouchableOpacity> - {isSetup && - localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - <TouchableOpacity - testID="openSelectPhotoButton" - key={`local-image-${index}`} - style={[pal.border, styles.photoButton]} - onPress={() => handleSelectPhoto(item)}> - <Image style={styles.photo} source={{uri: item.node.image.uri}} /> - </TouchableOpacity> - ))} - </ScrollView> - ) -} - -const styles = StyleSheet.create({ - photosContainer: { - width: '100%', - maxHeight: 96, - padding: 8, - overflow: 'hidden', - }, - galleryButton: { - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, - photoButton: { - width: 75, - height: 75, - marginRight: 8, - borderWidth: 1, - borderRadius: 16, - }, - photo: { - width: 75, - height: 75, - marginRight: 8, - borderRadius: 16, - }, -}) diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx deleted file mode 100644 index ff4350b0c..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -// Not used on Web - -export const PhotoCarouselPicker = (_opts: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - return <></> -} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx new file mode 100644 index 000000000..bdcb0534a --- /dev/null +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' +import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function SelectPhotoBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + + const onPressSelectPhotos = React.useCallback(async () => { + track('Composer:GalleryOpened') + if (!enabled) { + return + } + if (isDesktopWeb) { + const images = await pickImagesFlow( + store, + 4 - selectedPhotos.length, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onSelectPhotos([...selectedPhotos, ...images]) + } else { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + multiple: true, + maxFiles: 4 - selectedPhotos.length, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow( + store, + image.path, + image, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ), + ) + } + onSelectPhotos([...selectedPhotos, ...result]) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestPhotoAccessIfNeeded, + ]) + + return ( + <TouchableOpacity + testID="openGalleryBtn" + onPress={onPressSelectPhotos} + style={[s.pl5, s.pr20]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon={['far', 'image']} + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx index 6aeda33cd..c2a00ce53 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/photos/SelectedPhotos.tsx @@ -4,7 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import Image from 'view/com/util/images/Image' import {colors} from 'lib/styles' -export const SelectedPhoto = ({ +export const SelectedPhotos = ({ selectedPhotos, onSelectPhotos, }: { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index be6150e11..2a40fb518 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,64 +1,222 @@ import React from 'react' import { NativeSyntheticEvent, - StyleProp, + StyleSheet, TextInputSelectionChangeEventData, - TextStyle, } from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {Autocomplete} from './mobile/Autocomplete' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getImageDim} from 'lib/media/manip' +import {cropAndCompressFlow} from 'lib/media/picker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' -export type TextInputRef = PasteInputRef +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - <PasteInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </PasteInput> - ) +interface Selection { + start: number + end: number } + +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + onPhotoPasted, + onSuggestedLinksChanged, + onError, + }: TextInputProps, + ref, + ) => { + const pal = usePalette('default') + const store = useStores() + const textInput = React.useRef<PasteInputRef>(null) + const textInputSelection = React.useRef<Selection>({start: 0, end: 0}) + const theme = useTheme() + + React.useImperativeHandle(ref, () => ({ + focus: () => textInput.current?.focus(), + blur: () => textInput.current?.blur(), + })) + + React.useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onChangeText = React.useCallback( + (newText: string) => { + onTextChanged(newText) + + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix.value) + } else { + autocompleteView.setActive(false) + } + + const ents = extractEntities(newText)?.filter( + ent => ent.type === 'link', + ) + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) + } + }, + [ + onTextChanged, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + ], + ) + + const onPaste = React.useCallback( + async (err: string | undefined, files: PastedFile[]) => { + if (err) { + return onError(cleanError(err)) + } + const uris = files.map(f => f.uri) + const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) + if (imgUri) { + let imgDim + try { + imgDim = await getImageDim(imgUri) + } catch (e) { + imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + } + const finalImgPath = await cropAndCompressFlow( + store, + imgUri, + imgDim, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(finalImgPath) + } + }, + [store, onError, onPhotoPasted], + ) + + const onSelectionChange = React.useCallback( + (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + }, + [textInputSelection], + ) + + const onSelectAutocompleteItem = React.useCallback( + (item: string) => { + onChangeText( + insertMentionAt(text, textInputSelection.current?.start || 0, item), + ) + autocompleteView.setActive(false) + }, + [onChangeText, text, autocompleteView], + ) + + const textDecorated = React.useMemo(() => { + let i = 0 + return detectLinkables(text).map(v => { + if (typeof v === 'string') { + return ( + <Text key={i++} style={[pal.text, styles.textInputFormatting]}> + {v} + </Text> + ) + } else { + return ( + <Text key={i++} style={[pal.link, styles.textInputFormatting]}> + {v.link} + </Text> + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + <PasteInput + testID="composerTextInput" + ref={textInput} + onChangeText={onChangeText} + onPaste={onPaste} + onSelectionChange={onSelectionChange} + placeholder={placeholder} + keyboardAppearance={theme.colorScheme} + style={[pal.text, styles.textInput, styles.textInputFormatting]}> + {textDecorated} + </PasteInput> + <Autocomplete + view={autocompleteView} + onSelect={onSelectAutocompleteItem} + /> + </> + ) + }, +) + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + padding: 5, + marginLeft: 8, + alignSelf: 'flex-start', + }, + textInputFormatting: { + fontSize: 18, + letterSpacing: 0.2, + fontWeight: '400', + lineHeight: 23.4, // 1.3*16 + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 2b610850c..67ef836a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,58 +1,133 @@ import React from 'react' -import { - NativeSyntheticEvent, - StyleProp, - StyleSheet, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, - TextStyle, -} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {addStyle} from 'lib/styles' - -export type TextInputRef = RNTextInput +import {StyleSheet, View} from 'react-native' +import {useEditor, EditorContent, JSONContent} from '@tiptap/react' +import {Document} from '@tiptap/extension-document' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' +import {Paragraph} from '@tiptap/extension-paragraph' +import {Placeholder} from '@tiptap/extension-placeholder' +import {Text} from '@tiptap/extension-text' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {createSuggestion} from './web/Autocomplete' + +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - <RNTextInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </RNTextInput> - ) +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + // onPhotoPasted, TODO + onSuggestedLinksChanged, + }: // onError, TODO + TextInputProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + Link.configure({ + protocols: ['http', 'https'], + autolink: true, + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + ], + content: text, + autofocus: true, + editable: true, + injectCSS: true, + onUpdate({editor: editorProp}) { + const json = editorProp.getJSON() + const newText = editorJsonToText(json).trim() + onTextChanged(newText) + + const newSuggestedLinks = new Set(editorJsonToLinks(json)) + if (!isEqual(newSuggestedLinks, suggestedLinks)) { + onSuggestedLinksChanged(newSuggestedLinks) + } + }, + }) + + React.useImperativeHandle(ref, () => ({ + focus: () => {}, // TODO + blur: () => {}, // TODO + })) + + return ( + <View style={styles.container}> + <EditorContent editor={editor} /> + </View> + ) + }, +) + +function editorJsonToText(json: JSONContent): string { + let text = '' + if (json.type === 'doc' || json.type === 'paragraph') { + if (json.content?.length) { + for (const node of json.content) { + text += editorJsonToText(node) + } + } + text += '\n' + } else if (json.type === 'text') { + text += json.text || '' + } else if (json.type === 'mention') { + text += json.attrs?.id || '' + } + return text +} + +function editorJsonToLinks(json: JSONContent): string[] { + let links: string[] = [] + if (json.content?.length) { + for (const node of json.content) { + links = links.concat(editorJsonToLinks(node)) + } + } + + const link = json.marks?.find(m => m.type === 'link') + if (link?.attrs?.href) { + links.push(link.attrs.href) + } + + return links } const styles = StyleSheet.create({ - input: { - minHeight: 140, + container: { + flex: 1, + alignSelf: 'flex-start', + padding: 5, + marginLeft: 8, + marginBottom: 10, }, }) diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx new file mode 100644 index 000000000..424a8629f --- /dev/null +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -0,0 +1,75 @@ +import React, {useEffect} from 'react' +import { + Animated, + TouchableOpacity, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export const Autocomplete = observer( + ({ + view, + onSelect, + }: { + view: UserAutocompleteViewModel + onSelect: (item: string) => void + }) => { + const pal = usePalette('default') + const winDim = useWindowDimensions() + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [positionInterp, view.isActive]) + + const topAnimStyle = { + top: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winDim.height, winDim.height / 4], + }), + } + return ( + <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> + {view.suggestions.map(item => ( + <TouchableOpacity + testID="autocompleteButton" + key={item.handle} + style={[pal.border, styles.item]} + onPress={() => onSelect(item.handle)}> + <Text type="md-medium" style={pal.text}> + {item.displayName || item.handle} + <Text type="sm" style={pal.textLight}> + @{item.handle} + </Text> + </Text> + </TouchableOpacity> + ))} + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 16, + paddingHorizontal: 16, + height: 50, + }, +}) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx new file mode 100644 index 000000000..fbe438969 --- /dev/null +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -0,0 +1,157 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' + +interface MentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createSuggestion({ + autocompleteView, +}: { + autocompleteView: UserAutocompleteViewModel +}): Omit<SuggestionOptions, 'editor'> { + return { + async items({query}) { + autocompleteView.setActive(true) + await autocompleteView.setPrefix(query) + return autocompleteView.suggestions.slice(0, 8).map(s => s.handle) + }, + + render: () => { + let component: ReactRenderer<MentionListRef> | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const MentionList = forwardRef<MentionListRef, SuggestionProps>( + (props: SuggestionProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({id: item}) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( + <div className="items"> + {props.items.length ? ( + props.items.map((item, index) => ( + <button + className={`item ${index === selectedIndex ? 'is-selected' : ''}`} + key={index} + onClick={() => selectItem(index)}> + {item} + </button> + )) + ) : ( + <div className="item">No result</div> + )} + </div> + ) + }, +) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts new file mode 100644 index 000000000..75f833e84 --- /dev/null +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -0,0 +1,90 @@ +import {useState, useEffect} from 'react' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {getPostAsQuote} from 'lib/link-meta/bsky' +import {downloadAndResize} from 'lib/media/manip' +import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {ComposerOpts} from 'state/models/shell-ui' + +export function useExternalLinkFetch({ + setQuote, +}: { + setQuote: (opts: ComposerOpts['quote']) => void +}) { + const store = useStores() + const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( + undefined, + ) + + useEffect(() => { + let aborted = false + const cleanup = () => { + aborted = true + } + if (!extLink) { + return cleanup + } + if (!extLink.meta) { + if (isBskyPostUrl(extLink.uri)) { + getPostAsQuote(store, extLink.uri).then( + newQuote => { + if (aborted) { + return + } + setQuote(newQuote) + setExtLink(undefined) + }, + err => { + store.log.error('Failed to fetch post for quote embedding', {err}) + setExtLink(undefined) + }, + ) + } else { + getLinkMeta(store, extLink.uri).then(meta => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: !!meta.image, + meta, + }) + }) + } + return cleanup + } + if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { + console.log('attempting download') + downloadAndResize({ + uri: extLink.meta.image, + width: 2000, + height: 2000, + mode: 'contain', + maxSize: 1000000, + timeout: 15e3, + }) + .catch(() => undefined) + .then(localThumb => { + if (aborted) { + return + } + setExtLink({ + ...extLink, + isLoading: false, // done + localThumb, + }) + }) + return cleanup + } + if (extLink.isLoading) { + setExtLink({ + ...extLink, + isLoading: false, // done + }) + } + return cleanup + }, [store, extLink, setQuote]) + + return {extLink, setExtLink} +} diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index 3c09a6cc2..a24dc4e35 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -27,11 +27,13 @@ import {toNiceDomain} from 'lib/strings/url-helpers' import {useStores, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const {track, screen, identify} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isProcessing, setIsProcessing] = React.useState<boolean>(false) const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE) @@ -220,6 +222,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { autoCapitalize="none" autoCorrect={false} autoFocus + keyboardAppearance={theme.colorScheme} value={inviteCode} onChangeText={setInviteCode} onBlur={onBlurInviteCode} diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 4f994f831..6faf5ff12 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -26,6 +26,7 @@ import {ServiceDescription} from 'state/models/session' import {AccountData} from 'state/models/session' import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' enum Forms { @@ -195,12 +196,7 @@ const ChooseAccountForm = ({ <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <View style={s.p10}> - <UserAvatar - displayName={account.displayName} - handle={account.handle} - avatar={account.aviUrl} - size={30} - /> + <UserAvatar avatar={account.aviUrl} size={30} /> </View> <Text style={styles.accountText}> <Text type="lg-bold" style={pal.text}> @@ -273,6 +269,7 @@ const LoginForm = ({ }) => { const {track} = useAnalytics() const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [identifier, setIdentifier] = useState<string>(initialHandle) const [password, setPassword] = useState<string>('') @@ -383,6 +380,7 @@ const LoginForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={identifier} onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} @@ -400,6 +398,7 @@ const LoginForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} @@ -479,6 +478,7 @@ const ForgotPasswordForm = ({ onEmailSent: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [email, setEmail] = useState<string>('') const {screen} = useAnalytics() @@ -567,6 +567,7 @@ const ForgotPasswordForm = ({ autoCapitalize="none" autoFocus autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} editable={!isProcessing} @@ -630,11 +631,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const theme = useTheme() const {screen} = useAnalytics() - // useEffect(() => { - screen('Signin:SetNewPasswordForm') - // }, [screen]) + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) const [isProcessing, setIsProcessing] = useState<boolean>(false) const [resetCode, setResetCode] = useState<string>('') @@ -692,6 +694,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} autoFocus value={resetCode} onChangeText={setResetCode} @@ -710,6 +713,7 @@ const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 519be7b2e..0795d6d20 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -17,6 +17,7 @@ import {ServiceDescription} from 'state/models/session' import {s} from 'lib/styles' import {makeValidHandle, createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError} from 'lib/strings/errors' @@ -212,6 +213,7 @@ function ProvidedHandleForm({ setCanSave: (v: boolean) => void }) { const pal = usePalette('default') + const theme = useTheme() // events // = @@ -239,6 +241,7 @@ function ProvidedHandleForm({ placeholder="eg alice" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} @@ -283,6 +286,7 @@ function CustomHandleForm({ const pal = usePalette('default') const palSecondary = usePalette('secondary') const palError = usePalette('error') + const theme = useTheme() const [isVerifying, setIsVerifying] = React.useState(false) const [error, setError] = React.useState<string>('') @@ -348,6 +352,7 @@ function CustomHandleForm({ placeholder="eg alice.com" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" + keyboardAppearance={theme.colorScheme} value={handle} onChangeText={onChangeHandle} editable={!isProcessing} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index de29e728d..62fa9f386 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -12,13 +12,16 @@ import {Text} from '../util/text/Text' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' +import {resetToTab} from '../../../Navigation' export const snapPoints = ['60%'] export function Component({}: {}) { const pal = usePalette('default') + const theme = useTheme() const store = useStores() const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [confirmCode, setConfirmCode] = React.useState<string>('') @@ -46,7 +49,7 @@ export function Component({}: {}) { token: confirmCode, }) Toast.show('Your account has been deleted') - store.nav.tab.fixedTabReset() + resetToTab('HomeTab') store.session.clear() store.shell.closeModal() } catch (e: any) { @@ -117,6 +120,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} placeholder="Confirmation code" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} value={confirmCode} onChangeText={setConfirmCode} /> @@ -127,6 +131,7 @@ export function Component({}: {}) { style={[styles.textInput, pal.borderDark, pal.text]} placeholder="Password" placeholderTextColor={pal.textLight.color} + keyboardAppearance={theme.colorScheme} secureTextEntry value={password} onChangeText={setPassword} diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 121831ada..6eb21d17d 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -20,6 +20,7 @@ import {compressIfNeeded} from 'lib/media/manip' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics' import {cleanError, isNetworkError} from 'lib/strings/errors' @@ -35,6 +36,7 @@ export function Component({ const store = useStores() const [error, setError] = useState<string>('') const pal = usePalette('default') + const theme = useTheme() const {track} = useAnalytics() const [isProcessing, setProcessing] = useState<boolean>(false) @@ -133,9 +135,7 @@ export function Component({ <UserAvatar size={80} avatar={userAvatar} - handle={profileView.handle} onSelectNewAvatar={onSelectNewAvatar} - displayName={profileView.displayName} /> </View> </View> @@ -160,6 +160,7 @@ export function Component({ style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} + keyboardAppearance={theme.colorScheme} multiline value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 5a9a4cfed..1d352cec9 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -8,12 +8,14 @@ import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] export function Component({onSelect}: {onSelect: (url: string) => void}) { + const theme = useTheme() const store = useStores() const [customUrl, setCustomUrl] = useState<string>('') @@ -74,6 +76,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { autoCapitalize="none" autoComplete="off" autoCorrect={false} + keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} /> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index b21681c7f..306686557 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,6 +5,7 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' import {PickedMedia} from 'lib/media/types' +import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -54,7 +55,7 @@ export function Component({ mediaType: 'photo', path: dataUri, mime: 'image/jpeg', - size: Math.round((dataUri.length * 3) / 4), // very rough estimate + size: getDataUriSize(dataUri), width: DIMS[as].width, height: DIMS[as].height, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index acd00a67d..1c2299b03 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -24,7 +24,7 @@ import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' -import {Link} from '../util/Link' +import {Link, TextLink} from '../util/Link' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' @@ -186,15 +186,12 @@ export const FeedItem = observer(function FeedItem({ authors={authors} /> <View style={styles.meta}> - <Link + <TextLink key={authors[0].href} - style={styles.metaItem} + style={[pal.text, s.bold, styles.metaItem]} href={authors[0].href} - title={`@${authors[0].handle}`}> - <Text style={[pal.text, s.bold]} lineHeight={1.2}> - {authors[0].displayName || authors[0].handle} - </Text> - </Link> + text={authors[0].displayName || authors[0].handle} + /> {authors.length > 1 ? ( <> <Text style={[styles.metaItem, pal.text]}>and</Text> @@ -256,13 +253,9 @@ function CondensedAuthorsList({ <Link style={s.mr5} href={authors[0].href} - title={`@${authors[0].handle}`}> - <UserAvatar - size={35} - displayName={authors[0].displayName} - handle={authors[0].handle} - avatar={authors[0].avatar} - /> + title={`@${authors[0].handle}`} + asAnchor> + <UserAvatar size={35} avatar={authors[0].avatar} /> </Link> </View> ) @@ -271,12 +264,7 @@ function CondensedAuthorsList({ <View style={styles.avis}> {authors.slice(0, MAX_AUTHORS).map(author => ( <View key={author.href} style={s.mr5}> - <UserAvatar - size={35} - displayName={author.displayName} - handle={author.handle} - avatar={author.avatar} - /> + <UserAvatar size={35} avatar={author.avatar} /> </View> ))} {authors.length > MAX_AUTHORS ? ( @@ -326,14 +314,10 @@ function ExpandedAuthorsList({ key={author.href} href={author.href} title={author.displayName || author.handle} - style={styles.expandedAuthor}> + style={styles.expandedAuthor} + asAnchor> <View style={styles.expandedAuthorAvi}> - <UserAvatar - size={35} - displayName={author.displayName} - handle={author.handle} - avatar={author.avatar} - /> + <UserAvatar size={35} avatar={author.avatar} /> </View> <View style={s.flex1}> <Text diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 646d4b276..f84593db8 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,28 +1,43 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, } from 'state/models/post-thread-view' import {PostThreadItem} from './PostThreadItem' +import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' + +const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} +const BOTTOM_BORDER = { + _reactKey: '__bottom_border__', + _isHighlightedPost: false, +} +type YieldedItem = PostThreadViewPostModel | typeof REPLY_PROMPT export const PostThread = observer(function PostThread({ uri, view, + onPressReply, }: { uri: string view: PostThreadViewModel + onPressReply: () => void }) { + const pal = usePalette('default') const ref = useRef<FlatList>(null) const [isRefreshing, setIsRefreshing] = React.useState(false) - const posts = React.useMemo( - () => (view.thread ? Array.from(flattenThread(view.thread)) : []), - [view.thread], - ) + const posts = React.useMemo(() => { + if (view.thread) { + return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) + } + return [] + }, [view.thread]) // events // = @@ -58,6 +73,23 @@ export const PostThread = observer(function PostThread({ }, [ref], ) + const renderItem = React.useCallback( + ({item}: {item: YieldedItem}) => { + if (item === REPLY_PROMPT) { + return <ComposePrompt onPressCompose={onPressReply} /> + } else if (item === BOTTOM_BORDER) { + // HACK + // due to some complexities with how flatlist works, this is the easiest way + // I could find to get a border positioned directly under the last item + // -prf + return <View style={[styles.bottomBorder, pal.border]} /> + } else if (item instanceof PostThreadViewPostModel) { + return <PostThreadItem item={item} onPostReply={onRefresh} /> + } + return <></> + }, + [onRefresh, onPressReply, pal], + ) // loading // = @@ -81,9 +113,6 @@ export const PostThread = observer(function PostThread({ // loaded // = - const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( - <PostThreadItem item={item} onPostReply={onRefresh} /> - ) return ( <FlatList ref={ref} @@ -104,7 +133,7 @@ export const PostThread = observer(function PostThread({ function* flattenThread( post: PostThreadViewPostModel, isAscending = false, -): Generator<PostThreadViewPostModel, void> { +): Generator<YieldedItem, void> { if (post.parent) { if ('notFound' in post.parent && post.parent.notFound) { // TODO render not found @@ -113,6 +142,9 @@ function* flattenThread( } } yield post + if (isDesktopWeb && post._isHighlightedPost) { + yield REPLY_PROMPT + } if (post.replies?.length) { for (const reply of post.replies) { if ('notFound' in reply && reply.notFound) { @@ -125,3 +157,9 @@ function* flattenThread( post._hasMore = true } } + +const styles = StyleSheet.create({ + bottomBorder: { + borderBottomWidth: 1, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 1413148a9..17c7943d9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -135,13 +135,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -299,13 +294,8 @@ export const PostThreadItem = observer(function PostThreadItem({ )} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -313,6 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7b4161afc..ac7d1cc55 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -150,13 +150,8 @@ export const Post = observer(function Post({ {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -164,6 +159,7 @@ export const Post = observer(function Post({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} /> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 5751faa68..8f57900b5 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -7,6 +7,7 @@ import { StyleSheet, ViewStyle, } from 'react-native' +import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {CenteredView, FlatList} from '../util/Views' @@ -18,10 +19,10 @@ import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_FEED_ITEM = {_reactKey: '__error__'} @@ -47,9 +48,9 @@ export const Feed = observer(function Feed({ }) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const navigation = useNavigation<NavigationProp>() const data = React.useMemo(() => { let feedItems: any[] = [] @@ -112,7 +113,12 @@ export const Feed = observer(function Feed({ <Button type="inverted" style={styles.emptyBtn} - onPress={() => store.nav.navigate('/search')}> + onPress={ + () => + navigation.navigate( + 'SearchTab', + ) /* TODO make sure it goes to root of the tab */ + }> <Text type="lg-medium" style={palInverted.text}> Find accounts </Text> @@ -134,7 +140,7 @@ export const Feed = observer(function Feed({ } return <FeedItem item={item} showFollowBtn={showPostFollowBtn} /> }, - [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav], + [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation], ) const FeedFooter = React.useCallback( diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 8b9a6eb2c..ec8feb664 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -9,7 +9,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {FeedItemModel} from 'state/models/feed-view' -import {Link} from '../util/Link' +import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -169,19 +169,24 @@ export const FeedItem = observer(function ({ lineHeight={1.2} numberOfLines={1}> Reposted by{' '} - {item.reasonRepost.by.displayName || item.reasonRepost.by.handle} + <DesktopWebTextLink + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={ + item.reasonRepost.by.displayName || + item.reasonRepost.by.handle + } + href={`/profile/${item.reasonRepost.by.handle}`} + /> </Text> </Link> )} <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={item.post.author.handle}> - <UserAvatar - size={52} - displayName={item.post.author.displayName} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - /> + <Link href={authorHref} title={item.post.author.handle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> </Link> </View> <View style={styles.layoutContent}> @@ -189,6 +194,7 @@ export const FeedItem = observer(function ({ authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} timestamp={item.post.indexedAt} + postHref={itemHref} did={item.post.author.did} declarationCid={item.post.author.declaration.cid} showFollowBtn={showFollowBtn} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 3c487b70f..087536c36 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -37,15 +37,11 @@ export function ProfileCard({ ]} href={`/profile/${handle}`} title={handle} - noFeedback> + noFeedback + asAnchor> <View style={styles.layout}> <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={displayName} - handle={handle} - avatar={avatar} - /> + <UserAvatar size={40} avatar={avatar} /> </View> <View style={styles.layoutContent}> <Text diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 519d224ea..b061aac41 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -7,18 +7,18 @@ import { TouchableWithoutFeedback, View, } from 'react-native' -import LinearGradient from 'react-native-linear-gradient' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' import {BlurView} from '../util/BlurView' import {ProfileViewModel} from 'state/models/profile-view' import {useStores} from 'state/index' import {ProfileImageLightbox} from 'state/models/shell-ui' import {pluralize} from 'lib/strings/helpers' import {toShareUrl} from 'lib/strings/url-helpers' -import {s, gradients} from 'lib/styles' +import {s, colors} from 'lib/styles' import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -28,6 +28,8 @@ import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} @@ -40,16 +42,17 @@ export const ProfileHeader = observer(function ProfileHeader({ }) { const pal = usePalette('default') const store = useStores() + const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const onPressBack = () => { - store.nav.tab.goBack() - } - const onPressAvi = () => { + const onPressBack = React.useCallback(() => { + navigation.goBack() + }, [navigation]) + const onPressAvi = React.useCallback(() => { if (view.avatar) { store.shell.openLightbox(new ProfileImageLightbox(view)) } - } - const onPressToggleFollow = () => { + }, [store, view]) + const onPressToggleFollow = React.useCallback(() => { view?.toggleFollowing().then( () => { Toast.show( @@ -60,28 +63,28 @@ export const ProfileHeader = observer(function ProfileHeader({ }, err => store.log.error('Failed to toggle follow', err), ) - } - const onPressEditProfile = () => { + }, [view, store]) + const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal({ name: 'edit-profile', profileView: view, onUpdate: onRefreshAll, }) - } - const onPressFollowers = () => { + }, [track, store, view, onRefreshAll]) + const onPressFollowers = React.useCallback(() => { track('ProfileHeader:FollowersButtonClicked') - store.nav.navigate(`/profile/${view.handle}/followers`) - } - const onPressFollows = () => { + navigation.push('ProfileFollowers', {name: view.handle}) + }, [track, navigation, view]) + const onPressFollows = React.useCallback(() => { track('ProfileHeader:FollowsButtonClicked') - store.nav.navigate(`/profile/${view.handle}/follows`) - } - const onPressShare = () => { + navigation.push('ProfileFollows', {name: view.handle}) + }, [track, navigation, view]) + const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) - } - const onPressMuteAccount = async () => { + }, [track, view]) + const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { await view.muteAccount() @@ -90,8 +93,8 @@ export const ProfileHeader = observer(function ProfileHeader({ store.log.error('Failed to mute account', e) Toast.show(`There was an issue! ${e.toString()}`) } - } - const onPressUnmuteAccount = async () => { + }, [track, view, store]) + const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { await view.unmuteAccount() @@ -100,14 +103,14 @@ export const ProfileHeader = observer(function ProfileHeader({ store.log.error('Failed to unmute account', e) Toast.show(`There was an issue! ${e.toString()}`) } - } - const onPressReportAccount = () => { + }, [track, view, store]) + const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ name: 'report-account', did: view.did, }) - } + }, [track, store, view]) // loading // = @@ -189,23 +192,15 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : ( <TouchableOpacity testID="profileHeaderToggleFollowButton" - onPress={onPressToggleFollow}> - <LinearGradient - colors={[ - gradients.blueLight.start, - gradients.blueLight.end, - ]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn, styles.gradientBtn]}> - <FontAwesomeIcon - icon="plus" - style={[s.white as FontAwesomeIconStyle, s.mr5]} - /> - <Text type="button" style={[s.white, s.bold]}> - Follow - </Text> - </LinearGradient> + onPress={onPressToggleFollow} + style={[styles.btn, styles.primaryBtn]}> + <FontAwesomeIcon + icon="plus" + style={[s.white as FontAwesomeIconStyle, s.mr5]} + /> + <Text type="button" style={[s.white, s.bold]}> + Follow + </Text> </TouchableOpacity> )} </> @@ -287,24 +282,21 @@ export const ProfileHeader = observer(function ProfileHeader({ </View> ) : undefined} </View> - <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}> - <View style={styles.backBtnWrapper}> - <BlurView style={styles.backBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> - </BlurView> - </View> - </TouchableWithoutFeedback> + {!isDesktopWeb && ( + <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}> + <View style={styles.backBtnWrapper}> + <BlurView style={styles.backBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> + </BlurView> + </View> + </TouchableWithoutFeedback> + )} <TouchableWithoutFeedback testID="profileHeaderAviButton" onPress={onPressAvi}> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <UserAvatar - size={80} - handle={view.handle} - displayName={view.displayName} - avatar={view.avatar} - /> + <UserAvatar size={80} avatar={view.avatar} /> </View> </TouchableWithoutFeedback> </View> @@ -350,7 +342,8 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginBottom: 12, }, - gradientBtn: { + primaryBtn: { + backgroundColor: colors.blue3, paddingHorizontal: 24, paddingVertical: 6, }, diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 017265f48..c7374e195 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' +import {CenteredView} from './Views' interface Props { children?: ReactNode @@ -27,11 +28,13 @@ export class ErrorBoundary extends Component<Props, State> { public render() { if (this.state.hasError) { return ( - <ErrorScreen - title="Oh no!" - message="There was an unexpected issue in the application. Please let us know if this happened to you!" - details={this.state.error.toString()} - /> + <CenteredView> + <ErrorScreen + title="Oh no!" + message="There was an unexpected issue in the application. Please let us know if this happened to you!" + details={this.state.error.toString()} + /> + </CenteredView> ) } diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index bdc447937..cee4d4136 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -2,6 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { Linking, + GestureResponderEvent, + Platform, StyleProp, TouchableWithoutFeedback, TouchableOpacity, @@ -9,10 +11,22 @@ import { View, ViewStyle, } from 'react-native' +import { + useLinkProps, + useNavigation, + StackActions, +} from '@react-navigation/native' import {Text} from './text/Text' import {TypographyVariant} from 'lib/ThemeContext' +import {NavigationProp} from 'lib/routes/types' +import {router} from '../../../routes' import {useStores, RootStoreModel} from 'state/index' import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers' +import {isDesktopWeb} from 'platform/detection' + +type Event = + | React.MouseEvent<HTMLAnchorElement, MouseEvent> + | GestureResponderEvent export const Link = observer(function Link({ style, @@ -20,30 +34,33 @@ export const Link = observer(function Link({ title, children, noFeedback, + asAnchor, }: { style?: StyleProp<ViewStyle> href?: string title?: string children?: React.ReactNode noFeedback?: boolean + asAnchor?: boolean }) { const store = useStores() - const onPress = () => { - if (href) { - handleLink(store, href, false) - } - } - const onLongPress = () => { - if (href) { - handleLink(store, href, true) - } - } + const navigation = useNavigation<NavigationProp>() + + const onPress = React.useCallback( + (e?: Event) => { + if (typeof href === 'string') { + return onPressInner(store, navigation, href, e) + } + }, + [store, navigation, href], + ) + if (noFeedback) { return ( <TouchableWithoutFeedback onPress={onPress} - onLongPress={onLongPress} - delayPressIn={50}> + // @ts-ignore web only -prf + href={asAnchor ? href : undefined}> <View style={style}> {children ? children : <Text>{title || 'link'}</Text>} </View> @@ -52,10 +69,10 @@ export const Link = observer(function Link({ } return ( <TouchableOpacity + style={style} onPress={onPress} - onLongPress={onLongPress} - delayPressIn={50} - style={style}> + // @ts-ignore web only -prf + href={asAnchor ? href : undefined}> {children ? children : <Text>{title || 'link'}</Text>} </TouchableOpacity> ) @@ -66,35 +83,123 @@ export const TextLink = observer(function TextLink({ style, href, text, + numberOfLines, + lineHeight, }: { type?: TypographyVariant style?: StyleProp<TextStyle> href: string - text: string + text: string | JSX.Element + numberOfLines?: number + lineHeight?: number }) { + const {...props} = useLinkProps({to: href}) const store = useStores() - const onPress = () => { - handleLink(store, href, false) - } - const onLongPress = () => { - handleLink(store, href, true) + const navigation = useNavigation<NavigationProp>() + + props.onPress = React.useCallback( + (e?: Event) => { + return onPressInner(store, navigation, href, e) + }, + [store, navigation, href], + ) + + return ( + <Text + type={type} + style={style} + numberOfLines={numberOfLines} + lineHeight={lineHeight} + {...props}> + {text} + </Text> + ) +}) + +/** + * Only acts as a link on desktop web + */ +export const DesktopWebTextLink = observer(function DesktopWebTextLink({ + type = 'md', + style, + href, + text, + numberOfLines, + lineHeight, +}: { + type?: TypographyVariant + style?: StyleProp<TextStyle> + href: string + text: string | JSX.Element + numberOfLines?: number + lineHeight?: number +}) { + if (isDesktopWeb) { + return ( + <TextLink + type={type} + style={style} + href={href} + text={text} + numberOfLines={numberOfLines} + lineHeight={lineHeight} + /> + ) } return ( - <Text type={type} style={style} onPress={onPress} onLongPress={onLongPress}> + <Text + type={type} + style={style} + numberOfLines={numberOfLines} + lineHeight={lineHeight}> {text} </Text> ) }) -function handleLink(store: RootStoreModel, href: string, longPress: boolean) { - href = convertBskyAppUrlIfNeeded(href) - if (href.startsWith('http')) { - Linking.openURL(href) - } else if (longPress) { - store.shell.closeModal() // close any active modals - store.nav.newTab(href) - } else { - store.shell.closeModal() // close any active modals - store.nav.navigate(href) +// NOTE +// we can't use the onPress given by useLinkProps because it will +// match most paths to the HomeTab routes while we actually want to +// preserve the tab the app is currently in +// +// we also have some additional behaviors - closing the current modal, +// converting bsky urls, and opening http/s links in the system browser +// +// this method copies from the onPress implementation but adds our +// needed customizations +// -prf +function onPressInner( + store: RootStoreModel, + navigation: NavigationProp, + href: string, + e?: Event, +) { + let shouldHandle = false + + if (Platform.OS !== 'web' || !e) { + shouldHandle = e ? !e.defaultPrevented : true + } else if ( + !e.defaultPrevented && // onPress prevented default + // @ts-ignore Web only -prf + !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys + // @ts-ignore Web only -prf + (e.button == null || e.button === 0) && // ignore everything but left clicks + // @ts-ignore Web only -prf + [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. + ) { + e.preventDefault() + shouldHandle = true + } + + if (shouldHandle) { + href = convertBskyAppUrlIfNeeded(href) + if (href.startsWith('http')) { + Linking.openURL(href) + } else { + store.shell.closeModal() // close any active modals + + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } } } diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index 182c1ba5d..ba33f92a7 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -2,6 +2,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {Text} from './text/Text' import {usePalette} from 'lib/hooks/usePalette' +import {UpIcon} from 'lib/icons' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} @@ -9,10 +10,11 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { const pal = usePalette('default') return ( <TouchableOpacity - style={[pal.view, styles.loadLatest]} + style={[pal.view, pal.borderDark, styles.loadLatest]} onPress={onPress} hitSlop={HITSLOP}> <Text type="md-bold" style={pal.text}> + <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> Load new posts </Text> </TouchableOpacity> @@ -29,8 +31,15 @@ const styles = StyleSheet.create({ shadowOpacity: 0.2, shadowOffset: {width: 0, height: 2}, shadowRadius: 4, - paddingHorizontal: 24, + paddingLeft: 20, + paddingRight: 24, paddingVertical: 10, borderRadius: 30, + borderWidth: 1, + }, + icon: { + position: 'relative', + top: 2, + marginRight: 5, }, }) diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx index 76b71a53d..f98a66b76 100644 --- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx +++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx @@ -25,6 +25,7 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { authorAvatar={quote.author.avatar} authorHandle={quote.author.handle} authorDisplayName={quote.author.displayName} + postHref={itemHref} timestamp={quote.indexedAt} /> <Text type="post-text" style={pal.text} numberOfLines={6}> diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index cde5a3e92..0bb402100 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {Text} from './text/Text' +import {DesktopWebTextLink} from './Link' import {ago} from 'lib/strings/time' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' @@ -12,6 +13,7 @@ interface PostMetaOpts { authorAvatar?: string authorHandle: string authorDisplayName: string | undefined + postHref: string timestamp: string did?: string declarationCid?: string @@ -20,8 +22,8 @@ interface PostMetaOpts { export const PostMeta = observer(function (opts: PostMetaOpts) { const pal = usePalette('default') - let displayName = opts.authorDisplayName || opts.authorHandle - let handle = opts.authorHandle + const displayName = opts.authorDisplayName || opts.authorHandle + const handle = opts.authorHandle const store = useStores() const isMe = opts.did === store.me.did const isFollowing = @@ -41,31 +43,35 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { ) { // two-liner with follow button return ( - <View style={[styles.metaTwoLine]}> + <View style={styles.metaTwoLine}> <View> - <Text - type="lg-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {displayName}{' '} - <Text + <View style={styles.metaTwoLineTop}> + <DesktopWebTextLink + type="lg-bold" + style={pal.text} + numberOfLines={1} + lineHeight={1.2} + text={displayName} + href={`/profile/${opts.authorHandle}`} + /> + <Text type="md" style={pal.textLight} lineHeight={1.2}> + · + </Text> + <DesktopWebTextLink type="md" style={[styles.metaItem, pal.textLight]} - lineHeight={1.2}> - · {ago(opts.timestamp)} - </Text> - </Text> - <Text + lineHeight={1.2} + text={ago(opts.timestamp)} + href={opts.postHref} + /> + </View> + <DesktopWebTextLink type="md" style={[styles.metaItem, pal.textLight]} - lineHeight={1.2}> - {handle ? ( - <Text type="md" style={[pal.textLight]}> - @{handle} - </Text> - ) : undefined} - </Text> + lineHeight={1.2} + text={`@${handle}`} + href={`/profile/${opts.authorHandle}`} + /> </View> <View> @@ -84,31 +90,36 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <View style={styles.meta}> {typeof opts.authorAvatar !== 'undefined' && ( <View style={[styles.metaItem, styles.avatar]}> - <UserAvatar - avatar={opts.authorAvatar} - handle={opts.authorHandle} - displayName={opts.authorDisplayName} - size={16} - /> + <UserAvatar avatar={opts.authorAvatar} size={16} /> </View> )} <View style={[styles.metaItem, styles.maxWidth]}> - <Text + <DesktopWebTextLink type="lg-bold" - style={[pal.text]} + style={pal.text} numberOfLines={1} - lineHeight={1.2}> - {displayName} - {handle ? ( - <Text type="md" style={[pal.textLight]}> - {handle} - </Text> - ) : undefined} - </Text> + lineHeight={1.2} + text={ + <> + {displayName} + <Text type="md" style={[pal.textLight]}> + {handle} + </Text> + </> + } + href={`/profile/${opts.authorHandle}`} + /> </View> - <Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}> - · {ago(opts.timestamp)} + <Text type="md" style={pal.textLight} lineHeight={1.2}> + · </Text> + <DesktopWebTextLink + type="md" + style={[styles.metaItem, pal.textLight]} + lineHeight={1.2} + text={ago(opts.timestamp)} + href={opts.postHref} + /> </View> ) }) @@ -125,6 +136,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingBottom: 2, }, + metaTwoLineTop: { + flexDirection: 'row', + alignItems: 'baseline', + }, metaItem: { paddingRight: 5, }, diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx index d8573bd56..539a71ecf 100644 --- a/src/view/com/util/PostMuted.tsx +++ b/src/view/com/util/PostMuted.tsx @@ -7,7 +7,7 @@ import {Text} from './text/Text' export function PostMutedWrapper({ isMuted, children, -}: React.PropsWithChildren<{isMuted: boolean}>) { +}: React.PropsWithChildren<{isMuted?: boolean}>) { const pal = usePalette('default') const [override, setOverride] = React.useState(false) if (!isMuted || override) { diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d0d2c273b..2e0632521 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg' +import Svg, {Circle, Path} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {HighPriorityImage} from 'view/com/util/images/Image' @@ -11,52 +11,48 @@ import { PickedMedia, } from '../../../lib/media/picker' import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' + usePhotoLibraryPermission, + useCameraPermission, +} from 'lib/hooks/usePermissions' import {useStores} from 'state/index' -import {colors, gradients} from 'lib/styles' +import {colors} from 'lib/styles' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' +function DefaultAvatar({size}: {size: number}) { + return ( + <Svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="none"> + <Circle cx="12" cy="12" r="12" fill="#0070ff" /> + <Circle cx="12" cy="9.5" r="3.5" fill="#fff" /> + <Path + strokeLinecap="round" + strokeLinejoin="round" + fill="#fff" + d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z" + /> + </Svg> + ) +} + export function UserAvatar({ size, - handle, avatar, - displayName, onSelectNewAvatar, }: { size: number - handle: string - displayName: string | undefined avatar?: string | null onSelectNewAvatar?: (img: PickedMedia | null) => void }) { const store = useStores() const pal = usePalette('default') - const initials = getInitials(displayName || handle) - - const renderSvg = (svgSize: number, svgInitials: string) => ( - <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> - <Defs> - <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> - <Stop offset="0" stopColor={gradients.blue.start} stopOpacity="1" /> - <Stop offset="1" stopColor={gradients.blue.end} stopOpacity="1" /> - </LinearGradient> - </Defs> - <Circle cx="50" cy="50" r="50" fill="url(#grad)" /> - <Text - fill="white" - fontSize="50" - fontWeight="bold" - x="50" - y="67" - textAnchor="middle"> - {svgInitials} - </Text> - </Svg> - ) + const {requestCameraAccessIfNeeded} = useCameraPermission() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const dropdownItems = [ !isWeb && { @@ -124,7 +120,7 @@ export function UserAvatar({ source={{uri: avatar}} /> ) : ( - renderSvg(size, initials) + <DefaultAvatar size={size} /> )} <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon @@ -141,26 +137,10 @@ export function UserAvatar({ source={{uri: avatar}} /> ) : ( - renderSvg(size, initials) + <DefaultAvatar size={size} /> ) } -function getInitials(str: string): string { - const tokens = str - .toLowerCase() - .replace(/[^a-z]/g, '') - .split(' ') - .filter(Boolean) - .map(v => v.trim()) - if (tokens.length >= 2 && tokens[0][0] && tokens[0][1]) { - return tokens[0][0].toUpperCase() + tokens[1][0].toUpperCase() - } - if (tokens.length === 1 && tokens[0][0]) { - return tokens[0][0].toUpperCase() - } - return 'X' -} - const styles = StyleSheet.create({ editButtonContainer: { position: 'absolute', diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 16e05311b..d89de9158 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,10 +1,10 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' +import Svg, {Rect} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import Image from 'view/com/util/images/Image' -import {colors, gradients} from 'lib/styles' +import {colors} from 'lib/styles' import { openCamera, openCropper, @@ -13,9 +13,9 @@ import { } from '../../../lib/media/picker' import {useStores} from 'state/index' import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' + usePhotoLibraryPermission, + useCameraPermission, +} from 'lib/hooks/usePermissions' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -29,6 +29,9 @@ export function UserBanner({ }) { const store = useStores() const pal = usePalette('default') + const {requestCameraAccessIfNeeded} = useCameraPermission() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const dropdownItems = [ !isWeb && { label: 'Camera', @@ -80,19 +83,8 @@ export function UserBanner({ ] const renderSvg = () => ( - <Svg width="100%" height="150" viewBox="50 0 200 100"> - <Defs> - <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> - <Stop - offset="0" - stopColor={gradients.blueDark.start} - stopOpacity="1" - /> - <Stop offset="1" stopColor={gradients.blueDark.end} stopOpacity="1" /> - </LinearGradient> - </Defs> - <Rect x="0" y="0" width="400" height="100" fill="url(#grad)" /> - <Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" /> + <Svg width="100%" height="150" viewBox="0 0 400 100"> + <Rect x="0" y="0" width="400" height="100" fill="#0070ff" /> </Svg> ) diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index 84170b3bf..4753c9b01 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,7 +1,7 @@ import React, {useState, useEffect} from 'react' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' import {StyleProp, StyleSheet, TextStyle} from 'react-native' -import {Link} from './Link' +import {DesktopWebTextLink} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' import {useStores} from 'state/index' @@ -14,7 +14,6 @@ export function UserInfoText({ failed, prefix, style, - asLink, }: { type?: TypographyVariant did: string @@ -23,7 +22,6 @@ export function UserInfoText({ failed?: string prefix?: string style?: StyleProp<TextStyle> - asLink?: boolean }) { attr = attr || 'handle' failed = failed || 'user' @@ -64,9 +62,14 @@ export function UserInfoText({ ) } else if (profile) { inner = ( - <Text type={type} style={style} lineHeight={1.2} numberOfLines={1}>{`${ - prefix || '' - }${profile[attr] || profile.handle}`}</Text> + <DesktopWebTextLink + type={type} + style={style} + lineHeight={1.2} + numberOfLines={1} + href={`/profile/${profile.handle}`} + text={`${prefix || ''}${profile[attr] || profile.handle}`} + /> ) } else { inner = ( @@ -78,17 +81,6 @@ export function UserInfoText({ ) } - if (asLink) { - const title = profile?.displayName || profile?.handle || 'User' - return ( - <Link - href={`/profile/${profile?.handle ? profile.handle : did}`} - title={title}> - {inner} - </Link> - ) - } - return inner } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index ffd1b1d63..a99282512 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -2,17 +2,19 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' import {UserAvatar} from './UserAvatar' import {Text} from './text/Text' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnalytics} from 'lib/analytics' -import {isDesktopWeb} from '../../../platform/detection' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const ViewHeader = observer(function ViewHeader({ +export const ViewHeader = observer(function ({ title, canGoBack, hideOnScroll, @@ -23,50 +25,55 @@ export const ViewHeader = observer(function ViewHeader({ }) { const pal = usePalette('default') const store = useStores() + const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const onPressBack = () => { - store.nav.tab.goBack() - } - const onPressMenu = () => { + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressMenu = React.useCallback(() => { track('ViewHeader:MenuButtonClicked') - store.shell.setMainMenuOpen(true) - } - if (typeof canGoBack === 'undefined') { - canGoBack = store.nav.tab.canGoBack - } + store.shell.openDrawer() + }, [track, store]) + if (isDesktopWeb) { return <></> + } else { + if (typeof canGoBack === 'undefined') { + canGoBack = navigation.canGoBack() + } + + return ( + <Container hideOnScroll={hideOnScroll || false}> + <TouchableOpacity + testID="viewHeaderBackOrMenuBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide}> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : ( + <UserAvatar size={30} avatar={store.me.avatar} /> + )} + </TouchableOpacity> + <View style={styles.titleContainer} pointerEvents="none"> + <Text type="title" style={[pal.text, styles.title]}> + {title} + </Text> + </View> + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + </Container> + ) } - return ( - <Container hideOnScroll={hideOnScroll || false}> - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={canGoBack ? onPressBack : onPressMenu} - hitSlop={BACK_HITSLOP} - style={canGoBack ? styles.backBtn : styles.backBtnWide}> - {canGoBack ? ( - <FontAwesomeIcon - size={18} - icon="angle-left" - style={[styles.backIcon, pal.text]} - /> - ) : ( - <UserAvatar - size={30} - handle={store.me.handle} - displayName={store.me.displayName} - avatar={store.me.avatar} - /> - )} - </TouchableOpacity> - <View style={styles.titleContainer} pointerEvents="none"> - <Text type="title" style={[pal.text, styles.title]}> - {title} - </Text> - </View> - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> - </Container> - ) }) const Container = observer( @@ -119,8 +126,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, - paddingTop: 6, - paddingBottom: 6, + paddingVertical: 6, }, headerFloating: { position: 'absolute', diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 8b5adaa04..9a43697b5 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -23,7 +23,6 @@ import { ViewProps, } from 'react-native' import {addStyle, colors} from 'lib/styles' -import {DESKTOP_HEADER_HEIGHT} from 'lib/constants' export function CenteredView({ style, @@ -73,14 +72,14 @@ export const ScrollView = React.forwardRef(function ( const styles = StyleSheet.create({ container: { width: '100%', - maxWidth: 550, + maxWidth: 600, marginLeft: 'auto', marginRight: 'auto', }, containerScroll: { width: '100%', - height: `calc(100vh - ${DESKTOP_HEADER_HEIGHT}px)`, - maxWidth: 550, + minHeight: '100vh', + maxWidth: 600, marginLeft: 'auto', marginRight: 'auto', }, diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index ac83d1a54..d6ae800c6 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -17,7 +17,6 @@ import {Button, ButtonType} from './Button' import {colors} from 'lib/styles' import {toShareUrl} from 'lib/strings/url-helpers' import {useStores} from 'state/index' -import {TABS_ENABLED} from 'lib/build-flags' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' @@ -138,15 +137,6 @@ export function PostDropdownBtn({ const store = useStores() const dropdownItems: DropdownItem[] = [ - TABS_ENABLED - ? { - icon: ['far', 'clone'], - label: 'Open in new tab', - onPress() { - store.nav.newTab(itemHref) - }, - } - : undefined, { icon: 'language', label: 'Translate...', diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 57a875cd3..d6b2bb119 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -41,6 +41,9 @@ export function RadioButton({ 'secondary-light': { borderColor: theme.palette.secondary.border, }, + default: { + borderColor: theme.palette.default.border, + }, 'default-light': { borderColor: theme.palette.default.border, }, @@ -69,6 +72,9 @@ export function RadioButton({ 'secondary-light': { backgroundColor: theme.palette.secondary.background, }, + default: { + backgroundColor: theme.palette.primary.background, + }, 'default-light': { backgroundColor: theme.palette.primary.background, }, @@ -103,6 +109,10 @@ export function RadioButton({ color: theme.palette.secondary.textInverted, fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, }, + default: { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, 'default-light': { color: theme.palette.default.text, fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 005d1165e..a6e0ba3fe 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -42,6 +42,9 @@ export function ToggleButton({ 'secondary-light': { borderColor: theme.palette.secondary.border, }, + default: { + borderColor: theme.palette.default.border, + }, 'default-light': { borderColor: theme.palette.default.border, }, @@ -77,6 +80,11 @@ export function ToggleButton({ backgroundColor: theme.palette.secondary.background, opacity: isSelected ? 1 : 0.5, }, + default: { + backgroundColor: isSelected + ? theme.palette.primary.background + : colors.gray3, + }, 'default-light': { backgroundColor: isSelected ? theme.palette.primary.background @@ -113,6 +121,10 @@ export function ToggleButton({ color: theme.palette.secondary.textInverted, fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, }, + default: { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, 'default-light': { color: theme.palette.default.text, fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, diff --git a/src/view/routes.ts b/src/view/routes.ts deleted file mode 100644 index 1cd9ef8e2..000000000 --- a/src/view/routes.ts +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react' -import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {Home} from './screens/Home' -import {Contacts} from './screens/Contacts' -import {Search} from './screens/Search' -import {Notifications} from './screens/Notifications' -import {NotFound} from './screens/NotFound' -import {PostThread} from './screens/PostThread' -import {PostUpvotedBy} from './screens/PostUpvotedBy' -import {PostDownvotedBy} from './screens/PostDownvotedBy' -import {PostRepostedBy} from './screens/PostRepostedBy' -import {Profile} from './screens/Profile' -import {ProfileFollowers} from './screens/ProfileFollowers' -import {ProfileFollows} from './screens/ProfileFollows' -import {Settings} from './screens/Settings' -import {Debug} from './screens/Debug' -import {Log} from './screens/Log' - -export type ScreenParams = { - navIdx: string - params: Record<string, any> - visible: boolean -} -export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp] -export type MatchResult = { - Com: React.FC<ScreenParams> - defaultTitle: string - icon: IconProp - params: Record<string, any> - isNotFound?: boolean -} - -const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i') -export const routes: Route[] = [ - [Home, 'Home', 'house', r('/')], - [Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')], - [Search, 'Search', 'magnifying-glass', r('/search')], - [Notifications, 'Notifications', 'bell', r('/notifications')], - [Settings, 'Settings', 'bell', r('/settings')], - [Profile, 'User', ['far', 'user'], r('/profile/(?<name>[^/]+)')], - [ - ProfileFollowers, - 'Followers', - 'users', - r('/profile/(?<name>[^/]+)/followers'), - ], - [ProfileFollows, 'Follows', 'users', r('/profile/(?<name>[^/]+)/follows')], - [ - PostThread, - 'Post', - ['far', 'message'], - r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)'), - ], - [ - PostUpvotedBy, - 'Liked by', - 'heart', - r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/upvoted-by'), - ], - [ - PostDownvotedBy, - 'Downvoted by', - 'heart', - r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/downvoted-by'), - ], - [ - PostRepostedBy, - 'Reposted by', - 'retweet', - r('/profile/(?<name>[^/]+)/post/(?<rkey>[^/]+)/reposted-by'), - ], - [Debug, 'Debug', 'house', r('/sys/debug')], - [Log, 'Log', 'house', r('/sys/log')], -] - -export function match(url: string): MatchResult { - for (const [Com, defaultTitle, icon, pattern] of routes) { - const res = pattern.exec(url) - if (res) { - // TODO: query params - return {Com, defaultTitle, icon, params: res.groups || {}} - } - } - return { - Com: NotFound, - defaultTitle: 'Not found', - icon: 'magnifying-glass', - params: {}, - isNotFound: true, - } -} diff --git a/src/view/screens/Contacts.tsx b/src/view/screens/Contacts.tsx deleted file mode 100644 index 21943a10a..000000000 --- a/src/view/screens/Contacts.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, {useEffect, useState, useRef} from 'react' -import {StyleSheet, TextInput, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' -import {Selector} from '../com/util/Selector' -import {Text} from '../com/util/text/Text' -import {colors} from 'lib/styles' -import {ScreenParams} from '../routes' -import {useStores} from 'state/index' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' - -export const Contacts = ({navIdx, visible}: ScreenParams) => { - const store = useStores() - const selectorInterp = useAnimatedValue(0) - - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, 'Contacts') - } - }, [store, visible, navIdx]) - - const [searchText, onChangeSearchText] = useState('') - const inputRef = useRef<TextInput | null>(null) - - return ( - <View> - <View style={styles.section}> - <Text testID="contactsTitle" style={styles.title}> - Contacts - </Text> - </View> - <View style={styles.section}> - <View style={styles.searchContainer}> - <FontAwesomeIcon - icon="magnifying-glass" - size={16} - style={styles.searchIcon} - /> - <TextInput - testID="contactsTextInput" - ref={inputRef} - value={searchText} - style={styles.searchInput} - placeholder="Search" - placeholderTextColor={colors.gray4} - onChangeText={onChangeSearchText} - /> - </View> - </View> - <Selector - items={['All', 'Following', 'Scenes']} - selectedIndex={0} - panX={selectorInterp} - /> - {!!store.me.handle && <ProfileFollowsComponent name={store.me.handle} />} - </View> - ) -} - -const styles = StyleSheet.create({ - section: { - backgroundColor: colors.white, - }, - title: { - fontSize: 30, - fontWeight: 'bold', - paddingHorizontal: 12, - paddingVertical: 6, - }, - - searchContainer: { - flexDirection: 'row', - backgroundColor: colors.gray1, - paddingHorizontal: 8, - paddingVertical: 8, - marginHorizontal: 10, - marginBottom: 6, - borderRadius: 4, - }, - searchIcon: { - color: colors.gray5, - marginRight: 8, - }, - searchInput: { - flex: 1, - color: colors.black, - }, -}) diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index eb5ffe20f..852025324 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -1,5 +1,6 @@ import React from 'react' import {ScrollView, View} from 'react-native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' @@ -20,7 +21,10 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage' const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs'] -export const Debug = () => { +export const DebugScreen = ({}: NativeStackScreenProps< + CommonNavigatorParams, + 'Debug' +>) => { const [colorScheme, setColorScheme] = React.useState<'light' | 'dark'>( 'light', ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 42759f7ff..505b1fcfe 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,14 +1,15 @@ import React from 'react' import {FlatList, View} from 'react-native' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' +import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/posts/Feed' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' import {WelcomeBanner} from '../com/util/WelcomeBanner' import {FAB} from '../com/util/FAB' import {useStores} from 'state/index' -import {ScreenParams} from '../routes' import {s} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics' @@ -16,19 +17,20 @@ import {ComposeIcon2} from 'lib/icons' const HEADER_HEIGHT = 42 -export const Home = observer(function Home({navIdx, visible}: ScreenParams) { +type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> +export const HomeScreen = observer(function Home(_opts: Props) { const store = useStores() const onMainScroll = useOnMainScroll(store) const {screen, track} = useAnalytics() const scrollElRef = React.useRef<FlatList>(null) - const [wasVisible, setWasVisible] = React.useState<boolean>(false) const {appState} = useAppState({ onForeground: () => doPoll(true), }) + const isFocused = useIsFocused() const doPoll = React.useCallback( (knownActive = false) => { - if ((!knownActive && appState !== 'active') || !visible) { + if ((!knownActive && appState !== 'active') || !isFocused) { return } if (store.me.mainFeed.isLoading) { @@ -37,7 +39,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) { store.log.debug('HomeScreen: Polling for new posts') store.me.mainFeed.checkForLatest() }, - [appState, visible, store], + [appState, isFocused, store], ) const scrollToTop = React.useCallback(() => { @@ -46,53 +48,35 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) { scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT}) }, [scrollElRef]) - React.useEffect(() => { - const softResetSub = store.onScreenSoftReset(scrollToTop) - const feedCleanup = store.me.mainFeed.registerListeners() - const pollInterval = setInterval(doPoll, 15e3) - const cleanup = () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(scrollToTop) + const feedCleanup = store.me.mainFeed.registerListeners() + const pollInterval = setInterval(doPoll, 15e3) - // guard to only continue when transitioning from !visible -> visible - // TODO is this 100% needed? depends on if useEffect() is getting refired - // for reasons other than `visible` changing -prf - if (!visible) { - setWasVisible(false) - return cleanup - } else if (wasVisible) { - return cleanup - } - setWasVisible(true) + screen('Feed') + store.log.debug('HomeScreen: Updating feed') + if (store.me.mainFeed.hasContent) { + store.me.mainFeed.update() + } - // just became visible - screen('Feed') - store.nav.setTitle(navIdx, 'Home') - store.log.debug('HomeScreen: Updating feed') - if (store.me.mainFeed.hasContent) { - store.me.mainFeed.update() - } - return cleanup - }, [ - visible, - store, - store.me.mainFeed, - navIdx, - doPoll, - wasVisible, - scrollToTop, - screen, - ]) + return () => { + clearInterval(pollInterval) + softResetSub.remove() + feedCleanup() + } + }, [store, doPoll, scrollToTop, screen]), + ) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') store.shell.openComposer({}) }, [store, track]) + const onPressTryAgain = React.useCallback(() => { store.me.mainFeed.refresh() }, [store]) + const onPressLoadLatest = React.useCallback(() => { store.me.mainFeed.refresh() scrollToTop() diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index c067d3506..8e0fe8dd3 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,28 +1,30 @@ -import React, {useEffect} from 'react' +import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ScrollView} from '../com/util/Views' import {useStores} from 'state/index' -import {ScreenParams} from '../routes' import {s} from 'lib/styles' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ago} from 'lib/strings/time' -export const Log = observer(function Log({navIdx, visible}: ScreenParams) { +export const LogScreen = observer(function Log({}: NativeStackScreenProps< + CommonNavigatorParams, + 'Log' +>) { const pal = usePalette('default') const store = useStores() const [expanded, setExpanded] = React.useState<string[]>([]) - useEffect(() => { - if (!visible) { - return - } - store.shell.setMinimalShellMode(false) - store.nav.setTitle(navIdx, 'Log') - }, [visible, store, navIdx]) + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) const toggler = (id: string) => () => { if (expanded.includes(id)) { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index 77bbdd2aa..6ab37f117 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -1,20 +1,41 @@ import React from 'react' -import {Button, StyleSheet, View} from 'react-native' +import {StyleSheet, View} from 'react-native' +import {useNavigation, StackActions} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export const NotFoundScreen = () => { + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const canGoBack = navigation.canGoBack() + const onPressHome = React.useCallback(() => { + if (canGoBack) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + }, [navigation, canGoBack]) -export const NotFound = () => { - const stores = useStores() return ( - <View testID="notFoundView"> + <View testID="notFoundView" style={pal.view}> <ViewHeader title="Page not found" /> <View style={styles.container}> - <Text style={styles.title}>Page not found</Text> + <Text type="title-2xl" style={[pal.text, s.mb10]}> + Page not found + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + We're sorry! We can't find the page you were looking for. + </Text> <Button - testID="navigateHomeButton" - title="Home" - onPress={() => stores.nav.navigate('/')} + type="primary" + label={canGoBack ? 'Go back' : 'Go home'} + onPress={onPressHome} /> </View> </View> @@ -23,12 +44,9 @@ export const NotFound = () => { const styles = StyleSheet.create({ container: { - justifyContent: 'center', - alignItems: 'center', paddingTop: 100, - }, - title: { - fontSize: 40, - fontWeight: 'bold', + paddingHorizontal: 20, + alignItems: 'center', + height: '100%', }, }) diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index f1a9e8bf0..492177d1f 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,17 +1,25 @@ import React, {useEffect} from 'react' import {FlatList, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' import useAppState from 'react-native-appstate-hook' +import { + NativeStackScreenProps, + NotificationsTabNavigatorParams, +} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {useStores} from 'state/index' -import {ScreenParams} from '../routes' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' const NOTIFICATIONS_POLL_INTERVAL = 15e3 -export const Notifications = ({navIdx, visible}: ScreenParams) => { +type Props = NativeStackScreenProps< + NotificationsTabNavigatorParams, + 'Notifications' +> +export const NotificationsScreen = ({}: Props) => { const store = useStores() const onMainScroll = useOnMainScroll(store) const scrollElRef = React.useRef<FlatList>(null) @@ -59,21 +67,19 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => { // on-visible setup // = - useEffect(() => { - if (!visible) { - // mark read when the user leaves the screen - store.me.notifications.markAllRead() - return - } - store.log.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(scrollToTop) - store.me.notifications.update() - screen('Notifications') - store.nav.setTitle(navIdx, 'Notifications') - return () => { - softResetSub.remove() - } - }, [visible, store, navIdx, screen, scrollToTop]) + useFocusEffect( + React.useCallback(() => { + store.log.debug('NotificationsScreen: Updating feed') + const softResetSub = store.onScreenSoftReset(scrollToTop) + store.me.notifications.update() + screen('Notifications') + + return () => { + softResetSub.remove() + store.me.notifications.markAllRead() + } + }, [store, screen, scrollToTop]), + ) return ( <View style={s.hContentRegion}> diff --git a/src/view/screens/PostDownvotedBy.tsx b/src/view/screens/PostDownvotedBy.tsx deleted file mode 100644 index 570482598..000000000 --- a/src/view/screens/PostDownvotedBy.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, {useEffect} from 'react' -import {View} from 'react-native' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' -import {ScreenParams} from '../routes' -import {useStores} from 'state/index' -import {makeRecordUri} from 'lib/strings/url-helpers' - -export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => { - const store = useStores() - const {name, rkey} = params - const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, 'Downvoted by') - store.shell.setMinimalShellMode(false) - } - }, [store, visible, navIdx]) - - return ( - <View> - <ViewHeader title="Downvoted by" /> - <PostLikedByComponent uri={uri} direction="down" /> - </View> - ) -} diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index 4be4b4b42..1a63445e5 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,22 +1,23 @@ -import React, {useEffect} from 'react' +import React from 'react' import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' -export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> +export const PostRepostedByScreen = ({route}: Props) => { const store = useStores() - const {name, rkey} = params + const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, 'Reposted by') + useFocusEffect( + React.useCallback(() => { store.shell.setMinimalShellMode(false) - } - }, [store, visible, navIdx]) + }, [store]), + ) return ( <View> diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 0b6829735..0e9feae0b 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,58 +1,45 @@ -import React, {useEffect, useMemo} from 'react' +import React, {useMemo} from 'react' import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {ComposePrompt} from 'view/com/composer/Prompt' import {PostThreadViewModel} from 'state/models/post-thread-view' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' import {s} from 'lib/styles' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lodash' +import {isDesktopWeb} from 'platform/detection' const SHELL_FOOTER_HEIGHT = 44 -export const PostThread = ({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> +export const PostThreadScreen = ({route}: Props) => { const store = useStores() const safeAreaInsets = useSafeAreaInsets() - const {name, rkey} = params + const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const view = useMemo<PostThreadViewModel>( () => new PostThreadViewModel(store, {uri}), [store, uri], ) - useEffect(() => { - let aborted = false - const threadCleanup = view.registerListeners() - const setTitle = () => { - const author = view.thread?.post.author - const niceName = author?.handle || name - store.nav.setTitle(navIdx, `Post by ${niceName}`) - } - if (!visible) { - return threadCleanup - } - setTitle() - store.shell.setMinimalShellMode(false) - if (!view.hasLoaded && !view.isLoading) { - view.setup().then( - () => { - if (!aborted) { - setTitle() - } - }, - err => { + useFocusEffect( + React.useCallback(() => { + const threadCleanup = view.registerListeners() + store.shell.setMinimalShellMode(false) + if (!view.hasLoaded && !view.isLoading) { + view.setup().catch(err => { store.log.error('Failed to fetch thread', err) - }, - ) - } - return () => { - aborted = true - threadCleanup() - } - }, [visible, store.nav, store.log, store.shell, name, navIdx, view]) + }) + } + return () => { + threadCleanup() + } + }, [store, view]), + ) const onPressReply = React.useCallback(() => { if (!view.thread) { @@ -77,15 +64,24 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => { <View style={s.hContentRegion}> <ViewHeader title="Post" /> <View style={s.hContentRegion}> - <PostThreadComponent uri={uri} view={view} /> - </View> - <View - style={[ - styles.prompt, - {bottom: SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30)}, - ]}> - <ComposePrompt onPressCompose={onPressReply} /> + <PostThreadComponent + uri={uri} + view={view} + onPressReply={onPressReply} + /> </View> + {!isDesktopWeb && ( + <View + style={[ + styles.prompt, + { + bottom: + SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30), + }, + ]}> + <ComposePrompt onPressCompose={onPressReply} /> + </View> + )} </View> ) } diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostUpvotedBy.tsx index 4d6ad4114..b1690721b 100644 --- a/src/view/screens/PostUpvotedBy.tsx +++ b/src/view/screens/PostUpvotedBy.tsx @@ -1,21 +1,23 @@ -import React, {useEffect} from 'react' +import React from 'react' import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' -export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'> +export const PostUpvotedByScreen = ({route}: Props) => { const store = useStores() - const {name, rkey} = params + const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, 'Liked by') - } - }, [store, visible, navIdx]) + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) return ( <View> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index fa0c04106..e0d0a5884 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,9 +1,10 @@ import React, {useEffect, useState} from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' -import {ScreenParams} from '../routes' import {ProfileUiModel, Sections} from 'state/models/profile-ui' import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' @@ -23,7 +24,8 @@ const LOADING_ITEM = {_reactKey: '__loading__'} const END_ITEM = {_reactKey: '__end__'} const EMPTY_ITEM = {_reactKey: '__empty__'} -export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> +export const ProfileScreen = observer(({route}: Props) => { const store = useStores() const {screen, track} = useAnalytics() @@ -34,35 +36,30 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { const onMainScroll = useOnMainScroll(store) const [hasSetup, setHasSetup] = useState<boolean>(false) const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: params.name}), - [params.name, store], + () => new ProfileUiModel(store, {user: route.params.name}), + [route.params.name, store], ) - useEffect(() => { - store.nav.setTitle(navIdx, params.name) - }, [store, navIdx, params.name]) - - useEffect(() => { - let aborted = false - const feedCleanup = uiState.feed.registerListeners() - if (!visible) { - return feedCleanup - } - if (hasSetup) { - uiState.update() - } else { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - } - }, [visible, store, hasSetup, uiState]) + useFocusEffect( + React.useCallback(() => { + let aborted = false + const feedCleanup = uiState.feed.registerListeners() + if (hasSetup) { + uiState.update() + } else { + uiState.setup().then(() => { + if (aborted) { + return + } + setHasSetup(true) + }) + } + return () => { + aborted = true + feedCleanup() + } + }, [hasSetup, uiState]), + ) // events // = @@ -171,7 +168,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { <ErrorScreen testID="profileErrorScreen" title="Failed to load profile" - message={`There was an issue when attempting to load ${params.name}`} + message={`There was an issue when attempting to load ${route.params.name}`} details={uiState.profile.error} onPressTryAgain={onPressTryAgain} /> diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 9f1a9c741..b248cdc3a 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -1,20 +1,21 @@ -import React, {useEffect} from 'react' +import React from 'react' import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' -export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'> +export const ProfileFollowersScreen = ({route}: Props) => { const store = useStores() - const {name} = params + const {name} = route.params - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, `Followers of ${name}`) + useFocusEffect( + React.useCallback(() => { store.shell.setMinimalShellMode(false) - } - }, [store, visible, name, navIdx]) + }, [store]), + ) return ( <View> diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 1cdb5bccf..7edf8edba 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -1,20 +1,21 @@ -import React, {useEffect} from 'react' +import React from 'react' import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' -export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'> +export const ProfileFollowsScreen = ({route}: Props) => { const store = useStores() - const {name} = params + const {name} = route.params - useEffect(() => { - if (visible) { - store.nav.setTitle(navIdx, `Followed by ${name}`) + useFocusEffect( + React.useCallback(() => { store.shell.setMinimalShellMode(false) - } - }, [store, visible, name, navIdx]) + }, [store]), + ) return ( <View> diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index a87c41e76..a50d5c6a7 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -7,12 +7,19 @@ import { TouchableWithoutFeedback, View, } from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useFocusEffect} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' import {ScrollView} from '../com/util/Views' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' import {observer} from 'mobx-react-lite' import {UserAvatar} from '../com/util/UserAvatar' import {Text} from '../com/util/text/Text' -import {ScreenParams} from '../routes' import {useStores} from 'state/index' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {s} from 'lib/styles' @@ -21,14 +28,17 @@ import {WhoToFollow} from '../com/discover/WhoToFollow' import {SuggestedPosts} from '../com/discover/SuggestedPosts' import {ProfileCard} from '../com/profile/ProfileCard' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics' const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} const FIVE_MIN = 5 * 60 * 1e3 -export const Search = observer(({navIdx, visible, params}: ScreenParams) => { +type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> +export const SearchScreen = observer<Props>(({}: Props) => { const pal = usePalette('default') + const theme = useTheme() const store = useStores() const {track} = useAnalytics() const scrollElRef = React.useRef<ScrollView>(null) @@ -41,33 +51,32 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { () => new UserAutocompleteViewModel(store), [store], ) - const {name} = params const onSoftReset = () => { scrollElRef.current?.scrollTo({x: 0, y: 0}) } - React.useEffect(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } - if (visible) { const now = Date.now() if (now - lastRenderTime > FIVE_MIN) { setRenderTime(Date.now()) // trigger reload of suggestions } store.shell.setMinimalShellMode(false) autocompleteView.setup() - store.nav.setTitle(navIdx, 'Search') - } - return cleanup - }, [store, visible, name, navIdx, autocompleteView, lastRenderTime]) + + return cleanup + }, [store, autocompleteView, lastRenderTime, setRenderTime]), + ) const onPressMenu = () => { track('ViewHeader:MenuButtonClicked') - store.shell.setMainMenuOpen(true) + store.shell.openDrawer() } const onChangeQuery = (text: string) => { @@ -102,12 +111,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { onPress={onPressMenu} hitSlop={MENU_HITSLOP} style={styles.headerMenuBtn}> - <UserAvatar - size={30} - handle={store.me.handle} - displayName={store.me.displayName} - avatar={store.me.avatar} - /> + <UserAvatar size={30} avatar={store.me.avatar} /> </TouchableOpacity> <View style={[ @@ -127,13 +131,18 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { returnKeyType="search" value={query} style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} /> {query ? ( <TouchableOpacity onPress={onPressClearQuery}> - <FontAwesomeIcon icon="xmark" size={16} style={pal.textLight} /> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> </TouchableOpacity> ) : undefined} </View> diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index 886d49af7..75b5f01ce 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -1,8 +1,12 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' import {ScrollView} from '../com/util/Views' import {observer} from 'mobx-react-lite' -import {ScreenParams} from '../routes' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' import {useStores} from 'state/index' import {s} from 'lib/styles' import {WhoToFollow} from '../com/discover/WhoToFollow' @@ -12,7 +16,8 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' const FIVE_MIN = 5 * 60 * 1e3 -export const Search = observer(({navIdx, visible}: ScreenParams) => { +type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> +export const SearchScreen = observer(({}: Props) => { const pal = usePalette('default') const store = useStores() const scrollElRef = React.useRef<ScrollView>(null) @@ -23,22 +28,21 @@ export const Search = observer(({navIdx, visible}: ScreenParams) => { scrollElRef.current?.scrollTo({x: 0, y: 0}) } - React.useEffect(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) - if (visible) { const now = Date.now() if (now - lastRenderTime > FIVE_MIN) { setRenderTime(Date.now()) // trigger reload of suggestions } store.shell.setMinimalShellMode(false) - store.nav.setTitle(navIdx, 'Search') - } - return cleanup - }, [store, visible, navIdx, lastRenderTime]) + + return () => { + softResetSub.remove() + } + }, [store, lastRenderTime, setRenderTime]), + ) return ( <ScrollView diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 47e76a124..2e5d2c001 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react' +import React from 'react' import { ActivityIndicator, StyleSheet, @@ -6,13 +6,18 @@ import { View, } from 'react-native' import { + useFocusEffect, + useNavigation, + StackActions, +} from '@react-navigation/native' +import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import * as AppInfo from 'lib/app-info' import {useStores} from 'state/index' -import {ScreenParams} from '../routes' import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -25,41 +30,38 @@ import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {AccountData} from 'state/models/session' import {useAnalytics} from 'lib/analytics' +import {NavigationProp} from 'lib/routes/types' -export const Settings = observer(function Settings({ - navIdx, - visible, -}: ScreenParams) { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> +export const SettingsScreen = observer(function Settings({}: Props) { const theme = useTheme() const pal = usePalette('default') const store = useStores() + const navigation = useNavigation<NavigationProp>() const {screen, track} = useAnalytics() const [isSwitching, setIsSwitching] = React.useState(false) - useEffect(() => { - screen('Settings') - }, [screen]) - - useEffect(() => { - if (!visible) { - return - } - store.shell.setMinimalShellMode(false) - store.nav.setTitle(navIdx, 'Settings') - }, [visible, store, navIdx]) + useFocusEffect( + React.useCallback(() => { + screen('Settings') + store.shell.setMinimalShellMode(false) + }, [screen, store]), + ) const onPressSwitchAccount = async (acct: AccountData) => { track('Settings:SwitchAccountButtonClicked') setIsSwitching(true) if (await store.session.resumeSession(acct)) { setIsSwitching(false) - store.nav.tab.fixedTabReset() + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) Toast.show(`Signed in as ${acct.displayName || acct.handle}`) return } setIsSwitching(false) Toast.show('Sorry! We need you to enter your password.') - store.nav.tab.fixedTabReset() + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) store.session.clear() } const onPressAddAccount = () => { @@ -118,12 +120,7 @@ export const Settings = observer(function Settings({ noFeedback> <View style={[pal.view, styles.linkCard]}> <View style={styles.avi}> - <UserAvatar - size={40} - displayName={store.me.displayName} - handle={store.me.handle || ''} - avatar={store.me.avatar} - /> + <UserAvatar size={40} avatar={store.me.avatar} /> </View> <View style={[s.flex1]}> <Text type="md-bold" style={pal.text} numberOfLines={1}> @@ -152,12 +149,7 @@ export const Settings = observer(function Settings({ isSwitching ? undefined : () => onPressSwitchAccount(account) }> <View style={styles.avi}> - <UserAvatar - size={40} - displayName={account.displayName} - handle={account.handle || ''} - avatar={account.aviUrl} - /> + <UserAvatar size={40} avatar={account.aviUrl} /> </View> <View style={[s.flex1]}> <Text type="md-bold" style={pal.text}> diff --git a/src/view/shell/mobile/BottomBar.tsx b/src/view/shell/BottomBar.tsx index 73c2501ab..18b06968f 100644 --- a/src/view/shell/mobile/BottomBar.tsx +++ b/src/view/shell/BottomBar.tsx @@ -6,13 +6,14 @@ import { TouchableOpacity, View, } from 'react-native' +import {StackActions, useNavigationState} from '@react-navigation/native' +import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation' import {clamp} from 'lib/numbers' import { HomeIcon, @@ -25,13 +26,24 @@ import { } from 'lib/icons' import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {getTabState, TabState} from 'lib/routes/helpers' -export const BottomBar = observer(() => { +export const BottomBar = observer(({navigation}: BottomTabBarProps) => { const store = useStores() const pal = usePalette('default') const minimalShellInterp = useAnimatedValue(0) const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() + const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState( + state => { + return { + isAtHome: getTabState(state, 'Home') !== TabState.Outside, + isAtSearch: getTabState(state, 'Search') !== TabState.Outside, + isAtNotifications: + getTabState(state, 'Notifications') !== TabState.Outside, + } + }, + ) React.useEffect(() => { if (store.shell.minimalShellMode) { @@ -54,62 +66,34 @@ export const BottomBar = observer(() => { transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}], } - const onPressHome = React.useCallback(() => { - track('MobileShell:HomeButtonPressed') - if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) { - if (!store.nav.tab.canGoBack) { + const onPressTab = React.useCallback( + (tab: string) => { + track(`MobileShell:${tab}ButtonPressed`) + const state = navigation.getState() + const tabState = getTabState(state, tab) + if (tabState === TabState.InsideAtRoot) { store.emitScreenSoftReset() + } else if (tabState === TabState.Inside) { + navigation.dispatch(StackActions.popToTop()) } else { - store.nav.tab.fixedTabReset() - } - } else { - store.nav.switchTo(TabPurpose.Default, false) - if (store.nav.tab.index === 0) { - store.nav.tab.fixedTabReset() + navigation.navigate(`${tab}Tab`) } - } - }, [store, track]) - const onPressSearch = React.useCallback(() => { - track('MobileShell:SearchButtonPressed') - if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) { - if (!store.nav.tab.canGoBack) { - store.emitScreenSoftReset() - } else { - store.nav.tab.fixedTabReset() - } - } else { - store.nav.switchTo(TabPurpose.Search, false) - if (store.nav.tab.index === 0) { - store.nav.tab.fixedTabReset() - } - } - }, [store, track]) - const onPressNotifications = React.useCallback(() => { - track('MobileShell:NotificationsButtonPressed') - if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) { - if (!store.nav.tab.canGoBack) { - store.emitScreenSoftReset() - } else { - store.nav.tab.fixedTabReset() - } - } else { - store.nav.switchTo(TabPurpose.Notifs, false) - if (store.nav.tab.index === 0) { - store.nav.tab.fixedTabReset() - } - } - }, [store, track]) + }, + [store, track, navigation], + ) + const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) + const onPressSearch = React.useCallback( + () => onPressTab('Search'), + [onPressTab], + ) + const onPressNotifications = React.useCallback( + () => onPressTab('Notifications'), + [onPressTab], + ) const onPressProfile = React.useCallback(() => { track('MobileShell:ProfileButtonPressed') - store.nav.navigate(`/profile/${store.me.handle}`) - }, [store, track]) - - const isAtHome = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default] - const isAtSearch = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search] - const isAtNotifications = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs] + navigation.navigate('Profile', {name: store.me.handle}) + }, [navigation, track, store.me.handle]) return ( <Animated.View diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/Composer.tsx index 5fca118bd..2ab01c656 100644 --- a/src/view/shell/mobile/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,7 +1,7 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' -import {ComposePost} from '../../com/composer/ComposePost' +import {ComposePost} from '../com/composer/Composer' import {ComposerOpts} from 'state/models/shell-ui' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' @@ -11,7 +11,6 @@ export const Composer = observer( active, winHeight, replyTo, - imagesOpen, onPost, onClose, quote, @@ -19,7 +18,6 @@ export const Composer = observer( active: boolean winHeight: number replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void quote?: ComposerOpts['quote'] @@ -61,7 +59,6 @@ export const Composer = observer( <Animated.View style={[styles.wrapper, pal.view, wrapperAnimStyle]}> <ComposePost replyTo={replyTo} - imagesOpen={imagesOpen} onPost={onPost} onClose={onClose} quote={quote} diff --git a/src/view/shell/web/Composer.tsx b/src/view/shell/Composer.web.tsx index 0d8484262..465b475fb 100644 --- a/src/view/shell/web/Composer.tsx +++ b/src/view/shell/Composer.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' -import {ComposePost} from '../../com/composer/ComposePost' +import {ComposePost} from '../com/composer/Composer' import {ComposerOpts} from 'state/models/shell-ui' import {usePalette} from 'lib/hooks/usePalette' @@ -9,14 +9,12 @@ export const Composer = observer( ({ active, replyTo, - imagesOpen, onPost, onClose, }: { active: boolean winHeight: number replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void }) => { @@ -32,12 +30,7 @@ export const Composer = observer( return ( <View style={styles.mask}> <View style={[styles.container, pal.view]}> - <ComposePost - replyTo={replyTo} - imagesOpen={imagesOpen} - onPost={onPost} - onClose={onClose} - /> + <ComposePost replyTo={replyTo} onPost={onPost} onClose={onClose} /> </View> </View> ) diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx new file mode 100644 index 000000000..80944e10a --- /dev/null +++ b/src/view/shell/Drawer.tsx @@ -0,0 +1,386 @@ +import React from 'react' +import { + Linking, + SafeAreaView, + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import { + useNavigation, + useNavigationState, + StackActions, +} from '@react-navigation/native' +import {observer} from 'mobx-react-lite' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {s, colors} from 'lib/styles' +import {FEEDBACK_FORM_URL} from 'lib/constants' +import {useStores} from 'state/index' +import { + HomeIcon, + HomeIconSolid, + BellIcon, + BellIconSolid, + UserIcon, + CogIcon, + MagnifyingGlassIcon2, + MagnifyingGlassIcon2Solid, + MoonIcon, +} from 'lib/icons' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Text} from 'view/com/util/text/Text' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {pluralize} from 'lib/strings/helpers' +import {getCurrentRoute, isTab, getTabState, TabState} from 'lib/routes/helpers' +import {NavigationProp} from 'lib/routes/types' + +export const DrawerContent = observer(() => { + const theme = useTheme() + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + const {track} = useAnalytics() + const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState( + state => { + const currentRoute = state ? getCurrentRoute(state) : false + return { + isAtHome: currentRoute ? isTab(currentRoute.name, 'Home') : true, + isAtSearch: currentRoute ? isTab(currentRoute.name, 'Search') : false, + isAtNotifications: currentRoute + ? isTab(currentRoute.name, 'Notifications') + : false, + } + }, + ) + + // events + // = + + const onPressTab = React.useCallback( + (tab: string) => { + track('Menu:ItemClicked', {url: tab}) + const state = navigation.getState() + store.shell.closeDrawer() + const tabState = getTabState(state, tab) + if (tabState === TabState.InsideAtRoot) { + store.emitScreenSoftReset() + } else if (tabState === TabState.Inside) { + navigation.dispatch(StackActions.popToTop()) + } else { + // @ts-ignore must be Home, Search, or Notifications + navigation.navigate(`${tab}Tab`) + } + }, + [store, track, navigation], + ) + + const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) + + const onPressSearch = React.useCallback( + () => onPressTab('Search'), + [onPressTab], + ) + + const onPressNotifications = React.useCallback( + () => onPressTab('Notifications'), + [onPressTab], + ) + + const onPressProfile = React.useCallback(() => { + track('Menu:ItemClicked', {url: 'Profile'}) + navigation.navigate('Profile', {name: store.me.handle}) + store.shell.closeDrawer() + }, [navigation, track, store.me.handle, store.shell]) + + const onPressSettings = React.useCallback(() => { + track('Menu:ItemClicked', {url: 'Settings'}) + navigation.navigate('Settings') + store.shell.closeDrawer() + }, [navigation, track, store.shell]) + + const onPressFeedback = () => { + track('Menu:FeedbackClicked') + Linking.openURL(FEEDBACK_FORM_URL) + } + + // rendering + // = + + const MenuItem = ({ + icon, + label, + count, + bold, + onPress, + }: { + icon: JSX.Element + label: string + count?: number + bold?: boolean + onPress: () => void + }) => ( + <TouchableOpacity + testID={`menuItemButton-${label}`} + style={styles.menuItem} + onPress={onPress}> + <View style={[styles.menuItemIconWrapper]}> + {icon} + {count ? ( + <View style={styles.menuItemCount}> + <Text style={styles.menuItemCountLabel}>{count}</Text> + </View> + ) : undefined} + </View> + <Text + type={bold ? '2xl-bold' : '2xl'} + style={[pal.text, s.flex1]} + numberOfLines={1}> + {label} + </Text> + </TouchableOpacity> + ) + + const onDarkmodePress = () => { + track('Menu:ItemClicked', {url: '/darkmode'}) + store.shell.setDarkMode(!store.shell.darkMode) + } + + return ( + <View + testID="menuView" + style={[ + styles.view, + theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, + ]}> + <SafeAreaView style={s.flex1}> + <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> + <UserAvatar size={80} avatar={store.me.avatar} /> + <Text + type="title-lg" + style={[pal.text, s.bold, styles.profileCardDisplayName]}> + {store.me.displayName || store.me.handle} + </Text> + <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> + @{store.me.handle} + </Text> + <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> + <Text type="xl-medium" style={pal.text}> + {store.me.followersCount || 0} + </Text>{' '} + {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} + <Text type="xl-medium" style={pal.text}> + {store.me.followsCount || 0} + </Text>{' '} + following + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + <View> + <MenuItem + icon={ + isAtSearch ? ( + <MagnifyingGlassIcon2Solid + style={pal.text as StyleProp<ViewStyle>} + size={24} + strokeWidth={1.7} + /> + ) : ( + <MagnifyingGlassIcon2 + style={pal.text as StyleProp<ViewStyle>} + size={24} + strokeWidth={1.7} + /> + ) + } + label="Search" + bold={isAtSearch} + onPress={onPressSearch} + /> + <MenuItem + icon={ + isAtHome ? ( + <HomeIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={3.25} + /> + ) : ( + <HomeIcon + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={3.25} + /> + ) + } + label="Home" + bold={isAtHome} + onPress={onPressHome} + /> + <MenuItem + icon={ + isAtNotifications ? ( + <BellIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) : ( + <BellIcon + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) + } + label="Notifications" + count={store.me.notifications.unreadCount} + bold={isAtNotifications} + onPress={onPressNotifications} + /> + <MenuItem + icon={ + <UserIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.5} + /> + } + label="Profile" + onPress={onPressProfile} + /> + <MenuItem + icon={ + <CogIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.75} + /> + } + label="Settings" + onPress={onPressSettings} + /> + </View> + <View style={s.flex1} /> + <View style={styles.footer}> + <TouchableOpacity + onPress={onDarkmodePress} + style={[ + styles.footerBtn, + theme.colorScheme === 'light' + ? pal.btn + : styles.footerBtnDarkMode, + ]}> + <MoonIcon + size={22} + style={pal.text as StyleProp<ViewStyle>} + strokeWidth={2} + /> + </TouchableOpacity> + <TouchableOpacity + onPress={onPressFeedback} + style={[ + styles.footerBtn, + styles.footerBtnFeedback, + theme.colorScheme === 'light' + ? styles.footerBtnFeedbackLight + : styles.footerBtnFeedbackDark, + ]}> + <FontAwesomeIcon + style={pal.link as FontAwesomeIconStyle} + size={19} + icon={['far', 'message']} + /> + <Text type="2xl-medium" style={[pal.link, s.pl10]}> + Feedback + </Text> + </TouchableOpacity> + </View> + </SafeAreaView> + </View> + ) +}) + +const styles = StyleSheet.create({ + view: { + flex: 1, + paddingTop: 20, + paddingBottom: 50, + paddingLeft: 20, + }, + viewDarkMode: { + backgroundColor: '#1B1919', + }, + + profileCardDisplayName: { + marginTop: 20, + paddingRight: 30, + }, + profileCardHandle: { + marginTop: 4, + paddingRight: 30, + }, + profileCardFollowers: { + marginTop: 16, + paddingRight: 30, + }, + + menuItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingRight: 10, + }, + menuItemIconWrapper: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + menuItemCount: { + position: 'absolute', + right: -6, + top: -2, + backgroundColor: colors.red3, + paddingHorizontal: 4, + paddingBottom: 1, + borderRadius: 6, + }, + menuItemCountLabel: { + fontSize: 12, + fontWeight: 'bold', + color: colors.white, + }, + + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingRight: 30, + paddingTop: 80, + }, + footerBtn: { + flexDirection: 'row', + alignItems: 'center', + padding: 10, + borderRadius: 25, + }, + footerBtnDarkMode: { + backgroundColor: colors.black, + }, + footerBtnFeedback: { + paddingHorizontal: 24, + }, + footerBtnFeedbackLight: { + backgroundColor: '#DDEFFF', + }, + footerBtnFeedbackDark: { + backgroundColor: colors.blue6, + }, +}) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx new file mode 100644 index 000000000..46c77178b --- /dev/null +++ b/src/view/shell/desktop/LeftNav.tsx @@ -0,0 +1,254 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useNavigation, useNavigationState} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from 'view/com/util/text/Text' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Link} from 'view/com/util/Link' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import { + HomeIcon, + HomeIconSolid, + MagnifyingGlassIcon2, + MagnifyingGlassIcon2Solid, + BellIcon, + BellIconSolid, + UserIcon, + UserIconSolid, + CogIcon, + CogIconSolid, + ComposeIcon2, +} from 'lib/icons' +import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' +import {NavigationProp} from 'lib/routes/types' +import {router} from '../../../routes' + +const ProfileCard = observer(() => { + const store = useStores() + return ( + <Link href={`/profile/${store.me.handle}`} style={styles.profileCard}> + <UserAvatar avatar={store.me.avatar} size={64} /> + </Link> + ) +}) + +function BackBtn() { + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (!shouldShow) { + return <></> + } + return ( + <TouchableOpacity + testID="viewHeaderBackOrMenuBtn" + onPress={onPressBack} + style={styles.backBtn}> + <FontAwesomeIcon + size={24} + icon="angle-left" + style={pal.text as FontAwesomeIconStyle} + /> + </TouchableOpacity> + ) +} + +interface NavItemProps { + count?: number + href: string + icon: JSX.Element + iconFilled: JSX.Element + label: string +} +const NavItem = observer( + ({count, href, icon, iconFilled, label}: NavItemProps) => { + const pal = usePalette('default') + const [pathName] = React.useMemo(() => router.matchPath(href), [href]) + const currentRouteName = useNavigationState(state => { + if (!state) { + return 'Home' + } + return getCurrentRoute(state).name + }) + const isCurrent = isTab(currentRouteName, pathName) + + return ( + <Link href={href} style={styles.navItem}> + <View style={[styles.navItemIconWrapper]}> + {isCurrent ? iconFilled : icon} + {typeof count === 'number' && count > 0 && ( + <Text type="button" style={styles.navItemCount}> + {count} + </Text> + )} + </View> + <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> + {label} + </Text> + </Link> + ) + }, +) + +function ComposeBtn() { + const store = useStores() + const onPressCompose = () => store.shell.openComposer({}) + + return ( + <TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}> + <View style={styles.newPostBtnIconWrapper}> + <ComposeIcon2 + size={19} + strokeWidth={2} + style={styles.newPostBtnLabel} + /> + </View> + <Text type="button" style={styles.newPostBtnLabel}> + New Post + </Text> + </TouchableOpacity> + ) +} + +export const DesktopLeftNav = observer(function DesktopLeftNav() { + const store = useStores() + const pal = usePalette('default') + + return ( + <View style={styles.leftNav}> + <ProfileCard /> + <BackBtn /> + <NavItem + href="/" + icon={<HomeIcon size={24} style={pal.text} />} + iconFilled={ + <HomeIconSolid strokeWidth={4} size={24} style={pal.text} /> + } + label="Home" + /> + <NavItem + href="/search" + icon={ + <MagnifyingGlassIcon2 strokeWidth={2} size={24} style={pal.text} /> + } + iconFilled={ + <MagnifyingGlassIcon2Solid + strokeWidth={2} + size={24} + style={pal.text} + /> + } + label="Search" + /> + <NavItem + href="/notifications" + count={store.me.notifications.unreadCount} + icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />} + iconFilled={ + <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} /> + } + label="Notifications" + /> + <NavItem + href={`/profile/${store.me.handle}`} + icon={<UserIcon strokeWidth={1.75} size={28} style={pal.text} />} + iconFilled={ + <UserIconSolid strokeWidth={1.75} size={28} style={pal.text} /> + } + label="Profile" + /> + <NavItem + href="/settings" + icon={<CogIcon strokeWidth={1.75} size={28} style={pal.text} />} + iconFilled={ + <CogIconSolid strokeWidth={1.5} size={28} style={pal.text} /> + } + label="Settings" + /> + <ComposeBtn /> + </View> + ) +}) + +const styles = StyleSheet.create({ + leftNav: { + position: 'absolute', + top: 10, + right: 'calc(50vw + 300px)', + width: 220, + }, + + profileCard: { + marginVertical: 10, + width: 60, + }, + + backBtn: { + position: 'absolute', + top: 12, + right: 12, + width: 30, + height: 30, + }, + + navItem: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 14, + paddingBottom: 10, + }, + navItemIconWrapper: { + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + marginRight: 10, + marginTop: 2, + }, + navItemCount: { + position: 'absolute', + top: 0, + left: 15, + backgroundColor: colors.blue3, + color: colors.white, + fontSize: 12, + fontWeight: 'bold', + paddingHorizontal: 4, + borderRadius: 6, + }, + + newPostBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: 136, + borderRadius: 24, + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: colors.blue3, + marginTop: 20, + }, + newPostBtnIconWrapper: { + marginRight: 8, + }, + newPostBtnLabel: { + color: colors.white, + fontSize: 16, + fontWeight: 'bold', + }, +}) diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx new file mode 100644 index 000000000..a196951af --- /dev/null +++ b/src/view/shell/desktop/RightNav.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {DesktopSearch} from './Search' +import {Text} from 'view/com/util/text/Text' +import {TextLink} from 'view/com/util/Link' +import {FEEDBACK_FORM_URL} from 'lib/constants' + +export const DesktopRightNav = observer(function DesktopRightNav() { + const pal = usePalette('default') + return ( + <View style={[styles.rightNav, pal.view]}> + <DesktopSearch /> + <View style={styles.message}> + <Text type="md" style={[pal.textLight, styles.messageLine]}> + Welcome to Bluesky! This is a beta application that's still in + development. + </Text> + <TextLink + type="md" + style={pal.link} + href={FEEDBACK_FORM_URL} + text="Send feedback" + /> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + rightNav: { + position: 'absolute', + top: 20, + left: 'calc(50vw + 330px)', + width: 300, + }, + + message: { + marginTop: 20, + paddingHorizontal: 10, + }, + messageLine: { + marginBottom: 10, + }, +}) diff --git a/src/view/shell/web/DesktopSearch.tsx b/src/view/shell/desktop/Search.tsx index 43f13ca2b..7c96dbac2 100644 --- a/src/view/shell/web/DesktopSearch.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,11 +1,12 @@ import React from 'react' -import {TextInput, View, StyleSheet, TouchableOpacity, Text} from 'react-native' +import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {MagnifyingGlassIcon} from 'lib/icons' -import {ProfileCard} from '../../com/profile/ProfileCard' +import {MagnifyingGlassIcon2} from 'lib/icons' +import {ProfileCard} from 'view/com/profile/ProfileCard' +import {Text} from 'view/com/util/text/Text' export const DesktopSearch = observer(function DesktopSearch() { const store = useStores() @@ -35,9 +36,10 @@ export const DesktopSearch = observer(function DesktopSearch() { return ( <View style={styles.container}> - <View style={[pal.borderDark, pal.view, styles.search]}> + <View + style={[{backgroundColor: pal.colors.backgroundLight}, styles.search]}> <View style={[styles.inputContainer]}> - <MagnifyingGlassIcon + <MagnifyingGlassIcon2 size={18} style={[pal.textLight, styles.iconWrapper]} /> @@ -57,7 +59,9 @@ export const DesktopSearch = observer(function DesktopSearch() { {query ? ( <View style={styles.cancelBtn}> <TouchableOpacity onPress={onPressCancelSearch}> - <Text style={[pal.link]}>Cancel</Text> + <Text type="lg" style={[pal.link]}> + Cancel + </Text> </TouchableOpacity> </View> ) : undefined} @@ -97,21 +101,23 @@ const styles = StyleSheet.create({ width: 300, }, search: { - paddingHorizontal: 10, + paddingHorizontal: 16, + paddingVertical: 2, width: 300, borderRadius: 20, - borderWidth: 1, }, inputContainer: { flexDirection: 'row', }, iconWrapper: { + position: 'relative', + top: 2, paddingVertical: 7, - marginRight: 4, + marginRight: 8, }, input: { flex: 1, - fontSize: 16, + fontSize: 18, width: '100%', paddingTop: 7, paddingBottom: 7, diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx new file mode 100644 index 000000000..116915ff4 --- /dev/null +++ b/src/view/shell/index.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {StatusBar, StyleSheet, useWindowDimensions, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Drawer} from 'react-native-drawer-layout' +import {useNavigationState} from '@react-navigation/native' +import {useStores} from 'state/index' +import {Login} from 'view/screens/Login' +import {ModalsContainer} from 'view/com/modals/Modal' +import {Lightbox} from 'view/com/lightbox/Lightbox' +import {Text} from 'view/com/util/text/Text' +import {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {DrawerContent} from './Drawer' +import {Composer} from './Composer' +import {s} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {RoutesContainer, TabsNavigator} from '../../Navigation' +import {isStateAtTabRoot} from 'lib/routes/helpers' + +const ShellInner = observer(() => { + const store = useStores() + const winDim = useWindowDimensions() + const safeAreaInsets = useSafeAreaInsets() + const containerPadding = React.useMemo( + () => ({height: '100%', paddingTop: safeAreaInsets.top}), + [safeAreaInsets], + ) + const renderDrawerContent = React.useCallback(() => <DrawerContent />, []) + const onOpenDrawer = React.useCallback( + () => store.shell.openDrawer(), + [store], + ) + const onCloseDrawer = React.useCallback( + () => store.shell.closeDrawer(), + [store], + ) + const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) + + return ( + <> + <View style={containerPadding}> + <ErrorBoundary> + <Drawer + renderDrawerContent={renderDrawerContent} + open={store.shell.isDrawerOpen} + onOpen={onOpenDrawer} + onClose={onCloseDrawer} + swipeEdgeWidth={winDim.width} + swipeEnabled={!canGoBack}> + <TabsNavigator /> + </Drawer> + </ErrorBoundary> + </View> + <ModalsContainer /> + <Lightbox /> + <Composer + active={store.shell.isComposerActive} + onClose={() => store.shell.closeComposer()} + winHeight={winDim.height} + replyTo={store.shell.composerOpts?.replyTo} + onPost={store.shell.composerOpts?.onPost} + quote={store.shell.composerOpts?.quote} + /> + </> + ) +}) + +export const Shell: React.FC = observer(() => { + const theme = useTheme() + const pal = usePalette('default') + const store = useStores() + + if (store.hackUpgradeNeeded) { + return ( + <View style={styles.outerContainer}> + <View style={[s.flexCol, s.p20, s.h100pct]}> + <View style={s.flex1} /> + <View> + <Text type="title-2xl" style={s.pb10}> + Update required + </Text> + <Text style={[s.pb20, s.bold]}> + Please update your app to the latest version. If no update is + available yet, please check the App Store in a day or so. + </Text> + <Text type="title" style={s.pb10}> + What's happening? + </Text> + <Text style={s.pb10}> + We're in the final stages of the AT Protocol's v1 development. To + make sure everything works as well as possible, we're making final + breaking changes to the APIs. + </Text> + <Text> + If we didn't botch this process, a new version of the app should + be available now. + </Text> + </View> + <View style={s.flex1} /> + <View style={s.footerSpacer} /> + </View> + </View> + ) + } + + if (!store.session.hasSession) { + return ( + <View style={styles.outerContainer}> + <StatusBar + barStyle={ + theme.colorScheme === 'dark' ? 'light-content' : 'dark-content' + } + /> + <Login /> + <ModalsContainer /> + </View> + ) + } + + return ( + <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> + <StatusBar + barStyle={ + theme.colorScheme === 'dark' ? 'light-content' : 'dark-content' + } + /> + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + </View> + ) +}) + +const styles = StyleSheet.create({ + outerContainer: { + height: '100%', + }, +}) diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx new file mode 100644 index 000000000..9a97505e8 --- /dev/null +++ b/src/view/shell/index.web.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {View, StyleSheet} from 'react-native' +import {useStores} from 'state/index' +import {DesktopLeftNav} from './desktop/LeftNav' +import {DesktopRightNav} from './desktop/RightNav' +import {Login} from '../screens/Login' +import {ErrorBoundary} from '../com/util/ErrorBoundary' +import {Lightbox} from '../com/lightbox/Lightbox' +import {ModalsContainer} from '../com/modals/Modal' +import {Text} from 'view/com/util/text/Text' +import {Composer} from './Composer.web' +import {usePalette} from 'lib/hooks/usePalette' +import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {s, colors} from 'lib/styles' +import {isMobileWeb} from 'platform/detection' +import {RoutesContainer, FlatNavigator} from '../../Navigation' + +const ShellInner = observer(() => { + const store = useStores() + + return ( + <> + <View style={s.hContentRegion}> + <ErrorBoundary> + <FlatNavigator /> + </ErrorBoundary> + </View> + <DesktopLeftNav /> + <DesktopRightNav /> + <View style={[styles.viewBorder, styles.viewBorderLeft]} /> + <View style={[styles.viewBorder, styles.viewBorderRight]} /> + <Composer + active={store.shell.isComposerActive} + onClose={() => store.shell.closeComposer()} + winHeight={0} + replyTo={store.shell.composerOpts?.replyTo} + onPost={store.shell.composerOpts?.onPost} + /> + <ModalsContainer /> + <Lightbox /> + </> + ) +}) + +export const Shell: React.FC = observer(() => { + const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) + const store = useStores() + + if (isMobileWeb) { + return <NoMobileWeb /> + } + + if (!store.session.hasSession) { + return ( + <View style={[s.hContentRegion, pageBg]}> + <Login /> + <ModalsContainer /> + </View> + ) + } + + return ( + <View style={[s.hContentRegion, pageBg]}> + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + </View> + ) +}) + +function NoMobileWeb() { + const pal = usePalette('default') + return ( + <View style={[pal.view, styles.noMobileWeb]}> + <Text type="title-2xl" style={s.pb20}> + We're so sorry! + </Text> + <Text type="lg"> + This app is not available for mobile Web yet. Please open it on your + desktop or download the iOS app. + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + bgLight: { + backgroundColor: colors.white, + }, + bgDark: { + backgroundColor: colors.black, // TODO + }, + viewBorder: { + position: 'absolute', + width: 1, + height: '100%', + borderLeftWidth: 1, + borderLeftColor: colors.gray2, + }, + viewBorderLeft: { + left: 'calc(50vw - 300px)', + }, + viewBorderRight: { + left: 'calc(50vw + 300px)', + }, + noMobileWeb: { + height: '100%', + justifyContent: 'center', + paddingHorizontal: 20, + paddingBottom: 40, + }, +}) diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx deleted file mode 100644 index 927e712e1..000000000 --- a/src/view/shell/mobile/Menu.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import React from 'react' -import { - Linking, - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {observer} from 'mobx-react-lite' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {s, colors} from 'lib/styles' -import {FEEDBACK_FORM_URL} from 'lib/constants' -import {useStores} from 'state/index' -import { - HomeIcon, - HomeIconSolid, - BellIcon, - BellIconSolid, - UserIcon, - CogIcon, - MagnifyingGlassIcon2, - MagnifyingGlassIcon2Solid, - MoonIcon, -} from 'lib/icons' -import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation' -import {UserAvatar} from '../../com/util/UserAvatar' -import {Text} from '../../com/util/text/Text' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics' -import {pluralize} from 'lib/strings/helpers' - -export const Menu = observer(({onClose}: {onClose: () => void}) => { - const theme = useTheme() - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - - // events - // = - - const onNavigate = (url: string) => { - track('Menu:ItemClicked', {url}) - - onClose() - if (url === TabPurposeMainPath[TabPurpose.Notifs]) { - store.nav.switchTo(TabPurpose.Notifs, true) - } else if (url === TabPurposeMainPath[TabPurpose.Search]) { - store.nav.switchTo(TabPurpose.Search, true) - } else { - store.nav.switchTo(TabPurpose.Default, true) - if (url !== '/') { - store.nav.navigate(url) - } - } - } - - const onPressFeedback = () => { - track('Menu:FeedbackClicked') - Linking.openURL(FEEDBACK_FORM_URL) - } - - // rendering - // = - - const MenuItem = ({ - icon, - label, - count, - url, - bold, - onPress, - }: { - icon: JSX.Element - label: string - count?: number - url?: string - bold?: boolean - onPress?: () => void - }) => ( - <TouchableOpacity - testID={`menuItemButton-${label}`} - style={styles.menuItem} - onPress={onPress ? onPress : () => onNavigate(url || '/')}> - <View style={[styles.menuItemIconWrapper]}> - {icon} - {count ? ( - <View style={styles.menuItemCount}> - <Text style={styles.menuItemCountLabel}>{count}</Text> - </View> - ) : undefined} - </View> - <Text - type={bold ? '2xl-bold' : '2xl'} - style={[pal.text, s.flex1]} - numberOfLines={1}> - {label} - </Text> - </TouchableOpacity> - ) - - const onDarkmodePress = () => { - track('Menu:ItemClicked', {url: '/darkmode'}) - store.shell.setDarkMode(!store.shell.darkMode) - } - - const isAtHome = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default] - const isAtSearch = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search] - const isAtNotifications = - store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs] - - return ( - <View - testID="menuView" - style={[ - styles.view, - theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, - ]}> - <TouchableOpacity - testID="profileCardButton" - onPress={() => onNavigate(`/profile/${store.me.handle}`)}> - <UserAvatar - size={80} - displayName={store.me.displayName} - handle={store.me.handle} - avatar={store.me.avatar} - /> - <Text - type="title-lg" - style={[pal.text, s.bold, styles.profileCardDisplayName]}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> - @{store.me.handle} - </Text> - <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> - <Text type="xl-medium" style={pal.text}> - {store.me.followersCount || 0} - </Text>{' '} - {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - <Text type="xl-medium" style={pal.text}> - {store.me.followsCount || 0} - </Text>{' '} - following - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - <View> - <MenuItem - icon={ - isAtSearch ? ( - <MagnifyingGlassIcon2Solid - style={pal.text as StyleProp<ViewStyle>} - size={24} - strokeWidth={1.7} - /> - ) : ( - <MagnifyingGlassIcon2 - style={pal.text as StyleProp<ViewStyle>} - size={24} - strokeWidth={1.7} - /> - ) - } - label="Search" - url="/search" - bold={isAtSearch} - /> - <MenuItem - icon={ - isAtHome ? ( - <HomeIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={3.25} - fillOpacity={1} - /> - ) : ( - <HomeIcon - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={3.25} - /> - ) - } - label="Home" - url="/" - bold={isAtHome} - /> - <MenuItem - icon={ - isAtNotifications ? ( - <BellIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - fillOpacity={1} - /> - ) : ( - <BellIcon - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - /> - ) - } - label="Notifications" - url="/notifications" - count={store.me.notifications.unreadCount} - bold={isAtNotifications} - /> - <MenuItem - icon={ - <UserIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.5} - /> - } - label="Profile" - url={`/profile/${store.me.handle}`} - /> - <MenuItem - icon={ - <CogIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.75} - /> - } - label="Settings" - url="/settings" - /> - </View> - <View style={s.flex1} /> - <View style={styles.footer}> - <TouchableOpacity - onPress={onDarkmodePress} - style={[ - styles.footerBtn, - theme.colorScheme === 'light' ? pal.btn : styles.footerBtnDarkMode, - ]}> - <MoonIcon - size={22} - style={pal.text as StyleProp<ViewStyle>} - strokeWidth={2} - /> - </TouchableOpacity> - <TouchableOpacity - onPress={onPressFeedback} - style={[ - styles.footerBtn, - styles.footerBtnFeedback, - theme.colorScheme === 'light' - ? styles.footerBtnFeedbackLight - : styles.footerBtnFeedbackDark, - ]}> - <FontAwesomeIcon - style={pal.link as FontAwesomeIconStyle} - size={19} - icon={['far', 'message']} - /> - <Text type="2xl-medium" style={[pal.link, s.pl10]}> - Feedback - </Text> - </TouchableOpacity> - </View> - </View> - ) -}) - -const styles = StyleSheet.create({ - view: { - flex: 1, - paddingTop: 20, - paddingBottom: 50, - paddingLeft: 30, - }, - viewDarkMode: { - backgroundColor: '#1B1919', - }, - - profileCardDisplayName: { - marginTop: 20, - paddingRight: 20, - }, - profileCardHandle: { - marginTop: 4, - paddingRight: 20, - }, - profileCardFollowers: { - marginTop: 16, - paddingRight: 20, - }, - - menuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 16, - paddingRight: 10, - }, - menuItemIconWrapper: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }, - menuItemCount: { - position: 'absolute', - right: -6, - top: -2, - backgroundColor: colors.red3, - paddingHorizontal: 4, - paddingBottom: 1, - borderRadius: 6, - }, - menuItemCountLabel: { - fontSize: 12, - fontWeight: 'bold', - color: colors.white, - }, - - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingRight: 30, - paddingTop: 80, - }, - footerBtn: { - flexDirection: 'row', - alignItems: 'center', - padding: 10, - borderRadius: 25, - }, - footerBtnDarkMode: { - backgroundColor: colors.black, - }, - footerBtnFeedback: { - paddingHorizontal: 24, - }, - footerBtnFeedbackLight: { - backgroundColor: '#DDEFFF', - }, - footerBtnFeedbackDark: { - backgroundColor: colors.blue6, - }, -}) diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx deleted file mode 100644 index 01df6c165..000000000 --- a/src/view/shell/mobile/index.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import React, {useState} from 'react' -import {observer} from 'mobx-react-lite' -import { - Animated, - StatusBar, - StyleSheet, - TouchableWithoutFeedback, - useWindowDimensions, - View, -} from 'react-native' -import {ScreenContainer, Screen} from 'react-native-screens' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {useStores} from 'state/index' -import {NavigationModel} from 'state/models/navigation' -import {match, MatchResult} from '../../routes' -import {Login} from '../../screens/Login' -import {Menu} from './Menu' -import {BottomBar} from './BottomBar' -import {HorzSwipe} from '../../com/util/gestures/HorzSwipe' -import {ModalsContainer} from '../../com/modals/Modal' -import {Lightbox} from '../../com/lightbox/Lightbox' -import {Text} from '../../com/util/text/Text' -import {ErrorBoundary} from '../../com/util/ErrorBoundary' -import {Composer} from './Composer' -import {s, colors} from 'lib/styles' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' - -export const MobileShell: React.FC = observer(() => { - const theme = useTheme() - const pal = usePalette('default') - const store = useStores() - const winDim = useWindowDimensions() - const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) - const swipeGestureInterp = useAnimatedValue(0) - const safeAreaInsets = useSafeAreaInsets() - const screenRenderDesc = constructScreenRenderDesc(store.nav) - - // navigation swipes - // = - const isMenuActive = store.shell.isMainMenuOpen - const canSwipeLeft = store.nav.tab.canGoBack || !isMenuActive - const canSwipeRight = isMenuActive - const onNavSwipeStartDirection = (dx: number) => { - if (dx < 0 && !store.nav.tab.canGoBack) { - setMenuSwipingDirection(dx) - } else if (dx > 0 && isMenuActive) { - setMenuSwipingDirection(dx) - } else { - setMenuSwipingDirection(0) - } - } - const onNavSwipeEnd = (dx: number) => { - if (dx < 0) { - if (store.nav.tab.canGoBack) { - store.nav.tab.goBack() - } else { - store.shell.setMainMenuOpen(true) - } - } else if (dx > 0) { - if (isMenuActive) { - store.shell.setMainMenuOpen(false) - } - } - setMenuSwipingDirection(0) - } - const swipeTranslateX = Animated.multiply( - swipeGestureInterp, - winDim.width * -1, - ) - const swipeTransform = store.nav.tab.canGoBack - ? {transform: [{translateX: swipeTranslateX}]} - : undefined - let shouldRenderMenu = false - let menuTranslateX - const menuDrawerWidth = winDim.width - 100 - if (isMenuActive) { - // menu is active, interpret swipes as closes - menuTranslateX = Animated.multiply(swipeGestureInterp, menuDrawerWidth * -1) - shouldRenderMenu = true - } else if (!store.nav.tab.canGoBack) { - // at back of history, interpret swipes as opens - menuTranslateX = Animated.subtract( - menuDrawerWidth * -1, - Animated.multiply(swipeGestureInterp, menuDrawerWidth), - ) - shouldRenderMenu = true - } - const menuSwipeTransform = menuTranslateX - ? { - transform: [{translateX: menuTranslateX}], - } - : undefined - const swipeOpacity = { - opacity: swipeGestureInterp.interpolate({ - inputRange: [-1, 0, 1], - outputRange: [0, 0.6, 0], - }), - } - const menuSwipeOpacity = - menuSwipingDirection !== 0 - ? { - opacity: swipeGestureInterp.interpolate({ - inputRange: menuSwipingDirection > 0 ? [0, 1] : [-1, 0], - outputRange: [0.6, 0], - }), - } - : undefined - - if (store.hackUpgradeNeeded) { - return ( - <View style={styles.outerContainer}> - <View style={[s.flexCol, s.p20, s.h100pct]}> - <View style={s.flex1} /> - <View> - <Text type="title-2xl" style={s.pb10}> - Update required - </Text> - <Text style={[s.pb20, s.bold]}> - Please update your app to the latest version. If no update is - available yet, please check the App Store in a day or so. - </Text> - <Text type="title" style={s.pb10}> - What's happening? - </Text> - <Text style={s.pb10}> - We're in the final stages of the AT Protocol's v1 development. To - make sure everything works as well as possible, we're making final - breaking changes to the APIs. - </Text> - <Text> - If we didn't botch this process, a new version of the app should - be available now. - </Text> - </View> - <View style={s.flex1} /> - <View style={s.footerSpacer} /> - </View> - </View> - ) - } - - if (!store.session.hasSession) { - return ( - <View style={styles.outerContainer}> - <StatusBar - barStyle={ - theme.colorScheme === 'dark' ? 'light-content' : 'dark-content' - } - /> - <Login /> - <ModalsContainer /> - </View> - ) - } - - const screenBg = { - backgroundColor: theme.colorScheme === 'dark' ? colors.black : colors.gray1, - } - return ( - <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> - <StatusBar - barStyle={ - theme.colorScheme === 'dark' ? 'light-content' : 'dark-content' - } - /> - <View style={[styles.innerContainer, {paddingTop: safeAreaInsets.top}]}> - <HorzSwipe - distThresholdDivisor={2.5} - useNativeDriver - panX={swipeGestureInterp} - swipeEnabled - canSwipeLeft={canSwipeLeft} - canSwipeRight={canSwipeRight} - onSwipeStartDirection={onNavSwipeStartDirection} - onSwipeEnd={onNavSwipeEnd}> - <ScreenContainer style={styles.screenContainer}> - {screenRenderDesc.screens.map( - ({Com, navIdx, params, key, current, previous}) => { - if (isMenuActive) { - // HACK menu is active, treat current as previous - if (previous) { - previous = false - } else if (current) { - current = false - previous = true - } - } - return ( - <Screen - key={key} - style={[StyleSheet.absoluteFill]} - activityState={current ? 2 : previous ? 1 : 0}> - <Animated.View - style={ - current ? [styles.screenMask, swipeOpacity] : undefined - } - /> - <Animated.View - style={[ - s.h100pct, - screenBg, - current ? [swipeTransform] : undefined, - ]}> - <ErrorBoundary> - <Com - params={params} - navIdx={navIdx} - visible={current} - /> - </ErrorBoundary> - </Animated.View> - </Screen> - ) - }, - )} - </ScreenContainer> - <BottomBar /> - {isMenuActive || menuSwipingDirection !== 0 ? ( - <TouchableWithoutFeedback - onPress={() => store.shell.setMainMenuOpen(false)}> - <Animated.View style={[styles.screenMask, menuSwipeOpacity]} /> - </TouchableWithoutFeedback> - ) : undefined} - {shouldRenderMenu && ( - <Animated.View style={[styles.menuDrawer, menuSwipeTransform]}> - <Menu onClose={() => store.shell.setMainMenuOpen(false)} /> - </Animated.View> - )} - </HorzSwipe> - </View> - <ModalsContainer /> - <Lightbox /> - <Composer - active={store.shell.isComposerActive} - onClose={() => store.shell.closeComposer()} - winHeight={winDim.height} - replyTo={store.shell.composerOpts?.replyTo} - imagesOpen={store.shell.composerOpts?.imagesOpen} - onPost={store.shell.composerOpts?.onPost} - quote={store.shell.composerOpts?.quote} - /> - </View> - ) -}) - -/** - * This method produces the information needed by the shell to - * render the current screens with screen-caching behaviors. - */ -type ScreenRenderDesc = MatchResult & { - key: string - navIdx: string - current: boolean - previous: boolean - isNewTab: boolean -} -function constructScreenRenderDesc(nav: NavigationModel): { - icon: IconProp - hasNewTab: boolean - screens: ScreenRenderDesc[] -} { - let hasNewTab = false - let icon: IconProp = 'magnifying-glass' - let screens: ScreenRenderDesc[] = [] - for (const tab of nav.tabs) { - const tabScreens = [ - ...tab.getBackList(5), - Object.assign({}, tab.current, {index: tab.index}), - ] - const parsedTabScreens = tabScreens.map(screen => { - const isCurrent = nav.isCurrentScreen(tab.id, screen.index) - const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1) - const matchRes = match(screen.url) - if (isCurrent) { - icon = matchRes.icon - } - hasNewTab = hasNewTab || tab.isNewTab - return Object.assign(matchRes, { - key: `t${tab.id}-s${screen.index}`, - navIdx: `${tab.id}-${screen.id}`, - current: isCurrent, - previous: isPrevious, - isNewTab: tab.isNewTab, - }) as ScreenRenderDesc - }) - screens = screens.concat(parsedTabScreens) - } - return { - icon, - hasNewTab, - screens, - } -} - -const styles = StyleSheet.create({ - outerContainer: { - height: '100%', - }, - innerContainer: { - height: '100%', - }, - screenContainer: { - height: '100%', - }, - screenMask: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - backgroundColor: '#000', - opacity: 0.6, - }, - menuDrawer: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 100, - }, - topBarProtector: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: 50, // will be overwritten by insets - backgroundColor: colors.white, - }, - topBarProtectorDark: { - backgroundColor: colors.black, - }, -}) diff --git a/src/view/shell/web/DesktopHeader.tsx b/src/view/shell/web/DesktopHeader.tsx deleted file mode 100644 index 8748ebbde..000000000 --- a/src/view/shell/web/DesktopHeader.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React from 'react' -import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' -import {Text} from 'view/com/util/text/Text' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {usePalette} from 'lib/hooks/usePalette' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {useStores} from 'state/index' -import {colors} from 'lib/styles' -import { - ComposeIcon, - HomeIcon, - HomeIconSolid, - BellIcon, - BellIconSolid, - MagnifyingGlassIcon, - CogIcon, -} from 'lib/icons' -import {DesktopSearch} from './DesktopSearch' - -interface NavItemProps { - count?: number - href: string - icon: JSX.Element - iconFilled: JSX.Element - isProfile?: boolean -} -export const NavItem = observer( - ({count, href, icon, iconFilled}: NavItemProps) => { - const store = useStores() - const hoverBg = useColorSchemeStyle( - styles.navItemHoverBgLight, - styles.navItemHoverBgDark, - ) - const isCurrent = store.nav.tab.current.url === href - const onPress = () => store.nav.navigate(href) - return ( - <Pressable - style={state => [ - styles.navItem, - // @ts-ignore Pressable state differs for RNW -prf - (state.hovered || isCurrent) && hoverBg, - ]} - onPress={onPress}> - <View style={[styles.navItemIconWrapper]}> - {isCurrent ? iconFilled : icon} - {typeof count === 'number' && count > 0 && ( - <Text type="button" style={styles.navItemCount}> - {count} - </Text> - )} - </View> - </Pressable> - ) - }, -) - -export const ProfileItem = observer(() => { - const store = useStores() - const hoverBg = useColorSchemeStyle( - styles.navItemHoverBgLight, - styles.navItemHoverBgDark, - ) - const href = `/profile/${store.me.handle}` - const isCurrent = store.nav.tab.current.url === href - const onPress = () => store.nav.navigate(href) - return ( - <Pressable - style={state => [ - styles.navItem, - // @ts-ignore Pressable state differs for RNW -prf - (state.hovered || isCurrent) && hoverBg, - ]} - onPress={onPress}> - <View style={[styles.navItemIconWrapper]}> - <UserAvatar - handle={store.me.handle} - displayName={store.me.displayName} - avatar={store.me.avatar} - size={28} - /> - </View> - </Pressable> - ) -}) - -export const DesktopHeader = observer(function DesktopHeader({}: { - canGoBack?: boolean -}) { - const store = useStores() - const pal = usePalette('default') - const onPressCompose = () => store.shell.openComposer({}) - - return ( - <View style={[styles.header, pal.borderDark, pal.view]}> - <Text type="title-xl" style={[pal.text, styles.title]}> - Bluesky - </Text> - <View style={styles.space30} /> - <NavItem - href="/" - icon={<HomeIcon size={24} />} - iconFilled={<HomeIconSolid size={24} />} - /> - <View style={styles.space15} /> - <NavItem - href="/search" - icon={<MagnifyingGlassIcon size={24} />} - iconFilled={<MagnifyingGlassIcon strokeWidth={3} size={24} />} - /> - <View style={styles.space15} /> - <NavItem - href="/notifications" - count={store.me.notifications.unreadCount} - icon={<BellIcon size={24} />} - iconFilled={<BellIconSolid size={24} />} - /> - <View style={styles.spaceFlex} /> - <TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}> - <View style={styles.newPostBtnIconWrapper}> - <ComposeIcon - size={16} - strokeWidth={2} - style={styles.newPostBtnLabel} - /> - </View> - <Text type="md" style={styles.newPostBtnLabel}> - New Post - </Text> - </TouchableOpacity> - <View style={styles.space20} /> - <DesktopSearch /> - <View style={styles.space15} /> - <ProfileItem /> - <NavItem - href="/settings" - icon={<CogIcon strokeWidth={2} size={28} />} - iconFilled={<CogIcon strokeWidth={2.5} size={28} />} - /> - </View> - ) -}) - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - // paddingTop: 18, - // paddingBottom: 18, - paddingLeft: 30, - paddingRight: 40, - borderBottomWidth: 1, - zIndex: 1, - }, - - spaceFlex: { - flex: 1, - }, - space15: { - width: 15, - }, - space20: { - width: 20, - }, - space30: { - width: 30, - }, - - title: {}, - - navItem: { - paddingTop: 14, - paddingBottom: 10, - paddingHorizontal: 10, - alignItems: 'center', - borderBottomWidth: 2, - borderBottomColor: 'transparent', - }, - navItemHoverBgLight: { - borderBottomWidth: 2, - borderBottomColor: colors.blue3, - }, - navItemHoverBgDark: { - borderBottomWidth: 2, - backgroundColor: colors.blue3, - }, - navItemIconWrapper: { - alignItems: 'center', - justifyContent: 'center', - width: 28, - height: 28, - marginBottom: 2, - }, - navItemCount: { - position: 'absolute', - top: 0, - left: 15, - backgroundColor: colors.red3, - color: colors.white, - fontSize: 12, - fontWeight: 'bold', - paddingHorizontal: 4, - borderRadius: 6, - }, - - newPostBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 24, - paddingTop: 8, - paddingBottom: 8, - paddingHorizontal: 18, - backgroundColor: colors.blue3, - }, - newPostBtnIconWrapper: { - marginRight: 8, - }, - newPostBtnLabel: { - color: colors.white, - }, -}) diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx deleted file mode 100644 index a76ae8060..000000000 --- a/src/view/shell/web/index.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react' -import {observer} from 'mobx-react-lite' -import {View, StyleSheet} from 'react-native' -import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {useStores} from 'state/index' -import {NavigationModel} from 'state/models/navigation' -import {match, MatchResult} from '../../routes' -import {DesktopHeader} from './DesktopHeader' -import {Login} from '../../screens/Login' -import {ErrorBoundary} from '../../com/util/ErrorBoundary' -import {Lightbox} from '../../com/lightbox/Lightbox' -import {ModalsContainer} from '../../com/modals/Modal' -import {Text} from 'view/com/util/text/Text' -import {Composer} from './Composer' -import {usePalette} from 'lib/hooks/usePalette' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {s, colors} from 'lib/styles' -import {isMobileWeb} from 'platform/detection' - -export const WebShell: React.FC = observer(() => { - const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) - const store = useStores() - const screenRenderDesc = constructScreenRenderDesc(store.nav) - - if (isMobileWeb) { - return <NoMobileWeb /> - } - - if (!store.session.hasSession) { - return ( - <View style={styles.outerContainer}> - <Login /> - <ModalsContainer /> - </View> - ) - } - - return ( - <View style={[styles.outerContainer, pageBg]}> - <DesktopHeader /> - {screenRenderDesc.screens.map(({Com, navIdx, params, key, current}) => ( - <View - key={key} - style={[s.hContentRegion, current ? styles.visible : styles.hidden]}> - <ErrorBoundary> - <Com params={params} navIdx={navIdx} visible={current} /> - </ErrorBoundary> - </View> - ))} - <Composer - active={store.shell.isComposerActive} - onClose={() => store.shell.closeComposer()} - winHeight={0} - replyTo={store.shell.composerOpts?.replyTo} - imagesOpen={store.shell.composerOpts?.imagesOpen} - onPost={store.shell.composerOpts?.onPost} - /> - <ModalsContainer /> - <Lightbox /> - </View> - ) -}) - -/** - * This method produces the information needed by the shell to - * render the current screens with screen-caching behaviors. - */ -type ScreenRenderDesc = MatchResult & { - key: string - navIdx: string - current: boolean - previous: boolean - isNewTab: boolean -} -function constructScreenRenderDesc(nav: NavigationModel): { - icon: IconProp - hasNewTab: boolean - screens: ScreenRenderDesc[] -} { - let hasNewTab = false - let icon: IconProp = 'magnifying-glass' - let screens: ScreenRenderDesc[] = [] - for (const tab of nav.tabs) { - const tabScreens = [ - ...tab.getBackList(5), - Object.assign({}, tab.current, {index: tab.index}), - ] - const parsedTabScreens = tabScreens.map(screen => { - const isCurrent = nav.isCurrentScreen(tab.id, screen.index) - const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1) - const matchRes = match(screen.url) - if (isCurrent) { - icon = matchRes.icon - } - hasNewTab = hasNewTab || tab.isNewTab - return Object.assign(matchRes, { - key: `t${tab.id}-s${screen.index}`, - navIdx: `${tab.id}-${screen.id}`, - current: isCurrent, - previous: isPrevious, - isNewTab: tab.isNewTab, - }) as ScreenRenderDesc - }) - screens = screens.concat(parsedTabScreens) - } - return { - icon, - hasNewTab, - screens, - } -} - -function NoMobileWeb() { - const pal = usePalette('default') - return ( - <View style={[pal.view, styles.noMobileWeb]}> - <Text type="title-2xl" style={s.pb20}> - We're so sorry! - </Text> - <Text type="lg"> - This app is not available for mobile Web yet. Please open it on your - desktop or download the iOS app. - </Text> - </View> - ) -} - -const styles = StyleSheet.create({ - outerContainer: { - height: '100%', - }, - bgLight: { - backgroundColor: colors.white, - }, - bgDark: { - backgroundColor: colors.black, // TODO - }, - visible: { - display: 'flex', - }, - hidden: { - display: 'none', - }, - noMobileWeb: { - height: '100%', - justifyContent: 'center', - paddingHorizontal: 20, - paddingBottom: 40, - }, -}) |