Factor our feed source model (#1887)

* Refactor first onboarding step

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

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

View file

@ -1,231 +0,0 @@
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/models/root-store'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
export class FeedSourceModel {
// state
_reactKey: string
hasLoaded = false
error: string | undefined
// data
uri: string
cid: string = ''
type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
avatar: string | undefined = ''
displayName: string = ''
descriptionRT: RichText | null = null
creatorDid: string = ''
creatorHandle: string = ''
likeCount: number | undefined = 0
likeUri: string | undefined = ''
constructor(public rootStore: RootStoreModel, uri: string) {
this._reactKey = uri
this.uri = uri
try {
const urip = new AtUri(uri)
if (urip.collection === 'app.bsky.feed.generator') {
this.type = 'feed-generator'
} else if (urip.collection === 'app.bsky.graph.list') {
this.type = 'list'
}
} catch {}
this.displayName = uri.split('/').pop() || ''
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get href() {
const urip = new AtUri(this.uri)
const collection =
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
}
get isSaved() {
return this.rootStore.preferences.savedFeeds.includes(this.uri)
}
get isPinned() {
return false
}
get isLiked() {
return !!this.likeUri
}
get isOwner() {
return this.creatorDid === this.rootStore.me.did
}
setup = bundleAsync(async () => {
try {
if (this.type === 'feed-generator') {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
feed: this.uri,
})
this.hydrateFeedGenerator(res.data.view)
} else if (this.type === 'list') {
const res = await this.rootStore.agent.app.bsky.graph.getList({
list: this.uri,
limit: 1,
})
this.hydrateList(res.data.list)
}
} catch (e) {
runInAction(() => {
this.error = cleanError(e)
})
}
})
hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = view.likeCount
this.likeUri = view.viewer?.like
this.hasLoaded = true
}
hydrateList(view: AppBskyGraphDefs.ListView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = undefined
this.hasLoaded = true
}
async save() {
if (this.type !== 'feed-generator') {
return
}
try {
await this.rootStore.preferences.addSavedFeed(this.uri)
} catch (error) {
logger.error('Failed to save feed', {error})
} finally {
track('CustomFeed:Save')
}
}
async unsave() {
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
if (this.type !== 'feed-generator' && this.type !== 'list') {
return
}
try {
await this.rootStore.preferences.removeSavedFeed(this.uri)
} catch (error) {
logger.error('Failed to unsave feed', {error})
} finally {
track('CustomFeed:Unsave')
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
logger.error('Failed to pin feed', {error})
} finally {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
}
}
async togglePin() {
if (!this.isPinned) {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
return this.rootStore.preferences.addPinnedFeed(this.uri)
} else {
track('CustomFeed:Unpin', {
name: this.displayName,
uri: this.uri,
})
if (this.type === 'list') {
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
return this.unsave()
} else {
return this.rootStore.preferences.removePinnedFeed(this.uri)
}
}
}
async like() {
if (this.type !== 'feed-generator') {
return
}
try {
this.likeUri = 'pending'
this.likeCount = (this.likeCount || 0) + 1
const res = await this.rootStore.agent.like(this.uri, this.cid)
this.likeUri = res.uri
} catch (e: any) {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
logger.error('Failed to like feed', {error: e})
} finally {
track('CustomFeed:Like')
}
}
async unlike() {
if (this.type !== 'feed-generator') {
return
}
if (!this.likeUri) {
return
}
const uri = this.likeUri
try {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
await this.rootStore.agent.deleteLike(uri!)
} catch (e: any) {
this.likeUri = uri
this.likeCount = (this.likeCount || 0) + 1
logger.error('Failed to unlike feed', {error: e})
} finally {
track('CustomFeed:Unlike')
}
}
}

View file

