From 50108611600ae2addb3bff4f00562b02f9c35fdf Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Mon, 15 May 2023 10:42:23 -0700 Subject: custom feed screen --- src/state/models/feeds/posts.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) (limited to 'src/state/models/feeds/posts.ts') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 44cec3af7..7adc1cb1c 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -4,6 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, + AppBskyFeedGetFeed as GetCustomFeed, RichText, jsonToLex, } from '@atproto/api' @@ -305,8 +306,11 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, + public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom', + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, ) { makeAutoObservable( this, @@ -595,13 +599,15 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + async _replaceAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response, + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, replace = false, ) { this.loadMoreCursor = res.data.cursor @@ -640,7 +646,9 @@ export class PostsFeedModel { }) } - _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -657,8 +665,13 @@ export class PostsFeedModel { } protected async _getFeed( - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, - ): Promise { + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, + ): Promise< + GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response + > { params = Object.assign({}, this.params, params) if (this.feedType === 'suggested') { const responses = await getMultipleAuthorsPosts( @@ -680,6 +693,10 @@ export class PostsFeedModel { } } else if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) + } else if (this.feedType === 'custom') { + return this.rootStore.agent.app.bsky.feed.getFeed( + params as GetCustomFeed.QueryParams, + ) } else if (this.feedType === 'goodstuff') { const res = await getGoodStuff( this.rootStore.session.currentSession?.accessJwt || '', -- cgit 1.4.1 From 84990c509e9feb0cd44921a318aedcbad92b1da7 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 15:12:18 -0500 Subject: Drop the hard-coded what's hot algo --- src/state/models/feeds/posts.ts | 56 +------------- src/view/com/modals/ContentLanguagesSettings.tsx | 4 +- src/view/com/pager/FeedsTabBar.web.tsx | 7 +- src/view/com/pager/FeedsTabBarMobile.tsx | 7 +- src/view/com/posts/CustomFeedEmptyState.tsx | 97 ++++++++++++++++++++++++ src/view/com/posts/WhatsHotEmptyState.tsx | 76 ------------------- src/view/screens/Home.tsx | 35 ++------- 7 files changed, 109 insertions(+), 173 deletions(-) create mode 100644 src/view/com/posts/CustomFeedEmptyState.tsx delete mode 100644 src/view/com/posts/WhatsHotEmptyState.tsx (limited to 'src/state/models/feeds/posts.ts') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index dfd92b35c..5a5b28785 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -310,7 +310,7 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom', + public feedType: 'home' | 'author' | 'suggested' | 'custom', params: | GetTimeline.QueryParams | GetAuthorFeed.QueryParams @@ -391,10 +391,9 @@ export class PostsFeedModel { } get feedTuners() { - if (this.feedType === 'goodstuff') { + if (this.feedType === 'custom') { return [ FeedTuner.dedupReposts, - FeedTuner.likedRepliesOnly, FeedTuner.preferredLangOnly( this.rootStore.preferences.contentLanguages, ), @@ -701,15 +700,6 @@ export class PostsFeedModel { return this.rootStore.agent.app.bsky.feed.getFeed( params as GetCustomFeed.QueryParams, ) - } else if (this.feedType === 'goodstuff') { - const res = await getGoodStuff( - this.rootStore.session.currentSession?.accessJwt || '', - params as GetTimeline.QueryParams, - ) - res.data.feed = (res.data.feed || []).filter( - item => !item.post.author.viewer?.muted, - ) - return res } else { return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, @@ -717,45 +707,3 @@ export class PostsFeedModel { } } } - -// HACK -// temporary off-spec route to get the good stuff -// -prf -async function getGoodStuff( - accessJwt: string, - params: GetTimeline.QueryParams, -): Promise { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), 15e3) - - const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') - let k: keyof GetTimeline.QueryParams - for (k in params) { - if (typeof params[k] !== 'undefined') { - uri.searchParams.set(k, String(params[k])) - } - } - - const res = await fetch(String(uri), { - method: 'get', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessJwt}`, - }, - signal: controller.signal, - }) - - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - let resBody = await res.json() - - clearTimeout(to) - - return { - success: res.status === 200, - headers: resHeaders, - data: jsonToLex(resBody), - } -} diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx index 0c750fe0e..700f1cbcb 100644 --- a/src/view/com/modals/ContentLanguagesSettings.tsx +++ b/src/view/com/modals/ContentLanguagesSettings.tsx @@ -41,8 +41,8 @@ export function Component({}: {}) { Content Languages - Which languages would you like to see in the What's Hot feed? (Leave - them all unchecked to see any language.) + Which languages would you like to see in the your feed? (Leave them all + unchecked to see any language.) {languages.map(lang => ( diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 56ca6f2a1..78937611b 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -28,12 +28,7 @@ const FeedsTabBarDesktop = observer( ) => { const store = useStores() const items = useMemo( - () => [ - 'Following', - "What's hot", - ...store.me.savedFeeds.pinnedFeedNames, - 'My feeds', - ], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], [store.me.savedFeeds.pinnedFeedNames], ) const pal = usePalette('default') diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index cb910ccb9..a41f0ef32 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -33,12 +33,7 @@ export const FeedsTabBar = observer( }, [store]) const items = useMemo( - () => [ - 'Following', - "What's hot", - ...store.me.savedFeeds.pinnedFeedNames, - 'My feeds', - ], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], [store.me.savedFeeds.pinnedFeedNames], ) diff --git a/src/view/com/posts/CustomFeedEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx new file mode 100644 index 000000000..69dd79902 --- /dev/null +++ b/src/view/com/posts/CustomFeedEmptyState.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export function CustomFeedEmptyState() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation() + + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) + + const onPressSettings = React.useCallback(() => { + store.shell.openModal({name: 'content-languages-settings'}) + }, [store]) + + return ( + + + + + + This feed is empty! You may need to follow more users or tune your + language settings. + + + + + ) +} +const styles = StyleSheet.create({ + emptyContainer: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + emptyIconContainer: { + marginBottom: 16, + }, + emptyIcon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, + + feedsTip: { + position: 'absolute', + left: 22, + }, + feedsTipArrow: { + marginLeft: 32, + marginTop: 8, + }, +}) diff --git a/src/view/com/posts/WhatsHotEmptyState.tsx b/src/view/com/posts/WhatsHotEmptyState.tsx deleted file mode 100644 index ade94ca3f..000000000 --- a/src/view/com/posts/WhatsHotEmptyState.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from '../util/text/Text' -import {Button} from '../util/forms/Button' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' - -export function WhatsHotEmptyState() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - - const onPressSettings = React.useCallback(() => { - store.shell.openModal({name: 'content-languages-settings'}) - }, [store]) - - return ( - - - - - - Your What's Hot feed is empty! This is because there aren't enough users - posting in your selected language. - - - - ) -} -const styles = StyleSheet.create({ - emptyContainer: { - height: '100%', - paddingVertical: 40, - paddingHorizontal: 30, - }, - emptyIconContainer: { - marginBottom: 16, - }, - emptyIcon: { - marginLeft: 'auto', - marginRight: 'auto', - }, - emptyBtn: { - marginVertical: 20, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 18, - paddingHorizontal: 24, - borderRadius: 30, - }, - - feedsTip: { - position: 'absolute', - left: 22, - }, - feedsTipArrow: { - marginLeft: 32, - marginTop: 8, - }, -}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 54cec3b31..d761994f3 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -11,7 +11,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' -import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' +import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' @@ -34,15 +34,6 @@ export const HomeScreen = withAuthRequired( const pagerRef = React.useRef(null) const [selectedPage, setSelectedPage] = React.useState(0) const [customFeeds, setCustomFeeds] = React.useState([]) - const [initialLanguages] = React.useState( - store.preferences.contentLanguages, - ) - - const algoFeed: PostsFeedModel = React.useMemo(() => { - const feed = new PostsFeedModel(store, 'goodstuff', {}) - feed.setup() - return feed - }, [store]) React.useEffect(() => { const {pinned} = store.me.savedFeeds @@ -66,13 +57,6 @@ export const HomeScreen = withAuthRequired( setCustomFeeds(feeds) }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) - React.useEffect(() => { - // refresh whats hot when lang preferences change - if (initialLanguages !== store.preferences.contentLanguages) { - algoFeed.refresh() - } - }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) - useFocusEffect( React.useCallback(() => { store.shell.setMinimalShellMode(false) @@ -113,8 +97,8 @@ export const HomeScreen = withAuthRequired( return }, []) - const renderWhatsHotEmptyState = React.useCallback(() => { - return + const renderCustomFeedEmptyState = React.useCallback(() => { + return }, []) const initialPage = store.me.followsCount === 0 ? 1 : 0 @@ -133,26 +117,19 @@ export const HomeScreen = withAuthRequired( feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} /> - {customFeeds.map((f, index) => { return ( ) })} -- cgit 1.4.1 From 3c89dd40f90deda00dd4d717bec0bb2f4217c1d1 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 16:54:17 -0500 Subject: Fix lint --- src/state/models/feeds/posts.ts | 1 - src/state/models/ui/saved-feeds.ts | 1 - src/view/com/pager/TabBar.tsx | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) (limited to 'src/state/models/feeds/posts.ts') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 5a5b28785..ac32044b4 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -6,7 +6,6 @@ import { AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetFeed as GetCustomFeed, RichText, - jsonToLex, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 9de28e028..0d04f9c8d 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -66,7 +66,6 @@ export class SavedFeedsModel { this._xLoading(!quietRefresh) try { let feeds: AppBskyFeedDefs.GeneratorView[] = [] - let cursor for ( let i = 0; i < this.rootStore.preferences.savedFeeds.length; diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index f6c41ce7c..485219730 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -10,7 +10,7 @@ import {StyleSheet, View, ScrollView} from 'react-native' import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb, isWeb} from 'platform/detection' +import {isDesktopWeb} from 'platform/detection' export interface TabBarProps { testID?: string -- cgit 1.4.1 From 257686f3603e800e355850a23b3a4011e5558aeb Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 25 May 2023 20:02:37 -0500 Subject: Add feeds tab --- bskyweb/cmd/bskyweb/server.go | 1 + src/Navigation.tsx | 28 +++- src/lib/hooks/useNavigationTabState.ts | 4 +- src/lib/routes/types.ts | 7 + src/routes.ts | 1 + src/state/models/feeds/multi-feed.ts | 216 +++++++++++++++++++++++++ src/state/models/feeds/post.ts | 265 ++++++++++++++++++++++++++++++ src/state/models/feeds/posts.ts | 270 +------------------------------ src/view/com/pager/FeedsTabBarMobile.tsx | 8 +- src/view/com/pager/TabBar.tsx | 2 +- src/view/com/posts/Feed.tsx | 2 - src/view/com/posts/MultiFeed.tsx | 230 ++++++++++++++++++++++++++ src/view/screens/Feeds.tsx | 125 ++++++++++++++ src/view/screens/SavedFeeds.tsx | 6 +- src/view/shell/Drawer.tsx | 30 ++-- src/view/shell/bottom-bar/BottomBar.tsx | 30 +++- src/view/shell/desktop/LeftNav.tsx | 2 +- 17 files changed, 937 insertions(+), 290 deletions(-) create mode 100644 src/state/models/feeds/multi-feed.ts create mode 100644 src/state/models/feeds/post.ts create mode 100644 src/view/com/posts/MultiFeed.tsx create mode 100644 src/view/screens/Feeds.tsx (limited to 'src/state/models/feeds/posts.ts') diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 462740f54..85fffd4b8 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -106,6 +106,7 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/search", server.WebGeneric) e.GET("/search/feeds", server.WebGeneric) + e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/mute-lists", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0664ac526..7da77b877 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -14,6 +14,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs' import { HomeTabNavigatorParams, SearchTabNavigatorParams, + FeedsTabNavigatorParams, NotificationsTabNavigatorParams, FlatNavigatorParams, AllNavigatorParams, @@ -32,6 +33,7 @@ import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' +import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' import {ModerationScreen} from './view/screens/Moderation' import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' @@ -65,6 +67,7 @@ const navigationRef = createNavigationContainerRef() const HomeTab = createNativeStackNavigator() const SearchTab = createNativeStackNavigator() +const FeedsTab = createNativeStackNavigator() const NotificationsTab = createNativeStackNavigator() const MyProfileTab = createNativeStackNavigator() @@ -225,11 +228,12 @@ function TabsNavigator() { screenOptions={{headerShown: false}} tabBar={tabBar}> + + - ) @@ -269,6 +273,23 @@ function SearchTabNavigator() { ) } +function FeedsTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + + + {commonScreens(FeedsTab as typeof HomeTab)} + + ) +} + function NotificationsTabNavigator() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( @@ -342,6 +363,11 @@ const FlatNavigator = observer(() => { component={SearchScreen} options={{title: title('Search')}} /> + sub.remove() + } + + /** + * Reset and load + */ + async refresh() { + this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds + await this.loadMore(true) + } + + /** + * Load more posts to the end of the feed + */ + loadMore = bundleAsync(async (isRefreshing: boolean = false) => { + if (!isRefreshing && !this.hasMore) { + return + } + if (isRefreshing) { + this.isRefreshing = true // set optimistically for UI + this.feeds = [] + } + this._xLoading(isRefreshing) + const start = this.feeds.length + const newFeeds: PostsFeedModel[] = [] + for ( + let i = start; + i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; + i++ + ) { + const feed = new PostsFeedModel(this.rootStore, 'custom', { + feed: this.feedInfos[i].uri, + }) + feed.pageSize = FEED_PAGE_SIZE + await feed.setup() + newFeeds.push(feed) + } + runInAction(() => { + this.feeds = this.feeds.concat(newFeeds) + this.hasMore = this.feeds.length < this.feedInfos.length + }) + this._xIdle() + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.hasMore = true + return this.loadMore() + } + + /** + * Removes posts from the feed upon deletion. + */ + onPostDeleted(uri: string) { + for (const f of this.feeds) { + f.onPostDeleted(uri) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + } + + _xIdle() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + } + + // helper functions + // = +} diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts new file mode 100644 index 000000000..0c411d448 --- /dev/null +++ b/src/state/models/feeds/post.ts @@ -0,0 +1,265 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {updateDataOptimistically} from 'lib/async/revertible' +import {PostLabelInfo, PostModeration} from 'lib/labeling/types' +import {FeedViewPostsSlice} from 'lib/api/feed-manip' +import { + getEmbedLabels, + getEmbedMuted, + getEmbedMutedByList, + getEmbedBlocking, + getEmbedBlockedBy, + getPostModeration, + filterAccountLabels, + filterProfileLabels, + mergePostModerations, +} from 'lib/labeling/helpers' + +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView + +let _idCounter = 0 + +export class PostsFeedItemModel { + // ui state + _reactKey: string = '' + + // data + post: PostView + postRecord?: AppBskyFeedPost.Record + reply?: FeedViewPost['reply'] + reason?: FeedViewPost['reason'] + richText?: RichText + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: FeedViewPost, + ) { + this._reactKey = reactKey + this.post = v.post + if (AppBskyFeedPost.isRecord(this.post.record)) { + const valid = AppBskyFeedPost.validateRecord(this.post.record) + if (valid.success) { + this.postRecord = this.post.record + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'Received an invalid app.bsky.feed.post record', + valid.error, + ) + } + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', + this.post.record, + ) + } + this.reply = v.reply + this.reason = v.reason + makeAutoObservable(this, {rootStore: false}) + } + + get rootUri(): string { + if (this.reply?.root.uri) { + return this.reply.root.uri + } + return this.post.uri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + + get labelInfo(): PostLabelInfo { + return { + postLabels: (this.post.labels || []).concat( + getEmbedLabels(this.post.embed), + ), + accountLabels: filterAccountLabels(this.post.author.labels), + profileLabels: filterProfileLabels(this.post.author.labels), + isMuted: + this.post.author.viewer?.muted || + getEmbedMuted(this.post.embed) || + false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), + isBlocking: + !!this.post.author.viewer?.blocking || + getEmbedBlocking(this.post.embed) || + false, + isBlockedBy: + !!this.post.author.viewer?.blockedBy || + getEmbedBlockedBy(this.post.embed) || + false, + } + } + + get moderation(): PostModeration { + return getPostModeration(this.rootStore, this.labelInfo) + } + + copy(v: FeedViewPost) { + this.post = v.post + this.reply = v.reply + this.reason = v.reason + } + + copyMetrics(v: FeedViewPost) { + this.post.replyCount = v.post.replyCount + this.post.repostCount = v.post.repostCount + this.post.likeCount = v.post.likeCount + this.post.viewer = v.post.viewer + } + + get reasonRepost(): ReasonRepost | undefined { + if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { + return this.reason as ReasonRepost + } + } + + async toggleLike() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer.like) { + const url = this.post.viewer.like + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) - 1 + this.post.viewer!.like = undefined + }, + () => this.rootStore.agent.deleteLike(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) + 1 + this.post.viewer!.like = 'pending' + }, + () => this.rootStore.agent.like(this.post.uri, this.post.cid), + res => { + this.post.viewer!.like = res.uri + }, + ) + } + } + + async toggleRepost() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer?.repost) { + const url = this.post.viewer.repost + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) - 1 + this.post.viewer!.repost = undefined + }, + () => this.rootStore.agent.deleteRepost(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) + 1 + this.post.viewer!.repost = 'pending' + }, + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), + res => { + this.post.viewer!.repost = res.uri + }, + ) + } + } + + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + + async delete() { + await this.rootStore.agent.deletePost(this.post.uri) + this.rootStore.emitPostDeleted(this.post.uri) + } +} + +export class PostsFeedSliceModel { + // ui state + _reactKey: string = '' + + // data + items: PostsFeedItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + slice: FeedViewPostsSlice, + ) { + this._reactKey = reactKey + for (const item of slice.items) { + this.items.push( + new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item), + ) + } + makeAutoObservable(this, {rootStore: false}) + } + + get uri() { + if (this.isReply) { + return this.items[1].post.uri + } + return this.items[0].post.uri + } + + get isThread() { + return ( + this.items.length > 1 && + this.items.every( + item => item.post.author.did === this.items[0].post.author.did, + ) + ) + } + + get isReply() { + return this.items.length > 1 && !this.isThread + } + + get rootItem() { + if (this.isReply) { + return this.items[1] + } + return this.items[0] + } + + get moderation() { + return mergePostModerations(this.items.map(item => item.moderation)) + } + + containsUri(uri: string) { + return !!this.items.find(item => item.post.uri === uri) + } + + isThreadParentAt(i: number) { + if (this.items.length === 1) { + return false + } + return i < this.items.length - 1 + } + + isThreadChildAt(i: number) { + if (this.items.length === 1) { + return false + } + return i > 0 + } +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index ac32044b4..02ef5f38b 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -1,11 +1,8 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedDefs, - AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetFeed as GetCustomFeed, - RichText, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -19,269 +16,11 @@ import { mergePosts, } from 'lib/api/build-suggested-posts' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' -import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - getPostModeration, - mergePostModerations, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' - -type FeedViewPost = AppBskyFeedDefs.FeedViewPost -type ReasonRepost = AppBskyFeedDefs.ReasonRepost -type PostView = AppBskyFeedDefs.PostView +import {PostsFeedSliceModel} from './post' const PAGE_SIZE = 30 let _idCounter = 0 -export class PostsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - post: PostView - postRecord?: AppBskyFeedPost.Record - reply?: FeedViewPost['reply'] - reason?: FeedViewPost['reason'] - richText?: RichText - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: FeedViewPost, - ) { - this._reactKey = reactKey - this.post = v.post - if (AppBskyFeedPost.isRecord(this.post.record)) { - const valid = AppBskyFeedPost.validateRecord(this.post.record) - if (valid.success) { - this.postRecord = this.post.record - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'Received an invalid app.bsky.feed.post record', - valid.error, - ) - } - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', - this.post.record, - ) - } - this.reply = v.reply - this.reason = v.reason - makeAutoObservable(this, {rootStore: false}) - } - - get rootUri(): string { - if (this.reply?.root.uri) { - return this.reply.root.uri - } - return this.post.uri - } - - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - get labelInfo(): PostLabelInfo { - return { - postLabels: (this.post.labels || []).concat( - getEmbedLabels(this.post.embed), - ), - accountLabels: filterAccountLabels(this.post.author.labels), - profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: - this.post.author.viewer?.muted || - getEmbedMuted(this.post.embed) || - false, - mutedByList: - this.post.author.viewer?.mutedByList || - getEmbedMutedByList(this.post.embed), - isBlocking: - !!this.post.author.viewer?.blocking || - getEmbedBlocking(this.post.embed) || - false, - isBlockedBy: - !!this.post.author.viewer?.blockedBy || - getEmbedBlockedBy(this.post.embed) || - false, - } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) - } - - copy(v: FeedViewPost) { - this.post = v.post - this.reply = v.reply - this.reason = v.reason - } - - copyMetrics(v: FeedViewPost) { - this.post.replyCount = v.post.replyCount - this.post.repostCount = v.post.repostCount - this.post.likeCount = v.post.likeCount - this.post.viewer = v.post.viewer - } - - get reasonRepost(): ReasonRepost | undefined { - if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { - return this.reason as ReasonRepost - } - } - - async toggleLike() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer.like) { - const url = this.post.viewer.like - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) - 1 - this.post.viewer!.like = undefined - }, - () => this.rootStore.agent.deleteLike(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) + 1 - this.post.viewer!.like = 'pending' - }, - () => this.rootStore.agent.like(this.post.uri, this.post.cid), - res => { - this.post.viewer!.like = res.uri - }, - ) - } - } - - async toggleRepost() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer?.repost) { - const url = this.post.viewer.repost - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) - 1 - this.post.viewer!.repost = undefined - }, - () => this.rootStore.agent.deleteRepost(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) + 1 - this.post.viewer!.repost = 'pending' - }, - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), - res => { - this.post.viewer!.repost = res.uri - }, - ) - } - } - - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - - async delete() { - await this.rootStore.agent.deletePost(this.post.uri) - this.rootStore.emitPostDeleted(this.post.uri) - } -} - -export class PostsFeedSliceModel { - // ui state - _reactKey: string = '' - - // data - items: PostsFeedItemModel[] = [] - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - slice: FeedViewPostsSlice, - ) { - this._reactKey = reactKey - for (const item of slice.items) { - this.items.push( - new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item), - ) - } - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - if (this.isReply) { - return this.items[1].post.uri - } - return this.items[0].post.uri - } - - get isThread() { - return ( - this.items.length > 1 && - this.items.every( - item => item.post.author.did === this.items[0].post.author.did, - ) - ) - } - - get isReply() { - return this.items.length > 1 && !this.isThread - } - - get rootItem() { - if (this.isReply) { - return this.items[1] - } - return this.items[0] - } - - get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) - } - - containsUri(uri: string) { - return !!this.items.find(item => item.post.uri === uri) - } - - isThreadParentAt(i: number) { - if (this.items.length === 1) { - return false - } - return i < this.items.length - 1 - } - - isThreadChildAt(i: number) { - if (this.items.length === 1) { - return false - } - return i > 0 - } -} - export class PostsFeedModel { // state isLoading = false @@ -297,6 +36,7 @@ export class PostsFeedModel { loadMoreCursor: string | undefined pollCursor: string | undefined tuner = new FeedTuner() + pageSize = PAGE_SIZE // used to linearize async modifications to state lock = new AwaitLock() @@ -418,7 +158,7 @@ export class PostsFeedModel { this.tuner.reset() this._xLoading(isRefreshing) try { - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -457,7 +197,7 @@ export class PostsFeedModel { try { const res = await this._getFeed({ cursor: this.loadMoreCursor, - limit: PAGE_SIZE, + limit: this.pageSize, }) await this._appendAll(res) this._xIdle() @@ -526,7 +266,7 @@ export class PostsFeedModel { if (this.hasNewLatest || this.feedType === 'suggested') { return } - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) const tuner = new FeedTuner() const slices = tuner.tune(res.data.feed, this.feedTuners) this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri) diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 574265eb7..0d71b2b98 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -8,7 +8,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {Link} from '../util/Link' import {Text} from '../util/text/Text' -import {SatelliteDishIcon} from 'lib/icons' +import {CogIcon} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' @@ -69,11 +69,7 @@ export const FeedsTabBar = observer( accessibilityRole="button" accessibilityLabel="Edit Saved Feeds" accessibilityHint="Opens screen to edit Saved Feeds"> - + diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index bbba74c87..b0a02ea22 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -131,7 +131,7 @@ const styles = isDesktopWeb backgroundColor: 'transparent', }, contentContainer: { - columnGap: 16, + columnGap: 20, marginLeft: 18, paddingRight: 28, backgroundColor: 'transparent', diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index b90213472..8206ca509 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -33,7 +33,6 @@ export const Feed = observer(function Feed({ onPressTryAgain, onScroll, scrollEventThrottle, - onMomentumScrollEnd, renderEmptyState, testID, headerOffset = 0, @@ -186,7 +185,6 @@ export const Feed = observer(function Feed({ style={{paddingTop: headerOffset}} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} - onMomentumScrollEnd={onMomentumScrollEnd} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} onEndReachedThreshold={0.6} diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx new file mode 100644 index 000000000..4911c9e2c --- /dev/null +++ b/src/view/com/posts/MultiFeed.tsx @@ -0,0 +1,230 @@ +import React, {MutableRefObject} from 'react' +import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FlatList} from '../util/Views' +import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed' +import {FeedSlice} from './FeedSlice' +import {Text} from '../util/text/Text' +import {Link} from '../util/Link' +import {UserAvatar} from '../util/UserAvatar' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' + +export const MultiFeed = observer(function Feed({ + multifeed, + style, + showPostFollowBtn, + scrollElRef, + onScroll, + scrollEventThrottle, + testID, + headerOffset = 0, + extraData, +}: { + multifeed: PostsMultiFeedModel + style?: StyleProp + showPostFollowBtn?: boolean + scrollElRef?: MutableRefObject | null> + onPressTryAgain?: () => void + onScroll?: OnScrollCb + scrollEventThrottle?: number + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + extraData?: any +}) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const theme = useTheme() + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('MultiFeed:onRefresh') + setIsRefreshing(true) + try { + await multifeed.refresh() + } catch (err) { + multifeed.rootStore.log.error('Failed to refresh posts feed', err) + } + setIsRefreshing(false) + }, [multifeed, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('MultiFeed:onEndReached') + try { + await multifeed.loadMore() + } catch (err) { + multifeed.rootStore.log.error('Failed to load more posts', err) + } + }, [multifeed, track]) + + // rendering + // = + + const renderItem = React.useCallback( + ({item}: {item: MultiFeedItem}) => { + if (item.type === 'header') { + return + } else if (item.type === 'feed-header') { + return ( + + + + {item.title} + + + ) + } else if (item.type === 'feed-slice') { + return ( + + ) + } else if (item.type === 'feed-loading') { + return + } else if (item.type === 'feed-error') { + return + } else if (item.type === 'feed-footer') { + return ( + + + See more from {item.title} + + + + ) + } else if (item.type === 'footer') { + return ( + + + + Discover new feeds + + + ) + } + return null + }, + [showPostFollowBtn, pal, palInverted], + ) + + const FeedFooter = React.useCallback( + () => + multifeed.isLoading && !isRefreshing ? ( + + + + ) : ( + + ), + [multifeed.isLoading, isRefreshing, pal], + ) + + return ( + + {multifeed.items.length > 0 && ( + item._reactKey} + renderItem={renderItem} + ListFooterComponent={FeedFooter} + refreshControl={ + + } + contentContainerStyle={s.contentContainer} + style={[{paddingTop: headerOffset}, pal.viewLight, styles.container]} + onScroll={onScroll} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + extraData={extraData} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + + ) +}) + +const styles = StyleSheet.create({ + container: { + height: '100%', + }, + header: { + borderTopWidth: 1, + marginBottom: 4, + }, + feedHeader: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 8, + marginTop: 12, + }, + feedHeaderTitle: { + fontWeight: 'bold', + }, + feedFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + marginBottom: 12, + borderTopWidth: 1, + borderBottomWidth: 1, + }, + footerLink: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + marginHorizontal: 8, + marginBottom: 8, + gap: 8, + }, + loadMore: { + paddingTop: 10, + }, +}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx new file mode 100644 index 000000000..5d5ed6c16 --- /dev/null +++ b/src/view/screens/Feeds.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import isEqual from 'lodash.isequal' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FlatList} from 'view/com/util/Views' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {Link} from 'view/com/util/Link' +import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' +import {observer} from 'mobx-react-lite' +import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' +import {MultiFeed} from 'view/com/posts/MultiFeed' +import {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {ComposeIcon2, CogIcon} from 'lib/icons' +import {s} from 'lib/styles' + +const HEADER_OFFSET = isDesktopWeb ? 0 : 40 + +type Props = NativeStackScreenProps +export const FeedsScreen = withAuthRequired( + observer(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const flatListRef = React.useRef(null) + const multifeed = React.useMemo( + () => new PostsMultiFeedModel(store), + [store], + ) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) + + const onSoftReset = React.useCallback(() => { + flatListRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [flatListRef, resetMainScroll]) + + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const multifeedCleanup = multifeed.registerListeners() + const cleanup = () => { + softResetSub.remove() + multifeedCleanup() + } + + store.shell.setMinimalShellMode(false) + return cleanup + }, [store, multifeed, onSoftReset]), + ) + + React.useEffect(() => { + if ( + isEqual( + multifeed.feedInfos.map(f => f.uri), + store.me.savedFeeds.all.map(f => f.uri), + ) + ) { + // no changes + return + } + multifeed.refresh() + }, [multifeed, store.me.savedFeeds.all]) + + const onPressCompose = React.useCallback(() => { + store.shell.openComposer({}) + }, [store]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + + + + ) + }, [pal]) + + return ( + + + + {isScrolledDown ? ( + + ) : null} + } + accessibilityRole="button" + accessibilityLabel="Compose post" + accessibilityHint="" + /> + + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}) diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index dac554710..103b18c70 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -118,7 +118,11 @@ export const SavedFeeds = withAuthRequired( pal.border, isDesktopWeb && styles.desktopContainer, ]}> - + { const store = useStores() const navigation = useNavigation() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {notifications} = store.me @@ -97,11 +98,10 @@ export const DrawerContent = observer(() => { onPressTab('MyProfile') }, [onPressTab]) - const onPressMyFeeds = React.useCallback(() => { - track('Menu:ItemClicked', {url: 'MyFeeds'}) - navigation.navigate('SavedFeeds') - store.shell.closeDrawer() - }, [navigation, track, store.shell]) + const onPressMyFeeds = React.useCallback( + () => onPressTab('Feeds'), + [onPressTab], + ) const onPressModeration = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Moderation'}) @@ -240,11 +240,19 @@ export const DrawerContent = observer(() => { /> + isAtFeeds ? ( + + ) : ( + + ) } label="My Feeds" accessibilityLabel="My Feeds" diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 394aef7af..e8cba9047 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -18,6 +18,8 @@ import { HomeIconSolid, MagnifyingGlassIcon2, MagnifyingGlassIcon2Solid, + SatelliteDishIcon, + SatelliteDishIconSolid, BellIcon, BellIconSolid, } from 'lib/icons' @@ -33,7 +35,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {footerMinimalShellTransform} = useMinimalShellMode() @@ -59,6 +61,10 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { () => onPressTab('Search'), [onPressTab], ) + const onPressFeeds = React.useCallback( + () => onPressTab('Feeds'), + [onPressTab], + ) const onPressNotifications = React.useCallback( () => onPressTab('Notifications'), [onPressTab], @@ -120,6 +126,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { accessibilityLabel="Search" accessibilityHint="" /> + + ) : ( + + ) + } + onPress={onPressFeeds} + accessibilityRole="tab" + accessibilityLabel="Feeds" + accessibilityHint="" + /> Date: Thu, 25 May 2023 19:01:37 -0700 Subject: error for custom feed when it is not valid or offlien --- src/state/models/feeds/posts.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'src/state/models/feeds/posts.ts') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 02ef5f38b..911cc6309 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -436,6 +436,9 @@ export class PostsFeedModel { } else if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) } else if (this.feedType === 'custom') { + this.checkIfCustomFeedIsOnlineAndValid( + params as GetCustomFeed.QueryParams, + ) return this.rootStore.agent.app.bsky.feed.getFeed( params as GetCustomFeed.QueryParams, ) @@ -445,4 +448,18 @@ export class PostsFeedModel { ) } } + + private async checkIfCustomFeedIsOnlineAndValid( + params: GetCustomFeed.QueryParams, + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: params.feed, + }) + if (!res.data.isOnline || !res.data.isValid) { + runInAction(() => { + this.error = + 'This custom feed is not online or may be experiencing issues.' + }) + } + } } -- cgit 1.4.1