diff options
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/CustomFeed.tsx | 495 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 70 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 27 | ||||
-rw-r--r-- | src/view/screens/Lists.tsx | 92 | ||||
-rw-r--r-- | src/view/screens/Moderation.tsx | 6 | ||||
-rw-r--r-- | src/view/screens/ModerationModlists.tsx | 92 | ||||
-rw-r--r-- | src/view/screens/ModerationMuteLists.tsx | 124 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 13 | ||||
-rw-r--r-- | src/view/screens/ProfileFeed.tsx | 535 | ||||
-rw-r--r-- | src/view/screens/ProfileFeedLikedBy.tsx (renamed from src/view/screens/CustomFeedLikedBy.tsx) | 4 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 850 | ||||
-rw-r--r-- | src/view/screens/SavedFeeds.tsx | 36 |
12 files changed, 1553 insertions, 791 deletions
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx deleted file mode 100644 index f9383639c..000000000 --- a/src/view/screens/CustomFeed.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import React, {useMemo, useRef} from 'react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -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' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {FlatList, StyleSheet, View, ActivityIndicator} 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 {TextLink} from 'view/com/util/Link' -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' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -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 {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 {resolveName} from 'lib/api' -import {CenteredView} from 'view/com/util/Views' -import {NavigationProp} from 'lib/routes/types' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> - -export const CustomFeedScreen = withAuthRequired( - observer(function CustomFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation<NavigationProp>() - - const {name: handleOrDid} = props.route.params - - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load feed - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel="Go Back" - accessibilityHint="Return to previous page" - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - Go Back - </Text> - </Button> - </View> - </View> - </CenteredView> - ) - } - - return feedOwnerDid ? ( - <CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> - ) : ( - <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> - </CenteredView> - ) - }), -) - -export const CustomFeedScreenInner = observer( - function CustomFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const store = useStores() - const pal = usePalette('default') - 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( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - 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 onPressAbout = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: currentFeed?.displayName || '', - message: - currentFeed?.data.description || 'This feed has no description.', - confirmBtnText: 'Close', - onPressConfirm() {}, - }) - }, [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) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!currentFeed) return - store.shell.openModal({ - name: 'report', - uri: currentFeed.uri, - cid: currentFeed.data.cid, - }) - }, [store, currentFeed]) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) - - const onPressCompose = React.useCallback(() => { - 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(() => { - return [ - currentFeed - ? { - testID: 'feedHeaderDropdownAboutBtn', - label: 'About this feed', - onPress: onPressAbout, - icon: { - ios: { - name: 'info.circle', - }, - android: '', - web: 'info', - }, - } - : undefined, - { - testID: 'feedHeaderDropdownViewAuthorBtn', - label: 'View author', - onPress: onPressViewAuthor, - icon: { - ios: { - name: 'person', - }, - android: '', - web: ['far', 'user'], - }, - }, - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: currentFeed?.isSaved - ? 'Remove from my feeds' - : 'Add to my feeds', - onPress: onToggleSaved, - icon: currentFeed?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: 'trash', - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - currentFeed, - onPressAbout, - onToggleSaved, - onPressReport, - onPressShare, - onPressViewAuthor, - ]) - - const renderEmptyState = React.useCallback(() => { - return ( - <View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}> - <EmptyState icon="feed" message="This feed is empty!" /> - </View> - ) - }, [pal.border]) - - return ( - <View style={s.hContentRegion}> - <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} - accessibilityLabel="More options" - accessibilityHint=""> - <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} - renderEmptyState={renderEmptyState} - extraData={[uri, isPinned]} - style={!isTabletOrDesktop ? {flex: 1} : undefined} - /> - {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="New post" - accessibilityHint="" - /> - </View> - ) - }, -) - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - gap: 12, - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 16, - borderTopWidth: 1, - }, - headerText: { - flex: 1, - fontWeight: 'bold', - }, - headerBtn: { - paddingVertical: 0, - }, - headerAddBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 4, - paddingLeft: 10, - }, - liked: { - color: colors.red3, - }, - top1: { - position: 'relative', - top: 1, - }, - top2: { - position: 'relative', - top: 2, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index fc4d99cf5..383bbcaa5 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -2,7 +2,6 @@ import React from 'react' 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 {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' @@ -24,9 +23,10 @@ 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 {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> export const FeedsScreen = withAuthRequired( @@ -52,6 +52,10 @@ export const FeedsScreen = withAuthRequired( } }, [store, myFeeds]), ) + React.useEffect(() => { + // watch for changes to saved/pinned feeds + return myFeeds.registerListeners() + }, [myFeeds]) const onPressCompose = React.useCallback(() => { store.shell.openComposer({}) @@ -139,13 +143,7 @@ export const FeedsScreen = withAuthRequired( </> ) } else if (item.type === 'saved-feed') { - return ( - <SavedFeed - uri={item.feed.uri} - avatar={item.feed.data.avatar} - displayName={item.feed.displayName} - /> - ) + return <SavedFeed feed={item.feed} /> } else if (item.type === 'discover-feeds-header') { return ( <> @@ -187,7 +185,7 @@ export const FeedsScreen = withAuthRequired( ) } else if (item.type === 'discover-feed') { return ( - <CustomFeed + <FeedSourceCard item={item.feed} showSaveBtn showDescription @@ -257,33 +255,43 @@ export const FeedsScreen = withAuthRequired( }), ) -function SavedFeed({ - uri, - avatar, - displayName, -}: { - uri: string - avatar: string | undefined - displayName: string -}) { +function SavedFeed({feed}: {feed: FeedSourceModel}) { 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} + testID={`saved-feed-${feed.displayName}`} + href={feed.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={displayName} + accessibilityLabel={feed.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - <UserAvatar type="algo" size={28} avatar={avatar} /> - <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}> - {displayName} - </Text> + {feed.error ? ( + <View + style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> + <FontAwesomeIcon + icon="exclamation-circle" + color={pal.colors.textLight} + /> + </View> + ) : ( + <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + )} + <View + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> + <Text type="lg-medium" style={pal.text} numberOfLines={1}> + {feed.displayName} + </Text> + {feed.error && ( + <View style={[styles.offlineSlug, pal.borderDark]}> + <Text type="xs" style={pal.textLight}> + Feed offline + </Text> + </View> + )} + </View> {isMobile && ( <FontAwesomeIcon icon="chevron-right" @@ -342,4 +350,10 @@ const styles = StyleSheet.create({ savedFeedMobile: { paddingVertical: 10, }, + offlineSlug: { + borderWidth: 1, + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2, + }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index ad47e9f9b..d8bf4f637 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,7 +1,6 @@ import React from 'react' import {useWindowDimensions} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' @@ -30,29 +29,29 @@ export const HomeScreen = withAuthRequired( >([]) React.useEffect(() => { - const {pinned} = store.me.savedFeeds + const pinned = store.preferences.pinnedFeeds - if ( - isEqual( - pinned.map(p => p.uri), - requestedCustomFeeds, - ) - ) { + if (isEqual(pinned, requestedCustomFeeds)) { // no changes return } const feeds = [] - for (const feed of pinned) { - const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) - feeds.push(model) + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + const model = new PostsFeedModel(store, 'custom', {feed: uri}) + feeds.push(model) + } else if (uri.includes('app.bsky.graph.list')) { + const model = new PostsFeedModel(store, 'list', {list: uri}) + feeds.push(model) + } } pagerRef.current?.setPage(0) setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned.map(p => p.uri)) + setRequestedCustomFeeds(pinned) }, [ store, - store.me.savedFeeds.pinned, + store.preferences.pinnedFeeds, customFeeds, setCustomFeeds, pagerRef, @@ -124,7 +123,7 @@ export const HomeScreen = withAuthRequired( {customFeeds.map((f, index) => { return ( <FeedPage - key={(f.params as GetCustomFeed.QueryParams).feed} + key={f.reactKey} testID="customFeedPage" isPageFocused={selectedPage === 1 + index} feed={f} diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx new file mode 100644 index 000000000..6e2b11c97 --- /dev/null +++ b/src/view/screens/Lists.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Text} from 'view/com/util/text/Text' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> +export const ListsScreen = withAuthRequired( + observer(function ListsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + + const listsLists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-curatelists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + listsLists.refresh() + }, [store, listsLists]), + ) + + const onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + <View style={s.hContentRegion} testID="listsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + User Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists which can drive feeds. + </Text> + </View> + <View> + <Button + testID="newUserListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New + </Text> + </Button> + </View> + </SimpleViewHeader> + <ListsList listsList={listsLists} /> + </View> + ) + }), +) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 23a808feb..d24bc145a 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -66,9 +66,9 @@ export const ModerationScreen = withAuthRequired( </Text> </TouchableOpacity> <Link - testID="mutelistsBtn" + testID="moderationlistsBtn" style={[styles.linkCard, pal.view]} - href="/moderation/mute-lists"> + href="/moderation/modlists"> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="users-slash" @@ -76,7 +76,7 @@ export const ModerationScreen = withAuthRequired( /> </View> <Text type="lg" style={pal.text}> - Mute lists + Moderation lists </Text> </Link> <Link diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx new file mode 100644 index 000000000..fc148ed8e --- /dev/null +++ b/src/view/screens/ModerationModlists.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Text} from 'view/com/util/text/Text' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> +export const ModerationModlistsScreen = withAuthRequired( + observer(function ModerationModlistsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + + const mutelists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-modlists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + mutelists.refresh() + }, [store, mutelists]), + ) + + const onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + <View style={s.hContentRegion} testID="moderationModlistsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + Moderation Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists of users to mute or block in bulk. + </Text> + </View> + <View> + <Button + testID="newModListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New + </Text> + </Button> + </View> + </SimpleViewHeader> + <ListsList listsList={mutelists} /> + </View> + ) + }), +) diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx deleted file mode 100644 index bc933c24e..000000000 --- a/src/view/screens/ModerationMuteLists.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' -import {StyleSheet} from 'react-native' -import {useFocusEffect, useNavigation} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {AtUri} from '@atproto/api' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' -import {Button} from 'view/com/util/forms/Button' -import {NavigationProp} from 'lib/routes/types' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {CenteredView} from 'view/com/util/Views' -import {ViewHeader} from 'view/com/util/ViewHeader' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'ModerationMuteLists' -> -export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { - const pal = usePalette('default') - const store = useStores() - const {isTabletOrDesktop} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() - - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) - - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - mutelists.refresh() - }, [store, mutelists]), - ) - - const onPressNewMuteList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - const renderEmptyState = React.useCallback(() => { - return ( - <EmptyStateWithButton - testID="emptyMuteLists" - icon="users-slash" - message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." - buttonLabel="New Mute List" - onPress={onPressNewMuteList} - /> - ) - }, [onPressNewMuteList]) - - const renderHeaderButton = React.useCallback( - () => ( - <Button - type="primary-light" - onPress={onPressNewMuteList} - style={styles.createBtn}> - <FontAwesomeIcon - icon="plus" - style={pal.link as FontAwesomeIconStyle} - size={18} - /> - </Button> - ), - [onPressNewMuteList, pal], - ) - - return ( - <CenteredView - style={[ - styles.container, - pal.view, - pal.border, - isTabletOrDesktop && styles.containerDesktop, - ]} - testID="moderationMutelistsScreen"> - <ViewHeader - title="Mute Lists" - showOnDesktop - renderButton={renderHeaderButton} - /> - <ListsList - listsList={mutelists} - showAddBtns={isTabletOrDesktop} - renderEmptyState={renderEmptyState} - onPressCreateNew={onPressNewMuteList} - /> - </CenteredView> - ) -}) - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, - }, - createBtn: { - width: 40, - }, -}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 596bda57e..c1ab69313 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -25,8 +25,8 @@ import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {CustomFeed} from 'view/com/feeds/CustomFeed' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -189,9 +189,14 @@ export const ProfileScreen = withAuthRequired( style={styles.emptyState} /> ) - } else if (item instanceof CustomFeedModel) { + } else if (item instanceof FeedSourceModel) { return ( - <CustomFeed item={item} showSaveBtn showLikes showDescription /> + <FeedSourceCard + item={item} + showSaveBtn + showLikes + showDescription + /> ) } // if section is posts or posts & replies diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx new file mode 100644 index 000000000..70e52bf7a --- /dev/null +++ b/src/view/screens/ProfileFeed.tsx @@ -0,0 +1,535 @@ +import React, {useMemo, useCallback} from 'react' +import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useNavigation} from '@react-navigation/native' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIcon, HeartIconSolid} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CommonNavigatorParams} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {FeedSourceModel} from 'state/models/content/feed-source' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {TextLink} from 'view/com/util/Link' +import {Button} from 'view/com/util/forms/Button' +import {Text} from 'view/com/util/text/Text' +import {RichText} from 'view/com/util/text/RichText' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {EmptyState} from 'view/com/util/EmptyState' +import * as Toast from 'view/com/util/Toast' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {Haptics} from 'lib/haptics' +import {useAnalytics} from 'lib/analytics/analytics' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' +import {resolveName} from 'lib/api' +import {makeCustomFeedLink} from 'lib/routes/links' +import {pluralize} from 'lib/strings/helpers' +import {CenteredView, ScrollView} from 'view/com/util/Views' +import {NavigationProp} from 'lib/routes/types' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' + +const SECTION_TITLES = ['Posts', 'About'] + +interface SectionRef { + scrollToTop: () => void +} + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> +export const ProfileFeedScreen = withAuthRequired( + observer(function ProfileFeedScreenImpl(props: Props) { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const {name: handleOrDid} = props.route.params + + const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() + const [error, setError] = React.useState<string | undefined>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the feed owner before we can fetch the feed. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setFeedOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setFeedOwnerDid]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + Could not load feed + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel="Go Back" + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + Go Back + </Text> + </Button> + </View> + </View> + </CenteredView> + ) + } + + return feedOwnerDid ? ( + <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + }), +) + +export const ProfileFeedScreenInner = observer( + function ProfileFeedScreenInnerImpl({ + route, + feedOwnerDid, + }: Props & {feedOwnerDid: string}) { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const feedSectionRef = React.useRef<SectionRef>(null) + const {rkey, name: handleOrDid} = route.params + const uri = useMemo( + () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), + [rkey, feedOwnerDid], + ) + const feedInfo = useCustomFeed(uri) + const feed: PostsFeedModel = useMemo(() => { + const model = new PostsFeedModel(store, 'custom', { + feed: uri, + }) + model.setup() + return model + }, [store, uri]) + const isPinned = store.preferences.isPinnedFeed(uri) + useSetTitle(feedInfo?.displayName) + + // events + // = + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (feedInfo?.isSaved) { + await feedInfo?.unsave() + } else { + await feedInfo?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, feedInfo]) + + const onToggleLiked = React.useCallback(async () => { + Haptics.default() + try { + if (feedInfo?.isLiked) { + await feedInfo?.unlike() + } else { + await feedInfo?.like() + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + store.log.error('Failed up toggle like', {err}) + } + }, [store, feedInfo]) + + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + if (feedInfo) { + feedInfo.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + } + }, [store, feedInfo]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) + shareUrl(url) + track('CustomFeed:Share') + }, [handleOrDid, rkey, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + store.shell.openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [store, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', + onPress: onToggleSaved, + icon: feedInfo?.isSaved + ? { + ios: { + name: 'trash', + }, + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', + }, + }, + { + testID: 'feedHeaderDropdownReportBtn', + label: 'Report feed', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', + }, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: 'ic_menu_share', + web: 'share', + }, + }, + ] as DropdownItem[] + }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) + + const renderHeader = useCallback(() => { + return ( + <ProfileSubpageHeader + isLoading={!feedInfo?.hasLoaded} + href={makeCustomFeedLink(feedOwnerDid, rkey)} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo?.isOwner} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + {feedInfo && ( + <> + <Button + type="default" + label={feedInfo?.isSaved ? 'Unsave' : 'Save'} + onPress={onToggleSaved} + style={styles.btn} + /> + <Button + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + style={styles.btn} + /> + </> + )} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel="More options" + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.text} + /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) + }, [ + pal, + feedOwnerDid, + rkey, + feedInfo, + isPinned, + onTogglePinned, + onToggleSaved, + dropdownItems, + ]) + + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <FeedSection + key="1" + ref={feedSectionRef} + feed={feed} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight}) => ( + <ScrollView + key="2" + onScroll={onScroll} + scrollEventThrottle={1} + contentContainerStyle={{paddingTop: headerHeight}}> + <AboutSection + feedOwnerDid={feedOwnerDid} + feedRkey={rkey} + feedInfo={feedInfo} + onToggleLiked={onToggleLiked} + /> + </ScrollView> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + }, +) + +interface FeedSectionProps { + feed: PostsFeedModel + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isScrolledDown}, + ref, + ) { + const hasNew = feed.hasNewLatest && !feed.isRefreshing + const scrollElRef = React.useRef<FlatList>(null) + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) + + const onPressLoadLatest = React.useCallback(() => { + onScrollToTop() + feed.refresh() + }, [feed, onScrollToTop]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + feed={feed} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={5} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + +const AboutSection = observer(function AboutPageImpl({ + feedOwnerDid, + feedRkey, + feedInfo, + onToggleLiked, +}: { + feedOwnerDid: string + feedRkey: string + feedInfo: FeedSourceModel | undefined + onToggleLiked: () => void +}) { + const pal = usePalette('default') + + if (!feedInfo) { + return <View /> + } + return ( + <View + style={[ + { + borderTopWidth: 1, + paddingVertical: 20, + paddingHorizontal: 20, + gap: 12, + }, + pal.border, + ]}> + {feedInfo.descriptionRT ? ( + <RichText + testID="listDescription" + type="lg" + style={pal.text} + richText={feedInfo.descriptionRT} + /> + ) : ( + <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> + No description + </Text> + )} + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> + <Button + type="default" + testID="toggleLikeBtn" + accessibilityLabel="Like this feed" + accessibilityHint="" + onPress={onToggleLiked} + style={{paddingHorizontal: 10}}> + {feedInfo?.isLiked ? ( + <HeartIconSolid size={19} style={styles.liked} /> + ) : ( + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> + )} + </Button> + {typeof feedInfo.likeCount === 'number' && ( + <TextLink + href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} + text={`Liked by ${feedInfo.likeCount} ${pluralize( + feedInfo.likeCount, + 'user', + )}`} + style={[pal.textLight, s.semiBold]} + /> + )} + </View> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + Created by{' '} + {feedInfo.isOwner ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(feedInfo.creatorHandle, '@')} + href={makeProfileLink({ + did: feedInfo.creatorDid, + handle: feedInfo.creatorHandle, + })} + style={pal.textLight} + /> + )} + </Text> + </View> + ) +}) + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + liked: { + color: colors.red3, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, +}) diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 49d0d0482..2e9d12aae 100644 --- a/src/view/screens/CustomFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -8,8 +8,8 @@ import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedB import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' -type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'> -export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> +export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { const store = useStores() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 11a847db3..859f50bef 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -1,166 +1,802 @@ -import React from 'react' -import {StyleSheet} from 'react-native' +import React, {useCallback, useMemo} from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + View, +} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' +import {RichText as RichTextAPI} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {Text} from 'view/com/util/text/Text' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {CenteredView} from 'view/com/util/Views' -import {ListItems} from 'view/com/lists/ListItems' import {EmptyState} from 'view/com/util/EmptyState' +import {RichText} from 'view/com/util/text/RichText' +import {Button} from 'view/com/util/forms/Button' +import {TextLink} from 'view/com/util/Link' import * as Toast from 'view/com/util/Toast' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {Haptics} from 'lib/haptics' import {ListModel} from 'state/models/content/list' +import {PostsFeedModel} from 'state/models/feeds/posts' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {ListActions} from 'view/com/lists/ListActions' +import {resolveName} from 'lib/api' import {s} from 'lib/styles' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink, makeListLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' +import {ListItems} from 'view/com/lists/ListItems' + +const SECTION_TITLES_CURATE = ['Posts', 'About'] +const SECTION_TITLES_MOD = ['About'] + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl({route}: Props) { + observer(function ProfileListScreenImpl(props: Props) { + const pal = usePalette('default') const store = useStores() const navigation = useNavigation<NavigationProp>() - const {isTabletOrDesktop} = useWebMediaQueries() - const pal = usePalette('default') - const {name, rkey} = route.params - const list: ListModel = React.useMemo(() => { + const {name: handleOrDid} = props.route.params + + const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() + const [error, setError] = React.useState<string | undefined>() + + const onPressBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the list owner before we can fetch the list. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setListOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setListOwnerDid]) + + if (error) { + return ( + <CenteredView> + <View + style={[ + pal.view, + pal.border, + { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + Could not load list + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel="Go Back" + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + Go Back + </Text> + </Button> + </View> + </View> + </CenteredView> + ) + } + + return listOwnerDid ? ( + <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + }), +) + +export const ProfileListScreenInner = observer( + function ProfileListScreenInnerImpl({ + route, + listOwnerDid, + }: Props & {listOwnerDid: string}) { + const store = useStores() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + + const list: ListModel = useMemo(() => { const model = new ListModel( store, - `at://${name}/app.bsky.graph.list/${rkey}`, + `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, ) return model - }, [store, name, rkey]) - useSetTitle(list.list?.name) + }, [store, listOwnerDid, rkey]) + const feed = useMemo( + () => new PostsFeedModel(store, 'list', {list: list.uri}), + [store, list], + ) + useSetTitle(list.data?.name) useFocusEffect( - React.useCallback(() => { + useCallback(() => { store.shell.setMinimalShellMode(false) - list.loadMore(true) - }, [store, list]), + list.loadMore(true).then(() => { + if (list.isCuratelist) { + feed.setup() + } + }) + }, [store, list, feed]), ) - const onToggleSubscribed = React.useCallback(async () => { - try { - if (list.list?.viewer?.muted) { - await list.unsubscribe() - } else { - await list.subscribe() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your subscription, please check your internet connection and try again.', - ) - store.log.error('Failed up update subscription', {err}) - } - }, [store, list]) - - const onPressEditList = React.useCallback(() => { + const onPressAddUser = useCallback(() => { store.shell.openModal({ - name: 'create-or-edit-mute-list', + name: 'list-add-user', list, - onSave() { - list.refresh() + onAdd() { + if (list.isCuratelist) { + feed.refresh() + } }, }) - }, [store, list]) + }, [store, list, feed]) - const onPressDeleteList = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: 'Delete List', - message: 'Are you sure?', - async onPressConfirm() { - await list.delete() - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) + + if (list.isCuratelist) { + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <FeedSection + key="1" + ref={feedSectionRef} + feed={feed} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight, isScrolledDown}) => ( + <AboutSection + key="2" + ref={aboutSectionRef} + list={list} + descriptionRT={list.descriptionRT} + creator={list.data ? list.data.creator : undefined} + isCurateList={list.isCuratelist} + isOwner={list.isOwner} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + } + if (list.isModlist) { + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <AboutSection + key="2" + list={list} + descriptionRT={list.descriptionRT} + creator={list.data ? list.data.creator : undefined} + isCurateList={list.isCuratelist} + isOwner={list.isOwner} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + } + return <Header rkey={rkey} list={list} /> + }, +) + +const Header = observer(function HeaderImpl({ + rkey, + list, +}: { + rkey: string + list: ListModel +}) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const onTogglePinned = useCallback(async () => { + Haptics.default() + list.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned list', {e}) + }) + }, [store, list]) + + const onSubscribeMute = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Mute these accounts?', + message: + 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + confirmBtnText: 'Mute this List', + async onPressConfirm() { + try { + await list.mute() + Toast.show('List muted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeMute = useCallback(async () => { + try { + await list.unmute() + Toast.show('List unmuted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onSubscribeBlock = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Block these accounts?', + message: + 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + confirmBtnText: 'Block this List', + async onPressConfirm() { + try { + await list.block() + Toast.show('List blocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeBlock = useCallback(async () => { + try { + await list.unblock() + Toast.show('List unblocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onPressEdit = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDelete = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + Toast.show('List deleted') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const onPressReport = useCallback(() => { + if (!list.data) return + store.shell.openModal({ + name: 'report', + uri: list.uri, + cid: list.data.cid, + }) + }, [store, list]) + + const onPressShare = useCallback(() => { + const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + shareUrl(url) + }, [list.creatorDid, rkey]) + + const dropdownItems: DropdownItem[] = useMemo(() => { + if (!list.hasLoaded) { + return [] + } + let items: DropdownItem[] = [ + { + testID: 'listHeaderDropdownShareBtn', + label: 'Share', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: '', + web: 'share', + }, + }, + ] + if (list.isOwner) { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownEditBtn', + label: 'Edit List Details', + onPress: onPressEdit, + icon: { + ios: { + name: 'pencil', + }, + android: '', + web: 'pen', }, }) - }, [store, list, navigation]) - - const onPressReportList = React.useCallback(() => { - if (!list.list) return - store.shell.openModal({ - name: 'report', - uri: list.uri, - cid: list.list.cid, + items.push({ + testID: 'listHeaderDropdownDeleteBtn', + label: 'Delete List', + onPress: onPressDelete, + icon: { + ios: { + name: 'trash', + }, + android: '', + web: ['far', 'trash-can'], + }, }) - }, [store, list]) + } else { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownReportBtn', + label: 'Report List', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: '', + web: 'circle-exclamation', + }, + }) + } + return items + }, [ + list.hasLoaded, + list.isOwner, + onPressShare, + onPressEdit, + onPressDelete, + onPressReport, + ]) + + const subscribeDropdownItems: DropdownItem[] = useMemo(() => { + return [ + { + testID: 'subscribeDropdownMuteBtn', + label: 'Mute accounts', + onPress: onSubscribeMute, + icon: { + ios: { + name: 'speaker.slash', + }, + android: '', + web: 'user-slash', + }, + }, + { + testID: 'subscribeDropdownBlockBtn', + label: 'Block accounts', + onPress: onSubscribeBlock, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: '', + web: 'ban', + }, + }, + ] + }, [onSubscribeMute, onSubscribeBlock]) + + return ( + <ProfileSubpageHeader + isLoading={!list.hasLoaded} + href={makeListLink( + list.data?.creator.handle || list.data?.creator.did || '', + rkey, + )} + title={list.data?.name || 'User list'} + avatar={list.data?.avatar} + isOwner={list.isOwner} + creator={list.data?.creator} + avatarType="list"> + {list.isCuratelist ? ( + <Button + testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} + type={list.isPinned ? 'default' : 'inverted'} + label={list.isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + /> + ) : list.isModlist ? ( + list.isBlocking ? ( + <Button + testID="unblockBtn" + type="default" + label="Unblock" + onPress={onUnsubscribeBlock} + /> + ) : list.isMuting ? ( + <Button + testID="unmuteBtn" + type="default" + label="Unmute" + onPress={onUnsubscribeMute} + /> + ) : ( + <NativeDropdown + testID="subscribeBtn" + items={subscribeDropdownItems} + accessibilityLabel="Subscribe to this list" + accessibilityHint=""> + <View style={[palInverted.view, styles.btn]}> + <Text style={palInverted.text}>Subscribe</Text> + </View> + </NativeDropdown> + ) + ) : null} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel="More options" + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) +}) + +interface FeedSectionProps { + feed: PostsFeedModel + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isScrolledDown}, + ref, + ) { + const hasNew = feed.hasNewLatest && !feed.isRefreshing + const scrollElRef = React.useRef<FlatList>(null) - const onPressShareList = React.useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) - shareUrl(url) - }, [list.creatorDid, rkey]) + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) - const renderEmptyState = React.useCallback(() => { - return <EmptyState icon="users-slash" message="This list is empty!" /> + const onPressLoadLatest = React.useCallback(() => { + onScrollToTop() + feed.refresh() + }, [feed, onScrollToTop]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const renderHeaderBtns = React.useCallback(() => { - return ( - <ListActions - muted={list.list?.viewer?.muted} - isOwner={list.isOwner} - onPressDeleteList={onPressDeleteList} - onPressEditList={onPressEditList} - onToggleSubscribed={onToggleSubscribed} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - reversed={true} + return ( + <View> + <Feed + testID="listFeed" + feed={feed} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + +interface AboutSectionProps { + list: ListModel + descriptionRT: RichTextAPI | null + creator: {did: string; handle: string} | undefined + isCurateList: boolean | undefined + isOwner: boolean | undefined + onPressAddUser: () => void + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( + function AboutSectionImpl( + { + list, + descriptionRT, + creator, + isCurateList, + isOwner, + onPressAddUser, + onScroll, + headerHeight, + isScrolledDown, + }, + ref, + ) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + const scrollElRef = React.useRef<FlatList>(null) + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderHeader = React.useCallback(() => { + if (!list.data) { + return <View /> + } + return ( + <View> + <View + style={[ + { + borderTopWidth: 1, + padding: isMobile ? 14 : 20, + gap: 12, + }, + pal.border, + ]}> + {descriptionRT ? ( + <RichText + testID="listDescription" + type="lg" + style={pal.text} + richText={descriptionRT} + /> + ) : ( + <Text + testID="listDescriptionEmpty" + type="lg" + style={[{fontStyle: 'italic'}, pal.textLight]}> + No description + </Text> + )} + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {isCurateList ? 'User list' : 'Moderation list'} by{' '} + {isOwner ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(creator?.handle || '', '@')} + href={creator ? makeProfileLink(creator) : ''} + style={pal.textLight} + /> + )} + </Text> + </View> + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: isMobile ? 14 : 20, + paddingBottom: isMobile ? 14 : 18, + }, + ]}> + <Text type="lg-bold">Users</Text> + {isOwner && ( + <Pressable + testID="addUserBtn" + accessibilityRole="button" + accessibilityLabel="Add a user to this list" + accessibilityHint="" + onPress={onPressAddUser} + style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> + <FontAwesomeIcon + icon="user-plus" + color={pal.colors.link} + size={16} + /> + <Text style={pal.link}>Add</Text> + </Pressable> + )} + </View> + </View> ) }, [ - list.isOwner, - list.list?.viewer?.muted, - onPressDeleteList, - onPressEditList, - onPressShareList, - onToggleSubscribed, - onPressReportList, + pal, + list.data, + isMobile, + descriptionRT, + creator, + isCurateList, + isOwner, + onPressAddUser, ]) + const renderEmptyState = useCallback(() => { + return ( + <EmptyState + icon="users-slash" + message="This list is empty!" + style={{paddingTop: 40}} + /> + ) + }, []) + return ( - <CenteredView - style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="moderationMutelistsScreen"> - <ViewHeader title="" renderButton={renderHeaderBtns} /> + <View> <ListItems - list={list} + testID="listItems" + scrollElRef={scrollElRef} + renderHeader={renderHeader} renderEmptyState={renderEmptyState} - onToggleSubscribed={onToggleSubscribed} - onPressEditList={onPressEditList} - onPressDeleteList={onPressDeleteList} - onPressReportList={onPressReportList} - onPressShareList={onPressShareList} - style={[s.flex1]} + list={list} + headerOffset={headerHeight} + onScroll={onScroll} + scrollEventThrottle={1} /> - </CenteredView> + {isScrolledDown && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Scroll to top" + showIndicator={false} + /> + )} + </View> ) - }), + }, ) const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, }, }) diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 5253c5bd6..8f8cdc6c9 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams} from 'lib/routes/types' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' +import {SavedFeedsModel} from 'state/models/ui/saved-feeds' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -25,9 +26,9 @@ import DraggableFlatList, { ShadowDecorator, ScaleDecorator, } from 'react-native-draggable-flatlist' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {FeedSourceModel} from 'state/models/content/feed-source' 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' @@ -41,7 +42,11 @@ export const SavedFeeds = withAuthRequired( const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + const savedFeeds = useMemo(() => { + const model = new SavedFeedsModel(store) + model.refresh() + return model + }, [store]) useFocusEffect( useCallback(() => { screen('SavedFeeds') @@ -102,7 +107,7 @@ export const SavedFeeds = withAuthRequired( const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) const onDragEnd = useCallback( - async ({data}: {data: CustomFeedModel[]}) => { + async ({data}: {data: FeedSourceModel[]}) => { try { await savedFeeds.reorderPinnedFeeds(data) } catch (e) { @@ -123,8 +128,8 @@ export const SavedFeeds = withAuthRequired( <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> <DraggableFlatList containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]} - data={savedFeeds.all} - keyExtractor={item => item.data.uri} + data={savedFeeds.pinned.concat(savedFeeds.unpinned)} + keyExtractor={item => item.uri} refreshing={savedFeeds.isRefreshing} refreshControl={ <RefreshControl @@ -134,7 +139,9 @@ export const SavedFeeds = withAuthRequired( titleColor={pal.colors.text} /> } - renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} + renderItem={({item, drag}) => ( + <ListItem savedFeeds={savedFeeds} item={item} drag={drag} /> + )} getItemLayout={(data, index) => ({ length: 77, offset: 77 * index, @@ -152,24 +159,25 @@ export const SavedFeeds = withAuthRequired( ) const ListItem = observer(function ListItemImpl({ + savedFeeds, item, drag, }: { - item: CustomFeedModel + savedFeeds: SavedFeedsModel + item: FeedSourceModel drag: () => void }) { const pal = usePalette('default') const store = useStores() - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) - const isPinned = savedFeeds.isPinned(item) + const isPinned = item.isPinned const onTogglePinned = useCallback(() => { Haptics.default() - savedFeeds.togglePinnedFeed(item).catch(e => { + item.togglePin().catch(e => { Toast.show('There was an issue contacting the server') store.log.error('Failed to toggle pinned feed', {e}) }) - }, [savedFeeds, item, store]) + }, [item, store]) const onPressUp = useCallback( () => savedFeeds.movePinnedFeed(item, 'up').catch(e => { @@ -222,8 +230,8 @@ const ListItem = observer(function ListItemImpl({ style={s.ml20} /> ) : null} - <CustomFeed - key={item.data.uri} + <FeedSourceCard + key={item.uri} item={item} showSaveBtn style={styles.noBorder} |