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:
Paul Frazee 2023-03-13 16:01:43 -05:00 committed by GitHub
parent 503e03d91e
commit 56cf890deb
222 changed files with 8705 additions and 6338 deletions

View file

@ -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>
)
})

View file

@ -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
View 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,
}

View file

@ -1,4 +0,0 @@
{
"name": "xyz.blueskyweb.app",
"displayName": "Bluesky"
}

View file

@ -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'),
})

View file

@ -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

View file

@ -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') ||

View file

@ -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')

View file

@ -1,2 +1 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false

View file

@ -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

View file

@ -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
}

View 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}
}

View file

@ -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}>

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
View 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
}

View file

@ -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

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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
}

View file

@ -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
View 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',
})

View file

@ -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
}
}*/
}
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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
})
}
}

View file

@ -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,

View file

@ -75,6 +75,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: 8,
marginTop: 20,
marginBottom: 10,
},
inner: {
padding: 10,

View file

@ -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,
},
})

View file

@ -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}>
&nbsp;@{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,
},
})

View file

@ -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}>
&nbsp;@{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,
},
})

View 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>
)
}

View file

@ -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,
},
})

View file

@ -1,10 +0,0 @@
import React from 'react'
// Not used on Web
export const PhotoCarouselPicker = (_opts: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
return <></>
}

View 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>
)
}

View file

@ -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,
}: {

View file

@ -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
},
})

View file

@ -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,
},
})

View 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}>
&nbsp;@{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,
},
})

View 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>
)
},
)

View 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}
}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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))}

View file

@ -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}
/>

View file

@ -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,
})

View file

@ -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

View file

@ -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,
},
})

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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(

View file

@ -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}

View file

@ -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

View file

@ -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,
},

View file

@ -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>
)
}

View file

@ -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)))
}
}
}

View file

@ -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,
},
})

View file

@ -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}>

View file

@ -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}>
&nbsp;&middot;&nbsp;
</Text>
<DesktopWebTextLink
type="md"
style={[styles.metaItem, pal.textLight]}
lineHeight={1.2}>
&middot; {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]}>
&nbsp;{handle}
</Text>
) : undefined}
</Text>
lineHeight={1.2}
text={
<>
{displayName}
<Text type="md" style={[pal.textLight]}>
&nbsp;{handle}
</Text>
</>
}
href={`/profile/${opts.authorHandle}`}
/>
</View>
<Text type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2}>
&middot; {ago(opts.timestamp)}
<Text type="md" style={pal.textLight} lineHeight={1.2}>
&middot;&nbsp;
</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,
},

View file

@ -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) {

View file

@ -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',

View file

@ -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>
)

View file

@ -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
}

View file

@ -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',

View file

@ -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',
},

View file

@ -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...',

View file

@ -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,

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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,
},
})

View file

@ -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',
)

View file

@ -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()

View file

@ -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)) {

View file

@ -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%',
},
})

View file

@ -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}>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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}>

View file

@ -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

View file

@ -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}

View file

@ -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
View 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')} &middot;{' '}
<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,
},
})

View 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',
},
})

View 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,
},
})

View file

@ -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
View 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%',
},
})

View 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,
},
})

View file

@ -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')} &middot;{' '}
<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