Add feeds tab

zio/stable
Paul Frazee 2023-05-25 20:02:37 -05:00
parent df6d249e85
commit 257686f360
17 changed files with 937 additions and 290 deletions

View File

@ -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)

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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
// =
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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>

View File

@ -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',

View File

@ -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}

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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}

View File

@ -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={
<SatelliteDishIcon isAtFeeds ? (
strokeWidth={1.5} <SatelliteDishIconSolid
style={pal.text as FontAwesomeIconStyle} strokeWidth={1.5}
size={24} style={pal.text as FontAwesomeIconStyle}
/> size={24}
/>
) : (
<SatelliteDishIcon
strokeWidth={1.5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
)
} }
label="My Feeds" label="My Feeds"
accessibilityLabel="My Feeds" accessibilityLabel="My Feeds"

View File

@ -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={

View File

@ -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}