diff options
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/CustomFeed.tsx | 391 | ||||
-rw-r--r-- | src/view/screens/DiscoverFeeds.tsx | 157 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 320 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 87 | ||||
-rw-r--r-- | src/view/screens/Notifications.tsx | 57 | ||||
-rw-r--r-- | src/view/screens/PreferencesHomeFeed.tsx | 66 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 4 | ||||
-rw-r--r-- | src/view/screens/SavedFeeds.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 11 |
9 files changed, 569 insertions, 526 deletions
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index af4d01843..eaa21f292 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -1,7 +1,7 @@ import React, {useMemo, useRef} from 'react' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation} from '@react-navigation/native' +import {useNavigation, useIsFocused} from '@react-navigation/native' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {CommonNavigatorParams} from 'lib/routes/types' @@ -14,11 +14,8 @@ import {PostsFeedModel} from 'state/models/feeds/posts' import {useCustomFeed} from 'lib/hooks/useCustomFeed' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {Feed} from 'view/com/posts/Feed' -import {pluralize} from 'lib/strings/helpers' -import {sanitizeHandle} from 'lib/strings/handles' import {TextLink} from 'view/com/util/Link' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' @@ -34,7 +31,6 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {EmptyState} from 'view/com/util/EmptyState' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {makeProfileLink} from 'lib/routes/links' import {resolveName} from 'lib/api' import {CenteredView} from 'view/com/util/Views' import {NavigationProp} from 'lib/routes/types' @@ -125,7 +121,10 @@ export const CustomFeedScreenInner = observer( }: Props & {feedOwnerDid: string}) { const store = useStores() const pal = usePalette('default') - const {isTabletOrDesktop} = useWebMediaQueries() + const palInverted = usePalette('inverted') + const navigation = useNavigation<NavigationProp>() + const isScreenFocused = useIsFocused() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {track} = useAnalytics() const {rkey, name: handleOrDid} = route.params const uri = useMemo( @@ -186,6 +185,10 @@ export const CustomFeedScreenInner = observer( }) }, [store, currentFeed]) + const onPressViewAuthor = React.useCallback(() => { + navigation.navigate('Profile', {name: handleOrDid}) + }, [handleOrDid, navigation]) + const onPressShare = React.useCallback(() => { const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) shareUrl(url) @@ -210,9 +213,40 @@ export const CustomFeedScreenInner = observer( store.shell.openComposer({}) }, [store]) + const onSoftReset = React.useCallback(() => { + if (isScreenFocused) { + onScrollToTop() + algoFeed.refresh() + } + }, [isScreenFocused, onScrollToTop, algoFeed]) + + // fires when page within screen is activated/deactivated + React.useEffect(() => { + if (!isScreenFocused) { + return + } + + const softResetSub = store.onScreenSoftReset(onSoftReset) + return () => { + softResetSub.remove() + } + }, [store, onSoftReset, isScreenFocused]) + const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ { + testID: 'feedHeaderDropdownViewAuthorBtn', + label: 'View author', + onPress: onPressViewAuthor, + icon: { + ios: { + name: 'person', + }, + android: '', + web: ['far', 'user'], + }, + }, + { testID: 'feedHeaderDropdownToggleSavedBtn', label: currentFeed?.isSaved ? 'Remove from my feeds' @@ -260,232 +294,12 @@ export const CustomFeedScreenInner = observer( }, ] return items - }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare]) - - const renderHeaderBtns = React.useCallback(() => { - return ( - <View style={styles.headerBtns}> - <Button - type="default-light" - testID="toggleLikeBtn" - accessibilityLabel="Like this feed" - accessibilityHint="" - onPress={onToggleLiked}> - {currentFeed?.isLiked ? ( - <HeartIconSolid size={19} style={styles.liked} /> - ) : ( - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> - )} - </Button> - {currentFeed?.isSaved ? ( - <Button - type="default-light" - accessibilityLabel={ - isPinned ? 'Unpin this feed' : 'Pin this feed' - } - accessibilityHint="" - onPress={onTogglePinned}> - <FontAwesomeIcon - icon="thumb-tack" - size={17} - color={isPinned ? colors.blue3 : pal.colors.textLight} - style={styles.top1} - /> - </Button> - ) : undefined} - {!currentFeed?.isSaved ? ( - <Button - type="default-light" - onPress={onToggleSaved} - accessibilityLabel="Add to my feeds" - accessibilityHint="" - style={styles.headerAddBtn}> - <FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} /> - <Text type="xl-medium" style={pal.link}> - Add to My Feeds - </Text> - </Button> - ) : null} - <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> - <View - style={{ - paddingLeft: currentFeed?.isSaved ? 12 : 6, - paddingRight: 12, - paddingVertical: 8, - }}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.textLight} - /> - </View> - </NativeDropdown> - </View> - ) }, [ - pal, currentFeed?.isSaved, - currentFeed?.isLiked, - isPinned, - onToggleSaved, - onTogglePinned, - onToggleLiked, - dropdownItems, - ]) - - const renderListHeaderComponent = React.useCallback(() => { - return ( - <> - <View style={[styles.header, pal.border]}> - <View style={s.flex1}> - <Text - testID="feedName" - type="title-xl" - style={[pal.text, s.bold]}> - {currentFeed?.displayName} - </Text> - {currentFeed && ( - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - by{' '} - {currentFeed.data.creator.did === store.me.did ? ( - 'you' - ) : ( - <TextLink - text={sanitizeHandle( - currentFeed.data.creator.handle, - '@', - )} - href={makeProfileLink(currentFeed.data.creator)} - style={[pal.textLight]} - /> - )} - </Text> - )} - {isTabletOrDesktop && ( - <View style={[styles.headerBtns, styles.headerBtnsDesktop]}> - <Button - type={currentFeed?.isSaved ? 'default' : 'inverted'} - onPress={onToggleSaved} - accessibilityLabel={ - currentFeed?.isSaved - ? 'Unsave this feed' - : 'Save this feed' - } - accessibilityHint="" - label={ - currentFeed?.isSaved - ? 'Remove from My Feeds' - : 'Add to My Feeds' - } - /> - <Button - type="default" - accessibilityLabel={ - isPinned ? 'Unpin this feed' : 'Pin this feed' - } - accessibilityHint="" - onPress={onTogglePinned}> - <FontAwesomeIcon - icon="thumb-tack" - size={15} - color={isPinned ? colors.blue3 : pal.colors.icon} - style={styles.top2} - /> - </Button> - <Button - type="default" - accessibilityLabel="Like this feed" - accessibilityHint="" - onPress={onToggleLiked}> - {currentFeed?.isLiked ? ( - <HeartIconSolid size={18} style={styles.liked} /> - ) : ( - <HeartIcon strokeWidth={3} size={18} style={pal.icon} /> - )} - </Button> - <Button - type="default" - accessibilityLabel="Share this feed" - accessibilityHint="" - onPress={onPressShare}> - <FontAwesomeIcon - icon="share" - size={18} - color={pal.colors.icon} - /> - </Button> - <Button - type="default" - accessibilityLabel="Report this feed" - accessibilityHint="" - onPress={onPressReport}> - <FontAwesomeIcon - icon="circle-exclamation" - size={18} - color={pal.colors.icon} - /> - </Button> - </View> - )} - </View> - <View> - <UserAvatar - type="algo" - avatar={currentFeed?.data.avatar} - size={64} - /> - </View> - </View> - <View style={styles.headerDetails}> - {currentFeed?.data.description ? ( - <Text style={[pal.text, s.mb10]} numberOfLines={6}> - {currentFeed.data.description} - </Text> - ) : null} - <View style={styles.headerDetailsFooter}> - {currentFeed ? ( - <TextLink - type="md-medium" - style={pal.textLight} - href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`} - text={`Liked by ${currentFeed.data.likeCount} ${pluralize( - currentFeed?.data.likeCount || 0, - 'user', - )}`} - /> - ) : null} - </View> - </View> - <View - style={[ - styles.fakeSelector, - { - paddingHorizontal: isTabletOrDesktop ? 16 : 6, - }, - pal.border, - ]}> - <View - style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> - <Text type="md-medium" style={[pal.text]}> - Feed - </Text> - </View> - </View> - </> - ) - }, [ - pal, - currentFeed, - store.me.did, onToggleSaved, - onToggleLiked, - onPressShare, - handleOrDid, onPressReport, - rkey, - isPinned, - onTogglePinned, - isTabletOrDesktop, + onPressShare, + onPressViewAuthor, ]) const renderEmptyState = React.useCallback(() => { @@ -498,22 +312,100 @@ export const CustomFeedScreenInner = observer( return ( <View style={s.hContentRegion}> - {!isTabletOrDesktop && ( - <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} /> - )} + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <Text type="title-lg" style={styles.headerText} numberOfLines={1}> + {currentFeed ? ( + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={currentFeed?.displayName || ''} + onPress={() => store.emitScreenSoftReset()} + /> + ) : ( + 'Loading...' + )} + </Text> + {currentFeed ? ( + <> + <Button + type="default-light" + testID="toggleLikeBtn" + accessibilityLabel="Like this feed" + accessibilityHint="" + onPress={onToggleLiked} + style={styles.headerBtn}> + {currentFeed?.isLiked ? ( + <HeartIconSolid size={19} style={styles.liked} /> + ) : ( + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> + )} + </Button> + {currentFeed?.isSaved ? ( + <Button + type="default-light" + accessibilityLabel={ + isPinned ? 'Unpin this feed' : 'Pin this feed' + } + accessibilityHint="" + onPress={onTogglePinned} + style={styles.headerBtn}> + <FontAwesomeIcon + icon="thumb-tack" + size={17} + color={isPinned ? colors.blue3 : pal.colors.textLight} + style={styles.top1} + /> + </Button> + ) : ( + <Button + type="inverted" + onPress={onToggleSaved} + accessibilityLabel="Add to my feeds" + accessibilityHint="" + style={styles.headerAddBtn}> + <FontAwesomeIcon + icon="plus" + color={palInverted.colors.text} + size={19} + /> + <Text type="button" style={palInverted.text}> + Add{!isMobile && ' to My Feeds'} + </Text> + </Button> + )} + </> + ) : null} + <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> + <View + style={{ + paddingLeft: 12, + paddingRight: isMobile ? 12 : 0, + }}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.textLight} + /> + </View> + </NativeDropdown> + </SimpleViewHeader> <Feed scrollElRef={scrollElRef} feed={algoFeed} onScroll={onMainScroll} scrollEventThrottle={100} - ListHeaderComponent={renderListHeaderComponent} renderEmptyState={renderEmptyState} extraData={[uri, isPinned]} style={!isTabletOrDesktop ? {flex: 1} : undefined} /> {isScrolledDown ? ( <LoadLatestBtn - onPress={onScrollToTop} + onPress={onSoftReset} label="Scroll to top" showIndicator={false} /> @@ -540,36 +432,19 @@ const styles = StyleSheet.create({ paddingBottom: 16, borderTopWidth: 1, }, - headerBtns: { - flexDirection: 'row', - alignItems: 'center', + headerText: { + flex: 1, + fontWeight: 'bold', }, - headerBtnsDesktop: { - marginTop: 8, - gap: 4, + headerBtn: { + paddingVertical: 0, }, headerAddBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, - paddingLeft: 4, - }, - headerDetails: { - paddingHorizontal: 16, - paddingBottom: 16, - }, - headerDetailsFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - fakeSelector: { - flexDirection: 'row', - }, - fakeSelectorItem: { - paddingHorizontal: 12, - paddingBottom: 8, - borderBottomWidth: 3, + paddingVertical: 4, + paddingLeft: 10, }, liked: { color: colors.red3, diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx deleted file mode 100644 index 6aa7a9e31..000000000 --- a/src/view/screens/DiscoverFeeds.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react' -import {RefreshControl, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from '../com/util/ViewHeader' -import {useStores} from 'state/index' -import {FeedsDiscoveryModel} from 'state/models/discovery/feeds' -import {CenteredView, FlatList} from 'view/com/util/Views' -import {CustomFeed} from 'view/com/feeds/CustomFeed' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import debounce from 'lodash.debounce' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'> -export const DiscoverFeedsScreen = withAuthRequired( - observer(function DiscoverFeedsScreenImpl({}: Props) { - const store = useStores() - const pal = usePalette('default') - const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) - const {isTabletOrDesktop} = useWebMediaQueries() - - // search stuff - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => feeds.search(q), 500), // debounce for 500ms - [feeds], - ) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) - } else { - feeds.refresh() - } - }, - [debouncedSearchFeeds, feeds], - ) - const onPressClearQuery = React.useCallback(() => { - setQuery('') - feeds.refresh() - }, [feeds]) - const onPressCancelSearch = React.useCallback(() => { - setIsInputFocused(false) - setQuery('') - feeds.refresh() - }, [feeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) - - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - if (!feeds.hasLoaded) { - feeds.refresh() - } - }, [store, feeds]), - ) - - const onRefresh = React.useCallback(() => { - feeds.refresh() - }, [feeds]) - - const renderListEmptyComponent = () => { - return ( - <View style={styles.empty}> - <Text type="lg" style={pal.textLight}> - {feeds.isLoading - ? isTabletOrDesktop - ? 'Loading...' - : '' - : query - ? `No results found for "${query}"` - : `We can't find any feeds for some reason. This is probably an error - try refreshing!`} - </Text> - </View> - ) - } - - const renderItem = React.useCallback( - ({item}: {item: CustomFeedModel}) => ( - <CustomFeed - key={item.data.uri} - item={item} - showSaveBtn - showDescription - showLikes - /> - ), - [], - ) - - return ( - <CenteredView style={[styles.container, pal.view]}> - <View - style={[isTabletOrDesktop && styles.containerDesktop, pal.border]}> - <ViewHeader title="Discover Feeds" showOnDesktop /> - </View> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - showMenu={false} - /> - <FlatList - style={[!isTabletOrDesktop && s.flex1]} - data={feeds.feeds} - keyExtractor={item => item.data.uri} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={feeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - ListEmptyComponent={renderListEmptyComponent} - onEndReached={() => feeds.loadMore()} - extraData={feeds.isLoading} - /> - </CenteredView> - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - contentContainer: { - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - empty: { - paddingHorizontal: 16, - paddingTop: 10, - }, -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 97c6e8672..d2c4a6d2d 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,90 +1,72 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import isEqual from 'lodash.isequal' +import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList} from 'view/com/util/Views' import {ViewHeader} from 'view/com/util/ViewHeader' -import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 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 {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' -import {MultiFeed} from 'view/com/posts/MultiFeed' import {usePalette} from 'lib/hooks/usePalette' -import {useTimer} from 'lib/hooks/useTimer' import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' - -const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds -const MOBILE_HEADER_OFFSET = 40 +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import debounce from 'lodash.debounce' +import {Text} from 'view/com/util/text/Text' +import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' +import {FlatList} from 'view/com/util/Views' +import {useFocusEffect} from '@react-navigation/native' +import {CustomFeed} from 'view/com/feeds/CustomFeed' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> export const FeedsScreen = withAuthRequired( observer<Props>(function FeedsScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() - const {isMobile} = useWebMediaQueries() - const flatListRef = React.useRef<FlatList>(null) - const multifeed = React.useMemo<PostsMultiFeedModel>( - () => new PostsMultiFeedModel(store), - [store], + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store]) + const [query, setQuery] = React.useState<string>('') + const debouncedSearchFeeds = React.useMemo( + () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms + [myFeeds], ) - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) - const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) - const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { - setLoadPromptVisible(true) - }) - - const onSoftReset = React.useCallback(() => { - flatListRef.current?.scrollToOffset({offset: 0}) - multifeed.loadLatest() - resetPromptTimer() - setLoadPromptVisible(false) - resetMainScroll() - }, [ - flatListRef, - resetMainScroll, - multifeed, - resetPromptTimer, - setLoadPromptVisible, - ]) useFocusEffect( React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const multifeedCleanup = multifeed.registerListeners() - const cleanup = () => { - softResetSub.remove() - multifeedCleanup() - } - store.shell.setMinimalShellMode(false) - return cleanup - }, [store, multifeed, onSoftReset]), + myFeeds.setup() + }, [store.shell, myFeeds]), ) - React.useEffect(() => { - if ( - isEqual( - multifeed.feedInfos.map(f => f.uri), - store.me.savedFeeds.all.map(f => f.uri), - ) - ) { - // no changes - return - } - multifeed.refresh() - }, [multifeed, store.me.savedFeeds.all]) - const onPressCompose = React.useCallback(() => { store.shell.openComposer({}) }, [store]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearchFeeds(text) + } 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]) const renderHeaderBtn = React.useCallback(() => { return ( @@ -99,30 +81,150 @@ export const FeedsScreen = withAuthRequired( ) }, [pal]) + const onRefresh = React.useCallback(() => { + myFeeds.refresh() + }, [myFeeds]) + + 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"> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> + </View> + ) + } + return <View /> + } else if (item.type === 'saved-feed') { + return ( + <SavedFeed + uri={item.feed.uri} + avatar={item.feed.data.avatar} + displayName={item.feed.displayName} + /> + ) + } 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 ( + <CustomFeed + item={item.feed} + showSaveBtn + showDescription + showLikes + /> + ) + } else if (item.type === 'discover-feeds-no-results') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + No results found for "{query}" + </Text> + </View> + ) + } + return null + }, + [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], + ) + return ( <View style={[pal.view, styles.container]}> - <MultiFeed - scrollElRef={flatListRef} - multifeed={multifeed} - onScroll={onMainScroll} - scrollEventThrottle={100} - headerOffset={isMobile ? MOBILE_HEADER_OFFSET : undefined} - /> {isMobile && ( <ViewHeader - title="My Feeds" + title="Feeds" canGoBack={false} - hideOnScroll renderButton={renderHeaderBtn} + showBorder /> )} - {isScrolledDown || loadPromptVisible ? ( - <LoadLatestBtn - onPress={onSoftReset} - label="Load latest posts" - showIndicator={loadPromptVisible} - /> - ) : null} + + <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 + /> <FAB testID="composeFAB" onPress={onPressCompose} @@ -136,8 +238,76 @@ export const FeedsScreen = withAuthRequired( }), ) +function SavedFeed({ + uri, + avatar, + displayName, +}: { + uri: string + avatar: string | undefined + displayName: string +}) { + const pal = usePalette('default') + const urip = new AtUri(uri) + const href = `/profile/${urip.hostname}/feed/${urip.rkey}` + const {isMobile} = useWebMediaQueries() + return ( + <Link + testID={`saved-feed-${displayName}`} + href={href} + style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} + hoverStyle={pal.viewLight} + accessibilityLabel={displayName} + accessibilityHint="" + asAnchor + anchorNoUnderline> + <UserAvatar type="algo" size={28} avatar={avatar} /> + <Text + type={isMobile ? 'lg' : 'lg-medium'} + style={[pal.text, s.flex1]} + numberOfLines={1}> + {displayName} + </Text> + {isMobile && ( + <FontAwesomeIcon + icon="chevron-right" + size={14} + style={pal.textLight as FontAwesomeIconStyle} + /> + )} + </Link> + ) +} + const styles = StyleSheet.create({ container: { flex: 1, }, + list: { + height: '100%', + }, + contentContainer: { + paddingBottom: 100, + }, + + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingHorizontal: 16, + paddingVertical: 12, + }, + + savedFeed: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + gap: 12, + borderBottomWidth: 1, + }, + savedFeedMobile: { + paddingVertical: 10, + }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 33cc2e110..60cda31db 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,6 +1,8 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' @@ -8,6 +10,7 @@ import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {TextLink} from 'view/com/util/Link' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' @@ -16,14 +19,16 @@ import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FAB} from '../com/util/fab/FAB' import {useStores} from 'state/index' -import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s, colors} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2} from 'lib/icons' const HEADER_OFFSET_MOBILE = 78 -const HEADER_OFFSET_DESKTOP = 50 +const HEADER_OFFSET_TABLET = 50 +const HEADER_OFFSET_DESKTOP = 0 const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> @@ -154,17 +159,23 @@ const FeedPage = observer(function FeedPageImpl({ renderEmptyState?: () => JSX.Element }) { const store = useStores() - const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const {isMobile, isTablet, isDesktop} = useWebMediaQueries() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) const {screen, track} = useAnalytics() const [headerOffset, setHeaderOffset] = React.useState( - isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, + isMobile + ? HEADER_OFFSET_MOBILE + : isTablet + ? HEADER_OFFSET_TABLET + : HEADER_OFFSET_DESKTOP, ) const scrollElRef = React.useRef<FlatList>(null) const {appState} = useAppState({ onForeground: () => doPoll(true), }) const isScreenFocused = useIsFocused() + const hasNew = feed.hasNewLatest && !feed.isRefreshing React.useEffect(() => { // called on first load @@ -205,8 +216,14 @@ const FeedPage = observer(function FeedPageImpl({ // listens for resize events React.useEffect(() => { - setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP) - }, [isMobile]) + setHeaderOffset( + isMobile + ? HEADER_OFFSET_MOBILE + : isTablet + ? HEADER_OFFSET_TABLET + : HEADER_OFFSET_DESKTOP, + ) + }, [isMobile, isTablet]) // fires when page within screen is activated/deactivated // - check for latest @@ -222,9 +239,6 @@ const FeedPage = observer(function FeedPageImpl({ screen('Feed') store.log.debug('HomeScreen: Updating feed') feed.checkForLatest() - if (feed.hasContent) { - feed.update() - } return () => { clearInterval(pollInterval) @@ -247,7 +261,59 @@ const FeedPage = observer(function FeedPageImpl({ feed.refresh() }, [feed, scrollToTop]) - const hasNew = feed.hasNewLatest && !feed.isRefreshing + 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="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> + } + onPress={() => store.emitScreenSoftReset()} + /> + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, store, hasNew]) + return ( <View testID={testID} style={s.h100pct}> <Feed @@ -259,6 +325,7 @@ const FeedPage = observer(function FeedPageImpl({ onScroll={onMainScroll} scrollEventThrottle={100} renderEmptyState={renderEmptyState} + ListHeaderComponent={ListHeaderComponent} headerOffset={headerOffset} /> {(isScrolledDown || hasNew) && ( diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 3c257fac8..243cc9596 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -9,12 +9,15 @@ import { 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 {s} from 'lib/styles' +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' @@ -29,6 +32,12 @@ export const NotificationsScreen = withAuthRequired( useOnMainScroll(store) 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 // = @@ -88,9 +97,48 @@ export const NotificationsScreen = withAuthRequired( ), ) - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing + 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]) + return ( <View testID="notificationsScreen" style={s.hContentRegion}> <ViewHeader title="Notifications" canGoBack={false} /> @@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired( onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} /> {(isScrolledDown || hasNew) && ( <LoadLatestBtn diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 49c13bfa3..81bdfc95e 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -19,14 +19,7 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) return ( - <View style={[s.mt10, !enabled && styles.dimmed]}> - <Text type="xs" style={pal.text}> - {value === 0 - ? `Show all replies` - : `Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`} - </Text> + <View style={[!enabled && styles.dimmed]}> <Slider value={value} onValueChange={(v: number | number[]) => { @@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { disabled={!enabled} thumbTintColor={colors.blue3} /> + <Text type="xs" style={pal.text}> + {value === 0 + ? `Show all replies` + : `Show replies with at least ${value} ${ + value > 1 ? `likes` : `like` + }`} + </Text> </View> ) } @@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ Show Replies </Text> <Text style={[pal.text, s.pb10]}> - Adjust the number of likes a reply must have to be shown in your - feed. + Set this setting to "No" to hide all replies from your feed. </Text> <ToggleButton type="default-light" @@ -88,7 +87,36 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ isSelected={store.preferences.homeFeedRepliesEnabled} onPress={store.preferences.toggleHomeFeedRepliesEnabled} /> - + </View> + <View + style={[ + pal.viewLight, + styles.card, + !store.preferences.homeFeedRepliesEnabled && styles.dimmed, + ]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + Reply Filters + </Text> + <Text style={[pal.text, s.pb10]}> + Enable this setting to only see replies between people you follow. + </Text> + <ToggleButton + type="default-light" + label="Followed users only" + isSelected={ + store.preferences.homeFeedRepliesByFollowedOnlyEnabled + } + onPress={ + store.preferences.homeFeedRepliesEnabled + ? store.preferences.toggleHomeFeedRepliesByFollowedOnlyEnabled + : undefined + } + style={[s.mb10]} + /> + <Text style={[pal.text]}> + Adjust the number of likes a reply must have to be shown in your + feed. + </Text> <RepliesThresholdInput enabled={store.preferences.homeFeedRepliesEnabled} /> @@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} /> </View> + + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + Show Posts from My Feeds (Experimental) + </Text> + <Text style={[pal.text, s.pb10]}> + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. + </Text> + <ToggleButton + type="default-light" + label={store.preferences.homeFeedMergeFeedEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.homeFeedMergeFeedEnabled} + onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} + /> + </View> </View> </ScrollView> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 69b5ceee6..241bae1ed 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -69,9 +69,7 @@ export const ProfileScreen = withAuthRequired( let aborted = false store.shell.setMinimalShellMode(false) const feedCleanup = uiState.feed.registerListeners() - if (hasSetup) { - uiState.update() - } else { + if (!hasSetup) { uiState.setup().then(() => { if (aborted) { return diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index d5c02ba63..5253c5bd6 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired( return ( <> <View style={[styles.footerLinks, pal.border]}> - <Link style={styles.footerLink} href="/search/feeds"> + <Link style={styles.footerLink} href="/feeds"> <FontAwesomeIcon icon="search" size={18} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 8a543fa4c..761f50d0a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -40,7 +40,7 @@ import {AccountData} from 'state/models/session' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' import {pluralize} from 'lib/strings/helpers' -import {HandIcon} from 'lib/icons' +import {HandIcon, HashtagIcon} from 'lib/icons' import {formatCount} from 'view/com/util/numeric/format' import Clipboard from '@react-native-clipboard/clipboard' import {reset as resetNavigation} from '../../Navigation' @@ -423,17 +423,14 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="savedFeedsBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - accessibilityHint="Saved Feeds" + accessibilityHint="My Saved Feeds" accessibilityLabel="Opens screen with all saved feeds" onPress={onPressSavedFeeds}> <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="satellite-dish" - style={pal.text as FontAwesomeIconStyle} - /> + <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> </View> <Text type="lg" style={pal.text}> - Saved Feeds + My Saved Feeds </Text> </TouchableOpacity> <TouchableOpacity |