Refactor feeds to use react-query (#1862)
* Update to react-query v5 * Introduce post-feed react query * Add feed refresh behaviors * Only fetch feeds of visible pages * Implement polling for latest on feeds * Add moderation filtering to slices * Handle block errors * Update feed error messages * Remove old models * Replace simple-feed option with disable-tuner option * Add missing useMemo * Implement the mergefeed and fixes to polling * Correctly handle failed load more state * Improve error and empty state behaviors * Clearer naming
This commit is contained in:
parent
51f04b9620
commit
c8c308e31e
31 changed files with 904 additions and 1081 deletions
|
@ -1,91 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
import {PostsFeedItemModel} from './post'
|
||||
import {FeedSourceInfo} from 'lib/api/feed/types'
|
||||
|
||||
export class PostsFeedSliceModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
items: PostsFeedItemModel[] = []
|
||||
source: FeedSourceInfo | undefined
|
||||
|
||||
constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
|
||||
this._reactKey = slice._reactKey
|
||||
this.source = slice.source
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
this.items.push(
|
||||
new PostsFeedItemModel(
|
||||
rootStore,
|
||||
`${this._reactKey} - ${i}`,
|
||||
slice.items[i],
|
||||
),
|
||||
)
|
||||
}
|
||||
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() {
|
||||
// prefer the most stringent item
|
||||
const topItem = this.items.find(item => item.moderation.content.filter)
|
||||
if (topItem) {
|
||||
return topItem.moderation
|
||||
}
|
||||
// otherwise just use the first one
|
||||
return this.items[0].moderation
|
||||
}
|
||||
|
||||
shouldFilter(ignoreFilterForDid: string | undefined): boolean {
|
||||
const mods = this.items
|
||||
.filter(item => item.post.author.did !== ignoreFilterForDid)
|
||||
.map(item => item.moderation)
|
||||
return !!mods.find(mod => mod.content.filter)
|
||||
}
|
||||
|
||||
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,429 +0,0 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedGetTimeline as GetTimeline,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedTuner} from 'lib/api/feed-manip'
|
||||
import {PostsFeedSliceModel} from './posts-slice'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
|
||||
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
|
||||
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
||||
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
||||
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
|
||||
|
||||
export enum KnownError {
|
||||
FeedgenDoesNotExist,
|
||||
FeedgenMisconfigured,
|
||||
FeedgenBadResponse,
|
||||
FeedgenOffline,
|
||||
FeedgenUnknown,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Formats the feed in a flat array with no threading of replies, just
|
||||
* top-level posts.
|
||||
*/
|
||||
isSimpleFeed?: boolean
|
||||
}
|
||||
|
||||
type QueryParams =
|
||||
| GetTimeline.QueryParams
|
||||
| GetAuthorFeed.QueryParams
|
||||
| GetActorLikes.QueryParams
|
||||
| GetCustomFeed.QueryParams
|
||||
| GetListFeed.QueryParams
|
||||
|
||||
export class PostsFeedModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasNewLatest = false
|
||||
hasLoaded = false
|
||||
isBlocking = false
|
||||
isBlockedBy = false
|
||||
error = ''
|
||||
knownError: KnownError | undefined
|
||||
loadMoreError = ''
|
||||
params: QueryParams
|
||||
hasMore = true
|
||||
pollCursor: string | undefined
|
||||
api: FeedAPI
|
||||
tuner = new FeedTuner()
|
||||
pageSize = PAGE_SIZE
|
||||
options: Options = {}
|
||||
|
||||
// used to linearize async modifications to state
|
||||
lock = new AwaitLock()
|
||||
|
||||
// used to track if a feed is coming up empty
|
||||
emptyFetches = 0
|
||||
|
||||
// data
|
||||
slices: PostsFeedSliceModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedType: FeedType,
|
||||
params: QueryParams,
|
||||
options?: Options,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
this.options = options || {}
|
||||
if (feedType === 'home') {
|
||||
this.api = new MergeFeedAPI(rootStore)
|
||||
} else if (feedType === 'following') {
|
||||
this.api = new FollowingFeedAPI(rootStore)
|
||||
} else if (feedType === 'author') {
|
||||
this.api = new AuthorFeedAPI(
|
||||
rootStore,
|
||||
params as GetAuthorFeed.QueryParams,
|
||||
)
|
||||
} else if (feedType === 'likes') {
|
||||
this.api = new LikesFeedAPI(
|
||||
rootStore,
|
||||
params as GetActorLikes.QueryParams,
|
||||
)
|
||||
} else if (feedType === 'custom') {
|
||||
this.api = new CustomFeedAPI(
|
||||
rootStore,
|
||||
params as GetCustomFeed.QueryParams,
|
||||
)
|
||||
} else if (feedType === 'list') {
|
||||
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
|
||||
} else {
|
||||
this.api = new FollowingFeedAPI(rootStore)
|
||||
}
|
||||
}
|
||||
|
||||
get reactKey() {
|
||||
if (this.feedType === 'author') {
|
||||
return (this.params as GetAuthorFeed.QueryParams).actor
|
||||
}
|
||||
if (this.feedType === 'custom') {
|
||||
return (this.params as GetCustomFeed.QueryParams).feed
|
||||
}
|
||||
if (this.feedType === 'list') {
|
||||
return (this.params as GetListFeed.QueryParams).list
|
||||
}
|
||||
return this.feedType
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.slices.length !== 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get isLoadingMore() {
|
||||
return this.isLoading && !this.isRefreshing && this.hasContent
|
||||
}
|
||||
|
||||
setHasNewLatest(v: boolean) {
|
||||
this.hasNewLatest = v
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
/**
|
||||
* Nuke all data
|
||||
*/
|
||||
clear() {
|
||||
logger.debug('FeedModel:clear')
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasNewLatest = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.hasMore = true
|
||||
this.pollCursor = undefined
|
||||
this.slices = []
|
||||
this.tuner.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load for first render
|
||||
*/
|
||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||
logger.debug('FeedModel:setup', {isRefreshing})
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
}
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
this.setHasNewLatest(false)
|
||||
this.api.reset()
|
||||
this.tuner.reset()
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.api.fetchNext({limit: this.pageSize})
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
await this.setup(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more posts to the end of the feed
|
||||
*/
|
||||
loadMore = bundleAsync(async () => {
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
if (!this.hasMore || this.hasError) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
try {
|
||||
const res = await this.api.fetchNext({
|
||||
limit: this.pageSize,
|
||||
})
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(undefined, e)
|
||||
runInAction(() => {
|
||||
this.hasMore = false
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
if (this.feedType === 'custom') {
|
||||
track('CustomFeed:LoadMore')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Attempt to load more again after a failure
|
||||
*/
|
||||
async retryLoadMore() {
|
||||
this.loadMoreError = ''
|
||||
this.hasMore = true
|
||||
return this.loadMore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if new posts are available
|
||||
*/
|
||||
async checkForLatest() {
|
||||
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
|
||||
return
|
||||
}
|
||||
const post = await this.api.peekLatest()
|
||||
if (post) {
|
||||
const slices = this.tuner.tune(
|
||||
[post],
|
||||
this.rootStore.preferences.getFeedTuners(this.feedType),
|
||||
{
|
||||
dryRun: true,
|
||||
maintainOrder: true,
|
||||
},
|
||||
)
|
||||
if (slices[0]) {
|
||||
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
|
||||
if (sliceModel.moderation.content.filter) {
|
||||
return
|
||||
}
|
||||
this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the UI after the user has created a post
|
||||
*/
|
||||
onPostCreated() {
|
||||
if (!this.slices.length) {
|
||||
return this.refresh()
|
||||
} else {
|
||||
this.setHasNewLatest(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes posts from the feed upon deletion.
|
||||
*/
|
||||
onPostDeleted(uri: string) {
|
||||
let i
|
||||
do {
|
||||
i = this.slices.findIndex(slice => slice.containsUri(uri))
|
||||
if (i !== -1) {
|
||||
this.slices.splice(i, 1)
|
||||
}
|
||||
} while (i !== -1)
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
this.knownError = undefined
|
||||
}
|
||||
|
||||
_xIdle(error?: any, loadMoreError?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
|
||||
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
|
||||
this.error = cleanError(error)
|
||||
this.knownError = detectKnownError(this.feedType, error)
|
||||
this.loadMoreError = cleanError(loadMoreError)
|
||||
if (error) {
|
||||
logger.error('Posts feed request failed', {error})
|
||||
}
|
||||
if (loadMoreError) {
|
||||
logger.error('Posts feed load-more request failed', {
|
||||
error: loadMoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
async _replaceAll(res: FeedAPIResponse) {
|
||||
this.pollCursor = res.feed[0]?.post.uri
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
async _appendAll(res: FeedAPIResponse, replace = false) {
|
||||
this.hasMore = !!res.cursor && res.feed.length > 0
|
||||
if (replace) {
|
||||
this.emptyFetches = 0
|
||||
}
|
||||
|
||||
this.rootStore.me.follows.hydrateMany(
|
||||
res.feed.map(item => item.post.author),
|
||||
)
|
||||
for (const item of res.feed) {
|
||||
this.rootStore.posts.fromFeedItem(item)
|
||||
}
|
||||
|
||||
const slices = this.options.isSimpleFeed
|
||||
? res.feed.map(item => new FeedViewPostsSlice([item]))
|
||||
: this.tuner.tune(
|
||||
res.feed,
|
||||
this.rootStore.preferences.getFeedTuners(this.feedType),
|
||||
)
|
||||
|
||||
const toAppend: PostsFeedSliceModel[] = []
|
||||
for (const slice of slices) {
|
||||
const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
|
||||
const dupTest = (item: PostsFeedSliceModel) =>
|
||||
item._reactKey === sliceModel._reactKey
|
||||
// sanity check
|
||||
// if a duplicate _reactKey passes through, the UI breaks hard
|
||||
if (!replace) {
|
||||
if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
toAppend.push(sliceModel)
|
||||
}
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
this.slices = toAppend
|
||||
} else {
|
||||
this.slices = this.slices.concat(toAppend)
|
||||
}
|
||||
if (toAppend.length === 0) {
|
||||
this.emptyFetches++
|
||||
if (this.emptyFetches >= 10) {
|
||||
this.hasMore = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function detectKnownError(
|
||||
feedType: FeedType,
|
||||
error: any,
|
||||
): KnownError | undefined {
|
||||
if (!error) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof error !== 'string') {
|
||||
error = error.toString()
|
||||
}
|
||||
if (feedType !== 'custom') {
|
||||
return KnownError.Unknown
|
||||
}
|
||||
if (error.includes('could not find feed')) {
|
||||
return KnownError.FeedgenDoesNotExist
|
||||
}
|
||||
if (error.includes('feed unavailable')) {
|
||||
return KnownError.FeedgenOffline
|
||||
}
|
||||
if (error.includes('invalid did document')) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (error.includes('could not resolve did document')) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (
|
||||
error.includes('invalid feed generator service details in did document')
|
||||
) {
|
||||
return KnownError.FeedgenMisconfigured
|
||||
}
|
||||
if (error.includes('feed provided an invalid response')) {
|
||||
return KnownError.FeedgenBadResponse
|
||||
}
|
||||
return KnownError.FeedgenUnknown
|
||||
}
|
|
@ -4,7 +4,6 @@ import {
|
|||
ComAtprotoServerListAppPasswords,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {PostsFeedModel} from './feeds/posts'
|
||||
import {NotificationsFeedModel} from './feeds/notifications'
|
||||
import {MyFeedsUIModel} from './ui/my-feeds'
|
||||
import {MyFollowsCache} from './cache/my-follows'
|
||||
|
@ -22,7 +21,6 @@ export class MeModel {
|
|||
avatar: string = ''
|
||||
followsCount: number | undefined
|
||||
followersCount: number | undefined
|
||||
mainFeed: PostsFeedModel
|
||||
notifications: NotificationsFeedModel
|
||||
myFeeds: MyFeedsUIModel
|
||||
follows: MyFollowsCache
|
||||
|
@ -41,16 +39,12 @@ export class MeModel {
|
|||
{rootStore: false, serialize: false, hydrate: false},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.mainFeed = new PostsFeedModel(this.rootStore, 'home', {
|
||||
algorithm: 'reverse-chronological',
|
||||
})
|
||||
this.notifications = new NotificationsFeedModel(this.rootStore)
|
||||
this.myFeeds = new MyFeedsUIModel(this.rootStore)
|
||||
this.follows = new MyFollowsCache(this.rootStore)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.mainFeed.clear()
|
||||
this.notifications.clear()
|
||||
this.myFeeds.clear()
|
||||
this.follows.clear()
|
||||
|
@ -109,10 +103,6 @@ export class MeModel {
|
|||
if (sess.hasSession) {
|
||||
this.did = sess.currentSession?.did || ''
|
||||
await this.fetchProfile()
|
||||
this.mainFeed.clear()
|
||||
/* dont await */ this.mainFeed.setup().catch(e => {
|
||||
logger.error('Failed to setup main feed model', {error: e})
|
||||
})
|
||||
/* dont await */ this.notifications.setup().catch(e => {
|
||||
logger.error('Failed to setup notifications model', {
|
||||
error: e,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {ProfileModel} from '../content/profile'
|
||||
import {PostsFeedModel} from '../feeds/posts'
|
||||
import {ActorFeedsModel} from '../lists/actor-feeds'
|
||||
import {ListsListModel} from '../lists/lists-list'
|
||||
import {logger} from '#/logger'
|
||||
|
|
48
src/state/preferences/feed-tuners.tsx
Normal file
48
src/state/preferences/feed-tuners.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import {useMemo} from 'react'
|
||||
import {FeedTuner} from '#/lib/api/feed-manip'
|
||||
import {FeedDescriptor} from '../queries/post-feed'
|
||||
import {useLanguagePrefs} from './languages'
|
||||
|
||||
export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
const langPrefs = useLanguagePrefs()
|
||||
|
||||
return useMemo(() => {
|
||||
if (feedDesc.startsWith('feedgen')) {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
||||
]
|
||||
}
|
||||
if (feedDesc.startsWith('list')) {
|
||||
return [FeedTuner.dedupReposts]
|
||||
}
|
||||
if (feedDesc === 'home' || feedDesc === 'following') {
|
||||
const feedTuners = []
|
||||
|
||||
if (false /*TODOthis.homeFeed.hideReposts*/) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
|
||||
if (true /*TODOthis.homeFeed.hideReplies*/) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
} /* TODO else {
|
||||
feedTuners.push(
|
||||
FeedTuner.thresholdRepliesOnly({
|
||||
userDid: this.rootStore.session.data?.did || '',
|
||||
minLikes: this.homeFeed.hideRepliesByLikeCount,
|
||||
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
|
||||
}),
|
||||
)
|
||||
}*/
|
||||
|
||||
if (false /*TODOthis.homeFeed.hideQuotePosts*/) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
|
||||
return feedTuners
|
||||
}
|
||||
return []
|
||||
}, [feedDesc, langPrefs])
|
||||
}
|
176
src/state/queries/post-feed.ts
Normal file
176
src/state/queries/post-feed.ts
Normal file
|
@ -0,0 +1,176 @@
|
|||
import {useCallback, useMemo} from 'react'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
|
||||
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||
import {useSession} from '../session'
|
||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
|
||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
||||
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
||||
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||
import {useStores} from '../models/root-store'
|
||||
|
||||
type ActorDid = string
|
||||
type AuthorFilter =
|
||||
| 'posts_with_replies'
|
||||
| 'posts_no_replies'
|
||||
| 'posts_with_media'
|
||||
type FeedUri = string
|
||||
type ListUri = string
|
||||
export type FeedDescriptor =
|
||||
| 'home'
|
||||
| 'following'
|
||||
| `author|${ActorDid}|${AuthorFilter}`
|
||||
| `feedgen|${FeedUri}`
|
||||
| `likes|${ActorDid}`
|
||||
| `list|${ListUri}`
|
||||
export interface FeedParams {
|
||||
disableTuner?: boolean
|
||||
mergeFeedEnabled?: boolean
|
||||
mergeFeedSources?: string[]
|
||||
}
|
||||
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
|
||||
return ['post-feed', feedDesc, params || {}]
|
||||
}
|
||||
|
||||
export interface FeedPostSliceItem {
|
||||
_reactKey: string
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
||||
}
|
||||
|
||||
export interface FeedPostSlice {
|
||||
_reactKey: string
|
||||
rootUri: string
|
||||
isThread: boolean
|
||||
items: FeedPostSliceItem[]
|
||||
}
|
||||
|
||||
export interface FeedPage {
|
||||
cursor: string | undefined
|
||||
slices: FeedPostSlice[]
|
||||
}
|
||||
|
||||
export function usePostFeedQuery(
|
||||
feedDesc: FeedDescriptor,
|
||||
params?: FeedParams,
|
||||
opts?: {enabled?: boolean},
|
||||
) {
|
||||
const {agent} = useSession()
|
||||
const feedTuners = useFeedTuners(feedDesc)
|
||||
const store = useStores()
|
||||
const enabled = opts?.enabled !== false
|
||||
|
||||
const api: FeedAPI = useMemo(() => {
|
||||
if (feedDesc === 'home') {
|
||||
return new MergeFeedAPI(agent, params || {}, feedTuners)
|
||||
} else if (feedDesc === 'following') {
|
||||
return new FollowingFeedAPI(agent)
|
||||
} else if (feedDesc.startsWith('author')) {
|
||||
const [_, actor, filter] = feedDesc.split('|')
|
||||
return new AuthorFeedAPI(agent, {actor, filter})
|
||||
} else if (feedDesc.startsWith('likes')) {
|
||||
const [_, actor] = feedDesc.split('|')
|
||||
return new LikesFeedAPI(agent, {actor})
|
||||
} else if (feedDesc.startsWith('feedgen')) {
|
||||
const [_, feed] = feedDesc.split('|')
|
||||
return new CustomFeedAPI(agent, {feed})
|
||||
} else if (feedDesc.startsWith('list')) {
|
||||
const [_, list] = feedDesc.split('|')
|
||||
return new ListFeedAPI(agent, {list})
|
||||
} else {
|
||||
// shouldnt happen
|
||||
return new FollowingFeedAPI(agent)
|
||||
}
|
||||
}, [feedDesc, params, feedTuners, agent])
|
||||
const tuner = useMemo(
|
||||
() => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()),
|
||||
[params],
|
||||
)
|
||||
|
||||
const pollLatest = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
return false
|
||||
}
|
||||
console.log('poll')
|
||||
const post = await api.peekLatest()
|
||||
if (post) {
|
||||
const slices = tuner.tune([post], feedTuners, {
|
||||
dryRun: true,
|
||||
maintainOrder: true,
|
||||
})
|
||||
if (slices[0]) {
|
||||
if (
|
||||
!moderatePost(
|
||||
slices[0].items[0].post,
|
||||
store.preferences.moderationOpts,
|
||||
).content.filter
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [api, tuner, feedTuners, store.preferences.moderationOpts, enabled])
|
||||
|
||||
const out = useInfiniteQuery<
|
||||
FeedPage,
|
||||
Error,
|
||||
InfiniteData<FeedPage>,
|
||||
QueryKey,
|
||||
RQPageParam
|
||||
>({
|
||||
queryKey: RQKEY(feedDesc, params),
|
||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||
console.log('fetch', feedDesc, pageParam)
|
||||
if (!pageParam) {
|
||||
tuner.reset()
|
||||
}
|
||||
const res = await api.fetch({cursor: pageParam, limit: 30})
|
||||
const slices = tuner.tune(res.feed, feedTuners)
|
||||
return {
|
||||
cursor: res.cursor,
|
||||
slices: slices.map(slice => ({
|
||||
_reactKey: slice._reactKey,
|
||||
rootUri: slice.rootItem.post.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
slice.items.every(
|
||||
item => item.post.author.did === slice.items[0].post.author.did,
|
||||
),
|
||||
source: undefined, // TODO
|
||||
items: slice.items
|
||||
.map((item, i) => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(item.post.record).success
|
||||
) {
|
||||
return {
|
||||
_reactKey: `${slice._reactKey}-${i}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
reason: i === 0 && slice.source ? slice.source : item.reason,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter(Boolean) as FeedPostSliceItem[],
|
||||
})),
|
||||
}
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
enabled,
|
||||
})
|
||||
|
||||
return {...out, pollLatest}
|
||||
}
|
|
@ -57,17 +57,17 @@ export type ThreadNode =
|
|||
|
||||
export function usePostThreadQuery(uri: string | undefined) {
|
||||
const {agent} = useSession()
|
||||
return useQuery<ThreadNode, Error>(
|
||||
RQKEY(uri || ''),
|
||||
async () => {
|
||||
return useQuery<ThreadNode, Error>({
|
||||
queryKey: RQKEY(uri || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.getPostThread({uri: uri!})
|
||||
if (res.success) {
|
||||
return responseToThreadNodes(res.data.thread)
|
||||
}
|
||||
return {type: 'unknown', uri: uri!}
|
||||
},
|
||||
{enabled: !!uri},
|
||||
)
|
||||
enabled: !!uri,
|
||||
})
|
||||
}
|
||||
|
||||
export function sortThread(
|
||||
|
|
|
@ -7,9 +7,9 @@ export const RQKEY = (postUri: string) => ['post', postUri]
|
|||
|
||||
export function usePostQuery(uri: string | undefined) {
|
||||
const {agent} = useSession()
|
||||
return useQuery<AppBskyFeedDefs.PostView>(
|
||||
RQKEY(uri || ''),
|
||||
async () => {
|
||||
return useQuery<AppBskyFeedDefs.PostView>({
|
||||
queryKey: RQKEY(uri || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.getPosts({uris: [uri!]})
|
||||
if (res.success && res.data.posts[0]) {
|
||||
return res.data.posts[0]
|
||||
|
@ -17,10 +17,8 @@ export function usePostQuery(uri: string | undefined) {
|
|||
|
||||
throw new Error('No data')
|
||||
},
|
||||
{
|
||||
enabled: !!uri,
|
||||
},
|
||||
)
|
||||
enabled: !!uri,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostLikeMutation() {
|
||||
|
@ -29,7 +27,8 @@ export function usePostLikeMutation() {
|
|||
{uri: string}, // responds with the uri of the like
|
||||
Error,
|
||||
{uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes
|
||||
>(post => agent.like(post.uri, post.cid), {
|
||||
>({
|
||||
mutationFn: post => agent.like(post.uri, post.cid),
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.uri, {
|
||||
|
@ -59,27 +58,25 @@ export function usePostUnlikeMutation() {
|
|||
void,
|
||||
Error,
|
||||
{postUri: string; likeUri: string; likeCount: number}
|
||||
>(
|
||||
async ({likeUri}) => {
|
||||
>({
|
||||
mutationFn: async ({likeUri}) => {
|
||||
await agent.deleteLike(likeUri)
|
||||
},
|
||||
{
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.postUri, {
|
||||
likeCount: variables.likeCount - 1,
|
||||
likeUri: undefined,
|
||||
})
|
||||
},
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.postUri, {
|
||||
likeCount: variables.likeCount,
|
||||
likeUri: variables.likeUri,
|
||||
})
|
||||
},
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.postUri, {
|
||||
likeCount: variables.likeCount - 1,
|
||||
likeUri: undefined,
|
||||
})
|
||||
},
|
||||
)
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.postUri, {
|
||||
likeCount: variables.likeCount,
|
||||
likeUri: variables.likeUri,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostRepostMutation() {
|
||||
|
@ -88,7 +85,8 @@ export function usePostRepostMutation() {
|
|||
{uri: string}, // responds with the uri of the repost
|
||||
Error,
|
||||
{uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts
|
||||
>(post => agent.repost(post.uri, post.cid), {
|
||||
>({
|
||||
mutationFn: post => agent.repost(post.uri, post.cid),
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.uri, {
|
||||
|
@ -118,39 +116,35 @@ export function usePostUnrepostMutation() {
|
|||
void,
|
||||
Error,
|
||||
{postUri: string; repostUri: string; repostCount: number}
|
||||
>(
|
||||
async ({repostUri}) => {
|
||||
>({
|
||||
mutationFn: async ({repostUri}) => {
|
||||
await agent.deleteRepost(repostUri)
|
||||
},
|
||||
{
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.postUri, {
|
||||
repostCount: variables.repostCount - 1,
|
||||
repostUri: undefined,
|
||||
})
|
||||
},
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.postUri, {
|
||||
repostCount: variables.repostCount,
|
||||
repostUri: variables.repostUri,
|
||||
})
|
||||
},
|
||||
onMutate(variables) {
|
||||
// optimistically update the post-shadow
|
||||
updatePostShadow(variables.postUri, {
|
||||
repostCount: variables.repostCount - 1,
|
||||
repostUri: undefined,
|
||||
})
|
||||
},
|
||||
)
|
||||
onError(error, variables) {
|
||||
// revert the optimistic update
|
||||
updatePostShadow(variables.postUri, {
|
||||
repostCount: variables.repostCount,
|
||||
repostUri: variables.repostUri,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostDeleteMutation() {
|
||||
const {agent} = useSession()
|
||||
return useMutation<void, Error, {uri: string}>(
|
||||
async ({uri}) => {
|
||||
return useMutation<void, Error, {uri: string}>({
|
||||
mutationFn: async ({uri}) => {
|
||||
await agent.deletePost(uri)
|
||||
},
|
||||
{
|
||||
onSuccess(data, variables) {
|
||||
updatePostShadow(variables.uri, {isDeleted: true})
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
updatePostShadow(variables.uri, {isDeleted: true})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,12 +6,15 @@ export const RQKEY = (uri: string) => ['resolved-uri', uri]
|
|||
|
||||
export function useResolveUriQuery(uri: string) {
|
||||
const {agent} = useSession()
|
||||
return useQuery<string | undefined, Error>(RQKEY(uri), async () => {
|
||||
const urip = new AtUri(uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({handle: urip.host})
|
||||
urip.host = res.data.did
|
||||
}
|
||||
return urip.toString()
|
||||
return useQuery<string | undefined, Error>({
|
||||
queryKey: RQKEY(uri),
|
||||
async queryFn() {
|
||||
const urip = new AtUri(uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({handle: urip.host})
|
||||
urip.host = res.data.did
|
||||
}
|
||||
return urip.toString()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue