Rework logged out state to preserve routing and work for web

zio/stable
Paul Frazee 2023-03-13 23:30:12 -05:00
parent b5c64a03b6
commit 774fb83719
26 changed files with 1063 additions and 1078 deletions

View File

@ -122,7 +122,9 @@ export class SessionModel {
try {
return await this.resumeSession(sess)
} finally {
this.isResumingSession = false
runInAction(() => {
this.isResumingSession = false
})
}
} else {
this.rootStore.log.debug(

View File

@ -0,0 +1,67 @@
import React from 'react'
import {SafeAreaView} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Signin} from 'view/com/auth/Signin'
import {CreateAccount} from 'view/com/auth/CreateAccount'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics'
import {SplashScreen} from './SplashScreen'
import {CenteredView} from '../util/Views'
enum ScreenState {
S_SigninOrCreateAccount,
S_Signin,
S_CreateAccount,
}
export const LoggedOut = observer(() => {
const pal = usePalette('default')
const store = useStores()
const {screen} = useAnalytics()
const [screenState, setScreenState] = React.useState<ScreenState>(
ScreenState.S_SigninOrCreateAccount,
)
React.useEffect(() => {
screen('Login')
store.shell.setMinimalShellMode(true)
}, [store, screen])
if (
store.session.isResumingSession ||
screenState === ScreenState.S_SigninOrCreateAccount
) {
return (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Signin)}
onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
/>
)
}
return (
<CenteredView style={[s.hContentRegion, pal.view]}>
<SafeAreaView testID="noSessionView" style={s.hContentRegion}>
<ErrorBoundary>
{screenState === ScreenState.S_Signin ? (
<Signin
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
})

View File

@ -1,28 +1,19 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, gradients} from 'lib/styles'
import {StyleSheet, View} from 'react-native'
import {s, colors} from 'lib/styles'
import {Text} from '../util/text/Text'
export const LogoTextHero = () => {
return (
<LinearGradient
colors={[gradients.blue.start, gradients.blue.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.textHero]}>
<View style={[styles.textHero]}>
<Text type="title-lg" style={[s.white, s.bold]}>
Bluesky
</Text>
</LinearGradient>
</View>
)
}
const styles = StyleSheet.create({
logo: {
flexDirection: 'row',
justifyContent: 'center',
},
textHero: {
flexDirection: 'row',
alignItems: 'center',
@ -30,5 +21,6 @@ const styles = StyleSheet.create({
paddingRight: 20,
paddingVertical: 15,
marginBottom: 20,
backgroundColor: colors.blue3,
},
})

View File

@ -0,0 +1,92 @@
import React from 'react'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import Image, {Source as ImageSource} from 'view/com/util/images/Image'
import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {CLOUD_SPLASH} from 'lib/assets'
import {CenteredView} from '../util/Views'
export const SplashScreen = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const pal = usePalette('default')
return (
<CenteredView style={styles.container}>
<Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
<View style={styles.hero}>
<View style={styles.heroText}>
<Text style={styles.title}>Bluesky</Text>
</View>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[pal.view, styles.btn]}
onPress={onPressCreateAccount}>
<Text style={[pal.link, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[pal.view, styles.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
},
hero: {
flex: 2,
justifyContent: 'center',
},
bgImg: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
heroText: {
backgroundColor: colors.white,
paddingTop: 10,
paddingBottom: 20,
},
btns: {
paddingBottom: 40,
},
title: {
textAlign: 'center',
color: colors.blue3,
fontSize: 68,
fontWeight: 'bold',
},
btn: {
borderRadius: 4,
paddingVertical: 16,
marginBottom: 20,
marginHorizontal: 20,
backgroundColor: colors.blue3,
},
btnLabel: {
textAlign: 'center',
fontSize: 21,
color: colors.white,
},
})

View File

@ -0,0 +1,102 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from '../util/Link'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views'
export const SplashScreen = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const pal = usePalette('default')
return (
<CenteredView style={styles.container}>
<View testID="noSessionView" style={styles.containerInner}>
<ErrorBoundary>
<Text style={styles.title}>Bluesky</Text>
<Text style={styles.subtitle}>See what's next</Text>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}>
<Text style={[s.white, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
<Text
type="xl"
style={[styles.notice, pal.textLight]}
lineHeight={1.3}>
Bluesky will launch soon.{' '}
<TextLink
type="xl"
text="Join the waitlist"
href="#"
style={pal.link}
/>{' '}
to try the beta before it's publicly available.
</Text>
</ErrorBoundary>
</View>
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
backgroundColor: colors.gray1,
},
containerInner: {
backgroundColor: colors.white,
paddingVertical: 40,
paddingBottom: 50,
paddingHorizontal: 20,
},
title: {
textAlign: 'center',
color: colors.blue3,
fontSize: 68,
fontWeight: 'bold',
paddingBottom: 10,
},
subtitle: {
textAlign: 'center',
color: colors.gray5,
fontSize: 52,
fontWeight: 'bold',
paddingBottom: 30,
},
btns: {
flexDirection: 'row',
paddingBottom: 40,
},
btn: {
flex: 1,
borderRadius: 30,
paddingVertical: 12,
marginHorizontal: 10,
},
btnLabel: {
textAlign: 'center',
fontSize: 18,
},
notice: {
paddingHorizontal: 40,
textAlign: 'center',
},
})

View File

@ -0,0 +1,47 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {LoggedOut} from './LoggedOut'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const withAuthRequired = <P extends object>(
Component: React.ComponentType<P>,
): React.FC<P> =>
observer((props: P) => {
const store = useStores()
if (store.session.isResumingSession) {
return <Loading />
}
if (!store.session.hasSession) {
return <LoggedOut />
}
return <Component {...props} />
})
function Loading() {
const pal = usePalette('default')
return (
<View style={[styles.loading, pal.view]}>
<ActivityIndicator size="large" />
<Text type="2xl" style={[styles.loadingText, pal.textLight]}>
Firing up the grill...
</Text>
</View>
)
}
const styles = StyleSheet.create({
loading: {
height: '100%',
alignContent: 'center',
justifyContent: 'center',
paddingBottom: 100,
},
loadingText: {
paddingVertical: 20,
paddingHorizontal: 20,
textAlign: 'center',
},
})

View File

@ -4,6 +4,7 @@ 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 {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/posts/Feed'
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
@ -18,95 +19,97 @@ import {ComposeIcon2} from 'lib/icons'
const HEADER_HEIGHT = 42
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 {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isFocused = useIsFocused()
export const HomeScreen = withAuthRequired(
observer(function Home(_opts: Props) {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const {screen, track} = useAnalytics()
const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isFocused = useIsFocused()
const doPoll = React.useCallback(
(knownActive = false) => {
if ((!knownActive && appState !== 'active') || !isFocused) {
return
}
if (store.me.mainFeed.isLoading) {
return
}
store.log.debug('HomeScreen: Polling for new posts')
store.me.mainFeed.checkForLatest()
},
[appState, isFocused, store],
)
const doPoll = React.useCallback(
(knownActive = false) => {
if ((!knownActive && appState !== 'active') || !isFocused) {
return
}
if (store.me.mainFeed.isLoading) {
return
}
store.log.debug('HomeScreen: Polling for new posts')
store.me.mainFeed.checkForLatest()
},
[appState, isFocused, store],
)
const scrollToTop = React.useCallback(() => {
// NOTE: the feed is offset by the height of the collapsing header,
// so we scroll to the negative of that height -prf
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
}, [scrollElRef])
const scrollToTop = React.useCallback(() => {
// NOTE: the feed is offset by the height of the collapsing header,
// so we scroll to the negative of that height -prf
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
}, [scrollElRef])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(scrollToTop)
const feedCleanup = store.me.mainFeed.registerListeners()
const pollInterval = setInterval(doPoll, 15e3)
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(scrollToTop)
const feedCleanup = store.me.mainFeed.registerListeners()
const pollInterval = setInterval(doPoll, 15e3)
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
}
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
}
return () => {
clearInterval(pollInterval)
softResetSub.remove()
feedCleanup()
}
}, [store, doPoll, 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 onPressCompose = React.useCallback(() => {
track('HomeScreen:PressCompose')
store.shell.openComposer({})
}, [store, track])
const onPressTryAgain = React.useCallback(() => {
store.me.mainFeed.refresh()
}, [store])
const onPressTryAgain = React.useCallback(() => {
store.me.mainFeed.refresh()
}, [store])
const onPressLoadLatest = React.useCallback(() => {
store.me.mainFeed.refresh()
scrollToTop()
}, [store, scrollToTop])
const onPressLoadLatest = React.useCallback(() => {
store.me.mainFeed.refresh()
scrollToTop()
}, [store, scrollToTop])
return (
<View style={s.hContentRegion}>
{store.shell.isOnboarding && <WelcomeBanner />}
<Feed
testID="homeFeed"
key="default"
feed={store.me.mainFeed}
scrollElRef={scrollElRef}
style={s.hContentRegion}
showPostFollowBtn
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
/>
{!store.shell.isOnboarding && (
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
)}
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} />
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
/>
</View>
)
})
return (
<View style={s.hContentRegion}>
{store.shell.isOnboarding && <WelcomeBanner />}
<Feed
testID="homeFeed"
key="default"
feed={store.me.mainFeed}
scrollElRef={scrollElRef}
style={s.hContentRegion}
showPostFollowBtn
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
/>
{!store.shell.isOnboarding && (
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
)}
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} />
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
/>
</View>
)
}),
)

