Add feeds tab
parent
df6d249e85
commit
257686f360
|
@ -106,6 +106,7 @@ func serve(cctx *cli.Context) error {
|
||||||
// generic routes
|
// generic routes
|
||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGeneric)
|
||||||
e.GET("/search/feeds", server.WebGeneric)
|
e.GET("/search/feeds", server.WebGeneric)
|
||||||
|
e.GET("/feeds", server.WebGeneric)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGeneric)
|
||||||
e.GET("/moderation", server.WebGeneric)
|
e.GET("/moderation", server.WebGeneric)
|
||||||
e.GET("/moderation/mute-lists", server.WebGeneric)
|
e.GET("/moderation/mute-lists", server.WebGeneric)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
|
||||||
import {
|
import {
|
||||||
HomeTabNavigatorParams,
|
HomeTabNavigatorParams,
|
||||||
SearchTabNavigatorParams,
|
SearchTabNavigatorParams,
|
||||||
|
FeedsTabNavigatorParams,
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
FlatNavigatorParams,
|
FlatNavigatorParams,
|
||||||
AllNavigatorParams,
|
AllNavigatorParams,
|
||||||
|
@ -32,6 +33,7 @@ import {useStores} from './state'
|
||||||
|
|
||||||
import {HomeScreen} from './view/screens/Home'
|
import {HomeScreen} from './view/screens/Home'
|
||||||
import {SearchScreen} from './view/screens/Search'
|
import {SearchScreen} from './view/screens/Search'
|
||||||
|
import {FeedsScreen} from './view/screens/Feeds'
|
||||||
import {NotificationsScreen} from './view/screens/Notifications'
|
import {NotificationsScreen} from './view/screens/Notifications'
|
||||||
import {ModerationScreen} from './view/screens/Moderation'
|
import {ModerationScreen} from './view/screens/Moderation'
|
||||||
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
||||||
|
@ -65,6 +67,7 @@ const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
|
||||||
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
|
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
|
||||||
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
|
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
|
||||||
|
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
|
||||||
const NotificationsTab =
|
const NotificationsTab =
|
||||||
createNativeStackNavigator<NotificationsTabNavigatorParams>()
|
createNativeStackNavigator<NotificationsTabNavigatorParams>()
|
||||||
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
|
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
|
||||||
|
@ -225,11 +228,12 @@ function TabsNavigator() {
|
||||||
screenOptions={{headerShown: false}}
|
screenOptions={{headerShown: false}}
|
||||||
tabBar={tabBar}>
|
tabBar={tabBar}>
|
||||||
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
||||||
|
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||||
|
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="NotificationsTab"
|
name="NotificationsTab"
|
||||||
component={NotificationsTabNavigator}
|
component={NotificationsTabNavigator}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
|
||||||
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
)
|
)
|
||||||
|
@ -269,6 +273,23 @@ function SearchTabNavigator() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FeedsTabNavigator() {
|
||||||
|
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||||
|
return (
|
||||||
|
<FeedsTab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
gestureEnabled: true,
|
||||||
|
fullScreenGestureEnabled: true,
|
||||||
|
headerShown: false,
|
||||||
|
animationDuration: 250,
|
||||||
|
contentStyle,
|
||||||
|
}}>
|
||||||
|
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
|
||||||
|
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||||
|
</FeedsTab.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationsTabNavigator() {
|
function NotificationsTabNavigator() {
|
||||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||||
return (
|
return (
|
||||||
|
@ -342,6 +363,11 @@ const FlatNavigator = observer(() => {
|
||||||
component={SearchScreen}
|
component={SearchScreen}
|
||||||
options={{title: title('Search')}}
|
options={{title: title('Search')}}
|
||||||
/>
|
/>
|
||||||
|
<Flat.Screen
|
||||||
|
name="Feeds"
|
||||||
|
component={FeedsScreen}
|
||||||
|
options={{title: title('Feeds')}}
|
||||||
|
/>
|
||||||
<Flat.Screen
|
<Flat.Screen
|
||||||
name="Notifications"
|
name="Notifications"
|
||||||
component={NotificationsScreen}
|
component={NotificationsScreen}
|
||||||
|
|
|
@ -6,14 +6,16 @@ export function useNavigationTabState() {
|
||||||
const res = {
|
const res = {
|
||||||
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
||||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||||
|
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
|
||||||
isAtNotifications:
|
isAtNotifications:
|
||||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!res.isAtHome &&
|
!res.isAtHome &&
|
||||||
!res.isAtNotifications &&
|
|
||||||
!res.isAtSearch &&
|
!res.isAtSearch &&
|
||||||
|
!res.isAtFeeds &&
|
||||||
|
!res.isAtNotifications &&
|
||||||
!res.isAtMyProfile
|
!res.isAtMyProfile
|
||||||
) {
|
) {
|
||||||
// HACK for some reason useNavigationState will give us pre-hydration results
|
// HACK for some reason useNavigationState will give us pre-hydration results
|
||||||
|
|
|
@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
HomeTab: undefined
|
HomeTab: undefined
|
||||||
SearchTab: undefined
|
SearchTab: undefined
|
||||||
|
FeedsTab: undefined
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
}
|
}
|
||||||
|
@ -46,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FeedsTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
Feeds: undefined
|
||||||
|
}
|
||||||
|
|
||||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
}
|
}
|
||||||
|
@ -65,6 +70,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
Home: undefined
|
Home: undefined
|
||||||
SearchTab: undefined
|
SearchTab: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
|
FeedsTab: undefined
|
||||||
|
Feeds: undefined
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {Router} from 'lib/routes/router'
|
||||||
export const router = new Router({
|
export const router = new Router({
|
||||||
Home: '/',
|
Home: '/',
|
||||||
Search: '/search',
|
Search: '/search',
|
||||||
|
Feeds: '/feeds',
|
||||||
DiscoverFeeds: '/search/feeds',
|
DiscoverFeeds: '/search/feeds',
|
||||||
Notifications: '/notifications',
|
Notifications: '/notifications',
|
||||||
Settings: '/settings',
|
Settings: '/settings',
|
||||||
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
import {RootStoreModel} from '../root-store'
|
||||||
|
import {CustomFeedModel} from './custom-feed'
|
||||||
|
import {PostsFeedModel} from './posts'
|
||||||
|
import {PostsFeedSliceModel} from './post'
|
||||||
|
|
||||||
|
const FEED_PAGE_SIZE = 5
|
||||||
|
const FEEDS_PAGE_SIZE = 3
|
||||||
|
|
||||||
|
export type MultiFeedItem =
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'header'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'feed-header'
|
||||||
|
avatar: string | undefined
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'feed-slice'
|
||||||
|
slice: PostsFeedSliceModel
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'feed-loading'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'feed-error'
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'feed-footer'
|
||||||
|
title: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'footer'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostsMultiFeedModel {
|
||||||
|
// state
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
hasLoaded = false
|
||||||
|
hasMore = true
|
||||||
|
|
||||||
|
// data
|
||||||
|
feedInfos: CustomFeedModel[] = []
|
||||||
|
feeds: PostsFeedModel[] = []
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel) {
|
||||||
|
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasContent() {
|
||||||
|
return this.feeds.length !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return this.hasLoaded && !this.hasContent
|
||||||
|
}
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
|
||||||
|
for (let i = 0; i < this.feedInfos.length; i++) {
|
||||||
|
if (!this.feeds[i]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const feed = this.feeds[i]
|
||||||
|
const feedInfo = this.feedInfos[i]
|
||||||
|
const urip = new AtUri(feedInfo.uri)
|
||||||
|
items.push({
|
||||||
|
_reactKey: `__feed_header_${i}__`,
|
||||||
|
type: 'feed-header',
|
||||||
|
avatar: feedInfo.data.avatar,
|
||||||
|
title: feedInfo.displayName,
|
||||||
|
})
|
||||||
|
if (feed.isLoading) {
|
||||||
|
items.push({
|
||||||
|
_reactKey: `__feed_loading_${i}__`,
|
||||||
|
type: 'feed-loading',
|
||||||
|
})
|
||||||
|
} else if (feed.hasError) {
|
||||||
|
items.push({
|
||||||
|
_reactKey: `__feed_error_${i}__`,
|
||||||
|
type: 'feed-error',
|
||||||
|
error: feed.error,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (let j = 0; j < feed.slices.length; j++) {
|
||||||
|
items.push({
|
||||||
|
_reactKey: `__feed_slice_${i}_${j}__`,
|
||||||
|
type: 'feed-slice',
|
||||||
|
slice: feed.slices[j],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
_reactKey: `__feed_footer_${i}__`,
|
||||||
|
type: 'feed-footer',
|
||||||
|
title: feedInfo.displayName,
|
||||||
|
uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!this.hasMore) {
|
||||||
|
items.push({_reactKey: '__footer__', type: 'footer'})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// public api
|
||||||
|
// =
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nuke all data
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.rootStore.log.debug('MultiFeedModel:clear')
|
||||||
|
this.isLoading = false
|
||||||
|
this.isRefreshing = false
|
||||||
|
this.hasLoaded = false
|
||||||
|
this.hasMore = true
|
||||||
|
this.feeds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any event listeners. Returns a cleanup function.
|
||||||
|
*/
|
||||||
|
registerListeners() {
|
||||||
|
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
|
||||||
|
return () => 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
|
||||||
|
// =
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,8 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedGetTimeline as GetTimeline,
|
AppBskyFeedGetTimeline as GetTimeline,
|
||||||
AppBskyFeedDefs,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||||
AppBskyFeedGetFeed as GetCustomFeed,
|
AppBskyFeedGetFeed as GetCustomFeed,
|
||||||
RichText,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import AwaitLock from 'await-lock'
|
import AwaitLock from 'await-lock'
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
@ -19,269 +16,11 @@ import {
|
||||||
mergePosts,
|
mergePosts,
|
||||||
} from 'lib/api/build-suggested-posts'
|
} from 'lib/api/build-suggested-posts'
|
||||||
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
import {PostsFeedSliceModel} from './post'
|
||||||
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
|
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
const PAGE_SIZE = 30
|
||||||
let _idCounter = 0
|
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 {
|
export class PostsFeedModel {
|
||||||
// state
|
// state
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
@ -297,6 +36,7 @@ export class PostsFeedModel {
|
||||||
loadMoreCursor: string | undefined
|
loadMoreCursor: string | undefined
|
||||||
pollCursor: string | undefined
|
pollCursor: string | undefined
|
||||||
tuner = new FeedTuner()
|
tuner = new FeedTuner()
|
||||||
|
pageSize = PAGE_SIZE
|
||||||
|
|
||||||
// used to linearize async modifications to state
|
// used to linearize async modifications to state
|
||||||
lock = new AwaitLock()
|
lock = new AwaitLock()
|
||||||
|
@ -418,7 +158,7 @@ export class PostsFeedModel {
|
||||||
this.tuner.reset()
|
this.tuner.reset()
|
||||||
this._xLoading(isRefreshing)
|
this._xLoading(isRefreshing)
|
||||||
try {
|
try {
|
||||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
const res = await this._getFeed({limit: this.pageSize})
|
||||||
await this._replaceAll(res)
|
await this._replaceAll(res)
|
||||||
this._xIdle()
|
this._xIdle()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
@ -457,7 +197,7 @@ export class PostsFeedModel {
|
||||||
try {
|
try {
|
||||||
const res = await this._getFeed({
|
const res = await this._getFeed({
|
||||||
cursor: this.loadMoreCursor,
|
cursor: this.loadMoreCursor,
|
||||||
limit: PAGE_SIZE,
|
limit: this.pageSize,
|
||||||
})
|
})
|
||||||
await this._appendAll(res)
|
await this._appendAll(res)
|
||||||
this._xIdle()
|
this._xIdle()
|
||||||
|
@ -526,7 +266,7 @@ export class PostsFeedModel {
|
||||||
if (this.hasNewLatest || this.feedType === 'suggested') {
|
if (this.hasNewLatest || this.feedType === 'suggested') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
const res = await this._getFeed({limit: this.pageSize})
|
||||||
const tuner = new FeedTuner()
|
const tuner = new FeedTuner()
|
||||||
const slices = tuner.tune(res.data.feed, this.feedTuners)
|
const slices = tuner.tune(res.data.feed, this.feedTuners)
|
||||||
this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
|
this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {SatelliteDishIcon} from 'lib/icons'
|
import {CogIcon} from 'lib/icons'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
|
|
||||||
|
@ -69,11 +69,7 @@ export const FeedsTabBar = observer(
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Edit Saved Feeds"
|
accessibilityLabel="Edit Saved Feeds"
|
||||||
accessibilityHint="Opens screen to edit Saved Feeds">
|
accessibilityHint="Opens screen to edit Saved Feeds">
|
||||||
<SatelliteDishIcon
|
<CogIcon size={21} strokeWidth={2} style={pal.textLight} />
|
||||||
size={20}
|
|
||||||
strokeWidth={2}
|
|
||||||
style={pal.textLight}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -131,7 +131,7 @@ const styles = isDesktopWeb
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
columnGap: 16,
|
columnGap: 20,
|
||||||
marginLeft: 18,
|
marginLeft: 18,
|
||||||
paddingRight: 28,
|
paddingRight: 28,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
|
|
@ -33,7 +33,6 @@ export const Feed = observer(function Feed({
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScroll,
|
onScroll,
|
||||||
scrollEventThrottle,
|
scrollEventThrottle,
|
||||||
onMomentumScrollEnd,
|
|
||||||
renderEmptyState,
|
renderEmptyState,
|
||||||
testID,
|
testID,
|
||||||
headerOffset = 0,
|
headerOffset = 0,
|
||||||
|
@ -186,7 +185,6 @@ export const Feed = observer(function Feed({
|
||||||
style={{paddingTop: headerOffset}}
|
style={{paddingTop: headerOffset}}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
scrollEventThrottle={scrollEventThrottle}
|
scrollEventThrottle={scrollEventThrottle}
|
||||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
||||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
onEndReachedThreshold={0.6}
|
onEndReachedThreshold={0.6}
|
||||||
|
|
|
@ -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<ViewStyle>
|
||||||
|
showPostFollowBtn?: boolean
|
||||||
|
scrollElRef?: MutableRefObject<FlatList<any> | 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 <View style={[styles.header, pal.border]} />
|
||||||
|
} else if (item.type === 'feed-header') {
|
||||||
|
return (
|
||||||
|
<View style={styles.feedHeader}>
|
||||||
|
<UserAvatar type="algo" avatar={item.avatar} size={28} />
|
||||||
|
<Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
} else if (item.type === 'feed-slice') {
|
||||||
|
return (
|
||||||
|
<FeedSlice slice={item.slice} showFollowBtn={showPostFollowBtn} />
|
||||||
|
)
|
||||||
|
} else if (item.type === 'feed-loading') {
|
||||||
|
return <PostFeedLoadingPlaceholder />
|
||||||
|
} else if (item.type === 'feed-error') {
|
||||||
|
return <ErrorMessage message={item.error} />
|
||||||
|
} else if (item.type === 'feed-footer') {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.uri}
|
||||||
|
style={[styles.feedFooter, pal.border, pal.view]}>
|
||||||
|
<Text type="lg" style={pal.link}>
|
||||||
|
See more from {item.title}
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-right"
|
||||||
|
size={18}
|
||||||
|
color={pal.colors.link}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else if (item.type === 'footer') {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
style={[styles.footerLink, palInverted.view]}
|
||||||
|
href="/search/feeds">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="search"
|
||||||
|
size={18}
|
||||||
|
color={palInverted.colors.text}
|
||||||
|
/>
|
||||||
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
|
Discover new feeds
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
[showPostFollowBtn, pal, palInverted],
|
||||||
|
)
|
||||||
|
|
||||||
|
const FeedFooter = React.useCallback(
|
||||||
|
() =>
|
||||||
|
multifeed.isLoading && !isRefreshing ? (
|
||||||
|
<View style={styles.loadMore}>
|
||||||
|
<ActivityIndicator color={pal.colors.text} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
),
|
||||||
|
[multifeed.isLoading, isRefreshing, pal],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={style}>
|
||||||
|
{multifeed.items.length > 0 && (
|
||||||
|
<FlatList
|
||||||
|
testID={testID ? `${testID}-flatlist` : undefined}
|
||||||
|
ref={scrollElRef}
|
||||||
|
data={multifeed.items}
|
||||||
|
keyExtractor={item => item._reactKey}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ListFooterComponent={FeedFooter}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={pal.colors.text}
|
||||||
|
titleColor={pal.colors.text}
|
||||||
|
progressViewOffset={headerOffset}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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<FeedsTabNavigatorParams, 'Feeds'>
|
||||||
|
export const FeedsScreen = withAuthRequired(
|
||||||
|
observer<Props>(({}: Props) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const flatListRef = React.useRef<FlatList>(null)
|
||||||
|
const multifeed = React.useMemo<PostsMultiFeedModel>(
|
||||||
|
() => 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 (
|
||||||
|
<Link
|
||||||
|
href="/settings/saved-feeds"
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Edit Saved Feeds"
|
||||||
|
accessibilityHint="Opens screen to edit Saved Feeds">
|
||||||
|
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}, [pal])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[pal.view, styles.container]}>
|
||||||
|
<MultiFeed
|
||||||
|
scrollElRef={flatListRef}
|
||||||
|
multifeed={multifeed}
|
||||||
|
onScroll={onMainScroll}
|
||||||
|
scrollEventThrottle={100}
|
||||||
|
headerOffset={HEADER_OFFSET}
|
||||||
|
/>
|
||||||
|
<ViewHeader
|
||||||
|
title="My Feeds"
|
||||||
|
canGoBack={false}
|
||||||
|
hideOnScroll
|
||||||
|
renderButton={renderHeaderBtn}
|
||||||
|
/>
|
||||||
|
{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="Compose post"
|
||||||
|
accessibilityHint=""
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
})
|
|
@ -118,7 +118,11 @@ export const SavedFeeds = withAuthRequired(
|
||||||
pal.border,
|
pal.border,
|
||||||
isDesktopWeb && styles.desktopContainer,
|
isDesktopWeb && styles.desktopContainer,
|
||||||
]}>
|
]}>
|
||||||
<ViewHeader title="My Feeds" showOnDesktop showBorder={!isDesktopWeb} />
|
<ViewHeader
|
||||||
|
title="Edit My Feeds"
|
||||||
|
showOnDesktop
|
||||||
|
showBorder={!isDesktopWeb}
|
||||||
|
/>
|
||||||
<DraggableFlatList
|
<DraggableFlatList
|
||||||
containerStyle={[!isDesktopWeb && s.flex1]}
|
containerStyle={[!isDesktopWeb && s.flex1]}
|
||||||
data={savedFeeds.all}
|
data={savedFeeds.all}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
UserIconSolid,
|
UserIconSolid,
|
||||||
SatelliteDishIcon,
|
SatelliteDishIcon,
|
||||||
|
SatelliteDishIconSolid,
|
||||||
HandIcon,
|
HandIcon,
|
||||||
} from 'lib/icons'
|
} from 'lib/icons'
|
||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
@ -50,7 +51,7 @@ export const DrawerContent = observer(() => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
|
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
||||||
useNavigationTabState()
|
useNavigationTabState()
|
||||||
|
|
||||||
const {notifications} = store.me
|
const {notifications} = store.me
|
||||||
|
@ -97,11 +98,10 @@ export const DrawerContent = observer(() => {
|
||||||
onPressTab('MyProfile')
|
onPressTab('MyProfile')
|
||||||
}, [onPressTab])
|
}, [onPressTab])
|
||||||
|
|
||||||
const onPressMyFeeds = React.useCallback(() => {
|
const onPressMyFeeds = React.useCallback(
|
||||||
track('Menu:ItemClicked', {url: 'MyFeeds'})
|
() => onPressTab('Feeds'),
|
||||||
navigation.navigate('SavedFeeds')
|
[onPressTab],
|
||||||
store.shell.closeDrawer()
|
)
|
||||||
}, [navigation, track, store.shell])
|
|
||||||
|
|
||||||
const onPressModeration = React.useCallback(() => {
|
const onPressModeration = React.useCallback(() => {
|
||||||
track('Menu:ItemClicked', {url: 'Moderation'})
|
track('Menu:ItemClicked', {url: 'Moderation'})
|
||||||
|
@ -240,11 +240,19 @@ export const DrawerContent = observer(() => {
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={
|
icon={
|
||||||
|
isAtFeeds ? (
|
||||||
|
<SatelliteDishIconSolid
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SatelliteDishIcon
|
<SatelliteDishIcon
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
label="My Feeds"
|
label="My Feeds"
|
||||||
accessibilityLabel="My Feeds"
|
accessibilityLabel="My Feeds"
|
||||||
|
|
|
@ -18,6 +18,8 @@ import {
|
||||||
HomeIconSolid,
|
HomeIconSolid,
|
||||||
MagnifyingGlassIcon2,
|
MagnifyingGlassIcon2,
|
||||||
MagnifyingGlassIcon2Solid,
|
MagnifyingGlassIcon2Solid,
|
||||||
|
SatelliteDishIcon,
|
||||||
|
SatelliteDishIconSolid,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
BellIconSolid,
|
BellIconSolid,
|
||||||
} from 'lib/icons'
|
} from 'lib/icons'
|
||||||
|
@ -33,7 +35,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
|
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
||||||
useNavigationTabState()
|
useNavigationTabState()
|
||||||
|
|
||||||
const {footerMinimalShellTransform} = useMinimalShellMode()
|
const {footerMinimalShellTransform} = useMinimalShellMode()
|
||||||
|
@ -59,6 +61,10 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
||||||
() => onPressTab('Search'),
|
() => onPressTab('Search'),
|
||||||
[onPressTab],
|
[onPressTab],
|
||||||
)
|
)
|
||||||
|
const onPressFeeds = React.useCallback(
|
||||||
|
() => onPressTab('Feeds'),
|
||||||
|
[onPressTab],
|
||||||
|
)
|
||||||
const onPressNotifications = React.useCallback(
|
const onPressNotifications = React.useCallback(
|
||||||
() => onPressTab('Notifications'),
|
() => onPressTab('Notifications'),
|
||||||
[onPressTab],
|
[onPressTab],
|
||||||
|
@ -120,6 +126,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
||||||
accessibilityLabel="Search"
|
accessibilityLabel="Search"
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
|
<Btn
|
||||||
|
testID="bottomBarFeedsBtn"
|
||||||
|
icon={
|
||||||
|
isAtFeeds ? (
|
||||||
|
<SatelliteDishIconSolid
|
||||||
|
size={25}
|
||||||
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SatelliteDishIcon
|
||||||
|
size={25}
|
||||||
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={onPressFeeds}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityLabel="Feeds"
|
||||||
|
accessibilityHint=""
|
||||||
|
/>
|
||||||
<Btn
|
<Btn
|
||||||
testID="bottomBarNotificationsBtn"
|
testID="bottomBarNotificationsBtn"
|
||||||
icon={
|
icon={
|
||||||
|
|
|
@ -207,7 +207,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
||||||
label="Notifications"
|
label="Notifications"
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/settings/saved-feeds"
|
href="/feeds"
|
||||||
icon={
|
icon={
|
||||||
<SatelliteDishIcon
|
<SatelliteDishIcon
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.75}
|
||||||
|
|
Loading…
Reference in New Issue