diff options
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/CustomFeed.tsx | 160 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 11 | ||||
-rw-r--r-- | src/view/screens/ModerationMutedAccounts.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/PinnedFeeds.tsx | 181 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 4 | ||||
-rw-r--r-- | src/view/screens/SavedFeeds.tsx | 192 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 17 |
7 files changed, 566 insertions, 1 deletions
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx new file mode 100644 index 000000000..5c19556e2 --- /dev/null +++ b/src/view/screens/CustomFeed.tsx @@ -0,0 +1,160 @@ +import {NativeStackScreenProps} from '@react-navigation/native-stack' +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 React, {useMemo, useRef} from 'react' +import {FlatList, StyleSheet, TouchableOpacity, View} from 'react-native' +import {useStores} from 'state/index' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {useCustomFeed} from 'view/com/algos/useCustomFeed' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {Feed} from 'view/com/posts/Feed' +import {Link} 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' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> +export const CustomFeed = withAuthRequired( + observer(({route}: Props) => { + const rootStore = useStores() + const {rkey, name, displayName} = route.params + const uri = useMemo( + () => makeRecordUri(name, 'app.bsky.feed.generator', rkey), + [rkey, name], + ) + const currentFeed = useCustomFeed(uri) + const scrollElRef = useRef<FlatList>(null) + const algoFeed: PostsFeedModel = useMemo(() => { + const feed = new PostsFeedModel(rootStore, 'custom', { + feed: uri, + }) + feed.setup() + return feed + }, [rootStore, uri]) + + return ( + <View style={[styles.container]}> + <ViewHeader + title={ + displayName ?? `${currentFeed?.data.creator.displayName}'s feed` + } + showOnDesktop + /> + <Feed + scrollElRef={scrollElRef} + testID={'test-feed'} + key="default" + feed={algoFeed} + headerOffset={12} + ListHeaderComponent={() => <ListHeaderComponent uri={uri} />} + extraData={uri} + /> + </View> + ) + }), +) + +const ListHeaderComponent = observer(({uri}: {uri: string}) => { + const currentFeed = useCustomFeed(uri) + const pal = usePalette('default') + const rootStore = useStores() + return ( + <View style={[styles.headerContainer]}> + <View style={[styles.header]}> + <View style={styles.avatarContainer}> + <UserAvatar size={28} avatar={currentFeed?.data.creator.avatar} /> + <Link href={`/profile/${currentFeed?.data.creator.handle}`}> + <Text style={[pal.textLight]}> + @{currentFeed?.data.creator.handle} + </Text> + </Link> + </View> + <Text style={[pal.text]}>{currentFeed?.data.description}</Text> + </View> + + <View style={[styles.buttonsContainer]}> + <Button + type={currentFeed?.isSaved ? 'default' : 'inverted'} + style={[styles.saveButton]} + onPress={() => { + if (currentFeed?.data.viewer?.saved) { + rootStore.me.savedFeeds.unsave(currentFeed!) + } else { + rootStore.me.savedFeeds.save(currentFeed!) + } + }} + label={currentFeed?.data.viewer?.saved ? 'Unsave' : 'Save'} + /> + + <TouchableOpacity + accessibilityRole="button" + onPress={() => { + if (currentFeed?.isLiked) { + currentFeed?.unlike() + } else { + currentFeed?.like() + } + }} + style={[styles.likeButton, pal.viewLight]}> + <Text style={[pal.text, s.semiBold]}> + {currentFeed?.data.likeCount} + </Text> + {currentFeed?.isLiked ? ( + <HeartIconSolid size={18} style={styles.liked} /> + ) : ( + <HeartIcon strokeWidth={3} size={18} style={styles.liked} /> + )} + </TouchableOpacity> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + headerContainer: { + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginBottom: 12, + }, + header: { + alignItems: 'center', + gap: 4, + }, + avatarContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + buttonsContainer: { + flexDirection: 'row', + gap: 8, + }, + saveButton: { + minWidth: 100, + alignItems: 'center', + }, + liked: { + color: colors.red3, + }, + notLiked: { + color: colors.gray3, + }, + likeButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 24, + gap: 4, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 0ead6b65c..1457478d5 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -112,6 +112,17 @@ export const HomeScreen = withAuthRequired( feed={algoFeed} renderEmptyState={renderWhatsHotEmptyState} /> + {store.me.savedFeeds.pinned.map((f, index) => { + return ( + <FeedPage + key={String(2 + index + 1)} + testID="customFeed" + isPageFocused={selectedPage === 2 + index} + feed={new PostsFeedModel(store, 'custom', {feed: f.getUri})} + renderEmptyState={renderFollowingEmptyState} + /> + ) + })} </Pager> ) }), 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/PinnedFeeds.tsx b/src/view/screens/PinnedFeeds.tsx new file mode 100644 index 000000000..ac901ba71 --- /dev/null +++ b/src/view/screens/PinnedFeeds.tsx @@ -0,0 +1,181 @@ +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} from 'lib/styles' +import DraggableFlatList, { + ShadowDecorator, + ScaleDecorator, +} from 'react-native-draggable-flatlist' +import {SavedFeedItem} from 'view/com/algos/SavedFeedItem' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AlgoItemModel} from 'state/models/feeds/algo/algo-item' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'PinnedFeeds'> + +export const PinnedFeeds = withAuthRequired( + observer(({}: Props) => { + // hooks for global items + const pal = usePalette('default') + const rootStore = useStores() + const {screen} = useAnalytics() + + // hooks for local + const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore]) + useFocusEffect( + useCallback(() => { + screen('SavedFeeds') + rootStore.shell.setMinimalShellMode(false) + savedFeeds.refresh() + }, [screen, rootStore, savedFeeds]), + ) + const _ListEmptyComponent = () => { + return ( + <View + style={[ + pal.border, + !isDesktopWeb && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + You don't have any pinned feeds. To pin a feed, go back to the Saved + Feeds screen and click the pin icon! + </Text> + </View> + ) + } + const _ListFooterComponent = () => { + return ( + <View style={styles.footer}> + {savedFeeds.isLoading && <ActivityIndicator />} + </View> + ) + } + + return ( + <CenteredView style={[s.flex1]}> + <ViewHeader title="Arrange Pinned Feeds" showOnDesktop /> + <DraggableFlatList + containerStyle={[!isDesktopWeb && s.flex1]} + data={[...savedFeeds.pinned]} // make a copy so this FlatList re-renders when pinned changes + keyExtractor={item => item.data.uri} + refreshing={savedFeeds.isRefreshing} + refreshControl={ + <RefreshControl + refreshing={savedFeeds.isRefreshing} + onRefresh={() => savedFeeds.refresh()} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + renderItem={({item, drag}) => <PinnedItem item={item} drag={drag} />} + initialNumToRender={10} + ListFooterComponent={_ListFooterComponent} + ListEmptyComponent={_ListEmptyComponent} + extraData={savedFeeds.isLoading} + onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + </CenteredView> + ) + }), +) + +const PinnedItem = observer( + ({item, drag}: {item: AlgoItemModel; drag: () => void}) => { + const pal = usePalette('default') + const rootStore = useStores() + const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore]) + return ( + <ScaleDecorator> + <ShadowDecorator> + <Pressable + accessibilityRole="button" + onLongPress={drag} + style={styles.itemContainer}> + {isWeb ? ( + <View style={styles.webArrowButtonsContainer}> + <TouchableOpacity + accessibilityRole="button" + onPress={() => { + savedFeeds.movePinnedItem(item, 'up') + }}> + <FontAwesomeIcon + icon="arrow-up" + size={20} + style={[styles.icon, pal.text, styles.webArrowUpButton]} + /> + </TouchableOpacity> + <TouchableOpacity + accessibilityRole="button" + onPress={() => { + savedFeeds.movePinnedItem(item, 'down') + }}> + <FontAwesomeIcon + icon="arrow-down" + size={20} + style={[styles.icon, pal.text]} + /> + </TouchableOpacity> + </View> + ) : ( + <FontAwesomeIcon + icon="bars" + size={20} + style={[styles.icon, pal.text]} + /> + )} + <SavedFeedItem item={item} savedFeeds={savedFeeds} /> + </Pressable> + </ShadowDecorator> + </ScaleDecorator> + ) + }, +) + +const styles = StyleSheet.create({ + footer: { + paddingVertical: 20, + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + itemContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginLeft: 18, + }, + item: { + borderTopWidth: 0, + }, + icon: {marginRight: 10}, + webArrowButtonsContainer: { + flexDirection: 'column', + justifyContent: 'space-around', + }, + webArrowUpButton: {marginBottom: 10}, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b6d92e46b..9c8dd458c 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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 AlgoItem from 'view/com/algos/AlgoItem' +import {AlgoItemModel} from 'state/models/feeds/algo/algo-item' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -186,6 +188,8 @@ export const ProfileScreen = withAuthRequired( return ( <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> ) + } else if (item instanceof AlgoItemModel) { + return <AlgoItem item={item} /> } } return <View /> diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx new file mode 100644 index 000000000..c3a4542c6 --- /dev/null +++ b/src/view/screens/SavedFeeds.tsx @@ -0,0 +1,192 @@ +import React, {useCallback, useMemo} from 'react' +import { + RefreshControl, + StyleSheet, + View, + ActivityIndicator, + FlatList, + TouchableOpacity, + ScrollView, +} 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} from 'lib/styles' +import {SavedFeedsModel} from 'state/models/feeds/algo/saved' +import {Link} from 'view/com/util/Link' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {SavedFeedItem} from 'view/com/algos/SavedFeedItem' +import {AtUri} from '@atproto/api' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> + +export const SavedFeeds = withAuthRequired( + observer(({navigation}: Props) => { + // hooks for global items + const pal = usePalette('default') + const rootStore = useStores() + const {screen} = useAnalytics() + + // hooks for local + const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore]) + useFocusEffect( + useCallback(() => { + screen('SavedFeeds') + rootStore.shell.setMinimalShellMode(false) + savedFeeds.refresh() + }, [screen, rootStore, savedFeeds]), + ) + const _ListEmptyComponent = () => { + 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. To save a feed, click the save + button when a custom feed or algorithm shows up. + </Text> + </View> + ) + } + const _ListFooterComponent = () => { + return ( + <View style={styles.footer}> + {savedFeeds.isLoading && <ActivityIndicator />} + </View> + ) + } + + return ( + <CenteredView style={[s.flex1]}> + <ViewHeader title="Saved Feeds" showOnDesktop /> + <FlatList + style={[!isDesktopWeb && s.flex1]} + data={savedFeeds.feeds} + keyExtractor={item => item.data.uri} + refreshing={savedFeeds.isRefreshing} + refreshControl={ + <RefreshControl + refreshing={savedFeeds.isRefreshing} + onRefresh={() => savedFeeds.refresh()} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + renderItem={({item}) => ( + <SavedFeedItem item={item} savedFeeds={savedFeeds} /> + )} + initialNumToRender={10} + ListHeaderComponent={() => ( + <ListHeaderComponent + savedFeeds={savedFeeds} + navigation={navigation} + /> + )} + ListFooterComponent={_ListFooterComponent} + ListEmptyComponent={_ListEmptyComponent} + extraData={savedFeeds.isLoading} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + </CenteredView> + ) + }), +) + +const ListHeaderComponent = observer( + ({ + savedFeeds, + navigation, + }: { + savedFeeds: SavedFeedsModel + navigation: Props['navigation'] + }) => { + const pal = usePalette('default') + return ( + <View style={styles.headerContainer}> + {savedFeeds.pinned.length > 0 ? ( + <View style={styles.pinnedContainer}> + <View style={styles.pinnedHeader}> + <Text type="lg-bold" style={[pal.text]}> + Pinned Feeds + </Text> + <Link href="/settings/pinned-feeds"> + <Text style={[styles.editPinned, pal.text]}>Edit</Text> + </Link> + </View> + + <ScrollView + horizontal={true} + showsHorizontalScrollIndicator={isWeb}> + {savedFeeds.pinned.map(item => { + return ( + <TouchableOpacity + key={item.data.uri} + accessibilityRole="button" + onPress={() => { + navigation.navigate('CustomFeed', { + name: item.data.creator.did, + rkey: new AtUri(item.data.uri).rkey, + displayName: + item.data.displayName ?? + `${item.data.creator.displayName}'s feed`, + }) + }} + style={styles.pinnedItem}> + <UserAvatar avatar={item.data.avatar} size={80} /> + <Text + type="sm-medium" + numberOfLines={1} + style={[pal.text, styles.pinnedItemName]}> + {item.data.displayName ?? + `${item.data.creator.displayName}'s feed`} + </Text> + </TouchableOpacity> + ) + })} + </ScrollView> + </View> + ) : null} + + <Text type="lg-bold">All Saved Feeds</Text> + </View> + ) + }, +) + +const styles = StyleSheet.create({ + footer: { + paddingVertical: 20, + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + headerContainer: {paddingHorizontal: 18, paddingTop: 18}, + pinnedContainer: {marginBottom: 18, gap: 18}, + pinnedHeader: {flexDirection: 'row', justifyContent: 'space-between'}, + pinnedItem: { + flex: 1, + alignItems: 'center', + marginRight: 18, + maxWidth: 100, + }, + pinnedItemName: {marginTop: 8, textAlign: 'center'}, + editPinned: {textDecorationLine: 'underline'}, +}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 1571a6142..a919f11b0 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -284,6 +284,23 @@ export const SettingsScreen = withAuthRequired( <View style={styles.spacer20} /> + <Link + testID="bookmarkedAlgosBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + accessibilityHint="Custom Algorithms" + accessibilityLabel="Opens screen with all bookmarked custom algorithms" + href="/settings/saved-feeds"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="rss" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Custom Algorithms + </Text> + </Link> + <Text type="xl-bold" style={[pal.text, styles.heading]}> Advanced </Text> |