View File

@ -1,164 +0,0 @@
import React, {useEffect, useState} from 'react'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import Image, {Source as ImageSource} from 'view/com/util/images/Image'
import {observer} from 'mobx-react-lite'
import {Signin} from '../com/login/Signin'
import {CreateAccount} from '../com/login/CreateAccount'
import {Text} from '../com/util/text/Text'
import {ErrorBoundary} from '../com/util/ErrorBoundary'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {CLOUD_SPLASH} from 'lib/assets'
import {useAnalytics} from 'lib/analytics'
enum ScreenState {
S_SigninOrCreateAccount,
S_Signin,
S_CreateAccount,
}
const SigninOrCreateAccount = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const {screen} = useAnalytics()
useEffect(() => {
screen('Login')
}, [screen])
const pal = usePalette('default')
return (
<>
<View style={styles.hero}>
<View style={styles.heroText}>
<Text style={styles.title}>Bluesky</Text>
</View>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[pal.view, styles.btn]}
onPress={onPressCreateAccount}>
<Text style={[pal.link, styles.btnLabel]}>Create a new account</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[pal.view, styles.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
</>
)
}
export const Login = observer(() => {
const pal = usePalette('default')
const store = useStores()
const [screenState, setScreenState] = useState<ScreenState>(
ScreenState.S_SigninOrCreateAccount,
)
if (
store.session.isResumingSession ||
screenState === ScreenState.S_SigninOrCreateAccount
) {
return (
<View style={styles.container}>
<Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
{!store.session.isResumingSession && (
<SigninOrCreateAccount
onPressSignin={() => setScreenState(ScreenState.S_Signin)}
onPressCreateAccount={() =>
setScreenState(ScreenState.S_CreateAccount)
}
/>
)}
</ErrorBoundary>
</SafeAreaView>
</View>
)
}
return (
<View style={[styles.container, pal.view]}>
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
{screenState === ScreenState.S_Signin ? (
<Signin
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
</ErrorBoundary>
</SafeAreaView>
</View>
)
})
const styles = StyleSheet.create({
container: {
height: '100%',
},
outer: {
flex: 1,
},
hero: {
flex: 2,
justifyContent: 'center',
},
bgImg: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
heroText: {
backgroundColor: colors.white,
paddingTop: 10,
paddingBottom: 20,
},
btns: {
paddingBottom: 40,
},
title: {
textAlign: 'center',
color: colors.blue3,
fontSize: 68,
fontWeight: 'bold',
},
subtitle: {
textAlign: 'center',
color: colors.blue3,
fontSize: 18,
},
btn: {
borderRadius: 4,
paddingVertical: 16,
marginBottom: 20,
marginHorizontal: 20,
backgroundColor: colors.blue3,
},
btnLabel: {
textAlign: 'center',
fontSize: 21,
// fontWeight: '500',
color: colors.white,
},
})

View File

@ -1,156 +0,0 @@
import React, {useState} from 'react'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {CenteredView} from '../com/util/Views'
import {Signin} from '../com/login/Signin'
import {CreateAccount} from '../com/login/CreateAccount'
import {Text} from '../com/util/text/Text'
import {ErrorBoundary} from '../com/util/ErrorBoundary'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
enum ScreenState {
S_SigninOrCreateAccount,
S_Signin,
S_CreateAccount,
}
const SigninOrCreateAccount = ({
onPressSignin,
onPressCreateAccount,
}: {
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
const pal = usePalette('default')
return (
<>
<View style={styles.hero}>
<View style={styles.heroText}>
<Text style={styles.title}>Bluesky</Text>
</View>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[pal.view, styles.btn]}
onPress={onPressCreateAccount}>
<Text style={[pal.link, styles.btnLabel]}>New account</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[pal.view, styles.btn]}
onPress={onPressSignin}>
<Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity>
</View>
</>
)
}
export const Login = observer(() => {
const pal = usePalette('default')
const [screenState, setScreenState] = useState<ScreenState>(
ScreenState.S_SigninOrCreateAccount,
)
if (screenState === ScreenState.S_SigninOrCreateAccount) {
return (
<CenteredView style={[styles.container, styles.vertCenter]}>
<ErrorBoundary>
<SigninOrCreateAccount
onPressSignin={() => setScreenState(ScreenState.S_Signin)}
onPressCreateAccount={() =>
setScreenState(ScreenState.S_CreateAccount)
}
/>
</ErrorBoundary>
</CenteredView>
)
}
return (
<CenteredView
style={[
styles.container,
styles.containerBorder,
pal.view,
pal.borderDark,
]}>
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
{screenState === ScreenState.S_Signin ? (
<Signin
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
onPressBack={() =>
setScreenState(ScreenState.S_SigninOrCreateAccount)
}
/>
) : undefined}
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
height: '100%',
},
containerBorder: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
vertCenter: {
justifyContent: 'center',
},
bgImg: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
hero: {},
heroText: {
backgroundColor: colors.white,
paddingTop: 10,
paddingBottom: 20,
},
btns: {
flexDirection: 'row',
paddingTop: 40,
},
title: {
textAlign: 'center',
color: colors.blue3,
fontSize: 68,
fontWeight: 'bold',
},
subtitle: {
textAlign: 'center',
color: colors.blue3,
fontSize: 18,
},
btn: {
flex: 1,
borderRadius: 4,
paddingVertical: 16,
marginBottom: 20,
marginHorizontal: 20,
borderWidth: 1,
borderColor: colors.blue3,
},
btnLabel: {
textAlign: 'center',
fontSize: 21,
fontWeight: '500',
color: colors.blue3,
},
})

View File

@ -1,11 +1,13 @@
import React, {useEffect} from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
import {useStores} from 'state/index'
@ -19,77 +21,79 @@ type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
'Notifications'
>
export const NotificationsScreen = ({}: Props) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
export const NotificationsScreen = withAuthRequired(
observer(({}: Props) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
// event handlers
// =
const onPressTryAgain = () => {
store.me.notifications.refresh()
}
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef])
// event handlers
// =
const onPressTryAgain = () => {
store.me.notifications.refresh()
}
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef])
// periodic polling
// =
const doPoll = React.useCallback(
async (isForegrounding = false) => {
if (isForegrounding) {
// app is foregrounding, refresh optimistically
store.log.debug('NotificationsScreen: Refreshing on app foreground')
await Promise.all([
store.me.notifications.loadUnreadCount(),
store.me.notifications.refresh(),
])
} else if (appState === 'active') {
// periodic poll, refresh if there are new notifs
store.log.debug('NotificationsScreen: Polling for new notifications')
const didChange = await store.me.notifications.loadUnreadCount()
if (didChange) {
store.log.debug('NotificationsScreen: Loading new notifications')
await store.me.notifications.loadLatest()
// periodic polling
// =
const doPoll = React.useCallback(
async (isForegrounding = false) => {
if (isForegrounding) {
// app is foregrounding, refresh optimistically
store.log.debug('NotificationsScreen: Refreshing on app foreground')
await Promise.all([
store.me.notifications.loadUnreadCount(),
store.me.notifications.refresh(),
])
} else if (appState === 'active') {
// periodic poll, refresh if there are new notifs
store.log.debug('NotificationsScreen: Polling for new notifications')
const didChange = await store.me.notifications.loadUnreadCount()
if (didChange) {
store.log.debug('NotificationsScreen: Loading new notifications')
await store.me.notifications.loadLatest()
}
}
}
},
[appState, store],
)
useEffect(() => {
const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL)
return () => clearInterval(pollInterval)
}, [doPoll])
},
[appState, store],
)
useEffect(() => {
const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL)
return () => clearInterval(pollInterval)
}, [doPoll])
// on-visible setup
// =
useFocusEffect(
React.useCallback(() => {
store.log.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(scrollToTop)
store.me.notifications.update()
screen('Notifications')
// on-visible setup
// =
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 () => {
softResetSub.remove()
store.me.notifications.markAllRead()
}
}, [store, screen, scrollToTop]),
)
return (
<View style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
view={store.me.notifications}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
/>
</View>
)
}
return (
<View style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
view={store.me.notifications}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
/>
</View>
)
}),
)

