Move to expo and react-navigation (#288)
* WIP - adding expo * WIP - adding expo 2 * Fix tsc * Finish adding expo * Disable the 'require cycle' warning * Tweak plist * Modify some dependency versions to make expo happy * Fix icon fill * Get Web compiling for expo * 1.7 * Switch to react-navigation in expo2 (#287) * WIP Switch to react-navigation * WIP Switch to react-navigation 2 * WIP Switch to react-navigation 3 * Convert all screens to react navigation * Update BottomBar for react navigation * Update mobile menu to be react-native drawer * Fixes to drawer and bottombar * Factor out some helpers * Replace the navigation model with react-navigation * Restructure the shell folder and fix the header positioning * Restore the error boundary * Fix tsc * Implement not-found page * Remove react-native-gesture-handler (no longer used) * Handle notifee card presses * Handle all navigations from the state layer * Fix drawer behaviors * Fix two linking issues * Switch to our react-native-progress fork to fix an svg rendering issue * Get Web working with react-navigation * Refactor routes and navigation for a bit more clarity * Remove dead code * Rework Web shell to left/right nav to make this easier * Fix ViewHeader for desktop web * Hide profileheader back btn on desktop web * Move the compose button to the left nav * Implement reply prompt in threads for desktop web * Composer refactors * Factor out all platform-specific text input behaviors from the composer * Small fix * Update the web build to use tiptap for the composer * Tune up the mention autocomplete dropdown * Simplify the default avatar and banner * Fixes to link cards in web composer * Fix dropdowns on web * Tweak load latest on desktop * Add web beta message and feedback link * Fix up links in desktop web
This commit is contained in:
parent
503e03d91e
commit
56cf890deb
222 changed files with 8705 additions and 6338 deletions
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
287
src/Navigation.tsx
Normal file
287
src/Navigation.tsx
Normal file
|
@ -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,
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"name": "xyz.blueskyweb.app",
|
||||
"displayName": "Bluesky"
|
||||
}
|
12
src/index.js
12
src/index.js
|
@ -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'),
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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') ||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export const LOGIN_INCLUDE_DEV_SERVERS = true
|
||||
export const TABS_ENABLED = false
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
50
src/lib/hooks/usePermissions.ts
Normal file
50
src/lib/hooks/usePermissions.ts
Normal file
|
@ -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}
|
||||
}
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(';'))
|
||||
}
|
||||
|
|
7
src/lib/media/util.ts
Normal file
7
src/lib/media/util.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
77
src/lib/routes/helpers.ts
Normal file
77
src/lib/routes/helpers.ts
Normal file
|
@ -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}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
55
src/lib/routes/router.ts
Normal file
55
src/lib/routes/router.ts
Normal file
|
@ -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',
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
61
src/lib/routes/types.ts
Normal file
61
src/lib/routes/types.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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'},
|
||||
|
|
16
src/routes.ts
Normal file
16
src/routes.ts
Normal file
|
@ -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',
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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 onPressAddLinkCard = React.useCallback(
|
||||
(uri: string) => {
|
||||
setExtLink({uri, isLoading: true})
|
||||
},
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
const onPhotoPasted = React.useCallback(
|
||||
async (uri: string) => {
|
||||
if (selectedPhotos.length >= 4) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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,
|
|
@ -75,6 +75,7 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
},
|
||||
inner: {
|
||||
padding: 10,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
84
src/view/com/composer/photos/OpenCameraBtn.tsx
Normal file
84
src/view/com/composer/photos/OpenCameraBtn.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -1,10 +0,0 @@
|
|||
import React from 'react'
|
||||
|
||||
// Not used on Web
|
||||
|
||||
export const PhotoCarouselPicker = (_opts: {
|
||||
selectedPhotos: string[]
|
||||
onSelectPhotos: (v: string[]) => void
|
||||
}) => {
|
||||
return <></>
|
||||
}
|
94
src/view/com/composer/photos/SelectPhotoBtn.tsx
Normal file
94
src/view/com/composer/photos/SelectPhotoBtn.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}: {
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
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 type TextInputRef = RNTextInput
|
||||
|
||||
interface TextInputProps {
|
||||
testID: string
|
||||
innerRef: React.Ref<TextInputRef>
|
||||
placeholder: string
|
||||
style: StyleProp<TextStyle>
|
||||
onChangeText: (str: string) => void
|
||||
onSelectionChange?:
|
||||
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
|
||||
| undefined
|
||||
onPaste: (err: string | undefined, uris: string[]) => void
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
blur: () => 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>
|
||||
)
|
||||
interface TextInputProps {
|
||||
text: string
|
||||
placeholder: string
|
||||
suggestedLinks: Set<string>
|
||||
autocompleteView: UserAutocompleteViewModel
|
||||
onTextChanged: (v: string) => void
|
||||
onPhotoPasted: (uri: string) => void
|
||||
onSuggestedLinksChanged: (uris: Set<string>) => void
|
||||
onError: (err: string) => void
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
|
75
src/view/com/composer/text-input/mobile/Autocomplete.tsx
Normal file
75
src/view/com/composer/text-input/mobile/Autocomplete.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
157
src/view/com/composer/text-input/web/Autocomplete.tsx
Normal file
157
src/view/com/composer/text-input/web/Autocomplete.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
},
|
||||
)
|
90
src/view/com/composer/useExternalLinkFetch.ts
Normal file
90
src/view/com/composer/useExternalLinkFetch.ts
Normal file
|
@ -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}
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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))}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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} onPress={onPress} onLongPress={onLongPress}>
|
||||
<Text
|
||||
type={type}
|
||||
style={style}
|
||||
numberOfLines={numberOfLines}
|
||||
lineHeight={lineHeight}
|
||||
{...props}>
|
||||
{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)
|
||||
/**
|
||||
* 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}
|
||||
numberOfLines={numberOfLines}
|
||||
lineHeight={lineHeight}>
|
||||
{text}
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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...',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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%',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
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}>
|
||||
|
|
|
@ -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()
|
||||
navigation.navigate(`${tab}Tab`)
|
||||
}
|
||||
} else {
|
||||
store.nav.switchTo(TabPurpose.Default, false)
|
||||
if (store.nav.tab.index === 0) {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
}
|
||||
}, [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
|
|
@ -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}
|
|
@ -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>
|
||||
)
|
386
src/view/shell/Drawer.tsx
Normal file
386
src/view/shell/Drawer.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
254
src/view/shell/desktop/LeftNav.tsx
Normal file
254
src/view/shell/desktop/LeftNav.tsx
Normal file
|
@ -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',
|
||||
},
|
||||
})
|
46
src/view/shell/desktop/RightNav.tsx
Normal file
46
src/view/shell/desktop/RightNav.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
139
src/view/shell/index.tsx
Normal file
139
src/view/shell/index.tsx
Normal file
|
@ -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%',
|
||||
},
|
||||
})
|
113
src/view/shell/index.web.tsx
Normal file
113
src/view/shell/index.web.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue