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 export const CustomFeedScreen = withAuthRequired( observer(function CustomFeedScreenImpl(props: Props) { const pal = usePalette('default') const store = useStores() const navigation = useNavigation() const {name: handleOrDid} = props.route.params const [feedOwnerDid, setFeedOwnerDid] = React.useState() const [error, setError] = React.useState() 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 ( Could not load feed {error} ) } return feedOwnerDid ? ( ) : ( ) }), ) 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() 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(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 ( ) }, [pal.border]) return ( {currentFeed ? ( store.emitScreenSoftReset()} /> ) : ( 'Loading...' )} {currentFeed ? ( <> {currentFeed?.isSaved ? ( ) : ( )} ) : null} {isScrolledDown ? ( ) : null} } accessibilityRole="button" accessibilityLabel="New post" accessibilityHint="" /> ) }, ) 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, }, })