@ -1,148 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {FeedSourceModel} from '../content/feed-source'
import {logger} from '#/logger'
const DEFAULT_LIMIT = 50
export class FeedsDiscoveryModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreCursor: string | undefined = undefined
// data
feeds: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasMore() {
if (this.loadMoreCursor) {
return true
}
return false
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
refresh = bundleAsync(async () => {
this._xLoading()
try {
const res =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
})
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
loadMore = bundleAsync(async () => {
if (!this.hasMore) {
return
}
this._xLoading()
try {
const res =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
cursor: this.loadMoreCursor,
})
this._append(res)
} catch (e: any) {
this._xIdle(e)
}
this._xIdle()
})
search = async (query: string) => {
this._xLoading(false)
try {
const results =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
query: query,
})
this._replaceAll(results)
} catch (e: any) {
this._xIdle(e)
}
this._xIdle()
}
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.feeds = []
}
// state transitions
// =
_xLoading(isRefreshing = true) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch popular feeds', {error: err})
}
}
// helper functions
// =
_replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. set feeds data to empty array
this.feeds = []
// 2. call this._append()
this._append(res)
}
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. push data into feeds array
for (const f of res.data.feeds) {
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
// 2. set loadMoreCursor
this.loadMoreCursor = res.data.cursor
}
}

View file

@ -1,123 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {FeedSourceModel} from '../content/feed-source'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class ActorFeedsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
feeds: FeedSourceModel[] = []
constructor(
public rootStore: RootStoreModel,
public params: GetActorFeeds.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.feeds = []
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
actor: this.params.actor,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetActorFeeds.Response) {
this.feeds = []
this._appendAll(res)
}
_appendAll(res: GetActorFeeds.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) {
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
}
}

View file

@ -126,33 +126,6 @@ export class PreferencesModel {
],
}
}
// feeds
// =
isPinnedFeed(uri: string) {
return this.pinnedFeeds.includes(uri)
}
/**
* @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
*/
async addSavedFeed(_v: string) {}
/**
* @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
*/
async removeSavedFeed(_v: string) {}
/**
* @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
*/
async addPinnedFeed(_v: string) {}
/**
* @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
*/
async removePinnedFeed(_v: string) {}
}
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf

View file

