Factor our feed source model (#1887)

* Refactor first onboarding step

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

* Improve last commit
This commit is contained in:
Eric Bailey 2023-11-13 15:53:57 -06:00 committed by GitHub
parent a01463788d
commit 06eb8b9a4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 526 additions and 1356 deletions

View file

@ -10,10 +10,8 @@ import {Button} from 'view/com/util/forms/Button'
import {RecommendedFeedsItem} from './RecommendedFeedsItem'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette'
import {useQuery} from '@tanstack/react-query'
import {useStores} from 'state/index'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
type Props = {
next: () => void
@ -21,35 +19,11 @@ type Props = {
export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
next,
}: Props) {
const store = useStores()
const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries()
const {isLoading, data: recommendedFeeds} = useQuery({
staleTime: Infinity, // fixed list rn, never refetch
queryKey: ['onboarding', 'recommended_feeds'],
async queryFn() {
try {
const {
data: {feeds},
success,
} = await store.agent.app.bsky.feed.getSuggestedFeeds()
const {isLoading, data} = useSuggestedFeedsQuery()
if (!success) {
return []
}
return (feeds.length ? feeds : []).map(feed => {
const model = new FeedSourceModel(store, feed.uri)
model.hydrateFeedGenerator(feed)
return model
})
} catch (e) {
return []
}
},
})
const hasFeeds = recommendedFeeds && recommendedFeeds.length
const hasFeeds = data && data?.pages?.[0]?.feeds?.length
const title = (
<>
@ -118,7 +92,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
contentStyle={{paddingHorizontal: 0}}>
{hasFeeds ? (
<FlatList
data={recommendedFeeds}
data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri}
style={{flex: 1}}
@ -146,7 +120,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
{hasFeeds ? (
<FlatList
data={recommendedFeeds}
data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri}
style={{flex: 1}}

View file

@ -2,6 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
import {Text} from 'view/com/util/text/Text'
import {RichText} from 'view/com/util/text/RichText'
import {Button} from 'view/com/util/forms/Button'
@ -11,33 +12,58 @@ import {HeartIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeHandle} from 'lib/strings/handles'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {
usePreferencesQuery,
usePinFeedMutation,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
item,
}: {
item: FeedSourceModel
item: AppBskyFeedDefs.GeneratorView
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
if (!item) return null
const {data: preferences} = usePreferencesQuery()
const {
mutateAsync: pinFeed,
variables: pinnedFeed,
reset: resetPinFeed,
} = usePinFeedMutation()
const {
mutateAsync: removeFeed,
variables: removedFeed,
reset: resetRemoveFeed,
} = useRemoveFeedMutation()
if (!item || !preferences) return null
const isPinned =
!removedFeed?.uri &&
(pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
const onToggle = async () => {
if (item.isSaved) {
if (isPinned) {
try {
await item.unsave()
await removeFeed({uri: item.uri})
resetRemoveFeed()
} catch (e) {
Toast.show('There was an issue contacting your server')
console.error('Failed to unsave feed', {e})
logger.error('Failed to unsave feed', {error: e})
}
} else {
try {
await item.pin()
await pinFeed({uri: item.uri})
resetPinFeed()
} catch (e) {
Toast.show('There was an issue contacting your server')
console.error('Failed to pin feed', {e})
logger.error('Failed to pin feed', {error: e})
}
}
}
return (
<View testID={`feed-${item.displayName}`}>
<View
@ -66,10 +92,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
</Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
by {sanitizeHandle(item.creatorHandle, '@')}
by {sanitizeHandle(item.creator.handle, '@')}
</Text>
{item.descriptionRT ? (
{item.description ? (
<RichText
type="xl"
style={[
@ -80,7 +106,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
marginBottom: 18,
},
]}
richText={item.descriptionRT}
richText={new BskRichText({text: item.description || ''})}
numberOfLines={6}
/>
) : null}
@ -97,7 +123,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
paddingRight: 2,
gap: 6,
}}>
{item.isSaved ? (
{isPinned ? (
<>
<FontAwesomeIcon
icon="check"

View file

@ -7,7 +7,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar'
import {observer} from 'mobx-react-lite'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
@ -23,7 +22,7 @@ import {
} from '#/state/queries/preferences'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
export const FeedSourceCard = observer(function FeedSourceCardImpl({
feedUri,
style,
showSaveBtn = false,
@ -162,128 +161,6 @@ export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
)
})
export const FeedSourceCard = observer(function FeedSourceCardImpl({
item,
style,
showSaveBtn = false,
showDescription = false,
showLikes = false,
}: {
item: FeedSourceModel
style?: StyleProp<ViewStyle>
showSaveBtn?: boolean
showDescription?: boolean
showLikes?: boolean
}) {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
const onToggleSaved = React.useCallback(async () => {
if (item.isSaved) {
openModal({
name: 'confirm',
title: 'Remove from my feeds',
message: `Remove ${item.displayName} from my feeds?`,
onPressConfirm: async () => {
try {
await item.unsave()
Toast.show('Removed from my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
logger.error('Failed to unsave feed', {error: e})
}
},
})
} else {
try {
await item.save()
Toast.show('Added to my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
logger.error('Failed to save feed', {error: e})
}
}
}, [openModal, item])
return (
<Pressable
testID={`feed-${item.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
if (item.type === 'feed-generator') {
navigation.push('ProfileFeed', {
name: item.creatorDid,
rkey: new AtUri(item.uri).rkey,
})
} else if (item.type === 'list') {
navigation.push('ProfileList', {
name: item.creatorDid,
rkey: new AtUri(item.uri).rkey,
})
}
}}
key={item.uri}>
<View style={[styles.headerContainer]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={item.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{item.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
by {sanitizeHandle(item.creatorHandle, '@')}
</Text>
</View>
{showSaveBtn && (
<View>
<Pressable
accessibilityRole="button"
accessibilityLabel={
item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{item.isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && item.descriptionRT ? (
<RichText
style={[pal.textLight, styles.description]}
richText={item.descriptionRT}
numberOfLines={3}
/>
) : null}
{showLikes ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {item.likeCount || 0}{' '}
{pluralize(item.likeCount || 0, 'user')}
</Text>
) : null}
</Pressable>
)
})
const styles = StyleSheet.create({
container: {
paddingHorizontal: 18,

View file

@ -32,9 +32,12 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {useStores} from '#/state'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
@ -59,11 +62,9 @@ type YieldedItem =
export function PostThread({
uri,
onPressReply,
treeView,
}: {
uri: string | undefined
onPressReply: () => void
treeView: boolean
}) {
const {
isLoading,
@ -74,6 +75,7 @@ export function PostThread({
data: thread,
dataUpdatedAt,
} = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
@ -96,7 +98,7 @@ export function PostThread({
if (AppBskyFeedDefs.isBlockedPost(thread)) {
return <PostThreadBlocked />
}
if (!thread || isLoading) {
if (!thread || isLoading || !preferences) {
return (
<CenteredView>
<View style={s.p20}>
@ -110,7 +112,7 @@ export function PostThread({
thread={thread}
isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt}
treeView={treeView}
threadViewPrefs={preferences.threadViewPrefs}
onRefresh={refetch}
onPressReply={onPressReply}
/>
@ -121,20 +123,19 @@ function PostThreadLoaded({
thread,
isRefetching,
dataUpdatedAt,
treeView,
threadViewPrefs,
onRefresh,
onPressReply,
}: {
thread: ThreadNode
isRefetching: boolean
dataUpdatedAt: number
treeView: boolean
threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
onRefresh: () => void
onPressReply: () => void
}) {
const {_} = useLingui()
const pal = usePalette('default')
const store = useStores()
const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null)
// const hasScrolledIntoView = useRef<boolean>(false) TODO
@ -162,16 +163,14 @@ function PostThreadLoaded({
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat(
Array.from(
flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
),
Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
)
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
arr.push(BOTTOM_COMPONENT)
return arr
}, [thread, maxVisible, store.preferences.thread])
}, [thread, maxVisible, threadViewPrefs])
// TODO
/*const onContentSizeChange = React.useCallback(() => {
@ -297,7 +296,7 @@ function PostThreadLoaded({
post={item.post}
record={item.record}
dataUpdatedAt={dataUpdatedAt}
treeView={treeView}
treeView={threadViewPrefs.lab_treeViewEnabled}
depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
@ -322,7 +321,7 @@ function PostThreadLoaded({
pal.colors.border,
posts,
onRefresh,
treeView,
threadViewPrefs.lab_treeViewEnabled,
dataUpdatedAt,
_,
],

View file

@ -8,12 +8,12 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {useStores} from 'state/index'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors'
import {useRemoveFeedMutation} from '#/state/queries/preferences'
enum KnownError {
Block,
@ -86,12 +86,12 @@ function FeedgenErrorMessage({
knownError: KnownError
}) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError]
const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls()
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid})
@ -104,7 +104,7 @@ function FeedgenErrorMessage({
message: 'Remove this feed from your saved feeds?',
async onPressConfirm() {
try {
await store.preferences.removeSavedFeed(uri)
await removeFeed({uri})
} catch (err) {
Toast.show(
'There was an an issue removing this feed. Please check your internet connection and try again.',
@ -116,7 +116,7 @@ function FeedgenErrorMessage({
closeModal()
},
})
}, [store, openModal, closeModal, uri])
}, [openModal, closeModal, uri, removeFeed])
return (
<View

View file

@ -52,6 +52,7 @@ export function Button({
accessibilityLabelledBy,
onAccessibilityEscape,
withLoading = false,
disabled = false,
}: React.PropsWithChildren<{
type?: ButtonType
label?: string
@ -65,6 +66,7 @@ export function Button({
accessibilityLabelledBy?: string
onAccessibilityEscape?: () => void
withLoading?: boolean
disabled?: boolean
}>) {
const theme = useTheme()
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -198,7 +200,7 @@ export function Button({
<Pressable
style={getStyle}
onPress={onPressWrapped}
disabled={isLoading}
disabled={disabled || isLoading}
testID={testID}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}

View file

@ -1,38 +0,0 @@
import React, {useMemo} from 'react'
import {AppBskyFeedDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
import {useStores} from 'state/index'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
export function CustomFeedEmbed({
record,
}: {
record: AppBskyFeedDefs.GeneratorView
}) {
const pal = usePalette('default')
const store = useStores()
const item = useMemo(() => {
const model = new FeedSourceModel(store, record.uri)
model.hydrateFeedGenerator(record)
return model
}, [store, record])
return (
<FeedSourceCard
item={item}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
)
}
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
paddingHorizontal: 12,
paddingVertical: 12,
},
})

View file

@ -28,9 +28,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {CustomFeedEmbed} from './CustomFeedEmbed'
import {ListEmbed} from './ListEmbed'
import {isCauseALabelOnUri} from 'lib/moderation'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
type Embed =
| AppBskyEmbedRecord.View
@ -72,7 +72,13 @@ export function PostEmbeds({
// custom feed embed (i.e. generator view)
// =
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
return <CustomFeedEmbed record={embed.record} />
return (
<FeedSourceCard
feedUri={embed.record.uri}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
)
}
// list embed
@ -206,4 +212,11 @@ const styles = StyleSheet.create({
fontSize: 10,
fontWeight: 'bold',
},
customFeedOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
paddingHorizontal: 12,
paddingVertical: 12,
},
})

View file

@ -23,7 +23,7 @@ import debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text'
import {FlatList} from 'view/com/util/Views'
import {useFocusEffect} from '@react-navigation/native'
import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSetMinimalShellMode} from '#/state/shell'
@ -412,7 +412,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') {
return (
<NewFeedSourceCard
<FeedSourceCard
feedUri={item.feedUri}
showSaveBtn
showDescription

View file

@ -84,7 +84,6 @@ export const PostThreadScreen = withAuthRequired(
<PostThreadComponent
uri={resolvedUri?.uri}
onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled}
/>
)}
</View>

View file

@ -17,7 +17,6 @@ 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 {FeedDescriptor} from '#/state/queries/post-feed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
@ -32,7 +31,6 @@ 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 {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {shareUrl} from 'lib/sharing'
@ -40,7 +38,6 @@ 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'
@ -53,6 +50,18 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
useSaveFeedMutation,
useRemoveFeedMutation,
usePinFeedMutation,
useUnpinFeedMutation,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
const SECTION_TITLES = ['Posts', 'About']
@ -63,15 +72,17 @@ interface SectionRef {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
export const ProfileFeedScreen = withAuthRequired(
observer(function ProfileFeedScreenImpl(props: Props) {
const {rkey, name: handleOrDid} = props.route.params
const pal = usePalette('default')
const store = useStores()
const {_} = useLingui()
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 uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
[rkey, handleOrDid],
)
const {error, data: resolvedUri} = useResolveUriQuery(uri)
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
@ -81,24 +92,6 @@ export const ProfileFeedScreen = withAuthRequired(
}
}, [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>
@ -107,7 +100,7 @@ export const ProfileFeedScreen = withAuthRequired(
<Trans>Could not load feed</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
{error.toString()}
</Text>
<View style={{flexDirection: 'row'}}>
@ -127,8 +120,8 @@ export const ProfileFeedScreen = withAuthRequired(
)
}
return feedOwnerDid ? (
<ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
return resolvedUri ? (
<ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
) : (
<CenteredView>
<View style={s.p20}>
@ -139,255 +132,305 @@ export const ProfileFeedScreen = withAuthRequired(
}),
)
export const ProfileFeedScreenInner = observer(
function ProfileFeedScreenInnerImpl({
route,
feedOwnerDid,
}: Props & {feedOwnerDid: string}) {
const {openModal} = useModalControls()
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const {_} = useLingui()
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 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.',
)
logger.error('Failed up update feeds', {error: err})
}
}, [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.',
)
logger.error('Failed up toggle like', {error: err})
}
}, [feedInfo])
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
if (feedInfo) {
feedInfo.togglePin().catch(e => {
Toast.show('There was an issue contacting the server')
logger.error('Failed to toggle pinned feed', {error: e})
})
}
}, [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
openModal({
name: 'report',
uri: feedInfo.uri,
cid: feedInfo.cid,
})
}, [openModal, 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={_(msg`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,
_,
])
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
const {data: preferences} = usePreferencesQuery()
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
if (!preferences || !info) {
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES}
isHeaderReady={feedInfo?.hasLoaded ?? false}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
feed={`feedgen|${uri}`}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
/>
)}
{({onScroll, headerHeight, scrollElRef}) => (
<AboutSection
feedOwnerDid={feedOwnerDid}
feedRkey={rkey}
feedInfo={feedInfo}
headerHeight={headerHeight}
onToggleLiked={onToggleLiked}
onScroll={onScroll}
scrollElRef={
scrollElRef as React.MutableRefObject<ScrollView | null>
}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</View>
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
},
)
}
return (
<ProfileFeedScreenInner
preferences={preferences}
feedInfo={info as FeedSourceFeedInfo}
/>
)
}
export const ProfileFeedScreenInner = function ProfileFeedScreenInnerImpl({
preferences,
feedInfo,
}: {
preferences: UsePreferencesQueryResponse
feedInfo: FeedSourceFeedInfo
}) {
const {_} = useLingui()
const pal = usePalette('default')
const store = useStores()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const {track} = useAnalytics()
const feedSectionRef = React.useRef<SectionRef>(null)
const {
mutateAsync: saveFeed,
variables: savedFeed,
reset: resetSaveFeed,
isPending: isSavePending,
} = useSaveFeedMutation()
const {
mutateAsync: removeFeed,
variables: removedFeed,
reset: resetRemoveFeed,
isPending: isRemovePending,
} = useRemoveFeedMutation()
const {
mutateAsync: pinFeed,
variables: pinnedFeed,
reset: resetPinFeed,
isPending: isPinPending,
} = usePinFeedMutation()
const {
mutateAsync: unpinFeed,
variables: unpinnedFeed,
reset: resetUnpinFeed,
isPending: isUnpinPending,
} = useUnpinFeedMutation()
const isSaved =
!removedFeed &&
(!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
const isPinned =
!unpinnedFeed &&
(!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
useSetTitle(feedInfo?.displayName)
const onToggleSaved = React.useCallback(async () => {
try {
Haptics.default()
if (isSaved) {
await removeFeed({uri: feedInfo.uri})
resetRemoveFeed()
} else {
await saveFeed({uri: feedInfo.uri})
resetSaveFeed()
}
} catch (err) {
Toast.show(
'There was an an issue updating your feeds, please check your internet connection and try again.',
)
logger.error('Failed up update feeds', {error: err})
}
}, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed])
const onTogglePinned = React.useCallback(async () => {
try {
Haptics.default()
if (isPinned) {
await unpinFeed({uri: feedInfo.uri})
resetUnpinFeed()
} else {
await pinFeed({uri: feedInfo.uri})
resetPinFeed()
}
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to toggle pinned feed', {error: e})
}
}, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(feedInfo.route.href)
shareUrl(url)
track('CustomFeed:Share')
}, [feedInfo, track])
const onPressReport = React.useCallback(() => {
if (!feedInfo) return
openModal({
name: 'report',
uri: feedInfo.uri,
cid: feedInfo.cid,
})
}, [openModal, feedInfo])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
if (index === 0) {
feedSectionRef.current?.scrollToTop()
}
},
[feedSectionRef],
)
// render
// =
const dropdownItems: DropdownItem[] = React.useMemo(() => {
return [
{
testID: 'feedHeaderDropdownToggleSavedBtn',
label: isSaved ? 'Remove from my feeds' : 'Add to my feeds',
onPress: isSavePending || isRemovePending ? undefined : onToggleSaved,
icon: 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[]
}, [
onToggleSaved,
onPressReport,
onPressShare,
isSaved,
isSavePending,
isRemovePending,
])
const renderHeader = useCallback(() => {
return (
<ProfileSubpageHeader
isLoading={false}
href={feedInfo.route.href}
title={feedInfo?.displayName}
avatar={feedInfo?.avatar}
isOwner={feedInfo.creatorDid === currentAccount?.did}
creator={
feedInfo
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
: undefined
}
avatarType="algo">
{feedInfo && (
<>
<Button
disabled={isSavePending || isRemovePending}
type="default"
label={isSaved ? 'Unsave' : 'Save'}
onPress={onToggleSaved}
style={styles.btn}
/>
<Button
disabled={isPinPending || isUnpinPending}
type={isPinned ? 'default' : 'inverted'}
label={isPinned ? 'Unpin' : 'Pin to home'}
onPress={onTogglePinned}
style={styles.btn}
/>
</>
)}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.text}
/>
</View>
</NativeDropdown>
</ProfileSubpageHeader>
)
}, [
_,
pal,
feedInfo,
isPinned,
onTogglePinned,
onToggleSaved,
dropdownItems,
currentAccount?.did,
isPinPending,
isRemovePending,
isSavePending,
isSaved,
isUnpinPending,
])
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES}
isHeaderReady={true}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
feed={`feedgen|${feedInfo.uri}`}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
/>
)}
{({onScroll, headerHeight, scrollElRef}) => (
<AboutSection
feedOwnerDid={feedInfo.creatorDid}
feedRkey={feedInfo.route.params.rkey}
feedInfo={feedInfo}
headerHeight={headerHeight}
onScroll={onScroll}
scrollElRef={
scrollElRef as React.MutableRefObject<ScrollView | null>
}
isOwner={feedInfo.creatorDid === currentAccount?.did}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</View>
)
}
interface FeedSectionProps {
feed: FeedDescriptor
@ -447,25 +490,49 @@ const AboutSection = observer(function AboutPageImpl({
feedRkey,
feedInfo,
headerHeight,
onToggleLiked,
onScroll,
scrollElRef,
isOwner,
}: {
feedOwnerDid: string
feedRkey: string
feedInfo: FeedSourceModel | undefined
feedInfo: FeedSourceFeedInfo
headerHeight: number
onToggleLiked: () => void
onScroll: OnScrollHandler
scrollElRef: React.MutableRefObject<ScrollView | null>
isOwner: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const scrollHandler = useAnimatedScrollHandler(onScroll)
const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
if (!feedInfo) {
return <View />
}
const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
useUnlikeMutation()
const isLiked = !!likeUri
const likeCount =
isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount
const onToggleLiked = React.useCallback(async () => {
try {
Haptics.default()
if (isLiked && likeUri) {
await unlikeFeed({uri: likeUri})
setLikeUri('')
} else {
const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid})
setLikeUri(res.uri)
}
} catch (err) {
Toast.show(
'There was an an issue contacting the server, please check your internet connection and try again.',
)
logger.error('Failed up toggle like', {error: err})
}
}, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed])
return (
<ScrollView
@ -486,12 +553,12 @@ const AboutSection = observer(function AboutPageImpl({
},
pal.border,
]}>
{feedInfo.descriptionRT ? (
{feedInfo.description ? (
<RichText
testID="listDescription"
type="lg"
style={pal.text}
richText={feedInfo.descriptionRT}
richText={feedInfo.description}
/>
) : (
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
@ -504,28 +571,26 @@ const AboutSection = observer(function AboutPageImpl({
testID="toggleLikeBtn"
accessibilityLabel={_(msg`Like this feed`)}
accessibilityHint=""
disabled={isLikePending || isUnlikePending}
onPress={onToggleLiked}
style={{paddingHorizontal: 10}}>
{feedInfo?.isLiked ? (
{isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{typeof feedInfo.likeCount === 'number' && (
{typeof likeCount === 'number' && (
<TextLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
text={`Liked by ${feedInfo.likeCount} ${pluralize(
feedInfo.likeCount,
'user',
)}`}
text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
style={[pal.textLight, s.semiBold]}
/>
)}
</View>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
Created by{' '}
{feedInfo.isOwner ? (
{isOwner ? (
'you'
) : (
<TextLink

View file

@ -21,7 +21,7 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {ScrollView, CenteredView} from 'view/com/util/Views'
import {Text} from 'view/com/util/text/Text'
import {s, colors} from 'lib/styles'
import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics'
@ -250,7 +250,7 @@ const ListItem = observer(function ListItemImpl({
</TouchableOpacity>
</View>
) : null}
<NewFeedSourceCard
<FeedSourceCard
key={feedUri}
feedUri={feedUri}
style={styles.noBorder}