View File

@ -1,6 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
@ -8,7 +9,7 @@ import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
export const PostRepostedByScreen = ({route}: Props) => {
export const PostRepostedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
@ -25,4 +26,4 @@ export const PostRepostedByScreen = ({route}: Props) => {
<PostRepostedByComponent uri={uri} />
</View>
)
}
})

View File

@ -3,6 +3,7 @@ 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 {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ComposePrompt} from 'view/com/composer/Prompt'
@ -16,7 +17,7 @@ import {isDesktopWeb} from 'platform/detection'
const SHELL_FOOTER_HEIGHT = 44
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export const PostThreadScreen = ({route}: Props) => {
export const PostThreadScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const safeAreaInsets = useSafeAreaInsets()
const {name, rkey} = route.params
@ -84,7 +85,7 @@ export const PostThreadScreen = ({route}: Props) => {
)}
</View>
)
}
})
const styles = StyleSheet.create({
prompt: {

View File

@ -2,13 +2,14 @@ import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'>
export const PostUpvotedByScreen = ({route}: Props) => {
export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
@ -25,4 +26,4 @@ export const PostUpvotedByScreen = ({route}: Props) => {
<PostLikedByComponent uri={uri} direction="up" />
</View>
)
}
})

View File

@ -26,7 +26,7 @@ export const PrivacyPolicyScreen = (_props: Props) => {
<ViewHeader title="Privacy Policy" />
<ScrollView style={[s.hContentRegion, pal.view]}>
<View style={[s.p20]}>
<Text type="title-xl" style={[pal.text, s.pb20]}>
<Text type="title-xl" style={[pal.text, s.bold, s.pb20]}>
Privacy Policy
</Text>
<PrivacyPolicyHtml />

View File

@ -3,6 +3,7 @@ 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 {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewSelector} from '../com/util/ViewSelector'
import {CenteredView} from '../com/util/Views'
import {ProfileUiModel, Sections} from 'state/models/profile-ui'
@ -25,178 +26,182 @@ const END_ITEM = {_reactKey: '__end__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export const ProfileScreen = observer(({route}: Props) => {
const store = useStores()
const {screen, track} = useAnalytics()
export const ProfileScreen = withAuthRequired(
observer(({route}: Props) => {
const store = useStores()
const {screen, track} = useAnalytics()
useEffect(() => {
screen('Profile')
}, [screen])
useEffect(() => {
screen('Profile')
}, [screen])
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = React.useMemo(
() => new ProfileUiModel(store, {user: route.params.name}),
[route.params.name, store],
)
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = React.useMemo(
() => new ProfileUiModel(store, {user: route.params.name}),
[route.params.name, store],
)
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]),
)
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
// =
// events
// =
const onPressCompose = React.useCallback(() => {
track('ProfileScreen:PressCompose')
store.shell.openComposer({})
}, [store, track])
const onSelectView = (index: number) => {
uiState.setSelectedViewIndex(index)
}
const onRefresh = () => {
uiState
.refresh()
.catch((err: any) =>
store.log.error('Failed to refresh user profile', err),
)
}
const onEndReached = () => {
uiState
.loadMore()
.catch((err: any) =>
store.log.error('Failed to load more entries in user profile', err),
)
}
const onPressTryAgain = () => {
uiState.setup()
}
// rendering
// =
const renderHeader = () => {
if (!uiState) {
return <View />
const onPressCompose = React.useCallback(() => {
track('ProfileScreen:PressCompose')
store.shell.openComposer({})
}, [store, track])
const onSelectView = (index: number) => {
uiState.setSelectedViewIndex(index)
}
return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
}
let renderItem
let Footer
let items: any[] = []
if (uiState) {
if (uiState.isInitialLoading) {
items = items.concat([LOADING_ITEM])
renderItem = () => <PostFeedLoadingPlaceholder />
} else if (uiState.currentView.hasError) {
items = items.concat([
{
_reactKey: '__error__',
error: uiState.currentView.error,
},
])
renderItem = (item: any) => (
<View style={s.p5}>
<ErrorMessage
message={item.error}
const onRefresh = () => {
uiState
.refresh()
.catch((err: any) =>
store.log.error('Failed to refresh user profile', err),
)
}
const onEndReached = () => {
uiState
.loadMore()
.catch((err: any) =>
store.log.error('Failed to load more entries in user profile', err),
)
}
const onPressTryAgain = () => {
uiState.setup()
}
// rendering
// =
const renderHeader = () => {
if (!uiState) {
return <View />
}
return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
}
let renderItem
let Footer
let items: any[] = []
if (uiState) {
if (uiState.isInitialLoading) {
items = items.concat([LOADING_ITEM])
renderItem = () => <PostFeedLoadingPlaceholder />
} else if (uiState.currentView.hasError) {
items = items.concat([
{
_reactKey: '__error__',
error: uiState.currentView.error,
},
])
renderItem = (item: any) => (
<View style={s.p5}>
<ErrorMessage
message={item.error}
onPressTryAgain={onPressTryAgain}
/>
</View>
)
} else {
if (
uiState.selectedView === Sections.Posts ||
uiState.selectedView === Sections.PostsWithReplies
) {
if (uiState.feed.hasContent) {
if (uiState.selectedView === Sections.Posts) {
items = uiState.feed.nonReplyFeed
} else {
items = uiState.feed.feed.slice()
}
if (!uiState.feed.hasMore) {
items = items.concat([END_ITEM])
} else if (uiState.feed.isLoading) {
Footer = LoadingMoreFooter
}
renderItem = (item: any) => {
if (item === END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>
}
return (
<FeedItem item={item} ignoreMuteFor={uiState.profile.did} />
)
}
} else if (uiState.feed.isEmpty) {
items = items.concat([EMPTY_ITEM])
renderItem = () => (
<EmptyState
icon={['far', 'message']}
message="No posts yet!"
style={styles.emptyState}
/>
)
}
} else {
items = items.concat([EMPTY_ITEM])
renderItem = () => <Text>TODO</Text>
}
}
}
if (!renderItem) {
renderItem = () => <View />
}
return (
<View testID="profileView" style={styles.container}>
{uiState.profile.hasError ? (
<ErrorScreen
testID="profileErrorScreen"
title="Failed to load profile"
message={`There was an issue when attempting to load ${route.params.name}`}
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain}
/>
</View>
)
} else {
if (
uiState.selectedView === Sections.Posts ||
uiState.selectedView === Sections.PostsWithReplies
) {
if (uiState.feed.hasContent) {
if (uiState.selectedView === Sections.Posts) {
items = uiState.feed.nonReplyFeed
} else {
items = uiState.feed.feed.slice()
}
if (!uiState.feed.hasMore) {
items = items.concat([END_ITEM])
} else if (uiState.feed.isLoading) {
Footer = LoadingMoreFooter
}
renderItem = (item: any) => {
if (item === END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>
}
return <FeedItem item={item} ignoreMuteFor={uiState.profile.did} />
}
} else if (uiState.feed.isEmpty) {
items = items.concat([EMPTY_ITEM])
renderItem = () => (
<EmptyState
icon={['far', 'message']}
message="No posts yet!"
style={styles.emptyState}
/>
)
}
} else {
items = items.concat([EMPTY_ITEM])
renderItem = () => <Text>TODO</Text>
}
}
}
if (!renderItem) {
renderItem = () => <View />
}
return (
<View testID="profileView" style={styles.container}>
{uiState.profile.hasError ? (
<ErrorScreen
testID="profileErrorScreen"
title="Failed to load profile"
message={`There was an issue when attempting to load ${route.params.name}`}
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain}
) : uiState.profile.hasLoaded ? (
<ViewSelector
swipeEnabled
sections={uiState.selectorItems}
items={items}
renderHeader={renderHeader}
renderItem={renderItem}
ListFooterComponent={Footer}
refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView}
onScroll={onMainScroll}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
) : (
<CenteredView>{renderHeader()}</CenteredView>
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
/>
) : uiState.profile.hasLoaded ? (
<ViewSelector
swipeEnabled
sections={uiState.selectorItems}
items={items}
renderHeader={renderHeader}
renderItem={renderItem}
ListFooterComponent={Footer}
refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView}
onScroll={onMainScroll}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
) : (
<CenteredView>{renderHeader()}</CenteredView>
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
/>
</View>
)
})
</View>
)
}),
)
function LoadingMoreFooter() {
return (

View File

@ -2,12 +2,13 @@ import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {useStores} from 'state/index'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'>
export const ProfileFollowersScreen = ({route}: Props) => {
export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name} = route.params
@ -23,4 +24,4 @@ export const ProfileFollowersScreen = ({route}: Props) => {
<ProfileFollowersComponent name={name} />
</View>
)
}
})