@ -1,255 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile'
import {ActorFeedsModel} from '../lists/actor-feeds'
import {logger} from '#/logger'
export enum Sections {
PostsNoReplies = 'Posts',
PostsWithReplies = 'Posts & replies',
PostsWithMedia = 'Media',
Likes = 'Likes',
CustomAlgorithms = 'Feeds',
Lists = 'Lists',
}
export interface ProfileUiParams {
user: string
}
export class ProfileUiModel {
static LOADING_ITEM = {_reactKey: '__loading__'}
static END_ITEM = {_reactKey: '__end__'}
static EMPTY_ITEM = {_reactKey: '__empty__'}
isAuthenticatedUser = false
// data
profile: ProfileModel
feed: PostsFeedModel
algos: ActorFeedsModel
lists: ListsListModel
// ui state
selectedViewIndex = 0
constructor(
public rootStore: RootStoreModel,
public params: ProfileUiParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.profile = new ProfileModel(rootStore, {actor: params.user})
this.feed = new PostsFeedModel(rootStore, 'author', {
actor: params.user,
limit: 10,
filter: 'posts_no_replies',
})
this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
this.lists = new ListsListModel(rootStore, params.user)
}
get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
return this.feed
} else if (this.selectedView === Sections.Lists) {
return this.lists
}
if (this.selectedView === Sections.CustomAlgorithms) {
return this.algos
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
get isInitialLoading() {
const view = this.currentView
return view.isLoading && !view.isRefreshing && !view.hasContent
}
get isRefreshing() {
return this.profile.isRefreshing || this.currentView.isRefreshing
}
get selectorItems() {
const items = [
Sections.PostsNoReplies,
Sections.PostsWithReplies,
Sections.PostsWithMedia,
this.isAuthenticatedUser && Sections.Likes,
].filter(Boolean) as string[]
if (this.algos.hasLoaded && !this.algos.isEmpty) {
items.push(Sections.CustomAlgorithms)
}
if (this.lists.hasLoaded && !this.lists.isEmpty) {
items.push(Sections.Lists)
}
return items
}
get selectedView() {
// If, for whatever reason, the selected view index is not available, default back to posts
// This can happen when the user was focused on a view but performed an action that caused
// the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y)
return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies
}
get uiItems() {
let arr: any[] = []
// if loading, return loading item to show loading spinner
if (this.isInitialLoading) {
arr = arr.concat([ProfileUiModel.LOADING_ITEM])
} else if (this.currentView.hasError) {
// if error, return error item to show error message
arr = arr.concat([
{
_reactKey: '__error__',
error: this.currentView.error,
},
])
} else {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
if (this.feed.hasContent) {
arr = this.feed.slices.slice()
if (!this.feed.hasMore) {
arr = arr.concat([ProfileUiModel.END_ITEM])
}
} else if (this.feed.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else if (this.selectedView === Sections.CustomAlgorithms) {
if (this.algos.hasContent) {
arr = this.algos.feeds
} else if (this.algos.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else if (this.selectedView === Sections.Lists) {
if (this.lists.hasContent) {
arr = this.lists.lists
} else if (this.lists.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else {
// fallback, add empty item, to show empty message
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
}
return arr
}
get showLoadingMoreFooter() {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
} else if (this.selectedView === Sections.Lists) {
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
}
return false
}
// public api
// =
setSelectedViewIndex(index: number) {
// ViewSelector fires onSelectView on mount
if (index === this.selectedViewIndex) return
this.selectedViewIndex = index
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia
) {
let filter = 'posts_no_replies'
if (this.selectedView === Sections.PostsWithReplies) {
filter = 'posts_with_replies'
} else if (this.selectedView === Sections.PostsWithMedia) {
filter = 'posts_with_media'
}
this.feed = new PostsFeedModel(
this.rootStore,
'author',
{
actor: this.params.user,
limit: 10,
filter,
},
{
isSimpleFeed: ['posts_with_media'].includes(filter),
},
)
this.feed.setup()
} else if (this.selectedView === Sections.Likes) {
this.feed = new PostsFeedModel(
this.rootStore,
'likes',
{
actor: this.params.user,
limit: 10,
},
{
isSimpleFeed: true,
},
)
this.feed.setup()
}
}
async setup() {
await Promise.all([
this.profile
.setup()
.catch(err => logger.error('Failed to fetch profile', {error: err})),
this.feed
.setup()
.catch(err => logger.error('Failed to fetch feed', {error: err})),
])
runInAction(() => {
this.isAuthenticatedUser =
this.profile.did === this.rootStore.session.currentSession?.did
})
this.algos.refresh()
// HACK: need to use the DID as a param, not the username -prf
this.lists.source = this.profile.did
this.lists
.loadMore()
.catch(err => logger.error('Failed to fetch lists', {error: err}))
}
async refresh() {
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
}
async loadMore() {
if (
!this.currentView.isLoading &&
!this.currentView.hasError &&
!this.currentView.isEmpty
) {
await this.currentView.loadMore()
}
}
}

View file

@ -21,39 +21,41 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useSession} from '#/state/session'
import {usePreferencesQuery} from '#/state/queries/preferences'
export type FeedSourceInfo =
| {
type: 'feed'
uri: string
route: {
href: string
name: string
params: Record<string, string>
}
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
likeCount: number | undefined
likeUri: string | undefined
}
| {
type: 'list'
uri: string
route: {
href: string
name: string
params: Record<string, string>
}
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
}
export type FeedSourceFeedInfo = {
type: 'feed'
uri: string
route: {
href: string
name: string
params: Record<string, string>
}
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
likeCount: number | undefined
likeUri: string | undefined
}
export type FeedSourceListInfo = {
type: 'list'
uri: string
route: {
href: string
name: string
params: Record<string, string>
}
cid: string
avatar: string | undefined
displayName: string
description: RichText
creatorDid: string
creatorHandle: string
}
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [
'getFeedSourceInfo',

24
src/state/queries/like.ts Normal file
View file

@ -0,0 +1,24 @@
import {useMutation} from '@tanstack/react-query'
import {useSession} from '#/state/session'
export function useLikeMutation() {
const {agent} = useSession()
return useMutation({
mutationFn: async ({uri, cid}: {uri: string; cid: string}) => {
const res = await agent.like(uri, cid)
return {uri: res.uri}
},
})
}
export function useUnlikeMutation() {
const {agent} = useSession()
return useMutation({
mutationFn: async ({uri}: {uri: string}) => {
await agent.deleteLike(uri)
},
})
}

View file

@ -0,0 +1,29 @@
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
import {useSession} from '#/state/session'
export const suggestedFeedsQueryKey = ['suggestedFeeds']
export function useSuggestedFeedsQuery() {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyFeedGetSuggestedFeeds.OutputSchema,
Error,
InfiniteData<AppBskyFeedGetSuggestedFeeds.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: suggestedFeedsQueryKey,
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.feed.getSuggestedFeeds({
limit: 10,
cursor: pageParam,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}