Compare commits

...

7 Commits

Author SHA1 Message Date
Paul Frazee c39c71de5f Create new search suggestions component that has both feeds and accounts 2024-04-22 16:59:53 -07:00
Paul Frazee 54ca855851 Add placeholder feeds dropdown to home 2024-04-22 16:28:42 -07:00
Paul Frazee 4ec5669d21 Replace the home header btns and account switcher with one profile menu 2024-04-22 16:04:31 -07:00
Paul Frazee c67834518f Update web home header 2024-04-22 13:59:57 -07:00
Paul Frazee db240160f7 Update settings to include everything that used to be in the drawer 2024-04-22 13:10:35 -07:00
Paul Frazee 9aeed18ff4 Update headers 2024-04-22 12:43:49 -07:00
Paul Frazee 4b102de992 Remove the drawer from the shell 2024-04-22 12:25:42 -07:00
27 changed files with 978 additions and 1432 deletions
-1
View File
@@ -72,7 +72,6 @@
"@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.6.1",
"@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
"@react-navigation/native-stack": "^6.9.12",
"@segment/analytics-next": "^1.51.3",
+8 -3
View File
@@ -1,12 +1,13 @@
import React from 'react'
import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes'
export * from '#/alf/types'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
export * as tokens from '#/alf/tokens'
export * from '#/alf/types'
export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
type BreakpointName = keyof typeof breakpoints
@@ -89,6 +90,10 @@ export function ThemeProvider({
)
}
export function useThemeName() {
return React.useContext(Context).themeName
}
export function useTheme() {
return React.useContext(Context).theme
}
+11 -2
View File
@@ -1,5 +1,5 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,10 +16,14 @@ export function AccountList({
onSelectAccount,
onSelectOther,
otherLabel,
excludeCurrent,
style,
}: {
onSelectAccount: (account: SessionAccount) => void
onSelectOther: () => void
otherLabel?: string
excludeCurrent?: boolean
style?: StyleProp<ViewStyle>
}) {
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
const t = useTheme()
@@ -29,6 +33,10 @@ export function AccountList({
onSelectOther()
}, [onSelectOther])
const filteredAccounts = excludeCurrent
? accounts.filter(account => account.did !== currentAccount?.did)
: accounts
return (
<View
style={[
@@ -36,8 +44,9 @@ export function AccountList({
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
style,
]}>
{accounts.map(account => (
{filteredAccounts.map(account => (
<React.Fragment key={account.did}>
<AccountItem
account={account}
+131
View File
@@ -0,0 +1,131 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {cleanError} from '#/lib/strings/errors'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {NavigationProp} from 'lib/routes/types'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {router} from '#/routes'
import {Button} from '../Button'
import {Text} from '../Typography'
export function MyFeedsDialog({control}: {control: Dialog.DialogControlProps}) {
const {_} = useLingui()
const t = useTheme()
const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation<NavigationProp>()
const onPressFeed = useCallback(
(uri: string) => {
control.close()
const urip = new AtUri(uri)
const collection =
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}`
const route = router.matchPath(href)
// @ts-ignore This is correct -prf
navigation.navigate(route[0], route[1])
},
[control, navigation],
)
const onPressEditFeeds = useCallback(() => {
control.close()
navigation.navigate('SavedFeeds')
}, [control, navigation])
let feeds: string[] = []
if (preferences && preferences?.feeds?.saved.length !== 0) {
const {saved, pinned} = preferences.feeds
feeds = feeds.concat(pinned)
feeds = feeds.concat(saved.filter(uri => !pinned.includes(uri)))
}
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
{feeds.map(feedUri => (
<SavedFeed key={feedUri} feedUri={feedUri} onPress={onPressFeed} />
))}
<Button label="Edit feeds" onPress={onPressEditFeeds}>
{() => (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_md,
a.py_md,
a.flex_1,
]}>
<Text numberOfLines={1} style={[t.atoms.text, a.text_md]}>
Edit my feeds
</Text>
</View>
)}
</Button>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function SavedFeed({
feedUri,
onPress,
}: {
feedUri: string
onPress: (uri: string) => void
}) {
const t = useTheme()
const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
if (!info && !error) {
return <SavedFeedLoadingPlaceholder />
}
return (
<Button
testID={`saved-feed-${info?.displayName}`}
label={info ? info.displayName : 'Feed offline'}
onPress={() => onPress(feedUri)}>
{() => (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_md,
a.py_md,
a.flex_1,
]}>
<Text numberOfLines={1} style={[t.atoms.text, a.text_md]}>
{info ? info.displayName : cleanError(error)}
</Text>
</View>
)}
</Button>
)
}
function SavedFeedLoadingPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.border_b,
t.atoms.border_contrast_low,
a.px_md,
a.py_sm,
]}>
<LoadingPlaceholder width={140} height={12} />
</View>
)
}
-61
View File
@@ -1,61 +0,0 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {type SessionAccount, useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {AccountList} from '../AccountList'
import {Text} from '../Typography'
export function SwitchAccountDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const onSelectAccount = useCallback(
(account: SessionAccount) => {
if (account.did === currentAccount?.did) {
control.close()
} else {
onPressSwitchAccount(account, 'SwitchAccount')
}
},
[currentAccount, control, onPressSwitchAccount],
)
const onPressAddAccount = useCallback(() => {
setShowLoggedOut(true)
closeAllActiveElements()
}, [setShowLoggedOut, closeAllActiveElements])
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Switch Account</Trans>
</Text>
<AccountList
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+2 -4
View File
@@ -16,7 +16,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {enforceLen} from 'lib/strings/helpers'
import {isNative, isWeb} from 'platform/detection'
import {useSearchPostsQuery} from 'state/queries/search-posts'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from 'state/shell'
import {useSetMinimalShellMode} from 'state/shell'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {CenteredView} from '#/view/com/util/Views'
@@ -65,7 +65,6 @@ export default function HashtagScreen({
const [activeTab, setActiveTab] = React.useState(0)
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
React.useCallback(() => {
@@ -76,10 +75,9 @@ export default function HashtagScreen({
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
setActiveTab(index)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
[setMinimalShellMode],
)
const sections = React.useMemo(() => {
+1 -1
View File
@@ -176,7 +176,7 @@ export function useGetPopularFeedsQuery() {
queryKey: useGetPopularFeedsQueryKey,
queryFn: async ({pageParam}) => {
const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({
limit: 10,
limit: 30,
cursor: pageParam,
})
return res.data
-25
View File
@@ -1,25 +0,0 @@
import React from 'react'
type StateContext = boolean
type SetContext = (v: boolean) => void
const stateContext = React.createContext<StateContext>(false)
const setContext = React.createContext<SetContext>((_: boolean) => {})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(false)
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setState}>{children}</setContext.Provider>
</stateContext.Provider>
)
}
export function useIsDrawerOpen() {
return React.useContext(stateContext)
}
export function useSetDrawerOpen() {
return React.useContext(setContext)
}
-24
View File
@@ -1,24 +0,0 @@
import React from 'react'
type StateContext = boolean
type SetContext = (v: boolean) => void
const stateContext = React.createContext<StateContext>(false)
const setContext = React.createContext<SetContext>((_: boolean) => {})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(false)
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setState}>{children}</setContext.Provider>
</stateContext.Provider>
)
}
export function useIsDrawerSwipeDisabled() {
return React.useContext(stateContext)
}
export function useSetDrawerSwipeDisabled() {
return React.useContext(setContext)
}
+16 -26
View File
@@ -1,40 +1,30 @@
import React from 'react'
import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as DrawerOpenProvider} from './drawer-open'
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode'
import {Provider as ColorModeProvider} from './color-mode'
import {Provider as OnboardingProvider} from './onboarding'
import {Provider as ComposerProvider} from './composer'
import {Provider as MinimalModeProvider} from './minimal-mode'
import {Provider as OnboardingProvider} from './onboarding'
import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
export {
useIsDrawerSwipeDisabled,
useSetDrawerSwipeDisabled,
} from './drawer-swipe-disabled'
export {useSetThemePrefs, useThemePrefs} from './color-mode'
export {useComposerControls, useComposerState} from './composer'
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
export {useThemePrefs, useSetThemePrefs} from './color-mode'
export {useOnboardingState, useOnboardingDispatch} from './onboarding'
export {useComposerState, useComposerControls} from './composer'
export {useOnboardingDispatch, useOnboardingState} from './onboarding'
export {useTickEveryMinute} from './tick-every-minute'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<ShellLayoutProvder>
<DrawerOpenProvider>
<DrawerSwipableProvider>
<MinimalModeProvider>
<ColorModeProvider>
<OnboardingProvider>
<ComposerProvider>
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
</ComposerProvider>
</OnboardingProvider>
</ColorModeProvider>
</MinimalModeProvider>
</DrawerSwipableProvider>
</DrawerOpenProvider>
<MinimalModeProvider>
<ColorModeProvider>
<OnboardingProvider>
<ComposerProvider>
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
</ComposerProvider>
</OnboardingProvider>
</ColorModeProvider>
</MinimalModeProvider>
</ShellLayoutProvder>
)
}
+4 -14
View File
@@ -1,9 +1,9 @@
import {useCallback} from 'react'
import {useDialogStateControlContext} from '#/state/dialogs'
import {useLightboxControls} from './lightbox'
import {useModalControls} from './modals'
import {useComposerControls} from './shell/composer'
import {useSetDrawerOpen} from './shell/drawer-open'
import {useDialogStateControlContext} from '#/state/dialogs'
/**
* returns true if something was closed
@@ -14,7 +14,6 @@ export function useCloseAnyActiveElement() {
const {closeModal} = useModalControls()
const {closeComposer} = useComposerControls()
const {closeAllDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => {
if (closeLightbox()) {
return true
@@ -28,9 +27,8 @@ export function useCloseAnyActiveElement() {
if (closeAllDialogs()) {
return true
}
setDrawerOpen(false)
return false
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])
}, [closeLightbox, closeModal, closeComposer, closeAllDialogs])
}
/**
@@ -41,18 +39,10 @@ export function useCloseAllActiveElements() {
const {closeAllModals} = useModalControls()
const {closeComposer} = useComposerControls()
const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => {
closeLightbox()
closeAllModals()
closeComposer()
closeAlfDialogs()
setDrawerOpen(false)
}, [
closeLightbox,
closeAllModals,
closeComposer,
closeAlfDialogs,
setDrawerOpen,
])
}, [closeLightbox, closeAllModals, closeComposer, closeAlfDialogs])
}
+7 -12
View File
@@ -8,13 +8,14 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CogIcon} from '#/lib/icons'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {atoms} from '#/alf'
import {Link} from '../util/Link'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
@@ -47,8 +48,11 @@ function HomeHeaderLayoutDesktopAndTablet({
<>
{hasSession && (
<View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
<View style={[atoms.flex_row, atoms.align_end, atoms.gap_md]}>
<Logo width={28} />
</View>
<Link
href="/settings/following-feed"
href="/settings"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Following Feed Preferences`)}
@@ -58,15 +62,6 @@ function HomeHeaderLayoutDesktopAndTablet({
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
<Logo width={28} />
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit Saved Feeds`)}
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
</View>
)}
{tabBarAnchor}
@@ -101,7 +96,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 18,
paddingTop: 16,
paddingBottom: 8,
paddingBottom: 4,
},
tabBar: {
// @ts-ignore Web only
+3 -67
View File
@@ -1,24 +1,13 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {useShellLayout} from '#/state/shell/shell-layout'
import {HITSLOP_10} from 'lib/constants'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {Logo} from '#/view/icons/Logo'
import {atoms} from '#/alf'
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
import {Link as Link2} from '#/components/Link'
import {IS_DEV} from '#/env'
import {Link} from '../util/Link'
export function HomeHeaderLayoutMobile({
children,
@@ -27,15 +16,8 @@ export function HomeHeaderLayoutMobile({
tabBarAnchor: JSX.Element | null | undefined
}) {
const pal = usePalette('default')
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {headerHeight} = useShellLayout()
const {headerMinimalShellTransform} = useMinimalShellMode()
const {hasSession} = useSession()
const onPressAvi = React.useCallback(() => {
setDrawerOpen(true)
}, [setDrawerOpen])
return (
<Animated.View
@@ -44,55 +26,9 @@ export function HomeHeaderLayoutMobile({
headerHeight.value = e.nativeEvent.layout.height
}}>
<View style={[pal.view, styles.topBar]}>
<View style={[pal.view, {width: 100}]}>
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={onPressAvi}
accessibilityRole="button"
accessibilityLabel={_(msg`Open navigation`)}
accessibilityHint={_(
msg`Access profile and other navigation links`,
)}
hitSlop={HITSLOP_10}>
<FontAwesomeIcon
icon="bars"
size={18}
color={pal.colors.textLight}
/>
</TouchableOpacity>
</View>
<View>
<View style={[atoms.flex_row, atoms.align_end, atoms.gap_md]}>
<Logo width={30} />
</View>
<View
style={[
atoms.flex_row,
atoms.justify_end,
atoms.align_center,
atoms.gap_md,
pal.view,
{width: 100},
]}>
{IS_DEV && (
<Link2 to="/sys/debug">
<ColorPalette size="md" />
</Link2>
)}
{hasSession && (
<Link
testID="viewHeaderHomeFeedPrefsBtn"
href="/settings/following-feed"
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Following Feed Preferences`)}
accessibilityHint="">
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
)}
</View>
</View>
{children}
</Animated.View>
@@ -111,7 +47,7 @@ const styles = StyleSheet.create({
},
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
justifyContent: 'space-around',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 8,
+27 -5
View File
@@ -1,11 +1,16 @@
import React, {useRef, useMemo, useEffect, useState, useCallback} from 'react'
import {StyleSheet, View, ScrollView, LayoutChangeEvent} from 'react-native'
import {Text} from '../util/text/Text'
import {PressableWithHover} from '../util/PressableWithHover'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
import {isNative} from '#/platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useDialogControl} from '#/components/Dialog'
import {MyFeedsDialog} from '#/components/dialogs/MyFeeds'
// import {ArrowTriangleBottom_Stroke2_Corner1_Rounded as ArrowTriangleBottom} from '#/components/icons/ArrowTriangle'
import {ChevronBottom_Stroke2_Corner0_Rounded as ArrowTriangleBottom} from '#/components/icons/Chevron'
import {PressableWithHover} from '../util/PressableWithHover'
import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
import {isNative} from '#/platform/detection'
export interface TabBarProps {
testID?: string
@@ -36,6 +41,7 @@ export function TabBar({
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
[indicatorColor, pal],
)
const myFeedsControl = useDialogControl()
const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
@@ -147,6 +153,14 @@ export function TabBar({
)
})}
</DraggableScrollView>
<PressableWithHover
testID="feedsDropdownBtn"
style={styles.dropdownBtn}
hoverStyle={pal.viewLight}
onPress={() => myFeedsControl.open()}>
<ArrowTriangleBottom fill={pal.textLight.color} size="sm" />
</PressableWithHover>
<MyFeedsDialog control={myFeedsControl} />
<View style={[pal.border, styles.outerBottomBorder]} />
</View>
)
@@ -178,6 +192,10 @@ const desktopStyles = StyleSheet.create({
bottom: -1,
borderBottomWidth: 1,
},
dropdownBtn: {
paddingTop: 14,
paddingHorizontal: 14,
},
})
const mobileStyles = StyleSheet.create({
@@ -205,4 +223,8 @@ const mobileStyles = StyleSheet.create({
bottom: -1,
borderBottomWidth: 1,
},
dropdownBtn: {
paddingTop: 12,
paddingHorizontal: 14,
},
})
+20 -39
View File
@@ -1,24 +1,24 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {emitSoftReset} from '#/state/events'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {BACK_HITSLOP} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {CenteredView} from '../util/Views'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {BACK_HITSLOP} from 'lib/constants'
import {sanitizeHandle} from 'lib/strings/handles'
import {isNative} from 'platform/detection'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useSetDrawerOpen} from '#/state/shell'
import {emitSoftReset} from '#/state/events'
import {TextLink} from '../util/Link'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
import {CenteredView} from '../util/Views'
export function ProfileSubpageHeader({
isLoading,
@@ -43,13 +43,11 @@ export function ProfileSubpageHeader({
| undefined
avatarType: UserAvatarType
}>) {
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const {openLightbox} = useLightboxControls()
const pal = usePalette('default')
const canGoBack = navigation.canGoBack()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
@@ -59,10 +57,6 @@ export function ProfileSubpageHeader({
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
setDrawerOpen(true)
}, [setDrawerOpen])
const onPressAvi = React.useCallback(() => {
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
@@ -88,25 +82,17 @@ export function ProfileSubpageHeader({
]}>
<Pressable
testID="headerDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
style={styles.backBtn}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityLabel={'Back'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
</Pressable>
<View style={{flex: 1}} />
{children}
@@ -189,11 +175,6 @@ const styles = StyleSheet.create({
width: 20,
height: 30,
},
backBtnWide: {
width: 20,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
+11 -27
View File
@@ -8,13 +8,12 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {CenteredView} from './Views'
import {isWeb} from '#/platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {isWeb} from '#/platform/detection'
import {CenteredView} from './Views'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -27,9 +26,7 @@ export function SimpleViewHeader({
style?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isMobile} = useWebMediaQueries()
const canGoBack = navigation.canGoBack()
@@ -41,11 +38,6 @@ export function SimpleViewHeader({
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const Container = isMobile ? View : CenteredView
return (
<Container
@@ -56,28 +48,20 @@ export function SimpleViewHeader({
pal.view,
style,
]}>
{showBackButton ? (
{showBackButton && canGoBack ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityLabel={'Back'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
</TouchableOpacity>
) : null}
{children}
+23 -37
View File
@@ -6,8 +6,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -39,10 +37,8 @@ export function ViewHeader({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isDesktop, isTablet} = useWebMediaQueries()
const {isDesktop} = useWebMediaQueries()
const t = useTheme()
const onPressBack = React.useCallback(() => {
@@ -53,11 +49,6 @@ export function ViewHeader({
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
if (isDesktop) {
if (showOnDesktop) {
return (
@@ -75,37 +66,37 @@ export function ViewHeader({
canGoBack = navigation.canGoBack()
}
const showBackButtonAndCan = showBackButton && canGoBack
return (
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
<View style={{flex: 1}}>
<View style={{flexDirection: 'row', alignItems: 'center'}}>
{showBackButton ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}>
{showBackButtonAndCan ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
style={styles.backBtn}
accessibilityRole="button"
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
accessibilityHint={
canGoBack ? '' : _(msg`Access navigation links and settings`)
}>
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : !isTablet ? (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
) : null}
accessibilityHint="">
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
</TouchableOpacity>
) : null}
<View style={styles.titleContainer} pointerEvents="none">
<View
style={[
styles.titleContainer,
!showBackButtonAndCan && {marginLeft: 0},
]}
pointerEvents="none">
<Text type="title" style={[pal.text, styles.title]}>
{title}
</Text>
@@ -113,7 +104,7 @@ export function ViewHeader({
{renderButton ? (
renderButton()
) : showBackButton ? (
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
<View style={styles.backBtn} />
) : null}
</View>
{subtitle ? (
@@ -265,11 +256,6 @@ const styles = StyleSheet.create({
width: 30,
height: 30,
},
backBtnWide: {
width: 30,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
+3 -13
View File
@@ -13,11 +13,7 @@ import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
import {
useMinimalShellMode,
useSetDrawerSwipeDisabled,
useSetMinimalShellMode,
} from '#/state/shell'
import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
@@ -94,15 +90,10 @@ function HomeScreenReady({
const {hasSession} = useSession()
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(selectedIndex > 0)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
}, [setMinimalShellMode]),
)
useFocusEffect(
@@ -139,12 +130,11 @@ function HomeScreenReady({
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
const feed = allFeeds[index]
setSelectedFeed(feed)
lastPagerReportedIndexRef.current = index
},
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
[setSelectedFeed, setMinimalShellMode, allFeeds],
)
const onPageSelecting = React.useCallback(
+1 -11
View File
@@ -19,7 +19,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {getAgent, useSession} from '#/state/session'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
import {useSetTitle} from 'lib/hooks/useSetTitle'
@@ -154,7 +154,6 @@ function ProfileScreenLoaded({
})
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null)
@@ -280,15 +279,6 @@ function ProfileScreenLoaded({
}, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]),
)
useFocusEffect(
React.useCallback(() => {
setDrawerSwipeDisabled(currentPage > 0)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, currentPage]),
)
// events
// =
+5 -183
View File
@@ -17,14 +17,11 @@ import {useLingui} from '@lingui/react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics'
import {HITSLOP_10} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
@@ -32,13 +29,8 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search'
import {useModerationOpts} from '#/state/queries/preferences'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {
useGetSuggestedFollowersByActor,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSetMinimalShellMode} from '#/state/shell'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {
NativeStackScreenProps,
@@ -57,8 +49,8 @@ import {
SearchLinkCard,
SearchProfileCard,
} from '#/view/shell/desktop/Search'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {atoms as a} from '#/alf'
import {Suggestions} from './Suggestions'
function Loader() {
const pal = usePalette('default')
@@ -121,124 +113,6 @@ function EmptyState({message, error}: {message: string; error?: string}) {
)
}
function useSuggestedFollowsV1(): [
AppBskyActorDefs.ProfileViewBasic[],
() => void,
] {
const {currentAccount} = useSession()
const [suggestions, setSuggestions] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
React.useEffect(() => {
async function getSuggestions() {
const friends = await getSuggestedFollowsByActor(
currentAccount!.did,
).then(friendsRes => friendsRes.suggestions)
if (!friends) return // :(
const friendsOfFriends = new Map<
string,
AppBskyActorDefs.ProfileViewBasic
>()
await Promise.all(
friends.slice(0, 4).map(friend =>
getSuggestedFollowsByActor(friend.did).then(foafsRes => {
for (const user of foafsRes.suggestions) {
if (user.associated?.labeler) continue
friendsOfFriends.set(user.did, user)
}
}),
),
)
setSuggestions(Array.from(friendsOfFriends.values()))
}
try {
getSuggestions()
} catch (e) {
logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
message: e,
})
}
}, [currentAccount, setSuggestions, getSuggestedFollowsByActor])
return [suggestions, () => {}]
}
function useSuggestedFollowsV2(): [
AppBskyActorDefs.ProfileViewBasic[],
() => void,
] {
const {
data: suggestions,
hasNextPage,
isFetchingNextPage,
isError,
fetchNextPage,
} = useSuggestedFollowsQuery()
const onEndReached = React.useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const items: AppBskyActorDefs.ProfileViewBasic[] = []
if (suggestions) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of suggestions.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
items.push(actor)
}
}
}
}
return [items, onEndReached]
}
function SearchScreenSuggestedFollows() {
const pal = usePalette('default')
const gate = useGate()
const useSuggestedFollows = gate('use_new_suggestions_endpoint')
? // Conditional hook call here is *only* OK because useGate()
// result won't change until a remount.
useSuggestedFollowsV2
: useSuggestedFollowsV1
const [suggestions, onEndReached] = useSuggestedFollows()
return suggestions.length ? (
<List
data={suggestions}
renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onEndReached={onEndReached}
onEndReachedThreshold={2}
/>
) : (
<CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
<ProfileCardFeedLoadingPlaceholder />
<ProfileCardFeedLoadingPlaceholder />
</CenteredView>
)
}
type SearchResultSlice =
| {
type: 'post'
@@ -396,7 +270,6 @@ function SearchScreenUserResults({
export function SearchScreenInner({query}: {query?: string}) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const {hasSession} = useSession()
const {isDesktop} = useWebMediaQueries()
const [activeTab, setActiveTab] = React.useState(0)
@@ -405,10 +278,9 @@ export function SearchScreenInner({query}: {query?: string}) {
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
setActiveTab(index)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
[setMinimalShellMode],
)
const sections = React.useMemo(() => {
@@ -460,24 +332,7 @@ export function SearchScreenInner({query}: {query?: string}) {
</Pager>
) : hasSession ? (
<View>
<CenteredView sideBorders style={pal.border}>
<Text
type="title"
style={[
pal.text,
pal.border,
{
display: 'flex',
paddingVertical: 12,
paddingHorizontal: 18,
fontWeight: 'bold',
},
]}>
<Trans>Suggested Follows</Trans>
</Text>
</CenteredView>
<SearchScreenSuggestedFollows />
<Suggestions />
</View>
) : (
<CenteredView sideBorders style={pal.border}>
@@ -534,12 +389,10 @@ export function SearchScreen(
const textInput = React.useRef<TextInput>(null)
const {_} = useLingui()
const pal = usePalette('default')
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const moderationOpts = useModerationOpts()
const search = useActorAutocompleteFn()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
const {isTabletOrDesktop} = useWebMediaQueries()
const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
undefined,
@@ -590,11 +443,6 @@ export function SearchScreen(
loadSearchHistory()
}, [])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
@@ -716,23 +564,6 @@ export function SearchScreen(
isTabletOrDesktop && {paddingTop: 10},
]}
sideBorders={isTabletOrDesktop}>
{isTabletOrMobile && (
<Pressable
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={HITSLOP_10}
style={styles.headerMenuBtn}
accessibilityRole="button"
accessibilityLabel={_(msg`Menu`)}
accessibilityHint={_(msg`Access navigation links and settings`)}>
<FontAwesomeIcon
icon="bars"
size={18}
color={pal.colors.textLight}
/>
</Pressable>
)}
<View
style={[
{backgroundColor: pal.colors.backgroundLight},
@@ -914,15 +745,6 @@ const styles = StyleSheet.create({
top: 0,
zIndex: 1,
},
headerMenuBtn: {
width: 30,
height: 30,
borderRadius: 30,
marginRight: 6,
paddingBottom: 2,
alignItems: 'center',
justifyContent: 'center',
},
headerSearchContainer: {
flex: 1,
flexDirection: 'row',
+378
View File
@@ -0,0 +1,378 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {
useGetSuggestedFollowersByActor,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {cleanError} from 'lib/strings/errors'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {List} from 'view/com/util/List'
import {
FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {IconCircle} from '#/components/IconCircle'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
import {Person_Stroke2_Corner0_Rounded} from '#/components/icons/Person'
type FlatlistItem =
| {
type: 'error'
key: string
error: string
}
| {type: 'suggestedFollowsHeader'; key: string}
| {type: 'suggestedFollowsLoading'; key: string}
| {
type: 'suggestedFollow'
key: string
profile: AppBskyActorDefs.ProfileViewBasic
}
| {
type: 'popularFeedsHeader'
key: string
}
| {
type: 'popularFeedsLoading'
key: string
}
| {
type: 'popularFeedsNoResults'
key: string
}
| {
type: 'popularFeed'
key: string
feedUri: string
}
// HACK
// the protocol doesn't yet tell us which feeds are personalized
// this list is used to filter out feed recommendations from logged out users
// for the ones we know need it
// -prf
const KNOWN_AUTHED_ONLY_FEEDS = [
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
]
export function Suggestions() {
const gate = useGate()
const useSuggestedFollows = gate('use_new_suggestions_endpoint')
? // Conditional hook call here is *only* OK because useGate()
// result won't change until a remount.
useSuggestedFollowsV2
: useSuggestedFollowsV1
const [isPTR, setIsPTR] = React.useState(false)
const {
data: popularFeeds,
isFetching: isPopularFeedsFetching,
error: popularFeedsError,
refetch: refetchPopularFeeds,
} = useGetPopularFeedsQuery()
const [suggestedFollows, refetchSuggestedFollows] = useSuggestedFollows()
const {hasSession} = useSession()
/**
* A search query is present. We may not have search results yet.
*/
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
await Promise.all([
refetchPopularFeeds().catch(_e => undefined),
refetchSuggestedFollows().catch(_e => undefined),
])
setIsPTR(false)
}, [setIsPTR, refetchPopularFeeds, refetchSuggestedFollows])
const items = React.useMemo(() => {
let items: FlatlistItem[] = []
items.push({
key: 'suggestedFollowsHeader',
type: 'suggestedFollowsHeader',
})
if (!suggestedFollows.length) {
items.push({
key: 'suggestedFollowsLoading',
type: 'suggestedFollowsLoading',
})
} else {
items = items.concat(
suggestedFollows.map(follow => ({
key: `suggestedFollow:${follow.did}`,
type: 'suggestedFollow',
profile: follow,
})),
)
}
items.push({
key: 'popularFeedsHeader',
type: 'popularFeedsHeader',
})
if (popularFeedsError) {
items.push({
key: 'popularFeedsError',
type: 'error',
error: cleanError(popularFeedsError?.toString()),
})
} else {
if (isPopularFeedsFetching && !popularFeeds?.pages) {
items.push({
key: 'popularFeedsLoading',
type: 'popularFeedsLoading',
})
} else {
if (
!popularFeeds?.pages ||
popularFeeds?.pages[0]?.feeds?.length === 0
) {
items.push({
key: 'popularFeedsNoResults',
type: 'popularFeedsNoResults',
})
} else {
for (const page of popularFeeds.pages || []) {
items = items.concat(
page.feeds
.filter(feed => {
if (
!hasSession &&
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
) {
return false
}
return true
})
.map(feed => ({
key: `popularFeed:${feed.uri}`,
type: 'popularFeed',
feedUri: feed.uri,
})),
)
}
}
}
}
return items
}, [
hasSession,
suggestedFollows,
popularFeeds,
isPopularFeedsFetching,
popularFeedsError,
])
const renderItem = React.useCallback(
({item}: {item: FlatlistItem}) => {
if (item.type === 'error') {
return <ErrorMessage message={item.error} />
} else if (item.type === 'suggestedFollowsHeader') {
return <SuggestedFollowsHeader />
} else if (item.type === 'suggestedFollowsLoading') {
return <ProfileCardFeedLoadingPlaceholder />
} else if (item.type === 'suggestedFollow') {
return <ProfileCardWithFollowBtn profile={item.profile} noBg />
} else if (item.type === 'popularFeedsHeader') {
return <SuggestedFeedsHeader />
} else if (item.type === 'popularFeedsLoading') {
return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') {
return (
<FeedSourceCard
feedUri={item.feedUri}
showSaveBtn={hasSession}
showDescription
showLikes
pinOnSave
/>
)
}
return null
},
[hasSession],
)
return (
<List
data={items}
keyExtractor={item => item.key}
contentContainerStyle={{paddingBottom: 200}}
renderItem={renderItem}
refreshing={isPTR}
onRefresh={onPullToRefresh}
initialNumToRender={10}
// @ts-ignore our .web version only -prf
desktopFixedHeight
scrollIndicatorInsets={{right: 1}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
)
}
function SuggestedFollowsHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
}>
<IconCircle icon={Person_Stroke2_Corner0_Rounded} size="lg" />
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Suggested Accounts</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>
Follow more accounts to get connected to your interests and build
your network.
</Trans>
</Text>
</View>
</View>
)
}
function SuggestedFeedsHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [
a.flex_row,
a.px_md,
a.pt_lg,
a.pb_lg,
a.gap_md,
a.border_t,
t.atoms.border_contrast_low,
a.mt_2xl,
]
: [
{flexDirection: 'row-reverse'},
a.p_lg,
a.gap_md,
a.border_t,
t.atoms.border_contrast_low,
a.mt_2xl,
]
}>
<IconCircle
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
size="lg"
/>
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Discover New Feeds</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>
Custom feeds built by the community bring you new experiences and
help you find the content you love.
</Trans>
</Text>
</View>
</View>
)
}
function useSuggestedFollowsV1(): [
AppBskyActorDefs.ProfileViewBasic[],
() => void,
] {
const {currentAccount} = useSession()
const [suggestions, setSuggestions] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
React.useEffect(() => {
async function getSuggestions() {
const friends = await getSuggestedFollowsByActor(
currentAccount!.did,
).then(friendsRes => friendsRes.suggestions)
if (!friends) return // :(
const friendsOfFriends = new Map<
string,
AppBskyActorDefs.ProfileViewBasic
>()
await Promise.all(
friends.slice(0, 4).map(friend =>
getSuggestedFollowsByActor(friend.did).then(foafsRes => {
for (const user of foafsRes.suggestions) {
if (user.associated?.labeler) continue
friendsOfFriends.set(user.did, user)
}
}),
),
)
setSuggestions(Array.from(friendsOfFriends.values()))
}
try {
getSuggestions()
} catch (e) {
logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
message: e,
})
}
}, [currentAccount, setSuggestions, getSuggestedFollowsByActor])
return [suggestions, async () => {}]
}
function useSuggestedFollowsV2(): [
AppBskyActorDefs.ProfileViewBasic[],
() => void,
] {
const {data: suggestions, refetch} = useSuggestedFollowsQuery()
const items: AppBskyActorDefs.ProfileViewBasic[] = []
if (suggestions) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of suggestions.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
items.push(actor)
}
}
}
}
return [items, refetch]
}
+56 -53
View File
@@ -316,48 +316,51 @@ export function SettingsScreen({}: Props) {
// @ts-ignore web only -prf
dataSet={{'stable-gutters': 1}}>
<View style={styles.spacer20} />
{currentAccount ? (
<>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Account</Trans>
</Text>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Email:</Trans>{' '}
</Text>
{currentAccount.emailConfirmed && (
<>
<FontAwesomeIcon
icon="check"
size={10}
style={{color: colors.green3, marginRight: 2}}
/>
</>
)}
<Text
type="lg"
numberOfLines={1}
style={[
pal.text,
{overflow: 'hidden', marginRight: 4, flex: 1},
]}>
{currentAccount.email || '(no email)'}
</Text>
<Link onPress={() => openModal({name: 'change-email'})}>
<Text type="lg" style={pal.link}>
<Trans context="action">Change</Trans>
<View style={[pal.view, {paddingBottom: 8, paddingTop: 16}]}>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Email:</Trans>{' '}
</Text>
</Link>
</View>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Birthday:</Trans>{' '}
</Text>
<Link onPress={onPressBirthday}>
<Text type="lg" style={pal.link}>
<Trans>Show</Trans>
{currentAccount.emailConfirmed && (
<>
<FontAwesomeIcon
icon="check"
size={10}
style={{color: colors.green3, marginRight: 2}}
/>
</>
)}
<Text
type="lg"
numberOfLines={1}
style={[
pal.text,
{overflow: 'hidden', marginRight: 4, flex: 1},
]}>
{currentAccount.email || '(no email)'}
</Text>
</Link>
<Link onPress={() => openModal({name: 'change-email'})}>
<Text type="lg" style={pal.link}>
<Trans context="action">Change</Trans>
</Text>
</Link>
</View>
<View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}>
<Trans>Birthday:</Trans>{' '}
</Text>
<Link onPress={onPressBirthday}>
<Text type="lg" style={pal.link}>
<Trans>Show</Trans>
</Text>
</Link>
</View>
</View>
<View style={styles.spacer20} />
@@ -535,24 +538,6 @@ export function SettingsScreen({}: Props) {
<Trans>Thread Preferences</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="savedFeedsBtn"
style={[
styles.linkCard,
pal.view,
isSwitchingAccounts && styles.dimmed,
]}
onPress={onPressSavedFeeds}
accessibilityRole="button"
accessibilityLabel={_(msg`My saved feeds`)}
accessibilityHint={_(msg`Opens screen with all saved feeds`)}>
<View style={[styles.iconContainer, pal.btn]}>
<HashtagIcon style={pal.text} size={18} strokeWidth={3} />
</View>
<Text type="lg" style={pal.text}>
<Trans>My Saved Feeds</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="languageSettingsBtn"
style={[
@@ -596,6 +581,24 @@ export function SettingsScreen({}: Props) {
<Trans>Moderation</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="savedFeedsBtn"
style={[
styles.linkCard,
pal.view,
isSwitchingAccounts && styles.dimmed,
]}
onPress={onPressSavedFeeds}
accessibilityRole="button"
accessibilityLabel={_(msg`My saved feeds`)}
accessibilityHint={_(msg`Opens screen with all saved feeds`)}>
<View style={[styles.iconContainer, pal.btn]}>
<HashtagIcon style={pal.text} size={18} strokeWidth={3} />
</View>
<Text type="lg" style={pal.text}>
<Trans>My Saved Feeds</Trans>
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
-731
View File
@@ -1,731 +0,0 @@
import React, {ComponentProps} from 'react'
import {
Linking,
SafeAreaView,
ScrollView,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {emitSoftReset} from '#/state/events'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile'
import {SessionAccount, useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
import {usePalette} from 'lib/hooks/usePalette'
import {
BellIcon,
BellIconSolid,
CogIcon,
HandIcon,
HashtagIcon,
HomeIcon,
HomeIconSolid,
ListIcon,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
UserIcon,
UserIconSolid,
} from 'lib/icons'
import {getTabState, TabState} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {colors, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection'
import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {formatCountShortOnly} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useTheme as useAlfTheme} from '#/alf'
import {TextLink} from '../com/util/Link'
let DrawerProfileCard = ({
account,
onPressProfile,
}: {
account: SessionAccount
onPressProfile: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
const {data: profile} = useProfileQuery({did: account.did})
return (
<TouchableOpacity
testID="profileCardButton"
accessibilityLabel={_(msg`Profile`)}
accessibilityHint={_(msg`Navigates to your profile`)}
onPress={onPressProfile}>
<UserAvatar
size={80}
avatar={profile?.avatar}
// See https://github.com/bluesky-social/social-app/pull/1801:
usePlainRNImage={true}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<Text
type="title-lg"
style={[pal.text, s.bold, styles.profileCardDisplayName]}
numberOfLines={1}>
{profile?.displayName || account.handle}
</Text>
<Text
type="2xl"
style={[pal.textLight, styles.profileCardHandle]}
numberOfLines={1}>
@{account.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)}
</Text>{' '}
{pluralize(profile?.followersCount || 0, 'follower')} &middot;{' '}
<Trans>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followsCount ?? 0)}
</Text>{' '}
following
</Trans>
</Text>
</TouchableOpacity>
)
}
DrawerProfileCard = React.memo(DrawerProfileCard)
export {DrawerProfileCard}
let DrawerContent = ({}: {}): React.ReactNode => {
const theme = useTheme()
const t = useAlfTheme()
const pal = usePalette('default')
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {hasSession, currentAccount} = useSession()
// events
// =
const onPressTab = React.useCallback(
(tab: string) => {
track('Menu:ItemClicked', {url: tab})
const state = navigation.getState()
setDrawerOpen(false)
if (isWeb) {
// hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh
if (tab === 'MyProfile') {
navigation.navigate('Profile', {name: currentAccount!.handle})
} else {
// @ts-ignore must be Home, Search, Notifications, or MyProfile
navigation.navigate(tab)
}
} else {
const tabState = getTabState(state, tab)
if (tabState === TabState.InsideAtRoot) {
emitSoftReset()
} else if (tabState === TabState.Inside) {
navigation.dispatch(StackActions.popToTop())
} else {
// @ts-ignore must be Home, Search, Notifications, or MyProfile
navigation.navigate(`${tab}Tab`)
}
}
},
[track, navigation, setDrawerOpen, currentAccount],
)
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(() => {
onPressTab('MyProfile')
}, [onPressTab])
const onPressMyFeeds = React.useCallback(
() => onPressTab('Feeds'),
[onPressTab],
)
const onPressLists = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Lists'})
navigation.navigate('Lists')
setDrawerOpen(false)
}, [navigation, track, setDrawerOpen])
const onPressModeration = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Moderation'})
navigation.navigate('Moderation')
setDrawerOpen(false)
}, [navigation, track, setDrawerOpen])
const onPressSettings = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Settings'})
navigation.navigate('Settings')
setDrawerOpen(false)
}, [navigation, track, setDrawerOpen])
const onPressFeedback = React.useCallback(() => {
track('Menu:FeedbackClicked')
Linking.openURL(
FEEDBACK_FORM_URL({
email: currentAccount?.email,
handle: currentAccount?.handle,
}),
)
}, [track, currentAccount])
const onPressHelp = React.useCallback(() => {
track('Menu:HelpClicked')
Linking.openURL(HELP_DESK_URL)
}, [track])
// rendering
// =
return (
<View
testID="drawer"
style={[
styles.view,
theme.colorScheme === 'light' ? pal.view : t.atoms.bg_contrast_25,
]}>
<SafeAreaView style={s.flex1}>
<ScrollView style={styles.main}>
{hasSession && currentAccount ? (
<View style={{}}>
<DrawerProfileCard
account={currentAccount}
onPressProfile={onPressProfile}
/>
</View>
) : (
<View style={{paddingRight: 20}}>
<NavSignupCard />
</View>
)}
{hasSession ? (
<>
<View style={{height: 16}} />
<SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
<HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
<NotificationsMenuItem
isActive={isAtNotifications}
onPress={onPressNotifications}
/>
<FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
<ListsMenuItem onPress={onPressLists} />
<ModerationMenuItem onPress={onPressModeration} />
<ProfileMenuItem
isActive={isAtMyProfile}
onPress={onPressProfile}
/>
<SettingsMenuItem onPress={onPressSettings} />
</>
) : (
<>
<HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
<FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
<SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
</>
)}
<View style={styles.smallSpacer} />
<View style={[{flexWrap: 'wrap', gap: 12}, s.flexCol]}>
<TextLink
type="md"
style={pal.link}
href="https://bsky.social/about/support/tos"
text={_(msg`Terms of Service`)}
/>
<TextLink
type="md"
style={pal.link}
href="https://bsky.social/about/support/privacy-policy"
text={_(msg`Privacy Policy`)}
/>
</View>
<View style={styles.smallSpacer} />
<View style={styles.smallSpacer} />
</ScrollView>
<DrawerFooter
onPressFeedback={onPressFeedback}
onPressHelp={onPressHelp}
/>
</SafeAreaView>
</View>
)
}
DrawerContent = React.memo(DrawerContent)
export {DrawerContent}
let DrawerFooter = ({
onPressFeedback,
onPressHelp,
}: {
onPressFeedback: () => void
onPressHelp: () => void
}): React.ReactNode => {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
return (
<View style={styles.footer}>
<TouchableOpacity
accessibilityRole="link"
accessibilityLabel={_(msg`Send feedback`)}
accessibilityHint=""
onPress={onPressFeedback}
style={[
styles.footerBtn,
styles.footerBtnFeedback,
theme.colorScheme === 'light'
? styles.footerBtnFeedbackLight
: styles.footerBtnFeedbackDark,
]}>
<FontAwesomeIcon
style={pal.link as FontAwesomeIconStyle}
size={18}
icon={['far', 'message']}
/>
<Text type="lg-medium" style={[pal.link, s.pl10]}>
<Trans>Feedback</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="link"
accessibilityLabel={_(msg`Send feedback`)}
accessibilityHint=""
onPress={onPressHelp}
style={[styles.footerBtn]}>
<Text type="lg-medium" style={[pal.link, s.pl10]}>
<Trans>Help</Trans>
</Text>
</TouchableOpacity>
</View>
)
}
DrawerFooter = React.memo(DrawerFooter)
interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> {
icon: JSX.Element
label: string
count?: string
bold?: boolean
}
let SearchMenuItem = ({
isActive,
onPress,
}: {
isActive: boolean
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={
isActive ? (
<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={_(msg`Search`)}
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
bold={isActive}
onPress={onPress}
/>
)
}
SearchMenuItem = React.memo(SearchMenuItem)
let HomeMenuItem = ({
isActive,
onPress,
}: {
isActive: boolean
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={
isActive ? (
<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={_(msg`Home`)}
accessibilityLabel={_(msg`Home`)}
accessibilityHint=""
bold={isActive}
onPress={onPress}
/>
)
}
HomeMenuItem = React.memo(HomeMenuItem)
let NotificationsMenuItem = ({
isActive,
onPress,
}: {
isActive: boolean
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
const numUnreadNotifications = useUnreadNotifications()
return (
<MenuItem
icon={
isActive ? (
<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={_(msg`Notifications`)}
accessibilityLabel={_(msg`Notifications`)}
accessibilityHint={
numUnreadNotifications === ''
? ''
: _(msg`${numUnreadNotifications} unread`)
}
count={numUnreadNotifications}
bold={isActive}
onPress={onPress}
/>
)
}
NotificationsMenuItem = React.memo(NotificationsMenuItem)
let FeedsMenuItem = ({
isActive,
onPress,
}: {
isActive: boolean
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={
isActive ? (
<HashtagIcon
strokeWidth={3}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
) : (
<HashtagIcon
strokeWidth={2}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
)
}
label={_(msg`Feeds`)}
accessibilityLabel={_(msg`Feeds`)}
accessibilityHint=""
bold={isActive}
onPress={onPress}
/>
)
}
FeedsMenuItem = React.memo(FeedsMenuItem)
let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />}
label={_(msg`Lists`)}
accessibilityLabel={_(msg`Lists`)}
accessibilityHint=""
onPress={onPress}
/>
)
}
ListsMenuItem = React.memo(ListsMenuItem)
let ModerationMenuItem = ({
onPress,
}: {
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />}
label={_(msg`Moderation`)}
accessibilityLabel={_(msg`Moderation`)}
accessibilityHint=""
onPress={onPress}
/>
)
}
ModerationMenuItem = React.memo(ModerationMenuItem)
let ProfileMenuItem = ({
isActive,
onPress,
}: {
isActive: boolean
onPress: () => void
}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={
isActive ? (
<UserIconSolid
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.5}
/>
) : (
<UserIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.5}
/>
)
}
label={_(msg`Profile`)}
accessibilityLabel={_(msg`Profile`)}
accessibilityHint=""
onPress={onPress}
/>
)
}
ProfileMenuItem = React.memo(ProfileMenuItem)
let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
const {_} = useLingui()
const pal = usePalette('default')
return (
<MenuItem
icon={
<CogIcon
style={pal.text as StyleProp<ViewStyle>}
size="26"
strokeWidth={1.75}
/>
}
label={_(msg`Settings`)}
accessibilityLabel={_(msg`Settings`)}
accessibilityHint=""
onPress={onPress}
/>
)
}
SettingsMenuItem = React.memo(SettingsMenuItem)
function MenuItem({
icon,
label,
accessibilityLabel,
count,
bold,
onPress,
}: MenuItemProps) {
const pal = usePalette('default')
return (
<TouchableOpacity
testID={`menuItemButton-${label}`}
style={styles.menuItem}
onPress={onPress}
accessibilityRole="tab"
accessibilityLabel={accessibilityLabel}
accessibilityHint="">
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View
style={[
styles.menuItemCount,
count.length > 2
? styles.menuItemCountHundreds
: count.length > 1
? styles.menuItemCountTens
: undefined,
]}>
<Text style={styles.menuItemCountLabel} numberOfLines={1}>
{count}
</Text>
</View>
) : undefined}
</View>
<Text
type={bold ? '2xl-bold' : '2xl'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
view: {
flex: 1,
paddingBottom: 50,
maxWidth: 300,
},
viewDarkMode: {
backgroundColor: '#1B1919',
},
main: {
paddingLeft: 20,
paddingTop: 20,
},
smallSpacer: {
height: 20,
},
profileCardDisplayName: {
marginTop: 20,
paddingRight: 30,
},
profileCardHandle: {
marginTop: 4,
paddingRight: 30,
},
profileCardFollowers: {
marginTop: 16,
paddingRight: 10,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingRight: 10,
},
menuItemIconWrapper: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemCount: {
position: 'absolute',
width: 'auto',
right: -6,
top: -4,
backgroundColor: colors.blue3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 6,
},
menuItemCountTens: {
width: 25,
},
menuItemCountHundreds: {
right: -12,
width: 34,
},
menuItemCountLabel: {
fontSize: 12,
fontWeight: 'bold',
fontVariant: ['tabular-nums'],
color: colors.white,
},
inviteCodes: {
paddingLeft: 0,
paddingVertical: 8,
flexDirection: 'row',
},
inviteCodesIcon: {
marginRight: 6,
flexShrink: 0,
marginTop: 2,
},
footer: {
flexWrap: 'wrap',
flexDirection: 'row',
gap: 8,
paddingRight: 20,
paddingTop: 20,
paddingLeft: 20,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderRadius: 25,
},
footerBtnFeedback: {
paddingHorizontal: 20,
},
footerBtnFeedbackLight: {
backgroundColor: '#DDEFFF',
},
footerBtnFeedbackDark: {
backgroundColor: colors.blue6,
},
})
+5 -13
View File
@@ -8,7 +8,6 @@ import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
import {StackActions} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useHaptics} from '#/lib/haptics'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useMinimalShellMode} from '#/lib/hooks/useMinimalShellMode'
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
@@ -38,8 +37,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {styles} from './BottomBarStyles'
import {ProfileMenuDialog} from '../profile-menu'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
@@ -58,8 +57,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const dedupe = useDedupe()
const accountSwitchControl = useDialogControl()
const playHaptic = useHaptics()
const profileMenuControl = useDialogControl()
const showSignIn = React.useCallback(() => {
closeAllActiveElements()
@@ -101,17 +99,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
[onPressTab],
)
const onPressProfile = React.useCallback(() => {
onPressTab('MyProfile')
}, [onPressTab])
const onLongPressProfile = React.useCallback(() => {
playHaptic()
accountSwitchControl.open()
}, [accountSwitchControl, playHaptic])
profileMenuControl.open()
}, [profileMenuControl])
return (
<>
<SwitchAccountDialog control={accountSwitchControl} />
<ProfileMenuDialog control={profileMenuControl} onPressTab={onPressTab} />
<Animated.View
style={[
@@ -256,7 +249,6 @@ export function BottomBar({navigation}: BottomTabBarProps) {
</View>
}
onPress={onPressProfile}
onLongPress={onLongPressProfile}
accessibilityRole="tab"
accessibilityLabel={_(msg`Profile`)}
accessibilityHint=""
+2 -32
View File
@@ -6,23 +6,15 @@ import {
useWindowDimensions,
View,
} from 'react-native'
import {Drawer} from 'react-native-drawer-layout'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar'
import {useNavigationState} from '@react-navigation/native'
import {useSession} from '#/state/session'
import {
useIsDrawerOpen,
useIsDrawerSwipeDisabled,
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
import {usePalette} from 'lib/hooks/usePalette'
import * as notifications from 'lib/notifications/notifications'
import {isStateAtTabRoot} from 'lib/routes/helpers'
import {useTheme} from 'lib/ThemeContext'
import {isAndroid} from 'platform/detection'
import {useDialogStateContext} from 'state/dialogs'
@@ -34,29 +26,15 @@ import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {Composer} from './Composer'
import {DrawerContent} from './Drawer'
function ShellInner() {
const isDrawerOpen = useIsDrawerOpen()
const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled()
const setIsDrawerOpen = useSetDrawerOpen()
const winDim = useWindowDimensions()
const safeAreaInsets = useSafeAreaInsets()
const containerPadding = React.useMemo(
() => ({height: '100%' as DimensionValue, paddingTop: safeAreaInsets.top}),
[safeAreaInsets],
)
const renderDrawerContent = React.useCallback(() => <DrawerContent />, [])
const onOpenDrawer = React.useCallback(
() => setIsDrawerOpen(true),
[setIsDrawerOpen],
)
const onCloseDrawer = React.useCallback(
() => setIsDrawerOpen(false),
[setIsDrawerOpen],
)
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession, currentAccount} = useSession()
const {currentAccount} = useSession()
const closeAnyActiveElement = useCloseAnyActiveElement()
const {importantForAccessibility} = useDialogStateContext()
// start undefined
@@ -90,15 +68,7 @@ function ShellInner() {
style={containerPadding}
importantForAccessibility={importantForAccessibility}>
<ErrorBoundary>
<Drawer
renderDrawerContent={renderDrawerContent}
open={isDrawerOpen}
onOpen={onOpenDrawer}
onClose={onCloseDrawer}
swipeEdgeWidth={winDim.width / 2}
swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}>
<TabsNavigator />
</Drawer>
<TabsNavigator />
</ErrorBoundary>
</Animated.View>
<Composer winHeight={winDim.height} />
+1 -48
View File
@@ -1,11 +1,7 @@
import React, {useEffect} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useCloseAllActiveElements} from '#/state/util'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {NavigationProp} from 'lib/routes/types'
@@ -13,23 +9,15 @@ import {colors, s} from 'lib/styles'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
import {FlatNavigator, RoutesContainer} from '../../Navigation'
import {Lightbox} from '../com/lightbox/Lightbox'
import {ModalsContainer} from '../com/modals/Modal'
import {ErrorBoundary} from '../com/util/ErrorBoundary'
import {Composer} from './Composer.web'
import {DrawerContent} from './Drawer'
function ShellInner() {
const isDrawerOpen = useIsDrawerOpen()
const setDrawerOpen = useSetDrawerOpen()
const {isDesktop} = useWebMediaQueries()
const navigator = useNavigation<NavigationProp>()
const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui()
useWebBodyScrollLock(isDrawerOpen)
useEffect(() => {
const unsubscribe = navigator.addListener('state', () => {
@@ -49,24 +37,6 @@ function ShellInner() {
<SigninDialog />
<Lightbox />
<PortalOutlet />
{!isDesktop && isDrawerOpen && (
<TouchableWithoutFeedback
onPress={ev => {
// Only close if press happens outside of the drawer
if (ev.target === ev.currentTarget) {
setDrawerOpen(false)
}
}}
accessibilityLabel={_(msg`Close navigation footer`)}
accessibilityHint={_(msg`Closes bottom navigation bar`)}>
<View style={styles.drawerMask}>
<View style={styles.drawerContainer}>
<DrawerContent />
</View>
</View>
</TouchableWithoutFeedback>
)}
</>
)
}
@@ -89,21 +59,4 @@ const styles = StyleSheet.create({
bgDark: {
backgroundColor: colors.black, // TODO
},
drawerMask: {
// @ts-ignore web only
position: 'fixed',
width: '100%',
height: '100%',
top: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.25)',
},
drawerContainer: {
display: 'flex',
// @ts-ignore web only
position: 'fixed',
top: 0,
left: 0,
height: '100%',
},
})
+263
View File
@@ -0,0 +1,263 @@
import React, {useCallback} from 'react'
import {Linking, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {NavigationProp} from 'lib/routes/types'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {AccountList} from '#/components/AccountList'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsGear} from '#/components/icons/Gear'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
import {Text} from '#/components/Typography'
import {resetToTab} from '#/Navigation'
export function ProfileMenuDialog({
control,
onPressTab,
}: {
control: Dialog.DialogControlProps
onPressTab: (tab: 'MyProfile') => void
}) {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const {logout} = useSessionApi()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const navigation = useNavigation<NavigationProp>()
const closeAllActiveElements = useCloseAllActiveElements()
const onPressProfile = useCallback(() => {
control.close()
onPressTab('MyProfile')
}, [control, onPressTab])
const onPressLink = useCallback(
(screen: 'Lists' | 'Moderation' | 'Settings') => {
control.close()
navigation.navigate(screen)
},
[control, navigation],
)
const onSelectAccount = useCallback(
(account: SessionAccount) => {
if (account.did === currentAccount?.did) {
control.close()
} else {
onPressSwitchAccount(account, 'SwitchAccount')
}
},
[currentAccount, control, onPressSwitchAccount],
)
const onPressAddAccount = useCallback(() => {
setShowLoggedOut(true)
closeAllActiveElements()
}, [setShowLoggedOut, closeAllActiveElements])
const onPressSignOut = useCallback(() => {
logout('Settings')
resetToTab('HomeTab')
closeAllActiveElements()
}, [logout, closeAllActiveElements])
const onPressFeedback = React.useCallback(() => {
Linking.openURL(
FEEDBACK_FORM_URL({
email: currentAccount?.email,
handle: currentAccount?.handle,
}),
)
}, [currentAccount])
const onPressHelp = React.useCallback(() => {
Linking.openURL(HELP_DESK_URL)
}, [])
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`App Menu`)}>
<View style={[a.gap_sm]}>
<ProfileCard onPress={onPressProfile} />
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Lists`)}
onPress={() => onPressLink('Lists')}
style={[
t.atoms.border_contrast_low,
a.border,
a.justify_start,
]}>
<ButtonIcon position="left" icon={ListSparkle} />
<ButtonText numberOfLines={1}>
<Trans>Lists</Trans>
</ButtonText>
</Button>
</View>
<View style={[a.flex_1]}>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Moderation`)}
onPress={() => onPressLink('Moderation')}
style={[
t.atoms.border_contrast_low,
a.border,
a.justify_start,
]}>
<ButtonIcon position="left" icon={RaisingHand} />
<ButtonText numberOfLines={1}>
<Trans>Moderation</Trans>
</ButtonText>
</Button>
</View>
</View>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Settings`)}
onPress={() => onPressLink('Settings')}
style={[t.atoms.border_contrast_low, a.border, a.justify_start]}>
<ButtonIcon position="left" icon={SettingsGear} />
<ButtonText>
<Trans>Settings</Trans>
</ButtonText>
</Button>
<Text style={[a.font_bold, t.atoms.text, a.mt_sm]}>
<Trans>Accounts</Trans>
</Text>
<AccountList
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
excludeCurrent
style={[t.atoms.bg_contrast_25]}
/>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Sign Out`)}
onPress={onPressSignOut}
style={[t.atoms.border_contrast_low, a.border, a.justify_start]}>
<ButtonIcon position="left" icon={SquareArrowTopRight} />
<ButtonText>
<Trans>Sign Out</Trans>
</ButtonText>
</Button>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Feedback`)}
onPress={onPressFeedback}
style={[
t.atoms.border_contrast_low,
a.border,
a.justify_start,
]}>
<ButtonText numberOfLines={1}>
<Trans>Feedback</Trans>
</ButtonText>
</Button>
</View>
<View style={[a.flex_1]}>
<Button
color="secondary"
variant="solid"
size="medium"
label={_(msg`Help`)}
onPress={onPressHelp}
style={[
t.atoms.border_contrast_low,
a.border,
a.justify_start,
]}>
<ButtonText numberOfLines={1}>
<Trans>Help</Trans>
</ButtonText>
</Button>
</View>
</View>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function ProfileCard({onPress}: {onPress: () => void}) {
const t = useTheme()
const {currentAccount} = useSession()
const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did})
const {_} = useLingui()
const size = 48
return (
<View style={[a.mb_md]}>
{!isLoading && profile ? (
<Button label={_(msg`My Profile`)} onPress={onPress}>
{() => (
<View style={[a.flex_row, a.gap_md, a.align_center]}>
<UserAvatar
avatar={profile.avatar}
size={size}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1]}>
<Text
style={[a.text_lg, a.font_bold, t.atoms.text]}
numberOfLines={1}>
{profile.displayName || profile.handle}
</Text>
<Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
@{profile.handle}
</Text>
</View>
<View>
<ChevronRight fill={t.atoms.text_contrast_low.color} />
</View>
</View>
)}
</Button>
) : (
<View>
<LoadingPlaceholder
width={size}
height={size}
style={{borderRadius: size}}
/>
</View>
)}
</View>
)
}