View File

@ -2,12 +2,13 @@ import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {useStores} from 'state/index'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'>
export const ProfileFollowsScreen = ({route}: Props) => {
export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name} = route.params
@ -23,4 +24,4 @@ export const ProfileFollowsScreen = ({route}: Props) => {
<ProfileFollowsComponent name={name} />
</View>
)
}
})

View File

@ -12,6 +12,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ScrollView} from '../com/util/Views'
import {
NativeStackScreenProps,
@ -36,159 +37,161 @@ const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
const FIVE_MIN = 5 * 60 * 1e3
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)
const onMainScroll = useOnMainScroll(store)
const textInput = React.useRef<TextInput>(null)
const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store),
[store],
)
export const SearchScreen = withAuthRequired(
observer<Props>(({}: Props) => {
const pal = usePalette('default')
const theme = useTheme()
const store = useStores()
const {track} = useAnalytics()
const scrollElRef = React.useRef<ScrollView>(null)
const onMainScroll = useOnMainScroll(store)
const textInput = React.useRef<TextInput>(null)
const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store),
[store],
)
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
autocompleteView.setup()
return cleanup
}, [store, autocompleteView, lastRenderTime, setRenderTime]),
)
const onPressMenu = () => {
track('ViewHeader:MenuButtonClicked')
store.shell.openDrawer()
}
const onChangeQuery = (text: string) => {
setQuery(text)
if (text.length > 0) {
autocompleteView.setActive(true)
autocompleteView.setPrefix(text)
} else {
autocompleteView.setActive(false)
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
}
const onPressClearQuery = () => {
setQuery('')
}
const onPressCancelSearch = () => {
setQuery('')
autocompleteView.setActive(false)
textInput.current?.blur()
}
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<View style={[pal.view, pal.border, styles.header]}>
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={MENU_HITSLOP}
style={styles.headerMenuBtn}>
<UserAvatar size={30} avatar={store.me.avatar} />
</TouchableOpacity>
<View
style={[
{backgroundColor: pal.colors.backgroundLight},
styles.headerSearchContainer,
]}>
<MagnifyingGlassIcon
style={[pal.icon, styles.headerSearchIcon]}
size={21}
/>
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
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 as FontAwesomeIconStyle}
/>
</TouchableOpacity>
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
autocompleteView.setup()
return cleanup
}, [store, autocompleteView, lastRenderTime, setRenderTime]),
)
const onPressMenu = () => {
track('ViewHeader:MenuButtonClicked')
store.shell.openDrawer()
}
const onChangeQuery = (text: string) => {
setQuery(text)
if (text.length > 0) {
autocompleteView.setActive(true)
autocompleteView.setPrefix(text)
} else {
autocompleteView.setActive(false)
}
}
const onPressClearQuery = () => {
setQuery('')
}
const onPressCancelSearch = () => {
setQuery('')
autocompleteView.setActive(false)
textInput.current?.blur()
}
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<View style={[pal.view, pal.border, styles.header]}>
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={MENU_HITSLOP}
style={styles.headerMenuBtn}>
<UserAvatar size={30} avatar={store.me.avatar} />
</TouchableOpacity>
<View
style={[
{backgroundColor: pal.colors.backgroundLight},
styles.headerSearchContainer,
]}>
<MagnifyingGlassIcon
style={[pal.icon, styles.headerSearchIcon]}
size={21}
/>
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
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 as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>
{query || isInputFocused ? (
<View style={styles.headerCancelBtn}>
<TouchableOpacity onPress={onPressCancelSearch}>
<Text style={pal.text}>Cancel</Text>
</TouchableOpacity>
</View>
) : undefined}
</View>
{query || isInputFocused ? (
<View style={styles.headerCancelBtn}>
<TouchableOpacity onPress={onPressCancelSearch}>
<Text style={pal.text}>Cancel</Text>
</TouchableOpacity>
{query && autocompleteView.searchRes.length ? (
<>
{autocompleteView.searchRes.map(item => (
<ProfileCard
key={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
))}
</>
) : query && !autocompleteView.searchRes.length ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
No results found for {autocompleteView.prefix}
</Text>
</View>
) : undefined}
</View>
{query && autocompleteView.searchRes.length ? (
<>
{autocompleteView.searchRes.map(item => (
<ProfileCard
key={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
))}
</>
) : query && !autocompleteView.searchRes.length ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
No results found for {autocompleteView.prefix}
</Text>
</View>
) : isInputFocused ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
Search for users on the network
</Text>
</View>
) : (
<ScrollView onScroll={Keyboard.dismiss}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} />
</ScrollView>
)}
<View style={s.footerSpacer} />
</ScrollView>
</TouchableWithoutFeedback>
)
})
) : isInputFocused ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
Search for users on the network
</Text>
</View>
) : (
<ScrollView onScroll={Keyboard.dismiss}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} />
</ScrollView>
)}
<View style={s.footerSpacer} />
</ScrollView>
</TouchableWithoutFeedback>
)
}),
)
const styles = StyleSheet.create({
container: {

View File

@ -1,6 +1,7 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ScrollView} from '../com/util/Views'
import {observer} from 'mobx-react-lite'
import {
@ -17,46 +18,48 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
const FIVE_MIN = 5 * 60 * 1e3
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const scrollElRef = React.useRef<ScrollView>(null)
const onMainScroll = useOnMainScroll(store)
const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
export const SearchScreen = withAuthRequired(
observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const scrollElRef = React.useRef<ScrollView>(null)
const onMainScroll = useOnMainScroll(store)
const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
const now = Date.now()
if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false)
return () => {
softResetSub.remove()
}
}, [store, lastRenderTime, setRenderTime]),
)
return () => {
softResetSub.remove()
}
}, [store, lastRenderTime, setRenderTime]),
)
return (
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} />
</ScrollView>
)
})
return (
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} />
</ScrollView>
)
}),
)
const styles = StyleSheet.create({
container: {

View File

@ -16,6 +16,7 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import * as AppInfo from 'lib/app-info'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
@ -33,235 +34,237 @@ import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
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)
export const SettingsScreen = withAuthRequired(
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)
useFocusEffect(
React.useCallback(() => {
screen('Settings')
store.shell.setMinimalShellMode(false)
}, [screen, store]),
)
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)) {
const onPressSwitchAccount = async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
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.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
store.session.clear()
}
const onPressAddAccount = () => {
track('Settings:AddAccountButtonClicked')
store.session.clear()
}
const onPressChangeHandle = () => {
track('Settings:ChangeHandleButtonClicked')
store.shell.openModal({
name: 'change-handle',
onChanged() {
setIsSwitching(true)
store.session.reloadFromServer().then(
() => {
setIsSwitching(false)
Toast.show('Your handle has been updated')
},
err => {
store.log.error(
'Failed to reload from server after handle update',
{err},
)
setIsSwitching(false)
},
)
},
})
}
const onPressSignout = () => {
track('Settings:SignOutButtonClicked')
store.session.logout()
}
const onPressDeleteAccount = () => {
store.shell.openModal({name: 'delete-account'})
}
setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressAddAccount = () => {
track('Settings:AddAccountButtonClicked')
store.session.clear()
}
const onPressChangeHandle = () => {
track('Settings:ChangeHandleButtonClicked')
store.shell.openModal({
name: 'change-handle',
onChanged() {
setIsSwitching(true)
store.session.reloadFromServer().then(
() => {
setIsSwitching(false)
Toast.show('Your handle has been updated')
},
err => {
store.log.error(
'Failed to reload from server after handle update',
{err},
)
setIsSwitching(false)
},
)
},
})
}
const onPressSignout = () => {
track('Settings:SignOutButtonClicked')
store.session.logout()
}
const onPressDeleteAccount = () => {
store.shell.openModal({name: 'delete-account'})
}
return (
<View style={[s.hContentRegion]} testID="settingsScreen">
<ViewHeader title="Settings" />
<ScrollView style={s.hContentRegion}>
<View style={styles.spacer20} />
<View style={[s.flexRow, styles.heading]}>
<Text type="xl-bold" style={pal.text}>
Signed in as
</Text>
<View style={s.flex1} />
</View>
{isSwitching ? (
<View style={[pal.view, styles.linkCard]}>
<ActivityIndicator />
return (
<View style={[s.hContentRegion]} testID="settingsScreen">
<ViewHeader title="Settings" />
<ScrollView style={s.hContentRegion}>
<View style={styles.spacer20} />
<View style={[s.flexRow, styles.heading]}>
<Text type="xl-bold" style={pal.text}>
Signed in as
</Text>
<View style={s.flex1} />
</View>
) : (
<Link
href={`/profile/${store.me.handle}`}
title="Your profile"
noFeedback>
{isSwitching ? (
<View style={[pal.view, styles.linkCard]}>
<ActivityIndicator />
</View>
) : (
<Link
href={`/profile/${store.me.handle}`}
title="Your profile"
noFeedback>
<View style={[pal.view, styles.linkCard]}>
<View style={styles.avi}>
<UserAvatar size={40} avatar={store.me.avatar} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{store.me.displayName || store.me.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{store.me.handle}
</Text>
</View>
<TouchableOpacity
testID="signOutBtn"
onPress={isSwitching ? undefined : onPressSignout}>
<Text type="lg" style={pal.link}>
Sign out
</Text>
</TouchableOpacity>
</View>
</Link>
)}
{store.session.switchableAccounts.map(account => (
<TouchableOpacity
testID={`switchToAccountBtn-${account.handle}`}
key={account.did}
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
onPress={
isSwitching ? undefined : () => onPressSwitchAccount(account)
}>
<View style={styles.avi}>
<UserAvatar size={40} avatar={store.me.avatar} />
<UserAvatar size={40} avatar={account.aviUrl} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{store.me.displayName || store.me.handle}
<Text type="md-bold" style={pal.text}>
{account.displayName || account.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{store.me.handle}
<Text type="sm" style={pal.textLight}>
{account.handle}
</Text>
</View>
<TouchableOpacity
testID="signOutBtn"
onPress={isSwitching ? undefined : onPressSignout}>
<Text type="lg" style={pal.link}>
Sign out
</Text>
</TouchableOpacity>
</View>
</Link>
)}
{store.session.switchableAccounts.map(account => (
<AccountDropdownBtn handle={account.handle} />
</TouchableOpacity>
))}
<TouchableOpacity
testID={`switchToAccountBtn-${account.handle}`}
key={account.did}
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
onPress={
isSwitching ? undefined : () => onPressSwitchAccount(account)
}>
<View style={styles.avi}>
<UserAvatar size={40} avatar={account.aviUrl} />
testID="switchToNewAccountBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressAddAccount}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="plus"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text}>
{account.displayName || account.handle}
</Text>
<Text type="sm" style={pal.textLight}>
{account.handle}
</Text>
</View>
<AccountDropdownBtn handle={account.handle} />
<Text type="lg" style={pal.text}>
Add account
</Text>
</TouchableOpacity>
))}
<TouchableOpacity
testID="switchToNewAccountBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressAddAccount}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="plus"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Add account
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressChangeHandle}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="at"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Change my handle
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
</Text>
<TouchableOpacity
testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressChangeHandle}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="at"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Change my handle
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Danger zone
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Danger zone
</Text>
<TouchableOpacity
style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount}>
<View
style={[
styles.iconContainer,
theme.colorScheme === 'dark'
? styles.trashIconContainerDark
: styles.trashIconContainerLight,
]}>
<FontAwesomeIcon
icon={['far', 'trash-can']}
<TouchableOpacity
style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount}>
<View
style={[
styles.iconContainer,
theme.colorScheme === 'dark'
? styles.trashIconContainerDark
: styles.trashIconContainerLight,
]}>
<FontAwesomeIcon
icon={['far', 'trash-can']}
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}
size={21}
/>
</View>
<Text
type="lg"
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}
size={21}
/>
</View>
<Text
type="lg"
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}>
Delete my account
</Text>
</TouchableOpacity>
}>
Delete my account
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Developer tools
</Text>
<Link
style={[pal.view, styles.linkCardNoIcon]}
href="/sys/log"
title="System log">
<Text type="lg" style={pal.text}>
System log
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Developer tools
</Text>
</Link>
<Link
style={[pal.view, styles.linkCardNoIcon]}
href="/sys/debug"
title="Debug tools">
<Text type="lg" style={pal.text}>
Storybook
<Link
style={[pal.view, styles.linkCardNoIcon]}
href="/sys/log"
title="System log">
<Text type="lg" style={pal.text}>
System log
</Text>
</Link>
<Link
style={[pal.view, styles.linkCardNoIcon]}
href="/sys/debug"
title="Debug tools">
<Text type="lg" style={pal.text}>
Storybook
</Text>
</Link>
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
</Text>
</Link>
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
</Text>
<View style={s.footerSpacer} />
</ScrollView>
</View>
)
})
<View style={s.footerSpacer} />
</ScrollView>
</View>
)
}),
)
function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores()

View File

@ -131,7 +131,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
return (
<View style={styles.leftNav}>
<ProfileCard />
{store.session.hasSession && <ProfileCard />}
<BackBtn />
<NavItem
href="/"
@ -164,14 +164,16 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
}
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"
/>
{store.session.hasSession && (
<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} />}
@ -180,7 +182,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
}
label="Settings"
/>
<ComposeBtn />
{store.session.hasSession && <ComposeBtn />}
</View>
)
})

View File

@ -7,12 +7,14 @@ import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL} from 'lib/constants'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
export const DesktopRightNav = observer(function DesktopRightNav() {
const store = useStores()
const pal = usePalette('default')
return (
<View style={[styles.rightNav, pal.view]}>
<DesktopSearch />
{store.session.hasSession && <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

View File

@ -5,7 +5,6 @@ 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'
@ -104,20 +103,6 @@ export const Shell: React.FC = observer(() => {
)
}
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

View File

@ -4,7 +4,6 @@ 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'
@ -45,21 +44,10 @@ const ShellInner = observer(() => {
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>