diff options
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/AppPasswords.tsx | 28 | ||||
-rw-r--r-- | src/view/screens/CustomFeed.tsx | 418 | ||||
-rw-r--r-- | src/view/screens/CustomFeedLikedBy.tsx | 29 | ||||
-rw-r--r-- | src/view/screens/DiscoverFeeds.tsx | 112 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 126 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 89 | ||||
-rw-r--r-- | src/view/screens/ModerationMutedAccounts.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Notifications.tsx | 21 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 35 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 4 | ||||
-rw-r--r-- | src/view/screens/SavedFeeds.tsx | 293 | ||||
-rw-r--r-- | src/view/screens/SearchMobile.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 33 |
13 files changed, 1127 insertions, 65 deletions
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 3adbf09df..ca60787d5 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -1,6 +1,5 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Alert} from 'view/com/util/Alert' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from 'react-native-gesture-handler' import {Text} from '../com/util/text/Text' @@ -160,24 +159,15 @@ function AppPassword({ const store = useStores() const onDelete = React.useCallback(async () => { - Alert.alert( - 'Delete App Password', - `Are you sure you want to delete the app password "${name}"?`, - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - await store.me.deleteAppPassword(name) - Toast.show('App password deleted') - }, - }, - ], - ) + store.shell.openModal({ + name: 'confirm', + title: 'Delete App Password', + message: `Are you sure you want to delete the app password "${name}"?`, + async onPressConfirm() { + await store.me.deleteAppPassword(name) + Toast.show('App password deleted') + }, + }) }, [store, name]) const {contentLanguages} = store.preferences diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx new file mode 100644 index 000000000..4149cd49d --- /dev/null +++ b/src/view/screens/CustomFeed.tsx @@ -0,0 +1,418 @@ +import React, {useMemo, useRef} from 'react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIcon, HeartIconSolid} from 'lib/icons' +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 {FlatList, StyleSheet, View} from 'react-native' +import {useStores} from 'state/index' +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 {TextLink} from 'view/com/util/Link' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {Button} from 'view/com/util/forms/Button' +import {Text} from 'view/com/util/text/Text' +import * as Toast from 'view/com/util/Toast' +import {isDesktopWeb} from 'platform/detection' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {Haptics} from 'lib/haptics' +import {ComposeIcon2} from 'lib/icons' +import {FAB} from '../com/util/fab/FAB' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {EmptyState} from 'view/com/util/EmptyState' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> +export const CustomFeedScreen = withAuthRequired( + observer(({route}: Props) => { + const store = useStores() + const pal = usePalette('default') + const {rkey, name} = route.params + const uri = useMemo( + () => makeRecordUri(name, 'app.bsky.feed.generator', rkey), + [rkey, name], + ) + const scrollElRef = useRef<FlatList>(null) + const currentFeed = useCustomFeed(uri) + const algoFeed: PostsFeedModel = useMemo(() => { + const feed = new PostsFeedModel(store, 'custom', { + feed: uri, + }) + feed.setup() + return feed + }, [store, uri]) + const isPinned = store.me.savedFeeds.isPinned(uri) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) + useSetTitle(currentFeed?.displayName) + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (currentFeed?.isSaved) { + await currentFeed?.unsave() + } else { + await currentFeed?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, currentFeed]) + + const onToggleLiked = React.useCallback(async () => { + Haptics.default() + try { + if (currentFeed?.isLiked) { + await currentFeed?.unlike() + } else { + await currentFeed?.like() + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + store.log.error('Failed up toggle like', {err}) + } + }, [store, currentFeed]) + + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [store, currentFeed]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${name}/feed/${rkey}`) + shareUrl(url) + }, [name, rkey]) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) + + const onPressCompose = React.useCallback(() => { + store.shell.openComposer({}) + }, [store]) + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'feedHeaderDropdownRemoveBtn', + label: 'Remove from my feeds', + onPress: onToggleSaved, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + }, + ] + return items + }, [onToggleSaved, 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 ? ( + <DropdownButton + testID="feedHeaderDropdownBtn" + type="default-light" + items={dropdownItems} + menuWidth={250}> + <FontAwesomeIcon + icon="ellipsis" + color={pal.colors.textLight} + size={18} + /> + </DropdownButton> + ) : ( + <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> + )} + </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={`@${currentFeed.data.creator.handle}`} + href={`/profile/${currentFeed.data.creator.did}`} + style={[pal.textLight]} + /> + )} + </Text> + )} + {isDesktopWeb && ( + <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> + </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/${name}/feed/${rkey}/liked-by`} + text={`Liked by ${currentFeed.data.likeCount} ${pluralize( + currentFeed?.data.likeCount || 0, + 'user', + )}`} + /> + ) : null} + </View> + </View> + <View style={[styles.fakeSelector, 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, + name, + rkey, + isPinned, + onTogglePinned, + ]) + + const renderEmptyState = React.useCallback(() => { + return <EmptyState icon="feed" message="This list is empty!" /> + }, []) + + return ( + <View style={s.hContentRegion}> + <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} /> + <Feed + scrollElRef={scrollElRef} + feed={algoFeed} + onScroll={onMainScroll} + scrollEventThrottle={100} + ListHeaderComponent={renderListHeaderComponent} + renderEmptyState={renderEmptyState} + extraData={[uri, isPinned]} + /> + {isScrolledDown ? ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Scroll to top" + showIndicator={false} + /> + ) : null} + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="Compose post" + accessibilityHint="" + /> + </View> + ) + }), +) + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 16, + borderTopWidth: 1, + }, + headerBtns: { + flexDirection: 'row', + alignItems: 'center', + }, + headerBtnsDesktop: { + marginTop: 8, + gap: 4, + }, + 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', + paddingHorizontal: isDesktopWeb ? 16 : 6, + }, + fakeSelectorItem: { + paddingHorizontal: 12, + paddingBottom: 8, + borderBottomWidth: 3, + }, + liked: { + color: colors.red3, + }, + top1: { + position: 'relative', + top: 1, + }, + top2: { + position: 'relative', + top: 2, + }, +}) diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/CustomFeedLikedBy.tsx new file mode 100644 index 000000000..49d0d0482 --- /dev/null +++ b/src/view/screens/CustomFeedLikedBy.tsx @@ -0,0 +1,29 @@ +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 {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'> +export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { + const store = useStores() + const {name, rkey} = route.params + const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) + + return ( + <View> + <ViewHeader title="Liked by" /> + <PostLikedByComponent uri={uri} /> + </View> + ) +}) diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx new file mode 100644 index 000000000..cd32ec655 --- /dev/null +++ b/src/view/screens/DiscoverFeeds.tsx @@ -0,0 +1,112 @@ +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 {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'> +export const DiscoverFeedsScreen = withAuthRequired( + observer(({}: Props) => { + const store = useStores() + const pal = usePalette('default') + const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + feeds.refresh() + }, [store, feeds]), + ) + + const onRefresh = React.useCallback(() => { + store.me.savedFeeds.refresh() + }, [store]) + + const renderListEmptyComponent = React.useCallback(() => { + return ( + <View + style={[ + pal.border, + !isDesktopWeb && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + {feeds.isLoading + ? 'Loading...' + : `We can't find any feeds for some reason. This is probably an error - try refreshing!`} + </Text> + </View> + ) + }, [pal, feeds.isLoading]) + + const renderItem = React.useCallback( + ({item}) => ( + <CustomFeed + key={item.data.uri} + item={item} + showSaveBtn + showDescription + showLikes + /> + ), + [], + ) + + return ( + <CenteredView style={[styles.container, pal.view]}> + <View style={[isDesktopWeb && styles.containerDesktop, pal.border]}> + <ViewHeader title="Discover Feeds" showOnDesktop /> + </View> + <FlatList + style={[!isDesktopWeb && 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} + extraData={feeds.isLoading} + /> + </CenteredView> + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingBottom: 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + empty: { + paddingHorizontal: 18, + paddingVertical: 16, + borderRadius: 8, + marginHorizontal: 18, + marginTop: 10, + }, +}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx new file mode 100644 index 000000000..169f88791 --- /dev/null +++ b/src/view/screens/Feeds.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import isEqual from 'lodash.isequal' +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 {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {ComposeIcon2, CogIcon} from 'lib/icons' +import {s} from 'lib/styles' + +const HEADER_OFFSET = isDesktopWeb ? 0 : 40 + +type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> +export const FeedsScreen = withAuthRequired( + observer<Props>(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const flatListRef = React.useRef<FlatList>(null) + const multifeed = React.useMemo<PostsMultiFeedModel>( + () => new PostsMultiFeedModel(store), + [store], + ) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) + + const onSoftReset = React.useCallback(() => { + flatListRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [flatListRef, resetMainScroll]) + + 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]), + ) + + 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 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]) + + return ( + <View style={[pal.view, styles.container]}> + <MultiFeed + scrollElRef={flatListRef} + multifeed={multifeed} + onScroll={onMainScroll} + scrollEventThrottle={100} + headerOffset={HEADER_OFFSET} + showPostFollowBtn + /> + <ViewHeader + title="My Feeds" + canGoBack={false} + hideOnScroll + renderButton={renderHeaderBtn} + /> + {isScrolledDown ? ( + <LoadLatestBtn + onPress={onSoftReset} + label="Scroll to top" + showIndicator={false} + /> + ) : null} + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="Compose post" + accessibilityHint="" + /> + </View> + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 0ead6b65c..d7a2aa231 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,18 +1,20 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' +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 {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' -import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' +import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' -import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' +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' @@ -21,30 +23,37 @@ import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' import {isDesktopWeb} from 'platform/detection' -const HEADER_OFFSET = isDesktopWeb ? 50 : 40 +const HEADER_OFFSET = isDesktopWeb ? 50 : 78 const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired( observer((_opts: Props) => { const store = useStores() + const pagerRef = React.useRef<PagerRef>(null) const [selectedPage, setSelectedPage] = React.useState(0) - const [initialLanguages] = React.useState( - store.preferences.contentLanguages, - ) - - const algoFeed: PostsFeedModel = React.useMemo(() => { - const feed = new PostsFeedModel(store, 'goodstuff', {}) - feed.setup() - return feed - }, [store]) + const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([]) React.useEffect(() => { - // refresh whats hot when lang preferences change - if (initialLanguages !== store.preferences.contentLanguages) { - algoFeed.refresh() + const {pinned} = store.me.savedFeeds + if ( + isEqual( + pinned.map(p => p.uri), + customFeeds.map(f => (f.params as GetCustomFeed.QueryParams).feed), + ) + ) { + // no changes + return + } + + const feeds = [] + for (const feed of pinned) { + const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) + model.setup() + feeds.push(model) } - }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) + setCustomFeeds(feeds) + }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) useFocusEffect( React.useCallback(() => { @@ -86,18 +95,17 @@ export const HomeScreen = withAuthRequired( return <FollowingEmptyState /> }, []) - const renderWhatsHotEmptyState = React.useCallback(() => { - return <WhatsHotEmptyState /> + const renderCustomFeedEmptyState = React.useCallback(() => { + return <CustomFeedEmptyState /> }, []) - const initialPage = store.me.followsCount === 0 ? 1 : 0 return ( <Pager + ref={pagerRef} testID="homeScreen" onPageSelected={onPageSelected} renderTabBar={renderTabBar} - tabBarPosition="top" - initialPage={initialPage}> + tabBarPosition="top"> <FeedPage key="1" testID="followingFeedPage" @@ -105,13 +113,17 @@ export const HomeScreen = withAuthRequired( feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} /> - <FeedPage - key="2" - testID="whatshotFeedPage" - isPageFocused={selectedPage === 1} - feed={algoFeed} - renderEmptyState={renderWhatsHotEmptyState} - /> + {customFeeds.map((f, index) => { + return ( + <FeedPage + key={(f.params as GetCustomFeed.QueryParams).feed} + testID="customFeedPage" + isPageFocused={selectedPage === 1 + index} + feed={f} + renderEmptyState={renderCustomFeedEmptyState} + /> + ) + })} </Pager> ) }), @@ -130,7 +142,8 @@ const FeedPage = observer( renderEmptyState?: () => JSX.Element }) => { const store = useStores() - const onMainScroll = useOnMainScroll(store) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) const {screen, track} = useAnalytics() const scrollElRef = React.useRef<FlatList>(null) const {appState} = useAppState({ @@ -158,12 +171,13 @@ const FeedPage = observer( const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET}) - }, [scrollElRef]) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) const onSoftReset = React.useCallback(() => { if (isPageFocused) { - feed.refresh() scrollToTop() + feed.refresh() } }, [isPageFocused, scrollToTop, feed]) @@ -188,7 +202,7 @@ const FeedPage = observer( } }, [store, doPoll, onSoftReset, screen, feed]), ) - // fires when tab is actived/deactivated + // fires when tab is activated/deactivated // - check for latest useTabFocusEffect( 'Home', @@ -224,6 +238,7 @@ const FeedPage = observer( feed.refresh() }, [feed, scrollToTop]) + const hasNew = feed.hasNewLatest && !feed.isRefreshing return ( <View testID={testID} style={s.h100pct}> <Feed @@ -234,11 +249,17 @@ const FeedPage = observer( showPostFollowBtn onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} + scrollEventThrottle={100} renderEmptyState={renderEmptyState} headerOffset={HEADER_OFFSET} /> - {feed.hasNewLatest && !feed.isRefreshing && ( - <LoadLatestBtn onPress={onPressLoadLatest} label="posts" /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + minimalShellMode={store.shell.minimalShellMode} + /> )} <FAB testID="composeFAB" diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index ec732f682..22b8c0d33 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -100,7 +100,7 @@ export const ModerationMutedAccounts = withAuthRequired( <FlatList style={[!isDesktopWeb && styles.flex1]} data={mutedAccounts.mutes} - keyExtractor={(item: ActorDefs.ProfileView) => item.did} + keyExtractor={item => item.did} refreshControl={ <RefreshControl refreshing={mutedAccounts.isRefreshing} diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 8d6f7c83a..4db1d14ae 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -25,7 +25,8 @@ type Props = NativeStackScreenProps< export const NotificationsScreen = withAuthRequired( observer(({}: Props) => { const store = useStores() - const onMainScroll = useOnMainScroll(store) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) const scrollElRef = React.useRef<FlatList>(null) const {screen} = useAnalytics() @@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired( const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) - }, [scrollElRef]) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) const onPressLoadLatest = React.useCallback(() => { scrollToTop() @@ -86,6 +88,9 @@ export const NotificationsScreen = withAuthRequired( ), ) + const hasNew = + store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing return ( <View testID="notificationsScreen" style={s.hContentRegion}> <ViewHeader title="Notifications" canGoBack={false} /> @@ -96,10 +101,14 @@ export const NotificationsScreen = withAuthRequired( onScroll={onMainScroll} scrollElRef={scrollElRef} /> - {store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing && ( - <LoadLatestBtn onPress={onPressLoadLatest} label="notifications" /> - )} + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new notifications" + showIndicator={hasNew} + minimalShellMode={true} + /> + )} </View> ) }), diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b6d92e46b..a34ceb32c 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -9,7 +9,7 @@ import {CenteredView} 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' +import {PostsFeedSliceModel} from 'state/models/feeds/post' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' import {ListCard} from 'view/com/lists/ListCard' @@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -45,6 +47,10 @@ export const ProfileScreen = withAuthRequired( ) useSetTitle(combinedDisplayName(uiState.profile)) + useEffect(() => { + setHasSetup(false) + }, [route.params.name]) + useFocusEffect( React.useCallback(() => { let aborted = false @@ -118,6 +124,7 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { + // if section is lists if (uiState.selectedView === Sections.Lists) { if (item === ProfileUiModel.LOADING_ITEM) { return <ProfileCardFeedLoadingPlaceholder /> @@ -142,6 +149,32 @@ export const ProfileScreen = withAuthRequired( } 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} + /> + ) + } else if (item instanceof CustomFeedModel) { + return <CustomFeed item={item} showSaveBtn showLikes /> + } + // if section is posts or posts & replies } else { if (item === ProfileUiModel.END_ITEM) { return <Text style={styles.endItem}>- end of feed -</Text> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 01f27bae1..7c3ed831c 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -87,7 +87,7 @@ export const ProfileListScreen = withAuthRequired( return <EmptyState icon="users-slash" message="This list is empty!" /> }, []) - const renderHeaderBtn = React.useCallback(() => { + const renderHeaderBtns = React.useCallback(() => { return ( <View style={styles.headerBtns}> {list?.isOwner && ( @@ -148,7 +148,7 @@ export const ProfileListScreen = withAuthRequired( pal.border, ]} testID="moderationMutelistsScreen"> - <ViewHeader title="" renderButton={renderHeaderBtn} /> + <ViewHeader title="" renderButton={renderHeaderBtns} /> <ListItems list={list} renderEmptyState={renderEmptyState} diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx new file mode 100644 index 000000000..103b18c70 --- /dev/null +++ b/src/view/screens/SavedFeeds.tsx @@ -0,0 +1,293 @@ +import React, {useCallback, useMemo} from 'react' +import { + RefreshControl, + StyleSheet, + View, + ActivityIndicator, + Pressable, + TouchableOpacity, +} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useAnalytics} from 'lib/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 {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {Text} from 'view/com/util/text/Text' +import {isDesktopWeb, isWeb} from 'platform/detection' +import {s, colors} from 'lib/styles' +import DraggableFlatList, { + ShadowDecorator, + ScaleDecorator, +} from 'react-native-draggable-flatlist' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import * as Toast from 'view/com/util/Toast' +import {Haptics} from 'lib/haptics' +import {Link, TextLink} from 'view/com/util/Link' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> + +export const SavedFeeds = withAuthRequired( + observer(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const {screen} = useAnalytics() + + const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + useFocusEffect( + useCallback(() => { + screen('SavedFeeds') + store.shell.setMinimalShellMode(false) + savedFeeds.refresh() + }, [screen, store, savedFeeds]), + ) + + const renderListEmptyComponent = useCallback(() => { + return ( + <View + style={[ + pal.border, + !isDesktopWeb && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + You don't have any saved feeds. + </Text> + </View> + ) + }, [pal]) + + const renderListFooterComponent = useCallback(() => { + return ( + <> + <View style={[styles.footerLinks, pal.border]}> + <Link style={styles.footerLink} href="/search/feeds"> + <FontAwesomeIcon + icon="search" + size={18} + color={pal.colors.icon} + /> + <Text type="lg-medium" style={pal.textLight}> + Discover new feeds + </Text> + </Link> + </View> + <View style={styles.footerText}> + <Text type="sm" style={pal.textLight}> + Feeds are custom algorithms that users build with a little coding + expertise.{' '} + <TextLink + type="sm" + style={pal.link} + href="https://github.com/bluesky-social/feed-generator" + text="See this guide" + />{' '} + for more information. + </Text> + </View> + {savedFeeds.isLoading && <ActivityIndicator />} + </> + ) + }, [pal, savedFeeds.isLoading]) + + const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) + + const onDragEnd = useCallback( + async ({data}) => { + try { + await savedFeeds.reorderPinnedFeeds(data) + } catch (e) { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to save pinned feed order', {e}) + } + }, + [savedFeeds, store], + ) + + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isDesktopWeb && styles.desktopContainer, + ]}> + <ViewHeader + title="Edit My Feeds" + showOnDesktop + showBorder={!isDesktopWeb} + /> + <DraggableFlatList + containerStyle={[!isDesktopWeb && s.flex1]} + data={savedFeeds.all} + keyExtractor={item => item.data.uri} + refreshing={savedFeeds.isRefreshing} + refreshControl={ + <RefreshControl + refreshing={savedFeeds.isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} + getItemLayout={(data, index) => ({ + length: 77, + offset: 77 * index, + index, + })} + initialNumToRender={10} + ListFooterComponent={renderListFooterComponent} + ListEmptyComponent={renderListEmptyComponent} + extraData={savedFeeds.isLoading} + onDragEnd={onDragEnd} + /> + </CenteredView> + ) + }), +) + +const ListItem = observer( + ({item, drag}: {item: CustomFeedModel; drag: () => void}) => { + const pal = usePalette('default') + const store = useStores() + const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + const isPinned = savedFeeds.isPinned(item) + + const onTogglePinned = useCallback(() => { + Haptics.default() + savedFeeds.togglePinnedFeed(item).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [savedFeeds, item, store]) + const onPressUp = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'up').catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) + const onPressDown = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'down').catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) + + return ( + <ScaleDecorator> + <ShadowDecorator> + <Pressable + accessibilityRole="button" + onLongPress={isPinned ? drag : undefined} + delayLongPress={200} + style={[styles.itemContainer, pal.border]}> + {isPinned && isWeb ? ( + <View style={styles.webArrowButtonsContainer}> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressUp}> + <FontAwesomeIcon + icon="arrow-up" + size={12} + style={[pal.text, styles.webArrowUpButton]} + /> + </TouchableOpacity> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressDown}> + <FontAwesomeIcon + icon="arrow-down" + size={12} + style={[pal.text]} + /> + </TouchableOpacity> + </View> + ) : isPinned ? ( + <FontAwesomeIcon + icon="bars" + size={20} + color={pal.colors.text} + style={s.ml20} + /> + ) : null} + <CustomFeed + key={item.data.uri} + item={item} + showSaveBtn + style={styles.noBorder} + /> + <TouchableOpacity + accessibilityRole="button" + hitSlop={10} + onPress={onTogglePinned}> + <FontAwesomeIcon + icon="thumb-tack" + size={20} + color={isPinned ? colors.blue3 : pal.colors.icon} + /> + </TouchableOpacity> + </Pressable> + </ShadowDecorator> + </ScaleDecorator> + ) + }, +) + +const styles = StyleSheet.create({ + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + minHeight: '100vh', + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + itemContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: 1, + paddingRight: 16, + }, + webArrowButtonsContainer: { + paddingLeft: 16, + flexDirection: 'column', + justifyContent: 'space-around', + }, + webArrowUpButton: { + marginBottom: 10, + }, + noBorder: { + borderTopWidth: 0, + }, + footerText: { + paddingHorizontal: 26, + paddingTop: 22, + paddingBottom: 100, + }, + footerLinks: { + borderBottomWidth: 1, + borderTopWidth: 0, + }, + footerLink: { + flexDirection: 'row', + paddingHorizontal: 26, + paddingVertical: 18, + gap: 18, + }, +}) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index f9b4864b2..c9d09373e 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired( const store = useStores() const scrollViewRef = React.useRef<ScrollView>(null) const flatListRef = React.useRef<FlatList>(null) - const onMainScroll = useOnMainScroll(store) + const [onMainScroll] = useOnMainScroll(store) const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [query, setQuery] = React.useState<string>('') const autocompleteView = React.useMemo<UserAutocompleteModel>( diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index b222d7dbd..3f6fa84d3 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -142,6 +142,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'delete-account'}) }, [store]) + const onPressResetPreferences = React.useCallback(async () => { + await store.preferences.reset() + Toast.show('Preferences reset') + }, [store]) + return ( <View style={[s.hContentRegion]} testID="settingsScreen"> <ViewHeader title="Settings" /> @@ -330,6 +335,22 @@ export const SettingsScreen = withAuthRequired( App passwords </Text> </Link> + <Link + testID="savedFeedsBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + accessibilityHint="Saved Feeds" + accessibilityLabel="Opens screen with all saved feeds" + href="/settings/saved-feeds"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="satellite-dish" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Saved Feeds + </Text> + </Link> <TouchableOpacity testID="contentLanguagesBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} @@ -406,8 +427,18 @@ export const SettingsScreen = withAuthRequired( Storybook </Text> </Link> + {__DEV__ ? ( + <Link + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetPreferences} + title="Debug tools"> + <Text type="lg" style={pal.text}> + Reset preferences state + </Text> + </Link> + ) : null} <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + Build version {AppInfo.appVersion} </Text> <View style={s.footerSpacer} /> </ScrollView> |