diff options
Diffstat (limited to 'src/view/screens')
36 files changed, 4697 insertions, 3236 deletions
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 74d293ef4..154035f22 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -1,80 +1,114 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from 'react-native-gesture-handler' import {Text} from '../com/util/text/Text' import {Button} from '../com/util/forms/Button' import * as Toast from '../com/util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs} from '#/state/preferences' +import { + useAppPasswordsQuery, + useAppPasswordDeleteMutation, +} from '#/state/queries/app-passwords' +import {ErrorScreen} from '../com/util/error/ErrorScreen' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> -export const AppPasswords = withAuthRequired( - observer(function AppPasswordsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function AppPasswords({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() + const {data: appPasswords, error} = useAppPasswordsQuery() - useFocusEffect( - React.useCallback(() => { - screen('AppPasswords') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('AppPasswords') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onAdd = React.useCallback(async () => { - store.shell.openModal({name: 'add-app-password'}) - }, [store]) + const onAdd = React.useCallback(async () => { + openModal({name: 'add-app-password'}) + }, [openModal]) - // no app passwords (empty) state - if (store.me.appPasswords.length === 0) { - return ( - <CenteredView - style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="appPasswordsScreen"> - <AppPasswordsHeader /> - <View style={[styles.empty, pal.viewLight]}> - <Text type="lg" style={[pal.text, styles.emptyText]}> + if (error) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ErrorScreen + title="Oops!" + message="There was an issue with fetching your app passwords" + details={cleanError(error)} + /> + </CenteredView> + ) + } + + // no app passwords (empty) state + if (appPasswords?.length === 0) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <AppPasswordsHeader /> + <View style={[styles.empty, pal.viewLight]}> + <Text type="lg" style={[pal.text, styles.emptyText]}> + <Trans> You have not created any app passwords yet. You can create one by pressing the button below. - </Text> - </View> - {!isTabletOrDesktop && <View style={styles.flex1} />} - <View - style={[ - styles.btnContainer, - isTabletOrDesktop && styles.btnContainerDesktop, - ]}> - <Button - testID="appPasswordBtn" - type="primary" - label="Add App Password" - style={styles.btn} - labelStyle={styles.btnLabel} - onPress={onAdd} - /> - </View> - </CenteredView> - ) - } + </Trans> + </Text> + </View> + {!isTabletOrDesktop && <View style={styles.flex1} />} + <View + style={[ + styles.btnContainer, + isTabletOrDesktop && styles.btnContainerDesktop, + ]}> + <Button + testID="appPasswordBtn" + type="primary" + label="Add App Password" + style={styles.btn} + labelStyle={styles.btnLabel} + onPress={onAdd} + /> + </View> + </CenteredView> + ) + } + if (appPasswords?.length) { // has app passwords return ( <CenteredView @@ -92,7 +126,7 @@ export const AppPasswords = withAuthRequired( pal.border, !isTabletOrDesktop && styles.flex1, ]}> - {store.me.appPasswords.map((password, i) => ( + {appPasswords.map((password, i) => ( <AppPassword key={password.name} testID={`appPassword-${i}`} @@ -127,15 +161,29 @@ export const AppPasswords = withAuthRequired( )} </CenteredView> ) - }), -) + } + + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ActivityIndicator /> + </CenteredView> + ) +} function AppPasswordsHeader() { const {isTabletOrDesktop} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() return ( <> - <ViewHeader title="App Passwords" showOnDesktop /> + <ViewHeader title={_(msg`App Passwords`)} showOnDesktop /> <Text type="sm" style={[ @@ -143,8 +191,10 @@ function AppPasswordsHeader() { pal.text, isTabletOrDesktop && styles.descriptionDesktop, ]}> - Use app passwords to login to other Bluesky clients without giving full - access to your account or password. + <Trans> + Use app passwords to login to other Bluesky clients without giving + full access to your account or password. + </Trans> </Text> </> ) @@ -160,21 +210,24 @@ function AppPassword({ createdAt: string }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() + const {contentLanguages} = useLanguagePrefs() + const deleteMutation = useAppPasswordDeleteMutation() const onDelete = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete App Password', - message: `Are you sure you want to delete the app password "${name}"?`, + title: _(msg`Delete app password`), + message: _( + msg`Are you sure you want to delete the app password "${name}"?`, + ), async onPressConfirm() { - await store.me.deleteAppPassword(name) + await deleteMutation.mutateAsync({name}) Toast.show('App password deleted') }, }) - }, [store, name]) - - const {contentLanguages} = store.preferences + }, [deleteMutation, openModal, name, _]) const primaryLocale = contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' @@ -185,22 +238,24 @@ function AppPassword({ style={[styles.item, pal.border]} onPress={onDelete} accessibilityRole="button" - accessibilityLabel="Delete app password" + accessibilityLabel={_(msg`Delete app password`)} accessibilityHint=""> <View> <Text type="md-bold" style={pal.text}> {name} </Text> <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}> - Created{' '} - {Intl.DateTimeFormat(primaryLocale, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(new Date(createdAt))} + <Trans> + Created{' '} + {Intl.DateTimeFormat(primaryLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(createdAt))} + </Trans> </Text> </View> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> diff --git a/src/view/screens/CommunityGuidelines.tsx b/src/view/screens/CommunityGuidelines.tsx index 712172c3b..1931c6f13 100644 --- a/src/view/screens/CommunityGuidelines.tsx +++ b/src/view/screens/CommunityGuidelines.tsx @@ -9,6 +9,8 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -16,6 +18,7 @@ type Props = NativeStackScreenProps< > export const CommunityGuidelinesScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -26,16 +29,18 @@ export const CommunityGuidelinesScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Community Guidelines" /> + <ViewHeader title={_(msg`Community Guidelines`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Community Guidelines have been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Community Guidelines have been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/CopyrightPolicy.tsx b/src/view/screens/CopyrightPolicy.tsx index 816c1c1ee..2026f28c6 100644 --- a/src/view/screens/CopyrightPolicy.tsx +++ b/src/view/screens/CopyrightPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CopyrightPolicy'> export const CopyrightPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const CopyrightPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Copyright Policy" /> + <ViewHeader title={_(msg`Copyright Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Copyright Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Copyright Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 169660a8f..f319fbc39 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,15 +1,12 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' import {Link} from 'view/com/util/Link' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' @@ -22,255 +19,525 @@ import { import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' -import {MyFeedsItem} from 'state/models/ui/my-feeds' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + useFeedSourceInfoQuery, + useGetPopularFeedsQuery, + useSearchPopularFeedsMutation, +} from '#/state/queries/feed' +import {cleanError} from 'lib/strings/errors' +import {useComposerControls} from '#/state/shell/composer' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> -export const FeedsScreen = withAuthRequired( - observer<Props>(function FeedsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const myFeeds = store.me.myFeeds - const [query, setQuery] = React.useState<string>('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms - [myFeeds], - ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - myFeeds.setup() +type FlatlistSlice = + | { + type: 'error' + key: string + error: string + } + | { + type: 'savedFeedsHeader' + key: string + } + | { + type: 'savedFeedsLoading' + key: string + // pendingItems: number, + } + | { + type: 'savedFeedNoResults' + key: string + } + | { + type: 'savedFeed' + key: string + feedUri: string + } + | { + type: 'savedFeedsLoadMore' + key: string + } + | { + type: 'popularFeedsHeader' + key: string + } + | { + type: 'popularFeedsLoading' + key: string + } + | { + type: 'popularFeedsNoResults' + key: string + } + | { + type: 'popularFeed' + key: string + feedUri: string + } + | { + type: 'popularFeedsLoadingMore' + key: string + } - const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh()) - return () => { - softResetSub.remove() - } - }, [store, myFeeds, setMinimalShellMode]), +export function FeedsScreen(_props: Props) { + const pal = usePalette('default') + const {openComposer} = useComposerControls() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const [query, setQuery] = React.useState('') + const [isPTR, setIsPTR] = React.useState(false) + const { + data: preferences, + isLoading: isPreferencesLoading, + error: preferencesError, + } = usePreferencesQuery() + const { + data: popularFeeds, + isFetching: isPopularFeedsFetching, + error: popularFeedsError, + refetch: refetchPopularFeeds, + fetchNextPage: fetchNextPopularFeedsPage, + isFetchingNextPage: isPopularFeedsFetchingNextPage, + hasNextPage: hasNextPopularFeedsPage, + } = useGetPopularFeedsQuery() + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const { + data: searchResults, + mutate: search, + reset: resetSearch, + isPending: isSearchPending, + error: searchError, + } = useSearchPopularFeedsMutation() + const {hasSession} = useSession() + + /** + * A search query is present. We may not have search results yet. + */ + const isUserSearching = query.length > 1 + const debouncedSearch = React.useMemo( + () => debounce(q => search(q), 500), // debounce for 500ms + [search], + ) + const onPressCompose = React.useCallback(() => { + openComposer({}) + }, [openComposer]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearch(text) + } else { + refetchPopularFeeds() + resetSearch() + } + }, + [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], + ) + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + refetchPopularFeeds() + resetSearch() + }, [refetchPopularFeeds, setQuery, resetSearch]) + const onSubmitQuery = React.useCallback(() => { + debouncedSearch(query) + }, [query, debouncedSearch]) + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetchPopularFeeds() + setIsPTR(false) + }, [setIsPTR, refetchPopularFeeds]) + const onEndReached = React.useCallback(() => { + if ( + isPopularFeedsFetching || + isUserSearching || + !hasNextPopularFeedsPage || + popularFeedsError ) - React.useEffect(() => { - // watch for changes to saved/pinned feeds - return myFeeds.registerListeners() - }, [myFeeds]) + return + fetchNextPopularFeedsPage() + }, [ + isPopularFeedsFetching, + isUserSearching, + popularFeedsError, + hasNextPopularFeedsPage, + fetchNextPopularFeedsPage, + ]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const items = React.useMemo(() => { + let slices: FlatlistSlice[] = [] - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) + if (hasSession) { + slices.push({ + key: 'savedFeedsHeader', + type: 'savedFeedsHeader', + }) + + if (preferencesError) { + slices.push({ + key: 'savedFeedsError', + type: 'error', + error: cleanError(preferencesError.toString()), + }) + } else { + if (isPreferencesLoading || !preferences?.feeds?.saved) { + slices.push({ + key: 'savedFeedsLoading', + type: 'savedFeedsLoading', + // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, + }) } else { - myFeeds.discovery.refresh() - } - }, - [debouncedSearchFeeds, myFeeds.discovery], - ) - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - myFeeds.discovery.refresh() - }, [myFeeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) + if (preferences?.feeds?.saved.length === 0) { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) + } else { + const {saved, pinned} = preferences.feeds - const renderHeaderBtn = React.useCallback(() => { - return ( - <Link - href="/settings/saved-feeds" - hitSlop={10} - accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> - </Link> - ) - }, [pal]) + slices = slices.concat( + pinned.map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) - const onRefresh = React.useCallback(() => { - myFeeds.refresh() - }, [myFeeds]) + slices = slices.concat( + saved + .filter(uri => !pinned.includes(uri)) + .map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) + } + } + } + } - const renderItem = React.useCallback( - ({item}: {item: MyFeedsItem}) => { - if (item.type === 'discover-feeds-loading') { - return <FeedFeedLoadingPlaceholder /> - } else if (item.type === 'spinner') { - return ( - <View style={s.p10}> - <ActivityIndicator /> - </View> - ) - } else if (item.type === 'error') { - return <ErrorMessage message={item.error} /> - } else if (item.type === 'saved-feeds-header') { - if (!isMobile) { - return ( - <View - style={[ - pal.view, - styles.header, - pal.border, - { - borderBottomWidth: 1, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - My Feeds - </Text> - <Link - href="/settings/saved-feeds" - accessibilityLabel="Edit My Feeds" - accessibilityHint=""> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> - </View> + slices.push({ + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', + }) + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) + } else { + if (isUserSearching) { + if (isSearchPending || !searchResults) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + slices = slices.concat( + searchResults.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), ) } - return <View /> - } else if (item.type === 'saved-feeds-loading') { - return ( - <> - {Array.from(Array(item.numItems)).map((_, i) => ( - <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} /> - ))} - </> - ) - } else if (item.type === 'saved-feed') { - return <SavedFeed feed={item.feed} /> - } else if (item.type === 'discover-feeds-header') { - return ( - <> - <View - style={[ - pal.view, - styles.header, - { - marginTop: 16, - paddingLeft: isMobile ? 12 : undefined, - paddingRight: 10, - paddingBottom: isMobile ? 6 : undefined, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - Discover new feeds - </Text> - {!isMobile && ( - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - style={{flex: 1, maxWidth: 250}} - /> - )} - </View> - {isMobile && ( - <View style={{paddingHorizontal: 8, paddingBottom: 10}}> - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - </View> - )} - </> - ) - } else if (item.type === 'discover-feed') { - return ( - <FeedSourceCard - item={item.feed} - showSaveBtn - showDescription - showLikes - /> - ) - } else if (item.type === 'discover-feeds-no-results') { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if ( + !popularFeeds?.pages || + popularFeeds?.pages[0]?.feeds?.length === 0 + ) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + for (const page of popularFeeds.pages || []) { + slices = slices.concat( + page.feeds + .filter(feed => !preferences?.feeds?.saved.includes(feed.uri)) + .map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), + ) + } + + if (isPopularFeedsFetchingNextPage) { + slices.push({ + key: 'popularFeedsLoadingMore', + type: 'popularFeedsLoadingMore', + }) + } + } + } + } + } + + return slices + }, [ + hasSession, + preferences, + isPreferencesLoading, + preferencesError, + popularFeeds, + isPopularFeedsFetching, + popularFeedsError, + isPopularFeedsFetchingNextPage, + searchResults, + isSearchPending, + searchError, + isUserSearching, + ]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + <Link + href="/settings/saved-feeds" + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={_(msg`Edit Saved Feeds`)} + accessibilityHint="Opens screen to edit Saved Feeds"> + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> + </Link> + ) + }, [pal, _]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistSlice}) => { + if (item.type === 'error') { + return <ErrorMessage message={item.error} /> + } else if ( + item.type === 'popularFeedsLoadingMore' || + item.type === 'savedFeedsLoading' + ) { + return ( + <View style={s.p10}> + <ActivityIndicator /> + </View> + ) + } else if (item.type === 'savedFeedsHeader') { + if (!isMobile) { return ( <View - style={{ - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: '150%', - }}> - <Text type="lg" style={pal.textLight}> - No results found for "{query}" + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>My Feeds</Trans> </Text> + <Link + href="/settings/saved-feeds" + accessibilityLabel={_(msg`Edit My Feeds`)} + accessibilityHint=""> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> </View> ) } - return null - }, - [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], - ) + return <View /> + } else if (item.type === 'savedFeedNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + }}> + <Text type="lg" style={pal.textLight}> + <Trans>You don't have any saved feeds!</Trans> + </Text> + </View> + ) + } else if (item.type === 'savedFeed') { + return <SavedFeed feedUri={item.feedUri} /> + } else if (item.type === 'popularFeedsHeader') { + return ( + <> + <View + style={[ + pal.view, + styles.header, + { + // This is first in the flatlist without a session -esb + marginTop: hasSession ? 16 : 0, + paddingLeft: isMobile ? 12 : undefined, + paddingRight: 10, + paddingBottom: isMobile ? 6 : undefined, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>Discover new feeds</Trans> + </Text> - return ( - <View style={[pal.view, styles.container]}> - {isMobile && ( - <ViewHeader - title="Feeds" - canGoBack={false} - renderButton={renderHeaderBtn} - showBorder + {!isMobile && ( + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + style={{flex: 1, maxWidth: 250}} + /> + )} + </View> + + {isMobile && ( + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + </View> + )} + </> + ) + } else if (item.type === 'popularFeedsLoading') { + return <FeedFeedLoadingPlaceholder /> + } else if (item.type === 'popularFeed') { + return ( + <FeedSourceCard + feedUri={item.feedUri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave /> - )} + ) + } else if (item.type === 'popularFeedsNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + <Trans>No results found for "{query}"</Trans> + </Text> + </View> + ) + } + return null + }, + [ + _, + hasSession, + isMobile, + pal, + query, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + ], + ) - <FlatList - style={[!isTabletOrDesktop && s.flex1, styles.list]} - data={myFeeds.items} - keyExtractor={item => item._reactKey} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={myFeeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - onEndReached={() => myFeeds.loadMore()} - extraData={myFeeds.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight + return ( + <View style={[pal.view, styles.container]}> + {isMobile && ( + <ViewHeader + title={_(msg`Feeds`)} + canGoBack={false} + renderButton={renderHeaderBtn} + showBorder /> + )} + + {preferences ? <View /> : <ActivityIndicator />} + + <FlatList + style={[!isTabletOrDesktop && s.flex1, styles.list]} + data={items} + keyExtractor={item => item.key} + contentContainerStyle={styles.contentContainer} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={isUserSearching ? undefined : onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + initialNumToRender={10} + onEndReached={onEndReached} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </View> - ) - }), -) + )} + </View> + ) +} -function SavedFeed({feed}: {feed: FeedSourceModel}) { +function SavedFeed({feedUri}: {feedUri: string}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!info) + return ( + <SavedFeedLoadingPlaceholder + key={`savedFeedLoadingPlaceholder:${feedUri}`} + /> + ) + return ( <Link - testID={`saved-feed-${feed.displayName}`} - href={feed.href} + testID={`saved-feed-${info.displayName}`} + href={info.route.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={feed.displayName} + accessibilityLabel={info.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - {feed.error ? ( + {error ? ( <View style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> <FontAwesomeIcon @@ -279,17 +546,17 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) { /> </View> ) : ( - <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + <UserAvatar type="algo" size={28} avatar={info.avatar} /> )} <View style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {feed.displayName} + {info.displayName} </Text> - {feed.error ? ( + {error ? ( <View style={[styles.offlineSlug, pal.borderDark]}> <Text type="xs" style={pal.textLight}> - Feed offline + <Trans>Feed offline</Trans> </Text> </View> ) : null} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index c58175327..e8001e973 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,154 +1,178 @@ import React from 'react' -import {useWindowDimensions} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' -import isEqual from 'lodash.isequal' +import {View, ActivityIndicator, StyleSheet} from 'react-native' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FeedsTabBar} from '../com/pager/FeedsTabBar' -import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FeedPage} from 'view/com/feeds/FeedPage' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -export const POLL_FREQ = 30e3 // 30sec +import {usePreferencesQuery} from '#/state/queries/preferences' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' +import {emitSoftReset} from '#/state/events' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired( - observer(function HomeScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const pagerRef = React.useRef<PagerRef>(null) - const [selectedPage, setSelectedPage] = React.useState(0) - const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([]) - const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState< - string[] - >([]) +export function HomeScreen(props: Props) { + const {data: preferences} = usePreferencesQuery() + + if (preferences) { + return <HomeScreenReady {...props} preferences={preferences} /> + } else { + return ( + <View style={styles.loading}> + <ActivityIndicator size="large" /> + </View> + ) + } +} + +function HomeScreenReady({ + preferences, +}: Props & { + preferences: UsePreferencesQueryResponse +}) { + const {hasSession} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const [selectedPage, setSelectedPage] = React.useState(0) + const isPageFocused = useIsFocused() - React.useEffect(() => { - const pinned = store.preferences.pinnedFeeds + /** + * Used to ensure that we re-compute `customFeeds` AND force a re-render of + * the pager with the new order of feeds. + */ + const pinnedFeedOrderKey = JSON.stringify(preferences.feeds.pinned) - if (isEqual(pinned, requestedCustomFeeds)) { - // no changes - return + const customFeeds = React.useMemo(() => { + const pinned = preferences.feeds.pinned + const feeds: FeedDescriptor[] = [] + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + feeds.push(`feedgen|${uri}`) + } else if (uri.includes('app.bsky.graph.list')) { + feeds.push(`list|${uri}`) } + } + return feeds + }, [preferences.feeds.pinned]) - const feeds = [] - for (const uri of pinned) { - if (uri.includes('app.bsky.feed.generator')) { - const model = new PostsFeedModel(store, 'custom', {feed: uri}) - feeds.push(model) - } else if (uri.includes('app.bsky.graph.list')) { - const model = new PostsFeedModel(store, 'list', {list: uri}) - feeds.push(model) - } + const homeFeedParams = React.useMemo<FeedParams>(() => { + return { + mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), + mergeFeedSources: preferences.feeds.saved, + } + }, [preferences]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(selectedPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - pagerRef.current?.setPage(0) - setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned) - }, [ - store, - store.preferences.pinnedFeeds, - customFeeds, - setCustomFeeds, - pagerRef, - requestedCustomFeeds, - setRequestedCustomFeeds, - ]) + }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - setDrawerSwipeDisabled(selectedPage > 0) - return () => { - setDrawerSwipeDisabled(false) - } - }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), - ) + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setSelectedPage(index) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], + ) + + const onPressSelected = React.useCallback(() => { + emitSoftReset() + }, []) - const onPageSelected = React.useCallback( - (index: number) => { + const onPageScrollStateChanged = React.useCallback( + (state: 'idle' | 'dragging' | 'settling') => { + if (state === 'dragging') { setMinimalShellMode(false) - setSelectedPage(index) - setDrawerSwipeDisabled(index > 0) - }, - [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], - ) + } + }, + [setMinimalShellMode], + ) - const onPressSelected = React.useCallback(() => { - store.emitScreenSoftReset() - }, [store]) + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <FeedsTabBar + key="FEEDS_TAB_BAR" + selectedPage={props.selectedPage} + onSelect={props.onSelect} + testID="homeScreenFeedTabs" + onPressSelected={onPressSelected} + /> + ) + }, + [onPressSelected], + ) - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { + const renderFollowingEmptyState = React.useCallback(() => { + return <FollowingEmptyState /> + }, []) + + const renderCustomFeedEmptyState = React.useCallback(() => { + return <CustomFeedEmptyState /> + }, []) + + return hasSession ? ( + <Pager + key={pinnedFeedOrderKey} + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + key="1" + testID="followingFeedPage" + isPageFocused={selectedPage === 0 && isPageFocused} + feed={homeFeedParams.mergeFeedEnabled ? 'home' : 'following'} + feedParams={homeFeedParams} + renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={FollowingEndOfFeed} + /> + {customFeeds.map((f, index) => { return ( - <FeedsTabBar - key="FEEDS_TAB_BAR" - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID="homeScreenFeedTabs" - onPressSelected={onPressSelected} + <FeedPage + key={f} + testID="customFeedPage" + isPageFocused={selectedPage === 1 + index && isPageFocused} + feed={f} + renderEmptyState={renderCustomFeedEmptyState} /> ) - }, - [onPressSelected], - ) - - const renderFollowingEmptyState = React.useCallback(() => { - return <FollowingEmptyState /> - }, []) - - const renderCustomFeedEmptyState = React.useCallback(() => { - return <CustomFeedEmptyState /> - }, []) - - return ( - <Pager - ref={pagerRef} - testID="homeScreen" - onPageSelected={onPageSelected} - renderTabBar={renderTabBar} - tabBarPosition="top"> - <FeedPage - key="1" - testID="followingFeedPage" - isPageFocused={selectedPage === 0} - feed={store.me.mainFeed} - renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={FollowingEndOfFeed} - /> - {customFeeds.map((f, index) => { - return ( - <FeedPage - key={f.reactKey} - testID="customFeedPage" - isPageFocused={selectedPage === 1 + index} - feed={f} - renderEmptyState={renderCustomFeedEmptyState} - /> - ) - })} - </Pager> - ) - }), -) - -export function useHeaderOffset() { - const {isDesktop, isTablet} = useWebMediaQueries() - const {fontScale} = useWindowDimensions() - if (isDesktop) { - return 0 - } - if (isTablet) { - return 50 - } - // default text takes 44px, plus 34px of pad - // scale the 44px by the font scale - return 34 + 44 * fontScale + })} + </Pager> + ) : ( + <Pager + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + testID="customFeedPage" + isPageFocused={isPageFocused} + feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`} + renderEmptyState={renderCustomFeedEmptyState} + /> + </Pager> + ) } + +const styles = StyleSheet.create({ + loading: { + height: '100%', + alignContent: 'center', + justifyContent: 'center', + paddingBottom: 100, + }, +}) diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index a68a3b5e3..7a2e54dc8 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -1,8 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -16,20 +14,25 @@ import { } from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' -import {LANGUAGES} from 'lib/../locale/languages' +import {APP_LANGUAGES, LANGUAGES} from 'lib/../locale/languages' import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'> -export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( - _: Props, -) { +export function LanguageSettingsScreen(_props: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const {isTabletOrDesktop} = useWebMediaQueries() const {screen, track} = useAnalytics() const setMinimalShellMode = useSetMinimalShellMode() + const {openModal} = useModalControls() useFocusEffect( React.useCallback(() => { @@ -40,26 +43,37 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( const onPressContentLanguages = React.useCallback(() => { track('Settings:ContentlanguagesButtonClicked') - store.shell.openModal({name: 'content-languages-settings'}) - }, [track, store]) + openModal({name: 'content-languages-settings'}) + }, [track, openModal]) const onChangePrimaryLanguage = React.useCallback( (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { - store.preferences.setPrimaryLanguage(value) + if (langPrefs.primaryLanguage !== value) { + setLangPrefs.setPrimaryLanguage(value) + } }, - [store.preferences], + [langPrefs, setLangPrefs], + ) + + const onChangeAppLanguage = React.useCallback( + (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { + if (langPrefs.appLanguage !== value) { + setLangPrefs.setAppLanguage(value) + } + }, + [langPrefs, setLangPrefs], ) const myLanguages = React.useMemo(() => { return ( - store.preferences.contentLanguages + langPrefs.contentLanguages .map(lang => LANGUAGES.find(l => l.code2 === lang)) .filter(Boolean) // @ts-ignore .map(l => l.name) .join(', ') ) - }, [store.preferences.contentLanguages]) + }, [langPrefs.contentLanguages]) return ( <CenteredView @@ -69,20 +83,114 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Language Settings" showOnDesktop /> + <ViewHeader title={_(msg`Language Settings`)} showOnDesktop /> <View style={{paddingTop: 20, paddingHorizontal: 20}}> + {/* APP LANGUAGE */} + <View style={{paddingBottom: 20}}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>App Language</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Select your app language for the default text to display in the + app + </Trans> + </Text> + + <View style={{position: 'relative'}}> + <RNPickerSelect + value={langPrefs.appLanguage} + onValueChange={onChangeAppLanguage} + items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ + label: l.name, + value: l.code2, + key: l.code2, + }))} + style={{ + inputAndroid: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputIOS: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputWeb: { + // @ts-ignore web only + cursor: 'pointer', + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 0, + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + }} + /> + + <View + style={{ + position: 'absolute', + top: 1, + right: 1, + bottom: 1, + width: 40, + backgroundColor: pal.viewLight.backgroundColor, + borderRadius: 24, + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + }}> + <FontAwesomeIcon + icon="chevron-down" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </View> + </View> + + <View + style={{ + height: 1, + backgroundColor: pal.border.borderColor, + marginBottom: 20, + }} + /> + + {/* PRIMARY LANGUAGE */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Primary Language + <Trans>Primary Language</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select your preferred language for translations in your feed. + <Trans> + Select your preferred language for translations in your feed. + </Trans> </Text> <View style={{position: 'relative'}}> <RNPickerSelect - value={store.preferences.primaryLanguage} + value={langPrefs.primaryLanguage} onValueChange={onChangePrimaryLanguage} items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ label: l.name, @@ -159,13 +267,16 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( }} /> + {/* CONTENT LANGUAGES */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Content Languages + <Trans>Content Languages</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select which languages you want your subscribed feeds to include. If - none are selected, all languages will be shown. + <Trans> + Select which languages you want your subscribed feeds to include. + If none are selected, all languages will be shown. + </Trans> </Text> <Button @@ -187,7 +298,7 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index a64b0ca3b..d28db7c6c 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,72 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> -export const ListsScreen = withAuthRequired( - observer(function ListsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ListsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const listsLists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-curatelists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - listsLists.refresh() - }, [listsLists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#curatelist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="listsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - User Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists which can drive feeds. + return ( + <View style={s.hContentRegion} testID="listsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>User Lists</Trans> + </Text> + <Text style={pal.textLight}> + <Trans>Public, shareable lists which can drive feeds.</Trans> + </Text> + </View> + <View> + <Button + testID="newUserListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + <Trans>New</Trans> </Text> - </View> - <View> - <Button - testID="newUserListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={listsLists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="curate" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index f524279a5..8680b851b 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ScrollView} from '../com/util/Views' @@ -11,13 +10,16 @@ import {Text} from '../com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {getEntries} from '#/logger/logDump' import {ago} from 'lib/strings/time' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {useSetMinimalShellMode} from '#/state/shell' -export const LogScreen = observer(function Log({}: NativeStackScreenProps< +export function LogScreen({}: NativeStackScreenProps< CommonNavigatorParams, 'Log' >) { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState<string[]>([]) @@ -47,7 +49,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< <TouchableOpacity style={[styles.entry, pal.border, pal.view]} onPress={toggler(entry.id)} - accessibilityLabel="View debug entry" + accessibilityLabel={_(msg`View debug entry`)} accessibilityHint="Opens additional details for a debug entry"> {entry.level === 'debug' ? ( <FontAwesomeIcon icon="info" /> @@ -85,7 +87,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< </ScrollView> </View> ) -}) +} const styles = StyleSheet.create({ entry: { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 142f3bce8..4d8d8cad7 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -5,10 +5,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } 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 {useStores} from 'state/index' import {s} from 'lib/styles' import {CenteredView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -18,101 +15,103 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export const ModerationScreen = withAuthRequired( - observer(function Moderation({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function ModerationScreen({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen, track} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - store.shell.openModal({name: 'content-filtering-settings'}) - }, [track, store]) + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + openModal({name: 'content-filtering-settings'}) + }, [track, openModal]) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title="Moderation" showOnDesktop /> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Content filtering - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Moderation lists - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Muted accounts - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Blocked accounts - </Text> - </Link> - </CenteredView> - ) - }), -) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, + ]} + testID="moderationScreen"> + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + <View style={styles.spacer} /> + <TouchableOpacity + testID="contentFilteringBtn" + style={[styles.linkCard, pal.view]} + onPress={onPressContentFiltering} + accessibilityRole="tab" + accessibilityHint="Content filtering" + accessibilityLabel=""> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="eye" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Content filtering</Trans> + </Text> + </TouchableOpacity> + <Link + testID="moderationlistsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/modlists"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="users-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation lists</Trans> + </Text> + </Link> + <Link + testID="mutedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/muted-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="user-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Muted accounts</Trans> + </Text> + </Link> + <Link + testID="blockedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/blocked-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="ban" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Blocked accounts</Trans> + </Text> + </Link> + </CenteredView> + ) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 0dc3b706b..8f6e2f729 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,133 +8,165 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationBlockedAccounts' > -export const ModerationBlockedAccounts = withAuthRequired( - observer(function ModerationBlockedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const blockedAccounts = useMemo( - () => new BlockedAccountsModel(store), - [store], - ) +export function ModerationBlockedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyBlockedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.blocks.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.blocks) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('BlockedAccounts') - setMinimalShellMode(false) - blockedAccounts.refresh() - }, [screen, setMinimalShellMode, blockedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('BlockedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - blockedAccounts.refresh() - }, [blockedAccounts]) - const onEndReached = React.useCallback(() => { - blockedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more blocked accounts', {error: err}), - ) - }, [blockedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`blockedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`blockedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="blockedAccountsScreen"> + <ViewHeader title={_(msg`Blocked Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="blockedAccountsScreen"> - <ViewHeader title="Blocked Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours. - </Text> - {!blockedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not blocked any accounts yet. To block an account, go - to their profile and selected "Block account" from the menu on - their account. + <Trans> + You have not blocked any accounts yet. To block an account, go + to their profile and selected "Block account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={blockedAccounts.blocks} - keyExtractor={(item: ActorDefs.ProfileView) => item.did} - refreshControl={ - <RefreshControl - refreshing={blockedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {blockedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={blockedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={(item: ActorDefs.ProfileView) => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 8794c6d17..145b35a42 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,71 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> -export const ModerationModlistsScreen = withAuthRequired( - observer(function ModerationModlistsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ModerationModlistsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - mutelists.refresh() - }, [mutelists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#modlist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="moderationModlistsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - Moderation Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists of users to mute or block in bulk. + return ( + <View style={s.hContentRegion} testID="moderationModlistsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + Moderation Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists of users to mute or block in bulk. + </Text> + </View> + <View> + <Button + testID="newModListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New </Text> - </View> - <View> - <Button - testID="newModListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={mutelists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="mod" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 2fa27ee54..41aee9f2f 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,129 +8,164 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {MutedAccountsModel} from 'state/models/lists/muted-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationMutedAccounts' > -export const ModerationMutedAccounts = withAuthRequired( - observer(function ModerationMutedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store]) +export function ModerationMutedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyMutedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.mutes.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.mutes) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('MutedAccounts') - setMinimalShellMode(false) - mutedAccounts.refresh() - }, [screen, setMinimalShellMode, mutedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('MutedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - mutedAccounts.refresh() - }, [mutedAccounts]) - const onEndReached = React.useCallback(() => { - mutedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more muted accounts', {error: err}), - ) - }, [mutedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`mutedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`mutedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="mutedAccountsScreen"> + <ViewHeader title={_(msg`Muted Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="mutedAccountsScreen"> - <ViewHeader title="Muted Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private. - </Text> - {!mutedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not muted any accounts yet. To mute an account, go to - their profile and selected "Mute account" from the menu on their - account. + <Trans> + You have not muted any accounts yet. To mute an account, go to + their profile and selected "Mute account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={mutedAccounts.mutes} - keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={mutedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {mutedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={mutedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={item => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index c2125756c..2508a9ed2 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -12,9 +12,12 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const NotFoundScreen = () => { const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const setMinimalShellMode = useSetMinimalShellMode() @@ -36,13 +39,15 @@ export const NotFoundScreen = () => { return ( <View testID="notFoundView" style={pal.view}> - <ViewHeader title="Page not found" /> + <ViewHeader title={_(msg`Page not found`)} /> <View style={styles.container}> <Text type="title-2xl" style={[pal.text, s.mb10]}> - Page not found + <Trans>Page not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - We're sorry! We can't find the page you were looking for. + <Trans> + We're sorry! We can't find the page you were looking for. + </Trans> </Text> <Button type="primary" diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index cd482bd1c..3ce1128a6 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,166 +1,135 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' 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 {TextLink} from 'view/com/util/Link' -import {InvitedUsers} from '../com/notifications/InvitedUsers' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' -import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + useUnreadNotifications, + useUnreadNotificationsApi, +} from '#/state/queries/notifications/unread' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' > -export const NotificationsScreen = withAuthRequired( - observer(function NotificationsScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() - const scrollElRef = React.useRef<FlatList>(null) - const {screen} = useAnalytics() - const pal = usePalette('default') - const {isDesktop} = useWebMediaQueries() - - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing - - // event handlers - // = - const onPressTryAgain = React.useCallback(() => { - store.me.notifications.refresh() - }, [store]) - - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) +export function NotificationsScreen({}: Props) { + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() + const scrollElRef = React.useRef<FlatList>(null) + const {screen} = useAnalytics() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const unreadNotifs = useUnreadNotifications() + const unreadApi = useUnreadNotificationsApi() + const hasNew = !!unreadNotifs - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - store.me.notifications.refresh() - }, [store, scrollToTop]) + // event handlers + // = + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) - // on-visible setup - // = - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - logger.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(onPressLoadLatest) - store.me.notifications.update() - screen('Notifications') + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + if (hasNew) { + // render what we have now + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } else { + // check with the server + unreadApi.checkUnread({invalidate: true}) + } + }, [scrollToTop, queryClient, unreadApi, hasNew]) - return () => { - softResetSub.remove() - store.me.notifications.markAllRead() - } - }, [store, screen, onPressLoadLatest, setMinimalShellMode]), - ) + // on-visible setup + // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + logger.debug('NotificationsScreen: Updating feed') + screen('Notifications') + return listenSoftReset(onPressLoadLatest) + }, [screen, onPressLoadLatest, setMinimalShellMode]), + ) - useTabFocusEffect( - 'Notifications', - React.useCallback( - isInside => { - // on mobile: - // fires with `isInside=true` when the user navigates to the root tab - // but not when the user goes back to the screen by pressing back - // on web: - // essentially equivalent to useFocusEffect because we dont used tabbed - // navigation - if (isInside) { - if (isWeb) { - store.me.notifications.syncQueue() - } else { - if (store.me.notifications.unreadCount > 0) { - store.me.notifications.refresh() - } else { - store.me.notifications.syncQueue() - } + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/notifications" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + <Trans>Notifications</Trans>{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> } - } - }, - [store], - ), - ) - - const ListHeaderComponent = React.useCallback(() => { - if (isDesktop) { - return ( - <View - style={[ - pal.view, - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 18, - paddingVertical: 12, - }, - ]}> - <TextLink - type="title-lg" - href="/notifications" - style={[pal.text, {fontWeight: 'bold'}]} - text={ - <> - Notifications{' '} - {hasNew && ( - <View - style={{ - top: -8, - backgroundColor: colors.blue3, - width: 8, - height: 8, - borderRadius: 4, - }} - /> - )} - </> - } - onPress={() => store.emitScreenSoftReset()} - /> - </View> - ) - } - return <></> - }, [isDesktop, pal, store, hasNew]) + onPress={emitSoftReset} + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, hasNew]) - return ( - <View testID="notificationsScreen" style={s.hContentRegion}> - <ViewHeader title="Notifications" canGoBack={false} /> - <InvitedUsers /> - <Feed - view={store.me.notifications} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollElRef={scrollElRef} - ListHeaderComponent={ListHeaderComponent} + return ( + <View testID="notificationsScreen" style={s.hContentRegion}> + <ViewHeader title={_(msg`Notifications`)} canGoBack={false} /> + <Feed + onScroll={onMainScroll} + scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label={_(msg`Load new notifications`)} + showIndicator={hasNew} /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new notifications" - showIndicator={hasNew} - /> - )} - </View> - ) - }), -) + )} + </View> + ) +} diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index 2f45908b3..7cbb81102 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -2,17 +2,19 @@ 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 {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> -export const PostLikedByScreen = withAuthRequired(({route}: Props) => { +export const PostLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index abe03467a..de95f33bf 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,18 +1,20 @@ 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' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> -export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { +export const PostRepostedByScreen = ({route}: Props) => { const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Reposted by" /> + <ViewHeader title={_(msg`Reposted by`)} /> <PostRepostedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 0bdd06269..4b1f51748 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,104 +1,107 @@ -import React, {useMemo} from 'react' -import {InteractionManager, StyleSheet, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Animated from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' 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' -import {PostThreadModel} from 'state/models/content/post-thread' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + RQKEY as POST_THREAD_RQKEY, + ThreadNode, +} from '#/state/queries/post-thread' import {clamp} from 'lodash' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {logger} from '#/logger' -import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' - -const SHELL_FOOTER_HEIGHT = 44 +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {ErrorMessage} from '../com/util/error/ErrorMessage' +import {CenteredView} from '../com/util/Views' +import {useComposerControls} from '#/state/shell/composer' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> -export const PostThreadScreen = withAuthRequired( - observer(function PostThreadScreenImpl({route}: Props) { - const store = useStores() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() - const safeAreaInsets = useSafeAreaInsets() - const {name, rkey} = route.params - const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const view = useMemo<PostThreadModel>( - () => new PostThreadModel(store, {uri}), - [store, uri], - ) - const {isMobile} = useWebMediaQueries() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - const threadCleanup = view.registerListeners() +export function PostThreadScreen({route}: Props) { + const queryClient = useQueryClient() + const {_} = useLingui() + const {fabMinimalShellTransform} = useMinimalShellMode() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const safeAreaInsets = useSafeAreaInsets() + const {name, rkey} = route.params + const {isMobile} = useWebMediaQueries() + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri) - InteractionManager.runAfterInteractions(() => { - if (!view.hasLoaded && !view.isLoading) { - view.setup().catch(err => { - logger.error('Failed to fetch thread', {error: err}) - }) - } - }) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - return () => { - threadCleanup() - } - }, [view, setMinimalShellMode]), + const onPressReply = React.useCallback(() => { + if (!resolvedUri) { + return + } + const thread = queryClient.getQueryData<ThreadNode>( + POST_THREAD_RQKEY(resolvedUri.uri), ) - - const onPressReply = React.useCallback(() => { - if (!view.thread) { - return - } - store.shell.openComposer({ - replyTo: { - uri: view.thread.post.uri, - cid: view.thread.post.cid, - text: view.thread.postRecord?.text as string, - author: { - handle: view.thread.post.author.handle, - displayName: view.thread.post.author.displayName, - avatar: view.thread.post.author.avatar, - }, + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: { + handle: thread.post.author.handle, + displayName: thread.post.author.displayName, + avatar: thread.post.author.avatar, }, - onPost: () => view.refresh(), - }) - }, [view, store]) + }, + onPost: () => + queryClient.invalidateQueries({ + queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), + }), + }) + }, [openComposer, queryClient, resolvedUri]) - return ( - <View style={s.hContentRegion}> - {isMobile && <ViewHeader title="Post" />} - <View style={s.flex1}> + return ( + <View style={s.hContentRegion}> + {isMobile && <ViewHeader title={_(msg`Post`)} />} + <View style={s.flex1}> + {uriError ? ( + <CenteredView> + <ErrorMessage message={String(uriError)} /> + </CenteredView> + ) : ( <PostThreadComponent - uri={uri} - view={view} + uri={resolvedUri?.uri} onPressReply={onPressReply} - treeView={!!store.preferences.thread.lab_treeViewEnabled} /> - </View> - {isMobile && !minimalShellMode && ( - <View - style={[ - styles.prompt, - { - bottom: - SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]}> - <ComposePrompt onPressCompose={onPressReply} /> - </View> )} </View> - ) - }), -) + {isMobile && ( + <Animated.View + style={[ + styles.prompt, + fabMinimalShellTransform, + { + bottom: clamp(safeAreaInsets.bottom, 15, 30), + }, + ]}> + <ComposePrompt onPressCompose={onPressReply} /> + </Animated.View> + )} + </View> + ) +} const styles = StyleSheet.create({ prompt: { diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 21c15931f..fe17be5e8 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,10 +1,8 @@ import React, {useState} from 'react' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Slider} from '@miblanchard/react-native-slider' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -14,21 +12,33 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import debounce from 'lodash.debounce' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetFeedViewPreferencesMutation, +} from '#/state/queries/preferences' -function RepliesThresholdInput({enabled}: {enabled: boolean}) { - const store = useStores() +function RepliesThresholdInput({ + enabled, + initialValue, +}: { + enabled: boolean + initialValue: number +}) { const pal = usePalette('default') - const [value, setValue] = useState( - store.preferences.homeFeed.hideRepliesByLikeCount, - ) + const [value, setValue] = useState(initialValue) + const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const save = React.useMemo( () => debounce( threshold => - store.preferences.setHomeFeedHideRepliesByLikeCount(threshold), + setFeedViewPref({ + hideRepliesByLikeCount: threshold, + }), 500, ), // debouce for 500ms - [store], + [setFeedViewPref], ) return ( @@ -61,12 +71,17 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesHomeFeed' > -export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ - navigation, -}: Props) { +export function PreferencesHomeFeed({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setFeedViewPref, variables} = + useSetFeedViewPreferencesMutation() + + const showReplies = !( + variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies + ) return ( <CenteredView @@ -77,14 +92,14 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Home Feed Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the content you see on your home screen. + <Trans>Fine-tune the content you see on your home screen.</Trans> </Text> </View> @@ -92,98 +107,175 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ <View style={styles.cardsContainer}> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Replies + <Trans>Show Replies</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all replies from your feed. + <Trans> + Set this setting to "No" to hide all replies from your feed. + </Trans> </Text> <ToggleButton testID="toggleRepliesBtn" type="default-light" - label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReplies} - onPress={store.preferences.toggleHomeFeedHideReplies} + label={showReplies ? 'Yes' : 'No'} + isSelected={showReplies} + onPress={() => + setFeedViewPref({ + hideReplies: !( + variables?.hideReplies ?? + preferences?.feedViewPrefs?.hideReplies + ), + }) + } /> </View> <View - style={[ - pal.viewLight, - styles.card, - store.preferences.homeFeed.hideReplies && styles.dimmed, - ]}> + style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Reply Filters + <Trans>Reply Filters</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Enable this setting to only see replies between people you follow. + <Trans> + Enable this setting to only see replies between people you + follow. + </Trans> </Text> <ToggleButton type="default-light" - label="Followed users only" - isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed} + label={_(msg`Followed users only`)} + isSelected={Boolean( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed, + )} onPress={ - !store.preferences.homeFeed.hideReplies - ? store.preferences.toggleHomeFeedHideRepliesByUnfollowed + showReplies + ? () => + setFeedViewPref({ + hideRepliesByUnfollowed: !( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed + ), + }) : undefined } style={[s.mb10]} /> <Text style={[pal.text]}> - Adjust the number of likes a reply must have to be shown in your - feed. + <Trans> + Adjust the number of likes a reply must have to be shown in your + feed. + </Trans> </Text> - <RepliesThresholdInput - enabled={!store.preferences.homeFeed.hideReplies} - /> + {preferences && ( + <RepliesThresholdInput + enabled={showReplies} + initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount} + /> + )} </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Reposts + <Trans>Show Reposts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all reposts from your feed. + <Trans> + Set this setting to "No" to hide all reposts from your feed. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReposts} - onPress={store.preferences.toggleHomeFeedHideReposts} + label={ + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ) + } + onPress={() => + setFeedViewPref({ + hideReposts: !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Quote Posts + <Trans>Show Quote Posts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. + <Trans> + Set this setting to "No" to hide all quote posts from your feed. + Reposts will still be visible. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideQuotePosts} - onPress={store.preferences.toggleHomeFeedHideQuotePosts} + label={ + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ) + } + onPress={() => + setFeedViewPref({ + hideQuotePosts: !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show - Posts from My Feeds + <FontAwesomeIcon icon="flask" color={pal.colors.text} /> + <Trans>Show Posts from My Feeds</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show samples of your saved feeds in - your following feed. This is an experimental feature. + <Trans> + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. This is an experimental feature. + </Trans> </Text> <ToggleButton type="default-light" label={ - store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No' + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ? _(msg`Yes`) + : _(msg`No`) + } + isSelected={ + !!( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ) + } + onPress={() => + setFeedViewPref({ + lab_mergeFeedEnabled: !( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ), + }) } - isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled} - onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} /> </View> </View> @@ -204,14 +296,16 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index af98a1833..73d941932 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -1,9 +1,13 @@ import React from 'react' -import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -12,14 +16,30 @@ import {RadioGroup} from 'view/com/util/forms/RadioGroup' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetThreadViewPreferencesMutation, +} from '#/state/queries/preferences' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> -export const PreferencesThreads = observer(function PreferencesThreadsImpl({ - navigation, -}: Props) { +export function PreferencesThreads({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setThreadViewPrefs, variables} = + useSetThreadViewPreferencesMutation() + + const prioritizeFollowedUsers = Boolean( + variables?.prioritizeFollowedUsers ?? + preferences?.threadViewPrefs?.prioritizeFollowedUsers, + ) + const treeViewEnabled = Boolean( + variables?.lab_treeViewEnabled ?? + preferences?.threadViewPrefs?.lab_treeViewEnabled, + ) return ( <CenteredView @@ -30,78 +50,90 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Thread Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Thread Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the discussion threads. + <Trans>Fine-tune the discussion threads.</Trans> </Text> </View> - <ScrollView> - <View style={styles.cardsContainer}> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Sort Replies - </Text> - <Text style={[pal.text, s.pb10]}> - Sort replies to the same post by: - </Text> - <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> - <RadioGroup + {preferences ? ( + <ScrollView> + <View style={styles.cardsContainer}> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Sort Replies</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans>Sort replies to the same post by:</Trans> + </Text> + <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> + <RadioGroup + type="default-light" + items={[ + {key: 'oldest', label: 'Oldest replies first'}, + {key: 'newest', label: 'Newest replies first'}, + {key: 'most-likes', label: 'Most-liked replies first'}, + {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, + ]} + onSelect={key => setThreadViewPrefs({sort: key})} + initialSelection={preferences?.threadViewPrefs?.sort} + /> + </View> + </View> + + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Prioritize Your Follows</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Show replies by people you follow before all other replies. + </Trans> + </Text> + <ToggleButton type="default-light" - items={[ - {key: 'oldest', label: 'Oldest replies first'}, - {key: 'newest', label: 'Newest replies first'}, - {key: 'most-likes', label: 'Most-liked replies first'}, - {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, - ]} - onSelect={store.preferences.setThreadSort} - initialSelection={store.preferences.thread.sort} + label={prioritizeFollowedUsers ? 'Yes' : 'No'} + isSelected={prioritizeFollowedUsers} + onPress={() => + setThreadViewPrefs({ + prioritizeFollowedUsers: !prioritizeFollowedUsers, + }) + } /> </View> - </View> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Prioritize Your Follows - </Text> - <Text style={[pal.text, s.pb10]}> - Show replies by people you follow before all other replies. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.prioritizeFollowedUsers ? 'Yes' : 'No' - } - isSelected={store.preferences.thread.prioritizeFollowedUsers} - onPress={store.preferences.togglePrioritizedFollowedUsers} - /> - </View> - - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded - Mode - </Text> - <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show replies in a threaded view. This - is an experimental feature. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.lab_treeViewEnabled ? 'Yes' : 'No' - } - isSelected={!!store.preferences.thread.lab_treeViewEnabled} - onPress={store.preferences.toggleThreadTreeViewEnabled} - /> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '} + <Trans>Threaded Mode</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Set this setting to "Yes" to show replies in a threaded view. + This is an experimental feature. + </Trans> + </Text> + <ToggleButton + type="default-light" + label={treeViewEnabled ? 'Yes' : 'No'} + isSelected={treeViewEnabled} + onPress={() => + setThreadViewPrefs({ + lab_treeViewEnabled: !treeViewEnabled, + }) + } + /> + </View> </View> - </View> - </ScrollView> + </ScrollView> + ) : ( + <ActivityIndicator /> + )} <View style={[ @@ -118,14 +150,16 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx index f709c9fda..247afc316 100644 --- a/src/view/screens/PrivacyPolicy.tsx +++ b/src/view/screens/PrivacyPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PrivacyPolicy'> export const PrivacyPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const PrivacyPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Privacy Policy" /> + <ViewHeader title={_(msg`Privacy Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Privacy Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/privacy-policy" - text="blueskyweb.xyz/support/privacy-policy" - /> + <Trans> + The Privacy Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/privacy-policy" + text="blueskyweb.xyz/support/privacy-policy" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 9a25612ad..4af1b650e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,317 +1,447 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import React, {useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector' -import {CenteredView} from '../com/util/Views' +import {CenteredView, FlatList} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel, Sections} from 'state/models/ui/profile' -import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' +import {Feed} from 'view/com/posts/Feed' +import {ProfileLists} from '../com/lists/ProfileLists' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {FeedSlice} from '../com/posts/FeedSlice' -import {ListCard} from 'view/com/lists/ListCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '../com/util/LoadingPlaceholder' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' -import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import {logger} from '#/logger' -import {useSetMinimalShellMode} from '#/state/shell' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useProfileQuery} from '#/state/queries/profile' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {cleanError} from '#/lib/strings/errors' +import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> -export const ProfileScreen = withAuthRequired( - observer(function ProfileScreenImpl({route}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) - const name = route.params.name === 'me' ? store.me.did : route.params.name +export function ProfileScreen({route}: Props) { + const {currentAccount} = useSession() + const name = + route.params.name === 'me' ? currentAccount?.did : route.params.name + const moderationOpts = useModerationOpts() + const { + data: resolvedDid, + error: resolveError, + refetch: refetchDid, + isInitialLoading: isInitialLoadingDid, + } = useResolveDidQuery(name) + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isInitialLoading: isInitialLoadingProfile, + } = useProfileQuery({ + did: resolvedDid, + }) - useEffect(() => { - screen('Profile') - }, [screen]) + const onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) - const [hasSetup, setHasSetup] = useState<boolean>(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], + if (isInitialLoadingDid || isInitialLoadingProfile || !moderationOpts) { + return ( + <CenteredView> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> ) - useSetTitle(combinedDisplayName(uiState.profile)) + } + if (resolveError || profileError) { + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message={cleanError(resolveError || profileError)} + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) + } + if (profile && moderationOpts) { + return ( + <ProfileScreenLoaded + profile={profile} + moderationOpts={moderationOpts} + hideBackButton={!!route.params.hideBackButton} + /> + ) + } + // should never happen + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) +} - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) +function ProfileScreenLoaded({ + profile: profileUnshadowed, + moderationOpts, + hideBackButton, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + hideBackButton: boolean +}) { + const profile = useProfileShadow(profileUnshadowed) + const {hasSession, currentAccount} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const {screen, track} = useAnalytics() + const [currentPage, setCurrentPage] = React.useState(0) + const {_} = useLingui() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const extraInfoQuery = useProfileExtraInfoQuery(profile.did) + const postsSectionRef = React.useRef<SectionRef>(null) + const repliesSectionRef = React.useRef<SectionRef>(null) + const mediaSectionRef = React.useRef<SectionRef>(null) + const likesSectionRef = React.useRef<SectionRef>(null) + const feedsSectionRef = React.useRef<SectionRef>(null) + const listsSectionRef = React.useRef<SectionRef>(null) - useEffect(() => { - setHasSetup(false) - }, [name]) + useSetTitle(combinedDisplayName(profile)) - // We don't need this to be reactive, so we can just register the listeners once - useEffect(() => { - const listCleanup = uiState.lists.registerListeners() - return () => listCleanup() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - let aborted = false - setMinimalShellMode(false) - const feedCleanup = uiState.feed.registerListeners() - if (!hasSetup) { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - softResetSub.remove() - } - }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), - ) + const isMe = profile.did === currentAccount?.did + const showRepliesTab = hasSession + const showLikesTab = isMe + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens + const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const sectionTitles = useMemo<string[]>(() => { + return [ + 'Posts', + showRepliesTab ? 'Posts & Replies' : undefined, + 'Media', + showLikesTab ? 'Likes' : undefined, + showFeedsTab ? 'Feeds' : undefined, + showListsTab ? 'Lists' : undefined, + ].filter(Boolean) as string[] + }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab]) - // events - // = + let nextIndex = 0 + const postsIndex = nextIndex++ + let repliesIndex: number | null = null + if (showRepliesTab) { + repliesIndex = nextIndex++ + } + const mediaIndex = nextIndex++ + let likesIndex: number | null = null + if (showLikesTab) { + likesIndex = nextIndex++ + } + let feedsIndex: number | null = null + if (showFeedsTab) { + feedsIndex = nextIndex++ + } + let listsIndex: number | null = null + if (showListsTab) { + listsIndex = nextIndex++ + } - const onPressCompose = React.useCallback(() => { - track('ProfileScreen:PressCompose') - const mention = - uiState.profile.handle === store.me.handle || - uiState.profile.handle === 'handle.invalid' - ? undefined - : uiState.profile.handle - store.shell.openComposer({mention}) - }, [store, track, uiState]) - const onSelectView = React.useCallback( - (index: number) => { - uiState.setSelectedViewIndex(index) - }, - [uiState], - ) - const onRefresh = React.useCallback(() => { - uiState - .refresh() - .catch((err: any) => - logger.error('Failed to refresh user profile', {error: err}), - ) - }, [uiState]) - const onEndReached = React.useCallback(() => { - uiState.loadMore().catch((err: any) => - logger.error('Failed to load more entries in user profile', { - error: err, - }), - ) - }, [uiState]) - const onPressTryAgain = React.useCallback(() => { - uiState.setup() - }, [uiState]) + const scrollSectionToTop = React.useCallback( + (index: number) => { + if (index === postsIndex) { + postsSectionRef.current?.scrollToTop() + } else if (index === repliesIndex) { + repliesSectionRef.current?.scrollToTop() + } else if (index === mediaIndex) { + mediaSectionRef.current?.scrollToTop() + } else if (index === likesIndex) { + likesSectionRef.current?.scrollToTop() + } else if (index === feedsIndex) { + feedsSectionRef.current?.scrollToTop() + } else if (index === listsIndex) { + listsSectionRef.current?.scrollToTop() + } + }, + [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + ) - // rendering - // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + return listenSoftReset(() => { + scrollSectionToTop(currentPage) + }) + }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]), + ) - const renderHeader = React.useCallback(() => { - if (!uiState) { - return <View /> + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - return ( - <ProfileHeader - view={uiState.profile} - onRefreshAll={onRefresh} - hideBackButton={route.params.hideBackButton} - /> - ) - }, [uiState, onRefresh, route.params.hideBackButton]) + }, [setDrawerSwipeDisabled, currentPage]), + ) - const Footer = React.useMemo(() => { - return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined - }, [uiState.showLoadingMoreFooter]) - const renderItem = React.useCallback( - (item: any) => { - // if section is lists - if (uiState.selectedView === Sections.Lists) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="listsEmpty" - icon="list-ul" - message="No lists yet!" - style={styles.emptyState} + // events + // = + + const onPressCompose = React.useCallback(() => { + track('ProfileScreen:PressCompose') + const mention = + profile.handle === currentAccount?.handle || + profile.handle === 'handle.invalid' + ? undefined + : profile.handle + openComposer({mention}) + }, [openComposer, currentAccount, track, profile]) + + const onPageSelected = React.useCallback( + (i: number) => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + scrollSectionToTop(index) + }, + [scrollSectionToTop], + ) + + // rendering + // = + + const renderHeader = React.useCallback(() => { + return ( + <ProfileHeader + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + /> + ) + }, [profile, moderation, hideBackButton]) + + return ( + <ScreenHider + testID="profileView" + style={styles.container} + screenDescription="profile" + moderation={moderation.account}> + <PagerWithHeader + testID="profilePager" + isHeaderReady={true} + items={sectionTitles} + onPageSelected={onPageSelected} + onCurrentPageSelected={onCurrentPageSelected} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_no_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showRepliesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={repliesSectionRef} + feed={`author|${profile.did}|posts_with_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else { - return <ListCard testID={`list-${item.name}`} list={item} /> - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="customAlgorithmsEmpty" - icon="list-ul" - message="No custom algorithms yet!" - style={styles.emptyState} + : null} + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showLikesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={likesSectionRef} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else if (item instanceof FeedSourceModel) { - return ( - <FeedSourceCard - item={item} - showSaveBtn - showLikes - showDescription + : null} + {showFeedsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedgens + ref={feedsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } - // if section is posts or posts & replies - } else { - if (item === ProfileUiModel.END_ITEM) { - return <Text style={styles.endItem}>- end of feed -</Text> - } else if (item === ProfileUiModel.LOADING_ITEM) { - return <PostFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - if (uiState.feed.isBlockedBy) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} + : null} + {showListsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> - ) - } - } - return <View /> - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) - - return ( - <ScreenHider - testID="profileView" - style={styles.container} - screenDescription="profile" - moderation={uiState.profile.moderation.account}> - {uiState.profile.hasError ? ( - <ErrorScreen - testID="profileErrorScreen" - title="Failed to load profile" - message={uiState.profile.error} - onPressTryAgain={onPressTryAgain} - /> - ) : uiState.profile.hasLoaded ? ( - <ViewSelector - ref={viewSelectorRef} - swipeEnabled={false} - sections={uiState.selectorItems} - items={uiState.uiItems} - renderHeader={renderHeader} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshing={uiState.isRefreshing || false} - onSelectView={onSelectView} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - ) : ( - <CenteredView>{renderHeader()}</CenteredView> - )} + : null} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </ScreenHider> - ) - }), -) - -function LoadingMoreFooter() { - return ( - <View style={styles.loadingMoreFooter}> - <ActivityIndicator /> - </View> + )} + </ScreenHider> ) } +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isFocused: boolean + isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}, + ref, + ) { + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + pollInterval={30e3} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index a4d146d66..3a0bdcc0f 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,25 +1,21 @@ import React, {useMemo, useCallback} from 'react' import { - FlatList, - NativeScrollEvent, + Dimensions, StyleSheet, View, ActivityIndicator, + FlatList, } from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useNavigation} from '@react-navigation/native' -import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor} from '#/state/queries/post-feed' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -32,13 +28,13 @@ import {FAB} from 'view/com/util/fab/FAB' import {EmptyState} from 'view/com/util/EmptyState' import * as Toast from 'view/com/util/Toast' import {useSetTitle} from 'lib/hooks/useSetTitle' -import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {resolveName} from 'lib/api' import {makeCustomFeedLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' import {CenteredView, ScrollView} from 'view/com/util/Views' @@ -47,6 +43,28 @@ import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import { + useFeedSourceInfoQuery, + FeedSourceFeedInfo, + useIsFeedPublicQuery, +} from '#/state/queries/feed' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, + usePinFeedMutation, + useUnpinFeedMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import {useComposerControls} from '#/state/shell/composer' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES = ['Posts', 'About'] @@ -55,315 +73,372 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> -export const ProfileFeedScreen = withAuthRequired( - observer(function ProfileFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation<NavigationProp>() +export function ProfileFeedScreen(props: Props) { + const {rkey, name: handleOrDid} = props.route.params - const {name: handleOrDid} = props.route.params + const pal = usePalette('default') + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() + const uri = useMemo( + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), + [rkey, handleOrDid], + ) + const {error, data: resolvedUri} = useResolveUriQuery(uri) - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + <Trans>Could not load feed</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error.toString()} + </Text> - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load feed - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel="Go Back" - accessibilityHint="Return to previous page" - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - Go Back - </Text> - </Button> - </View> + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel={_(msg`Go Back`)} + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + <Trans>Go Back</Trans> + </Text> + </Button> </View> - </CenteredView> - ) - } + </View> + </CenteredView> + ) + } - return feedOwnerDid ? ( - <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> - ) : ( + return resolvedUri ? ( + <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + const {isLoading: isPublicStatusLoading, data: isPublic} = + useIsFeedPublicQuery({uri: feedUri}) + + if (!preferences || !info || isPublicStatusLoading) { + return ( <CenteredView> <View style={s.p20}> <ActivityIndicator size="large" /> </View> </CenteredView> ) - }), -) + } -export const ProfileFeedScreenInner = observer( - function ProfileFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - const feedSectionRef = React.useRef<SectionRef>(null) - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - const feedInfo = useCustomFeed(uri) - const feed: PostsFeedModel = useMemo(() => { - const model = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - model.setup() - return model - }, [store, uri]) - const isPinned = store.preferences.isPinnedFeed(uri) - useSetTitle(feedInfo?.displayName) - - // events - // = - - const onToggleSaved = React.useCallback(async () => { - try { - Haptics.default() - if (feedInfo?.isSaved) { - await feedInfo?.unsave() - } else { - await feedInfo?.save() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', - ) - logger.error('Failed up update feeds', {error: err}) - } - }, [feedInfo]) + return ( + <ProfileFeedScreenInner + preferences={preferences} + feedInfo={info as FeedSourceFeedInfo} + isPublic={Boolean(isPublic)} + /> + ) +} - const onToggleLiked = React.useCallback(async () => { +export function ProfileFeedScreenInner({ + preferences, + feedInfo, + isPublic, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo + isPublic: boolean +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {hasSession, currentAccount} = useSession() + const {openModal} = useModalControls() + const {openComposer} = useComposerControls() + const {track} = useAnalytics() + const feedSectionRef = React.useRef<SectionRef>(null) + + const { + mutateAsync: saveFeed, + variables: savedFeed, + reset: resetSaveFeed, + isPending: isSavePending, + } = useSaveFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + isPending: isRemovePending, + } = useRemoveFeedMutation() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + isPending: isPinPending, + } = usePinFeedMutation() + const { + mutateAsync: unpinFeed, + variables: unpinnedFeed, + reset: resetUnpinFeed, + isPending: isUnpinPending, + } = useUnpinFeedMutation() + + const isSaved = + !removedFeed && + (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) + const isPinned = + !unpinnedFeed && + (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) + + useSetTitle(feedInfo?.displayName) + + const onToggleSaved = React.useCallback(async () => { + try { Haptics.default() - try { - if (feedInfo?.isLiked) { - await feedInfo?.unlike() - } else { - await feedInfo?.like() - } - } catch (err) { - Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', - ) - logger.error('Failed up toggle like', {error: err}) + + if (isSaved) { + await removeFeed({uri: feedInfo.uri}) + resetRemoveFeed() + } else { + await saveFeed({uri: feedInfo.uri}) + resetSaveFeed() } - }, [feedInfo]) + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + logger.error('Failed up update feeds', {error: err}) + } + }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed]) - const onTogglePinned = React.useCallback(async () => { + const onTogglePinned = React.useCallback(async () => { + try { Haptics.default() - if (feedInfo) { - feedInfo.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned feed', {error: e}) - }) - } - }, [feedInfo]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!feedInfo) return - store.shell.openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [store, feedInfo]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - // render - // = + if (isPinned) { + await unpinFeed({uri: feedInfo.uri}) + resetUnpinFeed() + } else { + await pinFeed({uri: feedInfo.uri}) + resetPinFeed() + } + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to toggle pinned feed', {error: e}) + } + }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(feedInfo.route.href) + shareUrl(url) + track('CustomFeed:Share') + }, [feedInfo, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [openModal, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', - onPress: onToggleSaved, - icon: feedInfo?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + hasSession && { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`), + onPress: isSavePending || isRemovePending ? undefined : onToggleSaved, + icon: isSaved + ? { + ios: { + name: 'trash', }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', + }, + hasSession && { + testID: 'feedHeaderDropdownReportBtn', + label: _(msg`Report feed`), + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: _(msg`Share feed`), + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', }, + android: 'ic_menu_share', + web: 'share', }, - ] as DropdownItem[] - }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) - - const renderHeader = useCallback(() => { - return ( - <ProfileSubpageHeader - isLoading={!feedInfo?.hasLoaded} - href={makeCustomFeedLink(feedOwnerDid, rkey)} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo?.isOwner} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - {feedInfo && ( - <> - <Button - type="default" - label={feedInfo?.isSaved ? 'Unsave' : 'Save'} - onPress={onToggleSaved} - style={styles.btn} - /> - <Button - type={isPinned ? 'default' : 'inverted'} - label={isPinned ? 'Unpin' : 'Pin to home'} - onPress={onTogglePinned} - style={styles.btn} - /> - </> - )} - <NativeDropdown - testID="headerDropdownBtn" - items={dropdownItems} - accessibilityLabel="More options" - accessibilityHint=""> - <View style={[pal.viewLight, styles.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.text} - /> - </View> - </NativeDropdown> - </ProfileSubpageHeader> - ) - }, [ - pal, - feedOwnerDid, - rkey, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - dropdownItems, - ]) - + }, + ].filter(Boolean) as DropdownItem[] + }, [ + hasSession, + onToggleSaved, + onPressReport, + onPressShare, + isSaved, + isSavePending, + isRemovePending, + _, + ]) + + const renderHeader = useCallback(() => { return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES} - isHeaderReady={feedInfo?.hasLoaded ?? false} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( + <ProfileSubpageHeader + isLoading={false} + href={feedInfo.route.href} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo.creatorDid === currentAccount?.did} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + {feedInfo && hasSession && ( + <> + <Button + disabled={isSavePending || isRemovePending} + type="default" + label={isSaved ? 'Unsave' : 'Save'} + onPress={onToggleSaved} + style={styles.btn} + /> + <Button + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + disabled={isPinPending || isUnpinPending} + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + style={styles.btn} + /> + </> + )} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel={_(msg`More options`)} + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.text} + /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) + }, [ + _, + hasSession, + pal, + feedInfo, + isPinned, + onTogglePinned, + onToggleSaved, + dropdownItems, + currentAccount?.did, + isPinPending, + isRemovePending, + isSavePending, + isSaved, + isUnpinPending, + ]) + + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) => + isPublic ? ( <FeedSection ref={feedSectionRef} - feed={feed} + feed={`feedgen|${feedInfo.uri}`} onScroll={onScroll} headerHeight={headerHeight} isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + isFocused={isFocused} /> - )} - {({onScroll, headerHeight}) => ( - <AboutSection - feedOwnerDid={feedOwnerDid} - feedRkey={rkey} - feedInfo={feedInfo} - headerHeight={headerHeight} - onToggleLiked={onToggleLiked} - onScroll={onScroll} - /> - )} - </PagerWithHeader> + ) : ( + <CenteredView sideBorders style={[{paddingTop: headerHeight}]}> + <NonPublicFeedMessage /> + </CenteredView> + ) + } + {({onScroll, headerHeight, scrollElRef}) => ( + <AboutSection + feedOwnerDid={feedInfo.creatorDid} + feedRkey={feedInfo.route.params.rkey} + feedInfo={feedInfo} + headerHeight={headerHeight} + onScroll={onScroll} + scrollElRef={ + scrollElRef as React.MutableRefObject<ScrollView | null> + } + isOwner={feedInfo.creatorDid === currentAccount?.did} + /> + )} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" - onPress={() => store.shell.openComposer({})} + onPress={() => openComposer({})} icon={ <ComposeIcon2 strokeWidth={1.5} @@ -372,32 +447,67 @@ export const ProfileFeedScreenInner = observer( /> } accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> + )} + </View> + ) +} + +function NonPublicFeedMessage() { + const pal = usePalette('default') + + return ( + <View + style={[ + pal.border, + { + padding: 18, + borderTopWidth: 1, + minHeight: Dimensions.get('window').height * 1.5, + }, + ]}> + <View + style={[ + pal.viewLight, + { + padding: 12, + borderRadius: 8, + }, + ]}> + <Text style={[pal.text]}> + <Trans> + Looks like this feed is only available to users with a Bluesky + account. Please sign up or sign in to view this feed! + </Trans> + </Text> </View> - ) - }, -) + </View> + ) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const [hasNew, setHasNew] = React.useState(false) + const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, @@ -407,13 +517,15 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={5} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -430,32 +542,64 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, ) -const AboutSection = observer(function AboutPageImpl({ +function AboutSection({ feedOwnerDid, feedRkey, feedInfo, headerHeight, - onToggleLiked, onScroll, + scrollElRef, + isOwner, }: { feedOwnerDid: string feedRkey: string - feedInfo: FeedSourceModel | undefined + feedInfo: FeedSourceFeedInfo headerHeight: number - onToggleLiked: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler + scrollElRef: React.MutableRefObject<ScrollView | null> + isOwner: boolean }) { const pal = usePalette('default') - const scrollHandler = useAnimatedScrollHandler({onScroll}) + const {_} = useLingui() + const scrollHandler = useAnimatedScrollHandler(onScroll) + const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) + const {hasSession} = useSession() - if (!feedInfo) { - return <View /> - } + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount + + const onToggleLiked = React.useCallback(async () => { + try { + Haptics.default() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + logger.error('Failed up toggle like', {error: err}) + } + }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed]) return ( <ScrollView + ref={scrollElRef} scrollEventThrottle={1} - contentContainerStyle={{paddingTop: headerHeight}} + contentContainerStyle={{ + paddingTop: headerHeight, + minHeight: Dimensions.get('window').height * 1.5, + }} onScroll={scrollHandler}> <View style={[ @@ -467,46 +611,44 @@ const AboutSection = observer(function AboutPageImpl({ }, pal.border, ]}> - {feedInfo.descriptionRT ? ( + {feedInfo.description ? ( <RichText testID="listDescription" type="lg" style={pal.text} - richText={feedInfo.descriptionRT} + richText={feedInfo.description} /> ) : ( <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> <Button type="default" testID="toggleLikeBtn" - accessibilityLabel="Like this feed" + accessibilityLabel={_(msg`Like this feed`)} accessibilityHint="" + disabled={!hasSession || isLikePending || isUnlikePending} onPress={onToggleLiked} style={{paddingHorizontal: 10}}> - {feedInfo?.isLiked ? ( + {isLiked ? ( <HeartIconSolid size={19} style={styles.liked} /> ) : ( <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> )} </Button> - {typeof feedInfo.likeCount === 'number' && ( + {typeof likeCount === 'number' && ( <TextLink href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={`Liked by ${feedInfo.likeCount} ${pluralize( - feedInfo.likeCount, - 'user', - )}`} + text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`} style={[pal.textLight, s.semiBold]} /> )} </View> <Text type="md" style={[pal.textLight]} numberOfLines={1}> Created by{' '} - {feedInfo.isOwner ? ( + {isOwner ? ( 'you' ) : ( <TextLink @@ -522,7 +664,7 @@ const AboutSection = observer(function AboutPageImpl({ </View> </ScrollView> ) -}) +} const styles = StyleSheet.create({ btn: { diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 4972116f3..0460670e1 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -2,17 +2,19 @@ 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 {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> -export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { +export const ProfileFeedLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 49f55bf46..2cad08cb5 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -2,15 +2,17 @@ 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 {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'> -export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowersScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Followers" /> + <ViewHeader title={_(msg`Followers`)} /> <ProfileFollowersComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 4f0ff7d67..80502b98b 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -2,15 +2,17 @@ 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 {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'> -export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowsScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Following" /> + <ViewHeader title={_(msg`Following`)} /> <ProfileFollowsComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index b84732d53..421611764 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -2,7 +2,6 @@ import React, {useCallback, useMemo} from 'react' import { ActivityIndicator, FlatList, - NativeScrollEvent, Pressable, StyleSheet, View, @@ -11,10 +10,8 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useAnimatedScrollHandler} from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' -import {RichText as RichTextAPI} from '@atproto/api' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' +import {useQueryClient} from '@tanstack/react-query' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -29,23 +26,36 @@ import * as Toast from 'view/com/util/Toast' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Haptics} from 'lib/haptics' -import {ListModel} from 'state/models/content/list' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useStores} from 'state/index' +import {FeedDescriptor} from '#/state/queries/post-feed' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {resolveName} from 'lib/api' import {s} from 'lib/styles' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink, makeListLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' -import {ListItems} from 'view/com/lists/ListItems' -import {logger} from '#/logger' +import {ListMembers} from '#/view/com/lists/ListMembers' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + useListQuery, + useListMuteMutation, + useListBlockMutation, + useListDeleteMutation, +} from '#/state/queries/list' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {isWeb} from '#/platform/detection' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -55,240 +65,220 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> -export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl(props: Props) { - const store = useStores() - const {name: handleOrDid} = props.route.params - const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - React.useEffect(() => { - /* - * We must resolve the DID of the list owner before we can fetch the list. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setListOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setListOwnerDid]) - - if (error) { - return ( - <CenteredView> - <ErrorScreen error={error} /> - </CenteredView> - ) - } +export function ProfileListScreen(props: Props) { + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) - return listOwnerDid ? ( - <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> - ) : ( + if (resolveError) { + return ( <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> + <ErrorScreen + error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`} + /> </CenteredView> ) - }), -) - -export const ProfileListScreenInner = observer( - function ProfileListScreenInnerImpl({ - route, - listOwnerDid, - }: Props & {listOwnerDid: string}) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {rkey} = route.params - const feedSectionRef = React.useRef<SectionRef>(null) - const aboutSectionRef = React.useRef<SectionRef>(null) - - const list: ListModel = useMemo(() => { - const model = new ListModel( - store, - `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, - ) - return model - }, [store, listOwnerDid, rkey]) - const feed = useMemo( - () => new PostsFeedModel(store, 'list', {list: list.uri}), - [store, list], - ) - useSetTitle(list.data?.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - list.loadMore(true).then(() => { - if (list.isCuratelist) { - feed.setup() - } - }) - }, [setMinimalShellMode, list, feed]), + } + if (listError) { + return ( + <CenteredView> + <ErrorScreen error={cleanError(listError)} /> + </CenteredView> ) + } + + return resolvedUri && list ? ( + <ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} - const onPressAddUser = useCallback(() => { - store.shell.openModal({ - name: 'list-add-user', - list, - onAdd() { - if (list.isCuratelist) { - feed.refresh() - } - }, - }) - }, [store, list, feed]) +function ProfileListScreenLoaded({ + route, + uri, + list, +}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() + const setMinimalShellMode = useSetMinimalShellMode() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + const {openModal} = useModalControls() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + + useSetTitle(list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - if (index === 1) { - aboutSectionRef.current?.scrollToTop() + const onPressAddUser = useCallback(() => { + openModal({ + name: 'list-add-remove-users', + list, + onChange() { + if (isCurateList) { + // TODO(eric) should construct these strings with a fn too + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) } }, - [feedSectionRef], - ) + }) + }, [openModal, list, isCurateList, queryClient]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } else if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const renderHeader = useCallback(() => { - return <Header rkey={rkey} list={list} /> - }, [rkey, list]) + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) - if (list.isCuratelist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_CURATE} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <FeedSection - ref={feedSectionRef} - feed={feed} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - ref={aboutSectionRef} - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } - if (list.isModlist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_MOD} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } + if (isCurateList) { return ( - <CenteredView sideBorders style={s.hContentRegion}> - <Header rkey={rkey} list={list} /> - {list.error ? <ErrorScreen error={list.error} /> : null} - </CenteredView> + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({ + onScroll, + headerHeight, + isScrolledDown, + scrollElRef, + isFocused, + }) => ( + <FeedSection + ref={feedSectionRef} + feed={`list|${uri}`} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + isFocused={isFocused} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + ref={aboutSectionRef} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + list={list} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> ) - }, -) + } + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + isHeaderReady={true} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + list={list} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> + ) +} -const Header = observer(function HeaderImpl({ - rkey, - list, -}: { - rkey: string - list: ListModel -}) { +function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {currentAccount} = useSession() + const {openModal, closeModal} = useModalControls() + const listMuteMutation = useListMuteMutation() + const listBlockMutation = useListBlockMutation() + const listDeleteMutation = useListDeleteMutation() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isModList = list.purpose === 'app.bsky.graph.defs#modlist' + const isPinned = false // TODO + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isOwner = list.creator.did === currentAccount?.did const onTogglePinned = useCallback(async () => { Haptics.default() - list.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned list', {error: e}) - }) - }, [list]) + // TODO + // list.togglePin().catch(e => { + // Toast.show('There was an issue contacting the server') + // logger.error('Failed to toggle pinned list', {error: e}) + // }) + }, []) const onSubscribeMute = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Mute these accounts?', - message: - 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + title: _(msg`Mute these accounts?`), + message: _( + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, + ), confirmBtnText: 'Mute this List', async onPressConfirm() { try { - await list.mute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) Toast.show('List muted') } catch { Toast.show( @@ -297,32 +287,33 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listMuteMutation, _]) const onUnsubscribeMute = useCallback(async () => { try { - await list.unmute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show('List unmuted') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listMuteMutation]) const onSubscribeBlock = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Block these accounts?', - message: - 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + title: _(msg`Block these accounts?`), + message: _( + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ), confirmBtnText: 'Block this List', async onPressConfirm() { try { - await list.block() + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show('List blocked') } catch { Toast.show( @@ -331,39 +322,36 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listBlockMutation, _]) const onUnsubscribeBlock = useCallback(async () => { try { - await list.unblock() + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show('List unblocked') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listBlockMutation]) const onPressEdit = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'create-or-edit-list', list, - onSave() { - list.refresh() - }, }) - }, [store, list]) + }, [openModal, list]) const onPressDelete = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete List', - message: 'Are you sure?', + title: _(msg`Delete List`), + message: _(msg`Are you sure?`), async onPressConfirm() { - await list.delete() + await listDeleteMutation.mutateAsync({uri: list.uri}) Toast.show('List deleted') if (navigation.canGoBack()) { navigation.goBack() @@ -372,30 +360,26 @@ const Header = observer(function HeaderImpl({ } }, }) - }, [store, list, navigation]) + }, [openModal, list, listDeleteMutation, navigation, _]) const onPressReport = useCallback(() => { - if (!list.data) return - store.shell.openModal({ + openModal({ name: 'report', uri: list.uri, - cid: list.data.cid, + cid: list.cid, }) - }, [store, list]) + }, [openModal, list]) const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) - }, [list.creatorDid, rkey]) + }, [list, rkey]) const dropdownItems: DropdownItem[] = useMemo(() => { - if (!list.hasLoaded) { - return [] - } let items: DropdownItem[] = [ { testID: 'listHeaderDropdownShareBtn', - label: 'Share', + label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { @@ -406,11 +390,11 @@ const Header = observer(function HeaderImpl({ }, }, ] - if (list.isOwner) { + if (isOwner) { items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownEditBtn', - label: 'Edit List Details', + label: _(msg`Edit list details`), onPress: onPressEdit, icon: { ios: { @@ -422,7 +406,7 @@ const Header = observer(function HeaderImpl({ }) items.push({ testID: 'listHeaderDropdownDeleteBtn', - label: 'Delete List', + label: _(msg`Delete List`), onPress: onPressDelete, icon: { ios: { @@ -436,7 +420,7 @@ const Header = observer(function HeaderImpl({ items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownReportBtn', - label: 'Report List', + label: _(msg`Report List`), onPress: onPressReport, icon: { ios: { @@ -448,20 +432,13 @@ const Header = observer(function HeaderImpl({ }) } return items - }, [ - list.hasLoaded, - list.isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, - ]) + }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport, _]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { return [ { testID: 'subscribeDropdownMuteBtn', - label: 'Mute accounts', + label: _(msg`Mute accounts`), onPress: onSubscribeMute, icon: { ios: { @@ -473,7 +450,7 @@ const Header = observer(function HeaderImpl({ }, { testID: 'subscribeDropdownBlockBtn', - label: 'Block accounts', + label: _(msg`Block accounts`), onPress: onSubscribeBlock, icon: { ios: { @@ -484,36 +461,32 @@ const Header = observer(function HeaderImpl({ }, }, ] - }, [onSubscribeMute, onSubscribeBlock]) + }, [onSubscribeMute, onSubscribeBlock, _]) return ( <ProfileSubpageHeader - isLoading={!list.hasLoaded} - href={makeListLink( - list.data?.creator.handle || list.data?.creator.did || '', - rkey, - )} - title={list.data?.name || 'User list'} - avatar={list.data?.avatar} - isOwner={list.isOwner} - creator={list.data?.creator} + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} + title={list.name} + avatar={list.avatar} + isOwner={list.creator.did === currentAccount?.did} + creator={list.creator} avatarType="list"> - {list.isCuratelist || list.isPinned ? ( + {isCurateList || isPinned ? ( <Button testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} type={list.isPinned ? 'default' : 'inverted'} label={list.isPinned ? 'Unpin' : 'Pin to home'} onPress={onTogglePinned} /> - ) : list.isModlist ? ( - list.isBlocking ? ( + ) : isModList ? ( + isBlocking ? ( <Button testID="unblockBtn" type="default" label="Unblock" onPress={onUnsubscribeBlock} /> - ) : list.isMuting ? ( + ) : isMuting ? ( <Button testID="unmuteBtn" type="default" @@ -524,10 +497,12 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="subscribeBtn" items={subscribeDropdownItems} - accessibilityLabel="Subscribe to this list" + accessibilityLabel={_(msg`Subscribe to this list`)} accessibilityHint=""> <View style={[palInverted.view, styles.btn]}> - <Text style={palInverted.text}>Subscribe</Text> + <Text style={palInverted.text}> + <Trans>Subscribe</Trans> + </Text> </View> </NativeDropdown> ) @@ -535,7 +510,7 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="headerDropdownBtn" items={dropdownItems} - accessibilityLabel="More options" + accessibilityLabel={_(msg`More options`)} accessibilityHint=""> <View style={[pal.viewLight, styles.btn]}> <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> @@ -543,26 +518,29 @@ const Header = observer(function HeaderImpl({ </NativeDropdown> </ProfileSubpageHeader> ) -}) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -571,14 +549,16 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed testID="listFeed" + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={1} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -596,34 +576,35 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( ) interface AboutSectionProps { - list: ListModel - descriptionRT: RichTextAPI | null - creator: {did: string; handle: string} | undefined - isCurateList: boolean | undefined - isOwner: boolean | undefined + list: AppBskyGraphDefs.ListView onPressAddUser: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> } const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function AboutSectionImpl( - { - list, - descriptionRT, - creator, - isCurateList, - isOwner, - onPressAddUser, - onScroll, - headerHeight, - isScrolledDown, - }, + {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() - const scrollElRef = React.useRef<FlatList>(null) + const {currentAccount} = useSession() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isOwner = list.creator.did === currentAccount?.did + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) @@ -634,9 +615,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( })) const renderHeader = React.useCallback(() => { - if (!list.data) { - return <View /> - } return ( <View> <View @@ -660,7 +638,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( testID="listDescriptionEmpty" type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <Text type="md" style={[pal.textLight]} numberOfLines={1}> @@ -669,8 +647,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 'you' ) : ( <TextLink - text={sanitizeHandle(creator?.handle || '', '@')} - href={creator ? makeProfileLink(creator) : ''} + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} style={pal.textLight} /> )} @@ -686,12 +664,14 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( paddingBottom: isMobile ? 14 : 18, }, ]}> - <Text type="lg-bold">Users</Text> + <Text type="lg-bold"> + <Trans>Users</Trans> + </Text> {isOwner && ( <Pressable testID="addUserBtn" accessibilityRole="button" - accessibilityLabel="Add a user to this list" + accessibilityLabel={_(msg`Add a user to this list`)} accessibilityHint="" onPress={onPressAddUser} style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> @@ -700,7 +680,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( color={pal.colors.link} size={16} /> - <Text style={pal.link}>Add</Text> + <Text style={pal.link}> + <Trans>Add</Trans> + </Text> </Pressable> )} </View> @@ -708,13 +690,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, [ pal, - list.data, + list, isMobile, descriptionRT, - creator, isCurateList, isOwner, onPressAddUser, + _, ]) const renderEmptyState = useCallback(() => { @@ -727,17 +709,16 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> - <ListItems + <ListMembers testID="listItems" + list={list.uri} scrollElRef={scrollElRef} renderHeader={renderHeader} renderEmptyState={renderEmptyState} - list={list} headerOffset={headerHeight} - onScroll={scrollHandler} + onScroll={onScroll} scrollEventThrottle={1} /> {isScrolledDown && ( @@ -755,6 +736,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function ErrorScreen({error}: {error: string}) { const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const onPressBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() @@ -776,7 +758,7 @@ function ErrorScreen({error}: {error: string}) { }, ]}> <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load list + <Trans>Could not load list</Trans> </Text> <Text type="md" style={[pal.text, s.mb20]}> {error} @@ -785,12 +767,12 @@ function ErrorScreen({error}: {error: string}) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel="Go Back" + accessibilityLabel={_(msg`Go Back`)} accessibilityHint="Return to previous page" onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> - Go Back + <Trans>Go Back</Trans> </Text> </Button> </View> diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 487f56643..858a58a3c 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -1,33 +1,32 @@ -import React, {useCallback, useMemo} from 'react' -import { - StyleSheet, - View, - ActivityIndicator, - Pressable, - TouchableOpacity, -} from 'react-native' +import React from 'react' +import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {track} from '#/lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {SavedFeedsModel} from 'state/models/ui/saved-feeds' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {ScrollView, CenteredView} from 'view/com/util/Views' import {Text} from 'view/com/util/text/Text' import {s, colors} from 'lib/styles' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as Toast from 'view/com/util/Toast' import {Haptics} from 'lib/haptics' import {TextLink} from 'view/com/util/Link' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + usePinFeedMutation, + useUnpinFeedMutation, + useSetSaveFeedsMutation, +} from '#/state/queries/preferences' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' const HITSLOP_TOP = { top: 20, @@ -43,99 +42,118 @@ const HITSLOP_BOTTOM = { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> -export const SavedFeeds = withAuthRequired( - observer(function SavedFeedsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const setMinimalShellMode = useSetMinimalShellMode() +export function SavedFeeds({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const setMinimalShellMode = useSetMinimalShellMode() + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: setSavedFeeds, + variables: optimisticSavedFeedsResponse, + reset: resetSaveFeedsMutationState, + error: setSavedFeedsError, + } = useSetSaveFeedsMutation() + + /* + * Use optimistic data if exists and no error, otherwise fallback to remote + * data + */ + const currentFeeds = + optimisticSavedFeedsResponse && !setSavedFeedsError + ? optimisticSavedFeedsResponse + : preferences?.feeds || {saved: [], pinned: []} + const unpinned = currentFeeds.saved.filter(f => { + return !currentFeeds.pinned?.includes(f) + }) - const savedFeeds = useMemo(() => { - const model = new SavedFeedsModel(store) - model.refresh() - return model - }, [store]) - useFocusEffect( - useCallback(() => { - screen('SavedFeeds') - setMinimalShellMode(false) - savedFeeds.refresh() - }, [screen, setMinimalShellMode, savedFeeds]), - ) + useFocusEffect( + React.useCallback(() => { + screen('SavedFeeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop && styles.desktopContainer, - ]}> - <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> - <ScrollView style={s.flex1}> - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Pinned Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.pinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any pinned feeds. - </Text> - </View> - ) : ( - savedFeeds.pinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop && styles.desktopContainer, + ]}> + <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> + <ScrollView style={s.flex1}> + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Pinned Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !currentFeeds.pinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any pinned feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Saved Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.unpinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any saved feeds. - </Text> - </View> - ) : ( - savedFeeds.unpinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + currentFeeds.pinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Saved Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !unpinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any saved feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} + unpinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned={false} + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} - <View style={styles.footerText}> - <Text type="sm" style={pal.textLight}> + <View style={styles.footerText}> + <Text type="sm" style={pal.textLight}> + <Trans> Feeds are custom algorithms that users build with a little coding expertise.{' '} <TextLink @@ -145,48 +163,95 @@ export const SavedFeeds = withAuthRequired( text="See this guide" />{' '} for more information. - </Text> - </View> - <View style={{height: 100}} /> - </ScrollView> - </CenteredView> - ) - }), -) + </Trans> + </Text> + </View> + <View style={{height: 100}} /> + </ScrollView> + </CenteredView> + ) +} -const ListItem = observer(function ListItemImpl({ - savedFeeds, - item, +function ListItem({ + feedUri, + isPinned, + currentFeeds, + setSavedFeeds, + resetSaveFeedsMutationState, }: { - savedFeeds: SavedFeedsModel - item: FeedSourceModel + feedUri: string // uri + isPinned: boolean + currentFeeds: {saved: string[]; pinned: string[]} + setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync'] + resetSaveFeedsMutationState: ReturnType< + typeof useSetSaveFeedsMutation + >['reset'] }) { const pal = usePalette('default') - const isPinned = item.isPinned + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + const {isPending: isUnpinPending, mutateAsync: unpinFeed} = + useUnpinFeedMutation() + const isPending = isPinPending || isUnpinPending - const onTogglePinned = useCallback(() => { + const onTogglePinned = React.useCallback(async () => { Haptics.default() - item.togglePin().catch(e => { + + try { + resetSaveFeedsMutationState() + + if (isPinned) { + await unpinFeed({uri: feedUri}) + } else { + await pinFeed({uri: feedUri}) + } + } catch (e) { Toast.show('There was an issue contacting the server') logger.error('Failed to toggle pinned feed', {error: e}) - }) - }, [item]) - const onPressUp = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'up').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) - const onPressDown = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'down').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) + } + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) + + const onPressUp = React.useCallback(async () => { + if (!isPinned) return + + // create new array, do not mutate + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index === 0) return + ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) + + const onPressDown = React.useCallback(async () => { + if (!isPinned) return + + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index >= pinned.length - 1) return + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) return ( <Pressable @@ -194,43 +259,62 @@ const ListItem = observer(function ListItemImpl({ style={[styles.itemContainer, pal.border]}> {isPinned ? ( <View style={styles.webArrowButtonsContainer}> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressUp} - hitSlop={HITSLOP_TOP}> + hitSlop={HITSLOP_TOP} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-up" size={12} style={[pal.text, styles.webArrowUpButton]} /> - </TouchableOpacity> - <TouchableOpacity + </Pressable> + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressDown} - hitSlop={HITSLOP_BOTTOM}> + hitSlop={HITSLOP_BOTTOM} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} /> - </TouchableOpacity> + </Pressable> </View> ) : null} <FeedSourceCard - key={item.uri} - item={item} - showSaveBtn + key={feedUri} + feedUri={feedUri} style={styles.noBorder} + showSaveBtn + LoadingComponent={ + <FeedLoadingPlaceholder + style={{flex: 1}} + showLowerPlaceholder={false} + showTopBorder={false} + /> + } /> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" hitSlop={10} - onPress={onTogglePinned}> + onPress={onTogglePinned} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="thumb-tack" size={20} color={isPinned ? colors.blue3 : pal.colors.icon} /> - </TouchableOpacity> + </Pressable> </Pressable> ) -}) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx deleted file mode 100644 index bf9857df4..000000000 --- a/src/view/screens/Search.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './SearchMobile' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx deleted file mode 100644 index 2d0c0288a..000000000 --- a/src/view/screens/Search.web.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {View, StyleSheet} from 'react-native' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {observer} from 'mobx-react-lite' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {useStores} from 'state/index' -import {CenteredView} from 'view/com/util/Views' -import * as Mobile from './SearchMobile' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({navigation, route}: Props) { - const store = useStores() - const params = route.params || {} - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const searchUIModel = React.useMemo<SearchUIModel | undefined>( - () => (params.q ? new SearchUIModel(store) : undefined), - [params.q, store], - ) - - React.useEffect(() => { - if (params.q && searchUIModel) { - searchUIModel.fetch(params.q) - } - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - }, [foafs, suggestedActors, searchUIModel, params.q]) - - const {isDesktop} = useWebMediaQueries() - - if (searchUIModel) { - return ( - <View style={styles.scrollContainer}> - <SearchResults model={searchUIModel} /> - </View> - ) - } - - if (!isDesktop) { - return ( - <CenteredView style={styles.scrollContainer}> - <Mobile.SearchScreen navigation={navigation} route={route} /> - </CenteredView> - ) - } - - return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> - }), -) - -const styles = StyleSheet.create({ - scrollContainer: { - height: '100%', - overflowY: 'auto', - }, -}) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx new file mode 100644 index 000000000..f031abcc2 --- /dev/null +++ b/src/view/screens/Search/Search.tsx @@ -0,0 +1,658 @@ +import React from 'react' +import { + View, + StyleSheet, + ActivityIndicator, + RefreshControl, + TextInput, + Pressable, + Platform, +} from 'react-native' +import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views' +import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useFocusEffect} from '@react-navigation/native' + +import {logger} from '#/logger' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' +import {Text} from '#/view/com/util/text/Text' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {Post} from '#/view/com/post/Post' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {HITSLOP_10} from '#/lib/constants' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {usePalette} from '#/lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSession} from '#/state/session' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useSetDrawerOpen} from '#/state/shell' +import {useAnalytics} from '#/lib/analytics/analytics' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {useModerationOpts} from '#/state/queries/preferences' +import {SearchResultCard} from '#/view/shell/desktop/Search' +import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' +import {isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {s} from '#/lib/styles' + +function Loader() { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <CenteredView + style={[ + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + pal.border, + ]} + sideBorders={!isMobile}> + <ActivityIndicator /> + </CenteredView> + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + + return ( + <CenteredView + sideBorders={!isMobile} + style={[ + pal.border, + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + ]}> + <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> + <Text style={[pal.text]}> + <Trans>{message}</Trans> + </Text> + + {error && ( + <> + <View + style={[ + { + marginVertical: 12, + height: 1, + width: '100%', + backgroundColor: pal.text.color, + opacity: 0.2, + }, + ]} + /> + + <Text style={[pal.textLight]}> + <Trans>Error:</Trans> {error} + </Text> + </> + )} + </View> + </CenteredView> + ) +} + +function SearchScreenSuggestedFollows() { + const pal = usePalette('default') + 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) { + friendsOfFriends.set(user.did, user) + } + }), + ), + ) + + setSuggestions(Array.from(friendsOfFriends.values())) + } + + try { + getSuggestions() + } catch (e) { + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { + error: e, + }) + } + }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) + + return suggestions.length ? ( + <FlatList + data={suggestions} + renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 1200}} + /> + ) : ( + <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> + <ProfileCardFeedLoadingPlaceholder /> + <ProfileCardFeedLoadingPlaceholder /> + </CenteredView> + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +function SearchScreenPostResults({query}: {query: string}) { + const {_} = useLingui() + const pal = usePalette('default') + const [isPTR, setIsPTR] = React.useState(false) + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useSearchPostsQuery({query}) + + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + + const posts = React.useMemo(() => { + return results?.pages.flatMap(page => page.posts) || [] + }, [results]) + const items = React.useMemo(() => { + let temp: SearchResultSlice[] = [] + + for (const post of posts) { + temp.push({ + type: 'post', + key: post.uri, + post, + }) + } + + if (isFetchingNextPage) { + temp.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return temp + }, [posts, isFetchingNextPage]) + + return error ? ( + <EmptyState + message={_( + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, + )} + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <FlatList + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} /> + } else { + return <Loader /> + } + }} + keyExtractor={item => item.key} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} + +function SearchScreenUserResults({query}: {query: string}) { + const {_} = useLingui() + const [isFetched, setIsFetched] = React.useState(false) + const [results, setResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const search = useActorAutocompleteFn() + + React.useEffect(() => { + async function getResults() { + try { + const searchResults = await search({query, limit: 30}) + + if (searchResults) { + setResults(searchResults) + } + } catch (e: any) { + logger.error(`SearchScreenUserResults: failed to get results`, { + error: e.toString(), + }) + } finally { + setIsFetched(true) + } + } + + if (query) { + getResults() + } else { + setResults([]) + setIsFetched(false) + } + }, [query, search, setResults]) + + return isFetched ? ( + <> + {results.length ? ( + <FlatList + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn profile={item} noBg /> + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} + +const SECTIONS = ['Posts', 'Users'] +export function SearchScreenInner({query}: {query?: string}) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const {hasSession} = useSession() + const {isDesktop} = useWebMediaQueries() + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setMinimalShellMode], + ) + + return query ? ( + <Pager + tabBarPosition="top" + onPageSelected={onPageSelected} + renderTabBar={props => ( + <CenteredView sideBorders style={pal.border}> + <TabBar items={SECTIONS} {...props} /> + </CenteredView> + )} + initialPage={0}> + <View> + <SearchScreenPostResults query={query} /> + </View> + <View> + <SearchScreenUserResults query={query} /> + </View> + </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 /> + </View> + ) : ( + <CenteredView sideBorders style={pal.border}> + <View + // @ts-ignore web only -esb + style={{ + height: Platform.select({web: '100vh'}), + }}> + {isDesktop && ( + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + borderBottomWidth: 1, + }, + ]}> + <Trans>Search</Trans> + </Text> + )} + + <Text + style={[ + pal.textLight, + {textAlign: 'center', paddingVertical: 12, paddingHorizontal: 18}, + ]}> + <Trans>Search for posts and users.</Trans> + </Text> + </View> + </CenteredView> + ) +} + +export function SearchScreenDesktop( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const {isDesktop} = useWebMediaQueries() + + return isDesktop ? ( + <SearchScreenInner query={props.route.params?.q} /> + ) : ( + <SearchScreenMobile {...props} /> + ) +} + +export function SearchScreenMobile( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const theme = useTheme() + 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 {isTablet} = useWebMediaQueries() + + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>(props.route?.params?.q || '') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const [inputIsFocused, setInputIsFocused] = React.useState(false) + const [showAutocompleteResults, setShowAutocompleteResults] = + React.useState(false) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + setDrawerOpen(true) + }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + textInput.current?.blur() + setQuery('') + setShowAutocompleteResults(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + setQuery('') + setShowAutocompleteResults(false) + }, [setQuery]) + const onChangeText = React.useCallback( + async (text: string) => { + setQuery(text) + + if (text.length > 0) { + setIsFetching(true) + setShowAutocompleteResults(true) + + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + + searchDebounceTimeout.current = setTimeout(async () => { + const results = await search({query: text, limit: 30}) + + if (results) { + setSearchResults(results) + setIsFetching(false) + } + }, 300) + } else { + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + setSearchResults([]) + setIsFetching(false) + setShowAutocompleteResults(false) + } + }, + [setQuery, search, setSearchResults], + ) + const onSubmit = React.useCallback(() => { + setShowAutocompleteResults(false) + }, [setShowAutocompleteResults]) + + const onSoftReset = React.useCallback(() => { + onPressCancelSearch() + }, [onPressCancelSearch]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + return listenSoftReset(onSoftReset) + }, [onSoftReset, setMinimalShellMode]), + ) + + return ( + <View style={{flex: 1}}> + <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint="Access navigation links and settings"> + <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> + </Pressable> + + <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={() => setInputIsFocused(true)} + onBlur={() => setInputIsFocused(false)} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + ) : undefined} + </View> + + {query || inputIsFocused ? ( + <View style={styles.headerCancelBtn}> + <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Text style={[pal.text]}> + <Trans>Cancel</Trans> + </Text> + </Pressable> + </View> + ) : undefined} + </CenteredView> + + {showAutocompleteResults && moderationOpts ? ( + <> + {isFetching ? ( + <Loader /> + ) : ( + <ScrollView style={{height: '100%'}}> + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) : ( + <SearchScreenInner query={query} /> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 4, + }, + headerMenuBtn: { + width: 30, + height: 30, + borderRadius: 30, + marginRight: 6, + paddingBottom: 2, + alignItems: 'center', + justifyContent: 'center', + }, + headerSearchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, + }, + headerSearchIcon: { + marginRight: 6, + alignSelf: 'center', + }, + headerSearchInput: { + flex: 1, + fontSize: 17, + }, + headerCancelBtn: { + paddingLeft: 10, + }, +}) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx new file mode 100644 index 000000000..a65149bf7 --- /dev/null +++ b/src/view/screens/Search/index.tsx @@ -0,0 +1,3 @@ +import {SearchScreenMobile} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenMobile diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx new file mode 100644 index 000000000..8e039e3cd --- /dev/null +++ b/src/view/screens/Search/index.web.tsx @@ -0,0 +1,3 @@ +import {SearchScreenDesktop} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenDesktop diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx deleted file mode 100644 index c1df58ffd..000000000 --- a/src/view/screens/SearchMobile.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, {useCallback} from 'react' -import { - StyleSheet, - TouchableWithoutFeedback, - Keyboard, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList, ScrollView} from 'view/com/util/Views' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {s} from 'lib/styles' -import {ProfileCard} from 'view/com/profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {isAndroid, isIOS} from 'platform/detection' -import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer<Props>(function SearchScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const scrollViewRef = React.useRef<ScrollView>(null) - const flatListRef = React.useRef<FlatList>(null) - const [onMainScroll] = useOnMainScroll() - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const [searchUIModel, setSearchUIModel] = React.useState< - SearchUIModel | undefined - >() - - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - }, - [setQuery, autocompleteView], - ) - - const onPressClearQuery = React.useCallback(() => { - setQuery('') - }, [setQuery]) - - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - autocompleteView.setActive(false) - setSearchUIModel(undefined) - setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) - - const onSubmitQuery = React.useCallback(() => { - if (query.length === 0) { - return - } - - const model = new SearchUIModel(store) - model.fetch(query) - setSearchUIModel(model) - setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) - - const onSoftReset = React.useCallback(() => { - scrollViewRef.current?.scrollTo({x: 0, y: 0}) - flatListRef.current?.scrollToOffset({offset: 0}) - onPressCancelSearch() - }, [scrollViewRef, flatListRef, onPressCancelSearch]) - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [ - store, - autocompleteView, - foafs, - suggestedActors, - onSoftReset, - setMinimalShellMode, - ]), - ) - - const onPress = useCallback(() => { - if (isIOS || isAndroid) { - Keyboard.dismiss() - } - }, []) - - return ( - <TouchableWithoutFeedback onPress={onPress} accessible={false}> - <View style={[pal.view, styles.container]}> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - {searchUIModel ? ( - <SearchResults model={searchUIModel} /> - ) : !isInputFocused && !query ? ( - <Suggestions - ref={flatListRef} - foafs={foafs} - suggestedActors={suggestedActors} - /> - ) : ( - <ScrollView - ref={scrollViewRef} - testID="searchScrollView" - style={pal.view} - onScroll={onMainScroll} - scrollEventThrottle={100}> - {query && autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((suggestion, index) => ( - <ProfileCard - key={suggestion.did} - testID={`searchAutoCompleteResult-${suggestion.handle}`} - profile={suggestion} - noBorder={index === 0} - /> - ))} - </> - ) : query && !autocompleteView.suggestions.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 and posts on the network - </Text> - </View> - ) : null} - <View style={s.footerSpacer} /> - </ScrollView> - )} - </View> - </TouchableWithoutFeedback> - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index ca4ef2a40..388a5d954 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -10,20 +10,13 @@ import { View, ViewStyle, } from 'react-native' -import { - useFocusEffect, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import * as AppInfo from 'lib/app-info' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -39,662 +32,766 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' -import {pluralize} from 'lib/strings/helpers' import {HandIcon, HashtagIcon} from 'lib/icons' -import {formatCount} from 'view/com/util/numeric/format' import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {logger} from '#/logger' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useModalControls} from '#/state/modals' import { useSetMinimalShellMode, useColorMode, useSetColorMode, + useOnboardingDispatch, } from '#/state/shell' +import { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from '#/state/preferences' +import { + useSession, + useSessionApi, + SessionAccount, + getAgent, +} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' +import {clearLegacyStorage} from '#/state/persisted/legacy' // TEMPORARY (APP-700) // remove after backend testing finishes // -prf import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header' import {STATUS_PAGE_URL} from 'lib/constants' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +function SettingsAccountCard({account}: {account: SessionAccount}) { + const pal = usePalette('default') + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {data: profile} = useProfileQuery({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {onPressSwitchAccount} = useAccountSwitcher() + + const contents = ( + <View style={[pal.view, styles.linkCard]}> + <View style={styles.avi}> + <UserAvatar size={40} avatar={profile?.avatar} /> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text}> + {profile?.displayName || account.handle} + </Text> + <Text type="sm" style={pal.textLight}> + {account.handle} + </Text> + </View> -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> -export const SettingsScreen = withAuthRequired( - observer(function Settings({}: Props) { - const colorMode = useColorMode() - const setColorMode = useSetColorMode() - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const navigation = useNavigation<NavigationProp>() - const {isMobile} = useWebMediaQueries() - const {screen, track} = useAnalytics() - const [isSwitching, setIsSwitching, onPressSwitchAccount] = - useAccountSwitcher() - const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( - store.agent, - ) + {isCurrentAccount ? ( + <TouchableOpacity + testID="signOutBtn" + onPress={logout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + Sign out + </Text> + </TouchableOpacity> + ) : ( + <AccountDropdownBtn account={account} /> + )} + </View> + ) + + return isCurrentAccount ? ( + <Link + href={makeProfileLink({ + did: currentAccount?.did, + handle: currentAccount?.handle, + })} + title="Your profile" + noFeedback> + {contents} + </Link> + ) : ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + onPress={ + isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + </TouchableOpacity> + ) +} - const primaryBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.blue0}, - dark: {backgroundColor: colors.blue6}, - }) - const primaryText = useCustomPalette<TextStyle>({ - light: {color: colors.blue3}, - dark: {color: colors.blue2}, +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> +export function SettingsScreen({}: Props) { + const queryClient = useQueryClient() + const colorMode = useColorMode() + const setColorMode = useSetColorMode() + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const requireAltTextEnabled = useRequireAltTextEnabled() + const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const onboardingDispatch = useOnboardingDispatch() + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const {screen, track} = useAnalytics() + const {openModal} = useModalControls() + const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( + getAgent(), + ) + const {mutate: clearPreferences} = useClearPreferencesMutation() + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAllActiveElements = useCloseAllActiveElements() + + const primaryBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.blue0}, + dark: {backgroundColor: colors.blue6}, + }) + const primaryText = useCustomPalette<TextStyle>({ + light: {color: colors.blue3}, + dark: {color: colors.blue2}, + }) + + const dangerBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.red1}, + dark: {backgroundColor: colors.red7}, + }) + const dangerText = useCustomPalette<TextStyle>({ + light: {color: colors.red4}, + dark: {color: colors.red2}, + }) + + useFocusEffect( + React.useCallback(() => { + screen('Settings') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + const onPressAddAccount = React.useCallback(() => { + track('Settings:AddAccountButtonClicked') + setShowLoggedOut(true) + closeAllActiveElements() + }, [track, setShowLoggedOut, closeAllActiveElements]) + + const onPressChangeHandle = React.useCallback(() => { + track('Settings:ChangeHandleButtonClicked') + openModal({ + name: 'change-handle', + onChanged() { + if (currentAccount) { + // refresh my profile + queryClient.invalidateQueries({ + queryKey: RQKEY_PROFILE(currentAccount.did), + }) + } + }, }) + }, [track, queryClient, openModal, currentAccount]) - const dangerBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.red1}, - dark: {backgroundColor: colors.red7}, - }) - const dangerText = useCustomPalette<TextStyle>({ - light: {color: colors.red4}, - dark: {color: colors.red2}, - }) + const onPressInviteCodes = React.useCallback(() => { + track('Settings:InvitecodesButtonClicked') + openModal({name: 'invite-codes'}) + }, [track, openModal]) - useFocusEffect( - React.useCallback(() => { - screen('Settings') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + const onPressLanguageSettings = React.useCallback(() => { + navigation.navigate('LanguageSettings') + }, [navigation]) + + const onPressDeleteAccount = React.useCallback(() => { + openModal({name: 'delete-account'}) + }, [openModal]) - const onPressAddAccount = React.useCallback(() => { - track('Settings:AddAccountButtonClicked') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() - }, [track, navigation, store]) - - const onPressChangeHandle = React.useCallback(() => { - 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 => { - logger.error('Failed to reload from server after handle update', { - error: err, - }) - setIsSwitching(false) - }, - ) - }, - }) - }, [track, store, setIsSwitching]) - - const onPressInviteCodes = React.useCallback(() => { - track('Settings:InvitecodesButtonClicked') - store.shell.openModal({name: 'invite-codes'}) - }, [track, store]) - - const onPressLanguageSettings = React.useCallback(() => { - navigation.navigate('LanguageSettings') - }, [navigation]) - - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - - const onPressDeleteAccount = React.useCallback(() => { - store.shell.openModal({name: 'delete-account'}) - }, [store]) - - const onPressResetPreferences = React.useCallback(async () => { - await store.preferences.reset() - Toast.show('Preferences reset') - }, [store]) - - const onPressResetOnboarding = React.useCallback(async () => { - store.onboarding.reset() - Toast.show('Onboarding reset') - }, [store]) - - const onPressBuildInfo = React.useCallback(() => { - Clipboard.setString( - `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, - ) - Toast.show('Copied build version to clipboard') - }, []) - - const openHomeFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesHomeFeed') - }, [navigation]) - - const openThreadsPreferences = React.useCallback(() => { - navigation.navigate('PreferencesThreads') - }, [navigation]) - - const onPressAppPasswords = React.useCallback(() => { - navigation.navigate('AppPasswords') - }, [navigation]) - - const onPressSystemLog = React.useCallback(() => { - navigation.navigate('Log') - }, [navigation]) - - const onPressStorybook = React.useCallback(() => { - navigation.navigate('Debug') - }, [navigation]) - - const onPressSavedFeeds = React.useCallback(() => { - navigation.navigate('SavedFeeds') - }, [navigation]) - - const onPressStatusPage = React.useCallback(() => { - Linking.openURL(STATUS_PAGE_URL) - }, []) - - return ( - <View style={[s.hContentRegion]} testID="settingsScreen"> - <ViewHeader title="Settings" /> - <ScrollView - style={[s.hContentRegion]} - contentContainerStyle={isMobile && pal.viewLight} - scrollIndicatorInsets={{right: 1}}> - <View style={styles.spacer20} /> - {store.session.currentSession !== undefined ? ( - <> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Account + const onPressResetPreferences = React.useCallback(async () => { + clearPreferences() + }, [clearPreferences]) + + const onPressResetOnboarding = React.useCallback(async () => { + onboardingDispatch({type: 'start'}) + Toast.show('Onboarding reset') + }, [onboardingDispatch]) + + const onPressBuildInfo = React.useCallback(() => { + Clipboard.setString( + `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, + ) + Toast.show('Copied build version to clipboard') + }, []) + + const openHomeFeedPreferences = React.useCallback(() => { + navigation.navigate('PreferencesHomeFeed') + }, [navigation]) + + const openThreadsPreferences = React.useCallback(() => { + navigation.navigate('PreferencesThreads') + }, [navigation]) + + const onPressAppPasswords = React.useCallback(() => { + navigation.navigate('AppPasswords') + }, [navigation]) + + const onPressSystemLog = React.useCallback(() => { + navigation.navigate('Log') + }, [navigation]) + + const onPressStorybook = React.useCallback(() => { + navigation.navigate('Debug') + }, [navigation]) + + const onPressSavedFeeds = React.useCallback(() => { + navigation.navigate('SavedFeeds') + }, [navigation]) + + const onPressStatusPage = React.useCallback(() => { + Linking.openURL(STATUS_PAGE_URL) + }, []) + + const clearAllStorage = React.useCallback(async () => { + await clearStorage() + Toast.show(`Storage cleared, you need to restart the app now.`) + }, []) + const clearAllLegacyStorage = React.useCallback(async () => { + await clearLegacyStorage() + Toast.show(`Legacy storage cleared, you need to restart the app now.`) + }, []) + + return ( + <View style={[s.hContentRegion]} testID="settingsScreen"> + <ViewHeader title={_(msg`Settings`)} /> + <ScrollView + style={[s.hContentRegion]} + contentContainerStyle={isMobile && pal.viewLight} + scrollIndicatorInsets={{right: 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> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Email:{' '} - </Text> - {!store.session.emailNeedsConfirmation && ( - <> - <FontAwesomeIcon - icon="check" - size={10} - style={{color: colors.green3, marginRight: 2}} - /> - </> - )} - <Text type="lg" style={pal.text}> - {store.session.currentSession?.email}{' '} - </Text> - <Link - onPress={() => store.shell.openModal({name: 'change-email'})}> - <Text type="lg" style={pal.link}> - Change - </Text> - </Link> - </View> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Birthday:{' '} + {currentAccount.emailConfirmed && ( + <> + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3, marginRight: 2}} + /> + </> + )} + <Text type="lg" style={pal.text}> + {currentAccount.email}{' '} + </Text> + <Link onPress={() => openModal({name: 'change-email'})}> + <Text type="lg" style={pal.link}> + <Trans>Change</Trans> </Text> - <Link - onPress={() => - store.shell.openModal({name: 'birth-date-settings'}) - }> - <Text type="lg" style={pal.link}> - Show - </Text> - </Link> - </View> - <View style={styles.spacer20} /> - <EmailConfirmationNotice /> - </> - ) : null} - <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 /> + </Link> </View> - ) : ( - <Link - href={makeProfileLink(store.me)} - 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} - accessibilityRole="button" - accessibilityLabel="Sign out" - accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> - <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) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - <View style={styles.avi}> - <UserAvatar size={40} avatar={account.aviUrl} /> - </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} + <View style={[styles.infoLine]}> + <Text type="lg-medium" style={pal.text}> + <Trans>Birthday:</Trans>{' '} + </Text> + <Link onPress={() => openModal({name: 'birth-date-settings'})}> + <Text type="lg" style={pal.link}> + <Trans>Show</Trans> </Text> - </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="switchToNewAccountBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressAddAccount} - accessibilityRole="button" - accessibilityLabel="Add account" - accessibilityHint="Create a new Bluesky account"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="plus" - style={pal.text as FontAwesomeIconStyle} - /> + </Link> </View> - <Text type="lg" style={pal.text}> - Add account - </Text> - </TouchableOpacity> + <View style={styles.spacer20} /> + + {!currentAccount.emailConfirmed && <EmailConfirmationNotice />} + </> + ) : null} + <View style={[s.flexRow, styles.heading]}> + <Text type="xl-bold" style={pal.text}> + <Trans>Signed in as</Trans> + </Text> + <View style={s.flex1} /> + </View> - <View style={styles.spacer20} /> + {isSwitchingAccounts ? ( + <View style={[pal.view, styles.linkCard]}> + <ActivityIndicator /> + </View> + ) : ( + <SettingsAccountCard account={currentAccount!} /> + )} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SettingsAccountCard key={account.did} account={account} /> + ))} - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Invite a Friend + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Add account`)} + accessibilityHint="Create a new Bluesky account"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="plus" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Add account</Trans> </Text> - <TouchableOpacity - testID="inviteFriendBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressInviteCodes} - accessibilityRole="button" - accessibilityLabel="Invite" - accessibilityHint="Opens invite code list"> - <View - style={[ - styles.iconContainer, - store.me.invitesAvailable > 0 ? primaryBg : pal.btn, - ]}> - <FontAwesomeIcon - icon="ticket" - style={ - (store.me.invitesAvailable > 0 - ? primaryText - : pal.text) as FontAwesomeIconStyle - } - /> - </View> - <Text - type="lg" - style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Accessibility - </Text> - <View style={[pal.view, styles.toggleCard]}> - <ToggleButton - type="default-light" - label="Require alt text before posting" - labelType="lg" - isSelected={store.preferences.requireAltTextEnabled} - onPress={store.preferences.toggleRequireAltTextEnabled} + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Invite a Friend</Trans> + </Text> + + <TouchableOpacity + testID="inviteFriendBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel={_(msg`Invite`)} + accessibilityHint="Opens invite code list" + disabled={invites?.disabled}> + <View + style={[ + styles.iconContainer, + invitesAvailable > 0 ? primaryBg : pal.btn, + ]}> + <FontAwesomeIcon + icon="ticket" + style={ + (invitesAvailable > 0 + ? primaryText + : pal.text) as FontAwesomeIconStyle + } /> </View> + <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}> + {invites?.disabled ? ( + <Trans> + Your invite codes are hidden when logged in using an App + Password + </Trans> + ) : invitesAvailable === 1 ? ( + <Trans>{invitesAvailable} invite code available</Trans> + ) : ( + <Trans>{invitesAvailable} invite codes available</Trans> + )} + </Text> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Appearance - </Text> - <View> - <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> - <SelectableBtn - selected={colorMode === 'system'} - label="System" - left - onSelect={() => setColorMode('system')} - accessibilityHint="Set color theme to system setting" - /> - <SelectableBtn - selected={colorMode === 'light'} - label="Light" - onSelect={() => setColorMode('light')} - accessibilityHint="Set color theme to light" - /> - <SelectableBtn - selected={colorMode === 'dark'} - label="Dark" - right - onSelect={() => setColorMode('dark')} - accessibilityHint="Set color theme to dark" - /> - </View> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Accessibility</Trans> + </Text> + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label="Require alt text before posting" + labelType="lg" + isSelected={requireAltTextEnabled} + onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} + /> + </View> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Appearance</Trans> + </Text> + <View> + <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> + <SelectableBtn + selected={colorMode === 'system'} + label="System" + left + onSelect={() => setColorMode('system')} + accessibilityHint="Set color theme to system setting" + /> + <SelectableBtn + selected={colorMode === 'light'} + label="Light" + onSelect={() => setColorMode('light')} + accessibilityHint="Set color theme to light" + /> + <SelectableBtn + selected={colorMode === 'dark'} + label="Dark" + right + onSelect={() => setColorMode('dark')} + accessibilityHint="Set color theme to dark" + /> </View> - <View style={styles.spacer20} /> + </View> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Basics + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Basics</Trans> + </Text> + <TouchableOpacity + testID="preferencesHomeFeedButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openHomeFeedPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the home feed preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="sliders" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Home Feed Preferences</Trans> </Text> - <TouchableOpacity - testID="preferencesHomeFeedButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openHomeFeedPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the home feed preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="sliders" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Home Feed Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="preferencesThreadsButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openThreadsPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the threads preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon={['far', 'comments']} - style={pal.text as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={pal.text}> - Thread Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="savedFeedsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - accessibilityHint="My Saved Feeds" - accessibilityLabel="Opens screen with all saved feeds" - onPress={onPressSavedFeeds}> - <View style={[styles.iconContainer, pal.btn]}> - <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> - </View> - <Text type="lg" style={pal.text}> - My Saved Feeds - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="languageSettingsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressLanguageSettings} - accessibilityRole="button" - accessibilityHint="Language settings" - accessibilityLabel="Opens configurable language settings"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="language" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Languages - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="moderationBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => navigation.navigate('Moderation') - } - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens moderation settings"> - <View style={[styles.iconContainer, pal.btn]}> - <HandIcon style={pal.text} size={18} strokeWidth={6} /> - </View> - <Text type="lg" style={pal.text}> - Moderation - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Advanced + </TouchableOpacity> + <TouchableOpacity + testID="preferencesThreadsButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openThreadsPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the threads preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon={['far', 'comments']} + style={pal.text as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Thread Preferences</Trans> </Text> - <TouchableOpacity - testID="appPasswordBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={onPressAppPasswords} - accessibilityRole="button" - accessibilityHint="Open app password settings" - accessibilityLabel="Opens the app password settings page"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="lock" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - App passwords - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="changeHandleBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressChangeHandle} - accessibilityRole="button" - accessibilityLabel="Change handle" - accessibilityHint="Choose a new Bluesky username or create"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="at" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text} numberOfLines={1}> - Change handle - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Danger Zone + </TouchableOpacity> + <TouchableOpacity + testID="savedFeedsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + accessibilityHint="My Saved Feeds" + accessibilityLabel={_(msg`Opens screen with all saved feeds`)} + onPress={onPressSavedFeeds}> + <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 - style={[pal.view, styles.linkCard]} - onPress={onPressDeleteAccount} - accessible={true} - accessibilityRole="button" - accessibilityLabel="Delete account" - accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> - <View style={[styles.iconContainer, dangerBg]}> - <FontAwesomeIcon - icon={['far', 'trash-can']} - style={dangerText as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={dangerText}> - Delete my account… - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Developer Tools + </TouchableOpacity> + <TouchableOpacity + testID="languageSettingsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings} + accessibilityRole="button" + accessibilityHint="Language settings" + accessibilityLabel={_(msg`Opens configurable language settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="language" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Languages</Trans> </Text> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressSystemLog} - accessibilityRole="button" - accessibilityHint="Open system log" - accessibilityLabel="Opens the system log page"> - <Text type="lg" style={pal.text}> - System log - </Text> - </TouchableOpacity> - {__DEV__ ? ( - <ToggleButton - type="default-light" - label="Experiment: Use AppView Proxy" - isSelected={debugHeaderEnabled} - onPress={toggleDebugHeader} + </TouchableOpacity> + <TouchableOpacity + testID="moderationBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={ + isSwitchingAccounts + ? undefined + : () => navigation.navigate('Moderation') + } + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens moderation settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <HandIcon style={pal.text} size={18} strokeWidth={6} /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Advanced</Trans> + </Text> + <TouchableOpacity + testID="appPasswordBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={onPressAppPasswords} + accessibilityRole="button" + accessibilityHint="Open app password settings" + accessibilityLabel={_(msg`Opens the app password settings page`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="lock" + style={pal.text as FontAwesomeIconStyle} /> - ) : null} - {__DEV__ ? ( - <> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressStorybook} - accessibilityRole="button" - accessibilityHint="Open storybook page" - accessibilityLabel="Opens the storybook page"> - <Text type="lg" style={pal.text}> - Storybook - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetPreferences} - accessibilityRole="button" - accessibilityHint="Reset preferences" - accessibilityLabel="Resets the preferences state"> - <Text type="lg" style={pal.text}> - Reset preferences state - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetOnboarding} - accessibilityRole="button" - accessibilityHint="Reset onboarding" - accessibilityLabel="Resets the onboarding state"> - <Text type="lg" style={pal.text}> - Reset onboarding state - </Text> - </TouchableOpacity> - </> - ) : null} - <View style={[styles.footer]}> + </View> + <Text type="lg" style={pal.text}> + <Trans>App passwords</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="changeHandleBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} + accessibilityRole="button" + accessibilityLabel={_(msg`Change handle`)} + accessibilityHint="Choose a new Bluesky username or create"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="at" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text} numberOfLines={1}> + <Trans>Change handle</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Danger Zone</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCard]} + onPress={onPressDeleteAccount} + accessible={true} + accessibilityRole="button" + accessibilityLabel={_(msg`Delete account`)} + accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> + <View style={[styles.iconContainer, dangerBg]}> + <FontAwesomeIcon + icon={['far', 'trash-can']} + style={dangerText as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={dangerText}> + <Trans>Delete my account…</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Developer Tools</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressSystemLog} + accessibilityRole="button" + accessibilityHint="Open system log" + accessibilityLabel={_(msg`Opens the system log page`)}> + <Text type="lg" style={pal.text}> + <Trans>System log</Trans> + </Text> + </TouchableOpacity> + {__DEV__ ? ( + <ToggleButton + type="default-light" + label="Experiment: Use AppView Proxy" + isSelected={debugHeaderEnabled} + onPress={toggleDebugHeader} + /> + ) : null} + {__DEV__ ? ( + <> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressStorybook} accessibilityRole="button" - onPress={onPressBuildInfo}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Build version {AppInfo.appVersion} {AppInfo.updateChannel} + accessibilityHint="Open storybook page" + accessibilityLabel={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Storybook</Trans> </Text> </TouchableOpacity> - <Text type="sm" style={[pal.textLight]}> - · - </Text> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetPreferences} accessibilityRole="button" - onPress={onPressStatusPage}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Status page + accessibilityHint="Reset preferences" + accessibilityLabel={_(msg`Resets the preferences state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset preferences state</Trans> </Text> </TouchableOpacity> - </View> - <View style={s.footerSpacer} /> - </ScrollView> - </View> - ) - }), -) - -const EmailConfirmationNotice = observer( - function EmailConfirmationNoticeImpl() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - const {isMobile} = useWebMediaQueries() - - if (!store.session.emailNeedsConfirmation) { - return null - } - - return ( - <View style={{marginBottom: 20}}> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Verify email - </Text> - <View - style={[ - { - paddingVertical: isMobile ? 12 : 0, - paddingHorizontal: 18, - }, - pal.view, - ]}> - <View style={{flexDirection: 'row', marginBottom: 8}}> - <Pressable - style={[ - palInverted.view, - { - flexDirection: 'row', - gap: 6, - borderRadius: 6, - paddingHorizontal: 12, - paddingVertical: 10, - alignItems: 'center', - }, - isMobile && {flex: 1}, - ]} + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityLabel="Verify my email" - accessibilityHint="" - onPress={() => store.shell.openModal({name: 'verify-email'})}> - <FontAwesomeIcon - icon="envelope" - color={palInverted.colors.text} - size={16} - /> - <Text type="button" style={palInverted.text}> - Verify My Email + accessibilityHint="Reset onboarding" + accessibilityLabel={_(msg`Resets the onboarding state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset onboarding state</Trans> </Text> - </Pressable> - </View> - <Text style={pal.textLight}> - Protect your account by verifying your email. + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllLegacyStorage} + accessibilityRole="button" + accessibilityHint="Clear all legacy storage data" + accessibilityLabel={_(msg`Clear all legacy storage data`)}> + <Text type="lg" style={pal.text}> + <Trans> + Clear all legacy storage data (restart after this) + </Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllStorage} + accessibilityRole="button" + accessibilityHint="Clear all storage data" + accessibilityLabel={_(msg`Clear all storage data`)}> + <Text type="lg" style={pal.text}> + <Trans>Clear all storage data (restart after this)</Trans> + </Text> + </TouchableOpacity> + </> + ) : null} + <View style={[styles.footer]}> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressBuildInfo}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans> + Build version {AppInfo.appVersion} {AppInfo.updateChannel} + </Trans> + </Text> + </TouchableOpacity> + <Text type="sm" style={[pal.textLight]}> + · </Text> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressStatusPage}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans>Status page</Trans> + </Text> + </TouchableOpacity> + </View> + <View style={s.footerSpacer} /> + </ScrollView> + </View> + ) +} + +function EmailConfirmationNotice() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + + return ( + <View style={{marginBottom: 20}}> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Verify email</Trans> + </Text> + <View + style={[ + { + paddingVertical: isMobile ? 12 : 0, + paddingHorizontal: 18, + }, + pal.view, + ]}> + <View style={{flexDirection: 'row', marginBottom: 8}}> + <Pressable + style={[ + palInverted.view, + { + flexDirection: 'row', + gap: 6, + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: 'center', + }, + isMobile && {flex: 1}, + ]} + accessibilityRole="button" + accessibilityLabel={_(msg`Verify my email`)} + accessibilityHint="" + onPress={() => openModal({name: 'verify-email'})}> + <FontAwesomeIcon + icon="envelope" + color={palInverted.colors.text} + size={16} + /> + <Text type="button" style={palInverted.text}> + <Trans>Verify My Email</Trans> + </Text> + </Pressable> </View> + <Text style={pal.textLight}> + <Trans>Protect your account by verifying your email.</Trans> + </Text> </View> - ) - }, -) + </View> + ) +} const styles = StyleSheet.create({ dimmed: { diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index 7106b4136..6856f6759 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -10,11 +10,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {HELP_DESK_URL} from 'lib/constants' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'> export const SupportScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -24,19 +27,21 @@ export const SupportScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Support" /> + <ViewHeader title={_(msg`Support`)} /> <CenteredView> <Text type="title-xl" style={[pal.text, s.p20, s.pb5]}> - Support + <Trans>Support</Trans> </Text> <Text style={[pal.text, s.p20]}> - The support form has been moved. If you need help, please - <TextLink - href={HELP_DESK_URL} - text=" click here" - style={pal.link} - />{' '} - or visit {HELP_DESK_URL} to get in touch with us. + <Trans> + The support form has been moved. If you need help, please + <TextLink + href={HELP_DESK_URL} + text=" click here" + style={pal.link} + />{' '} + or visit {HELP_DESK_URL} to get in touch with us. + </Trans> </Text> </CenteredView> </View> diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx index b7a388b65..c20890e29 100644 --- a/src/view/screens/TermsOfService.tsx +++ b/src/view/screens/TermsOfService.tsx @@ -9,11 +9,14 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'TermsOfService'> export const TermsOfServiceScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -23,11 +26,11 @@ export const TermsOfServiceScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Terms of Service" /> + <ViewHeader title={_(msg`Terms of Service`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Terms of Service have been moved to{' '} + <Trans>The Terms of Service have been moved to</Trans>{' '} <TextLink style={pal.link} href="https://blueskyweb.xyz/support/tos" |