From c8c308e31e63607280648e3e9f1f56a371adcd05 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 15:34:25 -0800 Subject: [PATCH] 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 --- package.json | 2 +- src/lib/api/feed-manip.ts | 17 +- src/lib/api/feed/author.ts | 25 +- src/lib/api/feed/custom.ts | 25 +- src/lib/api/feed/following.ts | 26 +- src/lib/api/feed/likes.ts | 25 +- src/lib/api/feed/list.ts | 25 +- src/lib/api/feed/merge.ts | 88 ++-- src/lib/api/feed/types.ts | 21 +- src/state/models/feeds/posts-slice.ts | 91 ----- src/state/models/feeds/posts.ts | 429 -------------------- src/state/models/me.ts | 10 - src/state/models/ui/profile.ts | 1 - src/state/preferences/feed-tuners.tsx | 48 +++ src/state/queries/post-feed.ts | 176 ++++++++ src/state/queries/post-thread.ts | 10 +- src/state/queries/post.ts | 98 +++-- src/state/queries/resolve-uri.ts | 17 +- src/view/com/composer/Composer.tsx | 2 +- src/view/com/feeds/FeedPage.tsx | 91 ++--- src/view/com/post-thread/PostThreadItem.tsx | 14 +- src/view/com/posts/Feed.tsx | 144 +++++-- src/view/com/posts/FeedErrorMessage.tsx | 91 ++++- src/view/com/posts/FeedItem.tsx | 273 ++++++------- src/view/com/posts/FeedSlice.tsx | 92 +++-- src/view/com/testing/TestCtrls.e2e.tsx | 4 +- src/view/screens/Home.tsx | 48 +-- src/view/screens/Profile.tsx | 1 - src/view/screens/ProfileFeed.tsx | 25 +- src/view/screens/ProfileList.tsx | 45 +- yarn.lock | 21 +- 31 files changed, 904 insertions(+), 1081 deletions(-) delete mode 100644 src/state/models/feeds/posts-slice.ts delete mode 100644 src/state/models/feeds/posts.ts create mode 100644 src/state/preferences/feed-tuners.tsx create mode 100644 src/state/queries/post-feed.ts diff --git a/package.json b/package.json index 585e1e23e0..88d0c15ecd 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@segment/analytics-react-native": "^2.10.1", "@segment/sovran-react-native": "^0.4.5", "@sentry/react-native": "5.10.0", - "@tanstack/react-query": "^4.33.0", + "@tanstack/react-query": "^5.8.1", "@tiptap/core": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220", "@tiptap/extension-hard-break": "^2.0.3", diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 8f259a910b..7dfc9258a0 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,7 +4,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, } from '@atproto/api' -import {FeedSourceInfo} from './feed/types' +import {ReasonFeedSource} from './feed/types' import {isPostInLanguage} from '../../locale/helpers' type FeedViewPost = AppBskyFeedDefs.FeedViewPost @@ -65,9 +65,9 @@ export class FeedViewPostsSlice { ) } - get source(): FeedSourceInfo | undefined { + get source(): ReasonFeedSource | undefined { return this.items.find(item => '__source' in item && !!item.__source) - ?.__source as FeedSourceInfo + ?.__source as ReasonFeedSource } containsUri(uri: string) { @@ -116,6 +116,17 @@ export class FeedViewPostsSlice { } } +export class NoopFeedTuner { + reset() {} + tune( + feed: FeedViewPost[], + _tunerFns: FeedTunerFn[] = [], + _opts?: {dryRun: boolean; maintainOrder: boolean}, + ): FeedViewPostsSlice[] { + return feed.map(item => new FeedViewPostsSlice([item])) + } +} + export class FeedTuner { seenUris: Set = new Set() diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index ec8795e1a9..77c167869d 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -1,38 +1,37 @@ import { AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, + BskyAgent, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' export class AuthorFeedAPI implements FeedAPI { - cursor: string | undefined - constructor( - public rootStore: RootStoreModel, + public agent: BskyAgent, public params: GetAuthorFeed.QueryParams, ) {} - reset() { - this.cursor = undefined - } - async peekLatest(): Promise { - const res = await this.rootStore.agent.getAuthorFeed({ + const res = await this.agent.getAuthorFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - const res = await this.rootStore.agent.getAuthorFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + const res = await this.agent.getAuthorFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: this._filter(res.data.feed), diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index d05d5acd6b..0be98fb4a7 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,38 +1,37 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, + BskyAgent, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' export class CustomFeedAPI implements FeedAPI { - cursor: string | undefined - constructor( - public rootStore: RootStoreModel, + public agent: BskyAgent, public params: GetCustomFeed.QueryParams, ) {} - reset() { - this.cursor = undefined - } - async peekLatest(): Promise { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await this.agent.app.bsky.feed.getFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + const res = await this.agent.app.bsky.feed.getFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor // NOTE // some custom feeds fail to enforce the pagination limit // so we manually truncate here diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index f14807a576..13f06c7ab4 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -1,30 +1,28 @@ -import {AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from 'state/index' +import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' export class FollowingFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor(public rootStore: RootStoreModel) {} - - reset() { - this.cursor = undefined - } + constructor(public agent: BskyAgent) {} async peekLatest(): Promise { - const res = await this.rootStore.agent.getTimeline({ + const res = await this.agent.getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - const res = await this.rootStore.agent.getTimeline({ - cursor: this.cursor, + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + const res = await this.agent.getTimeline({ + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index e9bb14b0b1..434ed77192 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -1,38 +1,37 @@ import { AppBskyFeedDefs, AppBskyFeedGetActorLikes as GetActorLikes, + BskyAgent, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' export class LikesFeedAPI implements FeedAPI { - cursor: string | undefined - constructor( - public rootStore: RootStoreModel, + public agent: BskyAgent, public params: GetActorLikes.QueryParams, ) {} - reset() { - this.cursor = undefined - } - async peekLatest(): Promise { - const res = await this.rootStore.agent.getActorLikes({ + const res = await this.agent.getActorLikes({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - const res = await this.rootStore.agent.getActorLikes({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + const res = await this.agent.getActorLikes({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index e584946754..6cb0730e74 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -1,38 +1,37 @@ import { AppBskyFeedDefs, AppBskyFeedGetListFeed as GetListFeed, + BskyAgent, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' export class ListFeedAPI implements FeedAPI { - cursor: string | undefined - constructor( - public rootStore: RootStoreModel, + public agent: BskyAgent, public params: GetListFeed.QueryParams, ) {} - reset() { - this.cursor = undefined - } - async peekLatest(): Promise { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + const res = await this.agent.app.bsky.feed.getListFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + const res = await this.agent.app.bsky.feed.getListFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index e0fbcecd8e..7a0f02887c 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,11 +1,12 @@ -import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' +import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api' import shuffle from 'lodash.shuffle' -import {RootStoreModel} from 'state/index' import {timeout} from 'lib/async/timeout' import {bundleAsync} from 'lib/async/bundle' import {feedUriToHref} from 'lib/strings/url-helpers' import {FeedTuner} from '../feed-manip' -import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' +import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types' +import {FeedParams} from '#/state/queries/post-feed' +import {FeedTunerFn} from '../feed-manip' const REQUEST_WAIT_MS = 500 // 500ms const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours @@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor(public rootStore: RootStoreModel) { - this.following = new MergeFeedSource_Following(this.rootStore) + constructor( + public agent: BskyAgent, + public params: FeedParams, + public feedTuners: FeedTunerFn[], + ) { + this.following = new MergeFeedSource_Following(this.agent, this.feedTuners) } reset() { - this.following = new MergeFeedSource_Following(this.rootStore) + this.following = new MergeFeedSource_Following(this.agent, this.feedTuners) this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.feedCursor = 0 this.itemCursor = 0 this.sampleCursor = 0 + if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) { + this.customFeeds = shuffle( + this.params.mergeFeedSources.map( + feedUri => + new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners), + ), + ) + } else { + this.customFeeds = [] + } } async peekLatest(): Promise { - const res = await this.rootStore.agent.getTimeline({ + const res = await this.agent.getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise { - // we capture here to ensure the data has loaded - this._captureFeedsIfNeeded() + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + if (!cursor) { + this.reset() + } const promises = [] @@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI { } return { - cursor: posts.length ? 'fake' : undefined, + cursor: posts.length ? String(this.itemCursor) : undefined, feed: posts, } } @@ -107,28 +129,15 @@ export class MergeFeedAPI implements FeedAPI { // provide follow return this.following.take(1) } - - _captureFeedsIfNeeded() { - if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) { - return - } - if (this.customFeeds.length === 0) { - this.customFeeds = shuffle( - this.rootStore.preferences.savedFeeds.map( - feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), - ), - ) - } - } } class MergeFeedSource { - sourceInfo: FeedSourceInfo | undefined + sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public rootStore: RootStoreModel) {} + constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {} get numReady() { return this.queue.length @@ -190,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource { cursor: string | undefined, limit: number, ): Promise { - const res = await this.rootStore.agent.getTimeline({cursor, limit}) + const res = await this.agent.getTimeline({cursor, limit}) // run the tuner pre-emptively to ensure better mixing - const slices = this.tuner.tune( - res.data.feed, - this.rootStore.preferences.getFeedTuners('home'), - { - dryRun: false, - maintainOrder: true, - }, - ) + const slices = this.tuner.tune(res.data.feed, this.feedTuners, { + dryRun: false, + maintainOrder: true, + }) res.data.feed = slices.map(slice => slice.rootItem) return res } @@ -208,14 +213,19 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor(public rootStore: RootStoreModel, public feedUri: string) { - super(rootStore) + constructor( + public agent: BskyAgent, + public feedUri: string, + public feedTuners: FeedTunerFn[], + ) { + super(agent, feedTuners) this.sourceInfo = { + $type: 'reasonFeedSource', displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) - this.rootStore.agent.app.bsky.feed + this.agent.app.bsky.feed .getFeedGenerator({ feed: feedUri, }) @@ -234,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource { limit: number, ): Promise { try { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await this.agent.app.bsky.feed.getFeed({ cursor, limit, feed: this.feedUri, diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts index 0063443343..5d2a90c1db 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -6,12 +6,27 @@ export interface FeedAPIResponse { } export interface FeedAPI { - reset(): void peekLatest(): Promise - fetchNext({limit}: {limit: number}): Promise + fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise } -export interface FeedSourceInfo { +export interface ReasonFeedSource { + $type: 'reasonFeedSource' uri: string displayName: string } + +export function isReasonFeedSource(v: unknown): v is ReasonFeedSource { + return ( + !!v && + typeof v === 'object' && + '$type' in v && + v.$type === 'reasonFeedSource' + ) +} diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts deleted file mode 100644 index 2501cef6fc..0000000000 --- a/src/state/models/feeds/posts-slice.ts +++ /dev/null @@ -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 - } -} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts deleted file mode 100644 index 0a06c581c7..0000000000 --- a/src/state/models/feeds/posts.ts +++ /dev/null @@ -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 -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index d3061f166b..4bbb5a04b9 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -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, diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index f96340c651..0ef5929289 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -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' diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx new file mode 100644 index 0000000000..96770055c2 --- /dev/null +++ b/src/state/preferences/feed-tuners.tsx @@ -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]) +} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts new file mode 100644 index 0000000000..1a391d5c30 --- /dev/null +++ b/src/state/queries/post-feed.ts @@ -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, + 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} +} diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 4dea8aaf13..386c70483b 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -57,17 +57,17 @@ export type ThreadNode = export function usePostThreadQuery(uri: string | undefined) { const {agent} = useSession() - return useQuery( - RQKEY(uri || ''), - async () => { + return useQuery({ + 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( diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index f62190c670..ffff7f9675 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -7,9 +7,9 @@ export const RQKEY = (postUri: string) => ['post', postUri] export function usePostQuery(uri: string | undefined) { const {agent} = useSession() - return useQuery( - RQKEY(uri || ''), - async () => { + return useQuery({ + 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( - async ({uri}) => { + return useMutation({ + mutationFn: async ({uri}) => { await agent.deletePost(uri) }, - { - onSuccess(data, variables) { - updatePostShadow(variables.uri, {isDeleted: true}) - }, + onSuccess(data, variables) { + updatePostShadow(variables.uri, {isDeleted: true}) }, - ) + }) } diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 770be5cf89..26e0a475b7 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -6,12 +6,15 @@ export const RQKEY = (uri: string) => ['resolved-uri', uri] export function useResolveUriQuery(uri: string) { const {agent} = useSession() - return useQuery(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({ + 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() + }, }) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8f8c2eea36..65c485a29e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -245,7 +245,7 @@ export const ComposePost = observer(function ComposePost({ if (replyTo && replyTo.uri) track('Post:Reply') } if (!replyTo) { - store.me.mainFeed.onPostCreated() + // TODO onPostCreated } setLangPrefs.savePostLanguageToHistory() onPost?.() diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 6a846f6774..8d6a4a3d01 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -4,36 +4,38 @@ import { } from '@fortawesome/react-native-fontawesome' import {useIsFocused} from '@react-navigation/native' import {useAnalytics} from '@segment/analytics-react-native' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' import React from 'react' -import {FlatList, View} from 'react-native' +import {FlatList, View, useWindowDimensions} from 'react-native' import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' import {Feed} from '../posts/Feed' import {TextLink} from '../util/Link' import {FAB} from '../util/fab/FAB' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' -import useAppState from 'react-native-appstate-hook' -import {logger} from '#/logger' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' -export const FeedPage = observer(function FeedPageImpl({ +const POLL_FREQ = 30e3 // 30sec + +export function FeedPage({ testID, isPageFocused, feed, + feedParams, renderEmptyState, renderEndOfFeed, }: { testID?: string - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -43,40 +45,13 @@ export const FeedPage = observer(function FeedPageImpl({ const pal = usePalette('default') const {_} = useLingui() const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - logger.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, feed], - ) + const [hasNew, setHasNew] = React.useState(false) const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerOffset}) @@ -86,31 +61,22 @@ export const FeedPage = observer(function FeedPageImpl({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - feed.refresh() + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) } - }, [isPageFocused, scrollToTop, feed]) + }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew]) // fires when page within screen is activated/deactivated - // - check for latest React.useEffect(() => { if (!isPageFocused || !isScreenFocused) { return } - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - screen('Feed') - logger.debug('HomeScreen: Updating feed') - feed.checkForLatest() - return () => { - clearInterval(pollInterval) softResetSub.remove() - feedCleanup() } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') @@ -119,8 +85,9 @@ export const FeedPage = observer(function FeedPageImpl({ const onPressLoadLatest = React.useCallback(() => { scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollToTop, feed, queryClient, setHasNew]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -191,8 +158,12 @@ export const FeedPage = observer(function FeedPageImpl({ ) -}) +} + +function useHeaderOffset() { + const {isDesktop, isTablet} = useWebMediaQueries() + const {fontScale} = useWindowDimensions() + if (isDesktop) { + return 0 + } + if (isTablet) { + return 50 + } + // default text takes 44px, plus 34px of pad + // scale the 44px by the font scale + return 34 + 44 * fontScale +} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index a8e0c0f93c..0535cab53c 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -69,15 +69,11 @@ export function PostThreadItem({ const postShadowed = usePostShadow(post, dataUpdatedAt) const richText = useMemo( () => - post && - AppBskyFeedPost.isRecord(post?.record) && - AppBskyFeedPost.validateRecord(post?.record).success - ? new RichTextAPI({ - text: post.record.text, - facets: post.record.facets, - }) - : undefined, - [post], + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], ) const moderation = useMemo( () => diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 7e28712ee3..50afc19508 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -1,5 +1,4 @@ import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' import { ActivityIndicator, Dimensions, @@ -12,7 +11,6 @@ import { import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {FeedErrorMessage} from './FeedErrorMessage' -import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' @@ -21,17 +19,26 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' +import { + FeedDescriptor, + FeedParams, + usePostFeedQuery, +} from '#/state/queries/post-feed' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const Feed = observer(function Feed({ +export function Feed({ feed, + feedParams, style, + enabled, + pollInterval, scrollElRef, onScroll, + onHasNew, scrollEventThrottle, renderEmptyState, renderEndOfFeed, @@ -41,9 +48,13 @@ export const Feed = observer(function Feed({ ListHeaderComponent, extraData, }: { - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams style?: StyleProp + enabled?: boolean + pollInterval?: number scrollElRef?: MutableRefObject | null> + onHasNew?: (v: boolean) => void onScroll?: OnScrollHandler scrollEventThrottle?: number renderEmptyState: () => JSX.Element @@ -58,32 +69,68 @@ export const Feed = observer(function Feed({ const theme = useTheme() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const checkForNewRef = React.useRef<(() => void) | null>(null) + + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + dataUpdatedAt, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + pollLatest, + } = usePostFeedQuery(feed, feedParams, opts) + const isEmpty = !isFetching && !data?.pages[0]?.slices.length - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (feed.hasLoaded) { - if (feed.hasError) { - feedItems = feedItems.concat([ERROR_ITEM]) + const checkForNew = React.useCallback(async () => { + if (!isFetched || isFetching || !onHasNew) { + return + } + try { + if (await pollLatest()) { + onHasNew(true) } - if (feed.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(feed.slices) + } catch (e) { + logger.error('Poll latest failed', {feed, error: String(e)}) + } + }, [feed, isFetched, isFetching, pollLatest, onHasNew]) + + React.useEffect(() => { + // we store the interval handler in a ref to avoid needless + // reassignments of the interval + checkForNewRef.current = checkForNew + }, [checkForNew]) + React.useEffect(() => { + const i = setInterval(() => checkForNewRef.current?.(), pollInterval) + return () => clearInterval(i) + }, [pollInterval]) + + const feedItems = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isError && isEmpty) { + arr = arr.concat([ERROR_ITEM]) + } + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.slices) + } } - if (feed.loadMoreError) { - feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) } } else { - feedItems.push(LOADING_ITEM) + arr.push(LOADING_ITEM) } - return feedItems - }, [ - feed.hasError, - feed.hasLoaded, - feed.isEmpty, - feed.slices, - feed.loadMoreError, - ]) + return arr + }, [isFetched, isError, isEmpty, data]) // events // = @@ -92,31 +139,33 @@ export const Feed = observer(function Feed({ track('Feed:onRefresh') setIsRefreshing(true) try { - await feed.refresh() + await refetch() + onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } setIsRefreshing(false) - }, [feed, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing, onHasNew]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded || !feed.hasMore) return + if (isFetching || !hasNextPage || isError) return track('Feed:onEndReached') try { - await feed.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {error: err}) } - }, [feed, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + refetch() + onHasNew?.(false) + }, [refetch, onHasNew]) const onPressRetryLoadMore = React.useCallback(() => { - feed.retryLoadMore() - }, [feed]) + fetchNextPage() + }, [fetchNextPage]) // rendering // = @@ -127,7 +176,11 @@ export const Feed = observer(function Feed({ return renderEmptyState() } else if (item === ERROR_ITEM) { return ( - + ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( @@ -139,23 +192,32 @@ export const Feed = observer(function Feed({ } else if (item === LOADING_ITEM) { return } - return + return }, - [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [ + feed, + dataUpdatedAt, + error, + onPressTryAgain, + onPressRetryLoadMore, + renderEmptyState, + ], ) + const shouldRenderEndOfFeed = + !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = React.useCallback( () => - feed.isLoadingMore ? ( + isFetchingNextPage ? ( - ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + ) : shouldRenderEndOfFeed ? ( renderEndOfFeed() ) : ( ), - [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], + [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], ) const scrollHandler = useAnimatedScrollHandler(onScroll || {}) @@ -164,7 +226,7 @@ export const Feed = observer(function Feed({ item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} @@ -197,7 +259,7 @@ export const Feed = observer(function Feed({ /> ) -}) +} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 84e438fcda..feb4b1c99c 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,7 +1,6 @@ import React from 'react' import {View} from 'react-native' -import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' -import {PostsFeedModel, KnownError} from 'state/models/feeds/posts' +import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' @@ -12,9 +11,22 @@ import {NavigationProp} from 'lib/routes/types' import {useStores} from 'state/index' import {logger} from '#/logger' import {useModalControls} from '#/state/modals' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {EmptyState} from '../util/EmptyState' + +enum KnownError { + Block, + FeedgenDoesNotExist, + FeedgenMisconfigured, + FeedgenBadResponse, + FeedgenOffline, + FeedgenUnknown, + Unknown, +} const MESSAGES = { [KnownError.Unknown]: '', + [KnownError.Block]: '', [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`, [KnownError.FeedgenMisconfigured]: 'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.', @@ -27,36 +39,51 @@ const MESSAGES = { } export function FeedErrorMessage({ - feed, + feedDesc, + error, onPressTryAgain, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor + error: any onPressTryAgain: () => void }) { + const knownError = React.useMemo( + () => detectKnownError(feedDesc, error), + [feedDesc, error], + ) if ( - typeof feed.knownError === 'undefined' || - feed.knownError === KnownError.Unknown + typeof knownError !== 'undefined' && + knownError !== KnownError.Unknown && + feedDesc.startsWith('feedgen') ) { + return + } + + if (knownError === KnownError.Block) { return ( - + ) } - return + return } function FeedgenErrorMessage({ - feed, + feedDesc, knownError, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor knownError: KnownError }) { const pal = usePalette('default') const store = useStores() const navigation = useNavigation() const msg = MESSAGES[knownError] - const uri = (feed.params as GetCustomFeed.QueryParams).feed + const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) const {openModal, closeModal} = useModalControls() @@ -120,3 +147,45 @@ function safeParseFeedgenUri(uri: string): [string, string] { return ['', ''] } } + +function detectKnownError( + feedDesc: FeedDescriptor, + error: any, +): KnownError | undefined { + if (!error) { + return undefined + } + if ( + error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError || + error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError + ) { + return KnownError.Block + } + if (typeof error !== 'string') { + error = error.toString() + } + if (!feedDesc.startsWith('feedgen')) { + 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 +} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 527cbb76fc..c5a841e311 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,25 +1,27 @@ import React, {useMemo, useState} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' +import {StyleSheet, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostsFeedItemModel} from 'state/models/feeds/post' -import {FeedSourceInfo} from 'lib/api/feed/types' +import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types' import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' -import {PostCtrls} from '../util/post-ctrls/PostCtrls' +import {PostCtrls} from '../util/post-ctrls/PostCtrls2' import {PostEmbeds} from '../util/post-embeds' import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {useStores} from 'state/index' @@ -27,47 +29,91 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' -import {useLanguagePrefs} from '#/state/preferences' +import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const FeedItem = observer(function FeedItemImpl({ - item, - source, +export function FeedItem({ + post, + record, + reason, + moderation, + dataUpdatedAt, isThreadChild, isThreadLastChild, isThreadParent, }: { - item: PostsFeedItemModel - source?: FeedSourceInfo + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + moderation: PostModeration + dataUpdatedAt: number + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean +}) { + const postShadowed = usePostShadow(post, dataUpdatedAt) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + + ) + } + return null +} + +function FeedItemInner({ + post, + record, + reason, + richText, + moderation, + isThreadChild, + isThreadLastChild, + isThreadParent, +}: { + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + richText: RichTextAPI + moderation: PostModeration isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean - showReplyLine?: boolean }) { const store = useStores() - const langPrefs = useLanguagePrefs() const pal = usePalette('default') - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() const {track} = useAnalytics() - const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + countLines(richText.text) >= MAX_POST_LINES, ) - const record = item.postRecord - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` + + const href = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const replyAuthorDid = useMemo(() => { if (!record?.reply) { return '' @@ -75,77 +121,22 @@ export const FeedItem = observer(function FeedItemImpl({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const translatorUrl = getTranslatorLink( - record?.text || '', - langPrefs.primaryLanguage, - ) const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text || '', + uri: post.uri, + cid: post.cid, + text: record.text || '', author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [item, track, record, store]) - - const onPressToggleRepost = React.useCallback(() => { - track('FeedItem:PostRepost') - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [track, item]) - - const onPressToggleLike = React.useCallback(() => { - track('FeedItem:PostLike') - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [track, item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(() => { - track('FeedItem:ThreadMute') - try { - const muted = toggleThreadMute(item.rootUri) - if (muted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [track, toggleThreadMute, item]) - - const onDeletePost = React.useCallback(() => { - track('FeedItem:PostDelete') - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [track, item, setDeleted]) + }, [post, record, track, store]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -164,15 +155,11 @@ export const FeedItem = observer(function FeedItemImpl({ isThreadChild ? styles.outerSmallTop : undefined, ] - if (!record || deleted) { - return - } - return ( @@ -194,10 +181,10 @@ export const FeedItem = observer(function FeedItemImpl({ - {source ? ( + {isReasonFeedSource(reason) ? ( + title={sanitizeDisplayName(reason.displayName)} + href={reason.uri}> - ) : item.reasonRepost ? ( + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( @@ -256,10 +242,10 @@ export const FeedItem = observer(function FeedItemImpl({ {isThreadParent && ( {!isThreadChild && replyAuthorDid !== '' && ( @@ -308,19 +294,16 @@ export const FeedItem = observer(function FeedItemImpl({ )} - - {item.richText?.text ? ( + + {richText.text ? ( ) : undefined} - {item.post.embed ? ( + {post.embed ? ( - + ) : null} - + ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 1d26f6cbd8..c33c6028d5 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,24 +1,40 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' -import {AtUri} from '@atproto/api' +import {FeedPostSlice} from '#/state/queries/post-feed' +import {AtUri, moderatePost} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' +import {useStores} from '#/state' export const FeedSlice = observer(function FeedSliceImpl({ slice, + dataUpdatedAt, ignoreFilterFor, }: { - slice: PostsFeedSliceModel + slice: FeedPostSlice + dataUpdatedAt: number ignoreFilterFor?: string }) { - if (slice.shouldFilter(ignoreFilterFor)) { - return null + const store = useStores() + const moderations = React.useMemo(() => { + return slice.items.map(item => + moderatePost(item.post, store.preferences.moderationOpts), + ) + }, [slice, store.preferences.moderationOpts]) + + // apply moderation filter + for (let i = 0; i < slice.items.length; i++) { + if ( + moderations[i]?.content.filter && + slice.items[i].post.author.did !== ignoreFilterFor + ) { + return null + } } if (slice.isThread && slice.items.length > 3) { @@ -27,23 +43,34 @@ export const FeedSlice = observer(function FeedSliceImpl({ <> @@ -55,12 +82,15 @@ export const FeedSlice = observer(function FeedSliceImpl({ {slice.items.map((item, i) => ( ))} @@ -68,12 +98,12 @@ export const FeedSlice = observer(function FeedSliceImpl({ ) }) -function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { +function ViewFullThread({slice}: {slice: FeedPostSlice}) { const pal = usePalette('default') const itemHref = React.useMemo(() => { - const urip = new AtUri(slice.rootItem.post.uri) - return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) - }, [slice.rootItem.post.uri, slice.rootItem.post.author]) + const urip = new AtUri(slice.rootUri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [slice.rootUri]) return ( (arr: Array, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt(arr: Array, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 489705d107..5e9d816ac8 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native' import {useStores} from 'state/index' import {navigate} from '../../../Navigation' import {useModalControls} from '#/state/modals' +import {useQueryClient} from '@tanstack/react-query' import {useSessionApi} from '#/state/session' /** @@ -15,6 +16,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { const store = useStores() + const queryClient = useQueryClient() const {logout, login} = useSessionApi() const {openModal} = useModalControls() const onPressSignInAlice = async () => { @@ -83,7 +85,7 @@ export function TestCtrls() { /> store.me.mainFeed.refresh()} + onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})} accessibilityRole="button" style={BTN} /> diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index d6603a9369..53813f8225 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,10 +1,9 @@ import React from 'react' -import {useWindowDimensions} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' -import {PostsFeedModel} from 'state/models/feeds/posts' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' @@ -12,12 +11,9 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedPage} from 'view/com/feeds/FeedPage' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' -export const POLL_FREQ = 30e3 // 30sec - type Props = NativeStackScreenProps export const HomeScreen = withAuthRequired( observer(function HomeScreenImpl({}: Props) { @@ -26,7 +22,7 @@ export const HomeScreen = withAuthRequired( const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const pagerRef = React.useRef(null) const [selectedPage, setSelectedPage] = React.useState(0) - const [customFeeds, setCustomFeeds] = React.useState([]) + const [customFeeds, setCustomFeeds] = React.useState([]) const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState< string[] >([]) @@ -39,14 +35,12 @@ export const HomeScreen = withAuthRequired( return } - const feeds = [] + const feeds: FeedDescriptor[] = [] for (const uri of pinned) { if (uri.includes('app.bsky.feed.generator')) { - const model = new PostsFeedModel(store, 'custom', {feed: uri}) - feeds.push(model) + feeds.push(`feedgen|${uri}`) } else if (uri.includes('app.bsky.graph.list')) { - const model = new PostsFeedModel(store, 'list', {list: uri}) - feeds.push(model) + feeds.push(`list|${uri}`) } } pagerRef.current?.setPage(0) @@ -62,6 +56,19 @@ export const HomeScreen = withAuthRequired( setRequestedCustomFeeds, ]) + const homeFeedParams = React.useMemo(() => { + if (!store.preferences.homeFeed.lab_mergeFeedEnabled) { + return {} + } + return { + mergeFeedEnabled: true, + mergeFeedSources: store.preferences.savedFeeds, + } + }, [ + store.preferences.homeFeed.lab_mergeFeedEnabled, + store.preferences.savedFeeds, + ]) + useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) @@ -129,14 +136,15 @@ export const HomeScreen = withAuthRequired( key="1" testID="followingFeedPage" isPageFocused={selectedPage === 0} - feed={store.me.mainFeed} + feed="home" + feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} renderEndOfFeed={FollowingEndOfFeed} /> {customFeeds.map((f, index) => { return ( { - const model = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - model.setup() - return model - }, [store, uri]) const isPinned = store.preferences.isPinnedFeed(uri) useSetTitle(feedInfo?.displayName) @@ -352,7 +347,7 @@ export const ProfileFeedScreenInner = observer( {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( ( {feed, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const [hasNew, setHasNew] = React.useState(false) + const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, @@ -425,7 +422,9 @@ const FeedSection = React.forwardRef( (null) const aboutSectionRef = React.useRef(null) const {openModal} = useModalControls() const list: ListModel = useMemo(() => { - const model = new ListModel( - store, - `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, - ) + const model = new ListModel(store, listUri) return model - }, [store, listOwnerDid, rkey]) - const feed = useMemo( - () => new PostsFeedModel(store, 'list', {list: list.uri}), - [store, list], - ) + }, [store, listUri]) useSetTitle(list.data?.name) useFocusEffect( useCallback(() => { setMinimalShellMode(false) - list.loadMore(true).then(() => { - if (list.isCuratelist) { - feed.setup() - } - }) - }, [setMinimalShellMode, list, feed]), + list.loadMore(true) + }, [setMinimalShellMode, list]), ) const onPressAddUser = useCallback(() => { @@ -145,11 +138,13 @@ export const ProfileListScreenInner = observer( list, onAdd() { if (list.isCuratelist) { - feed.refresh() + queryClient.invalidateQueries({ + queryKey: FEED_RQKEY(`list|${listUri}`), + }) } }, }) - }, [openModal, list, feed]) + }, [openModal, list, queryClient, listUri]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -178,10 +173,10 @@ export const ProfileListScreenInner = observer( {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( | null> } - feed={feed} onScroll={onScroll} headerHeight={headerHeight} isScrolledDown={isScrolledDown} @@ -562,7 +557,7 @@ const Header = observer(function HeaderImpl({ }) interface FeedSectionProps { - feed: PostsFeedModel + feed: FeedDescriptor onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean @@ -573,12 +568,14 @@ const FeedSection = React.forwardRef( {feed, scrollElRef, onScroll, headerHeight, isScrolledDown}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -592,7 +589,9 @@ const FeedSection = React.forwardRef(