From 19c79db4e792942110291571ef5e5005a0bd7a60 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 9 Nov 2023 18:32:39 -0800 Subject: [PATCH 01/15] Update to react-query v5 --- package.json | 2 +- src/state/queries/post-thread.ts | 10 ++-- src/state/queries/post.ts | 98 +++++++++++++++----------------- src/state/queries/resolve-uri.ts | 17 +++--- yarn.lock | 21 ++++--- 5 files changed, 72 insertions(+), 76 deletions(-) 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/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/yarn.lock b/yarn.lock index fd7cd19bed..9e75b3e4b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5402,18 +5402,17 @@ "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" -"@tanstack/query-core@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.33.0.tgz#7756da9a75a424e521622b1d84eb55b7a2b33715" - integrity sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g== +"@tanstack/query-core@5.8.1": + version "5.8.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" + integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA== -"@tanstack/react-query@^4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.33.0.tgz#e927b0343a6ecaa948fee59e9ca98fe561062638" - integrity sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA== +"@tanstack/react-query@^5.8.1": + version "5.8.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade" + integrity sha512-YMagxS8iNPOLg0pK6WOjdSDlAvWKOf69udLOwQrBVmkC2SRLNLko7elo5Ro3ptlJkXvTVHidxC/h5KGi5bH1XQ== dependencies: - "@tanstack/query-core" "4.33.0" - use-sync-external-store "^1.2.0" + "@tanstack/query-core" "5.8.1" "@testing-library/jest-native@^5.4.1": version "5.4.2" @@ -18944,7 +18943,7 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: +use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== From 18acbd084db73a9fbc14a3ec5e6a8f890e5ab62d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 9 Nov 2023 19:11:37 -0800 Subject: [PATCH 02/15] Introduce post-feed react query --- 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 | 58 +++-- src/lib/api/feed/types.ts | 9 +- src/state/preferences/feed-tuners.tsx | 45 ++++ src/state/queries/post-feed.ts | 126 +++++++++ src/view/com/feeds/FeedPage.tsx | 77 +++--- src/view/com/post-thread/PostThreadItem.tsx | 14 +- src/view/com/posts/Feed.tsx | 103 +++++--- src/view/com/posts/FeedErrorMessage.tsx | 59 ++++- src/view/com/posts/FeedItem.tsx | 267 +++++++++----------- src/view/com/posts/FeedSlice.tsx | 73 ++++-- src/view/screens/Home.tsx | 16 +- src/view/screens/ProfileFeed.tsx | 19 +- src/view/screens/ProfileList.tsx | 36 +-- 18 files changed, 617 insertions(+), 411 deletions(-) create mode 100644 src/state/preferences/feed-tuners.tsx create mode 100644 src/state/queries/post-feed.ts 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..cdf91b83e8 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,6 +1,5 @@ -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' @@ -17,12 +16,12 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor(public rootStore: RootStoreModel) { - this.following = new MergeFeedSource_Following(this.rootStore) + constructor(public agent: BskyAgent) { + this.following = new MergeFeedSource_Following(this.agent) } reset() { - this.following = new MergeFeedSource_Following(this.rootStore) + this.following = new MergeFeedSource_Following(this.agent) this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.feedCursor = 0 this.itemCursor = 0 @@ -30,13 +29,23 @@ export class MergeFeedAPI implements FeedAPI { } 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 { + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise { + if (!cursor) { + this.reset() + } + // we capture here to ensure the data has loaded this._captureFeedsIfNeeded() @@ -109,16 +118,17 @@ export class MergeFeedAPI implements FeedAPI { } _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), - ), - ) - } + // TODO + // if (!this.agent.preferences.homeFeed.lab_mergeFeedEnabled) { + // return + // } + // if (this.customFeeds.length === 0) { + // this.customFeeds = shuffle( + // this.agent.preferences.savedFeeds.map( + // feedUri => new MergeFeedSource_Custom(this.agent, feedUri), + // ), + // ) + // } } } @@ -128,7 +138,7 @@ class MergeFeedSource { queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public rootStore: RootStoreModel) {} + constructor(public agent: BskyAgent) {} get numReady() { return this.queue.length @@ -190,11 +200,11 @@ 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'), + this.agent.preferences.getFeedTuners('home'), { dryRun: false, maintainOrder: true, @@ -208,14 +218,14 @@ 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) { + super(agent) this.sourceInfo = { 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..9acd9a6c66 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -6,9 +6,14 @@ 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 { diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx new file mode 100644 index 0000000000..84e85a714c --- /dev/null +++ b/src/state/preferences/feed-tuners.tsx @@ -0,0 +1,45 @@ +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() + + 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 [] +} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts new file mode 100644 index 0000000000..ca47663864 --- /dev/null +++ b/src/state/queries/post-feed.ts @@ -0,0 +1,126 @@ +import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import {useSession} from '../session' +import {useFeedTuners} from '../preferences/feed-tuners' +import {FeedTuner} from 'lib/api/feed-manip' +import {FeedAPI} 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' + +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}` + +type RQPageParam = string | undefined + +export function RQKEY(feedDesc: FeedDescriptor) { + return ['post-feed', feedDesc] +} + +export interface FeedPostSliceItem { + _reactKey: string + uri: string + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason?: AppBskyFeedDefs.ReasonRepost +} + +export interface FeedPostSlice { + _reactKey: string + rootUri: string + isThread: boolean + source: undefined // TODO + items: FeedPostSliceItem[] +} + +export interface FeedPage { + cursor: string | undefined + slices: FeedPostSlice[] +} + +export function usePostFeedQuery(feedDesc: FeedDescriptor) { + const {agent} = useSession() + const feedTuners = useFeedTuners(feedDesc) + + let api: FeedAPI + if (feedDesc === 'home') { + api = new MergeFeedAPI(agent) + } else if (feedDesc === 'following') { + api = new FollowingFeedAPI(agent) + } else if (feedDesc.startsWith('author')) { + const [_, actor, filter] = feedDesc.split('|') + api = new AuthorFeedAPI(agent, {actor, filter}) + } else if (feedDesc.startsWith('likes')) { + const [_, actor] = feedDesc.split('|') + api = new LikesFeedAPI(agent, {actor}) + } else if (feedDesc.startsWith('feedgen')) { + const [_, feed] = feedDesc.split('|') + api = new CustomFeedAPI(agent, {feed}) + } else if (feedDesc.startsWith('list')) { + const [_, list] = feedDesc.split('|') + api = new ListFeedAPI(agent, {list}) + } + const tuner = new FeedTuner() + + return useInfiniteQuery< + FeedPage, + Error, + InfiniteData, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(feedDesc), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + console.log('fetch', feedDesc, pageParam) + 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: item.reason, + } + } + return undefined + }) + .filter(Boolean) as FeedPostSliceItem[], + })), + } + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index ffae6cbf44..2609f7d1e4 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -7,13 +7,12 @@ import {useAnalytics} from '@segment/analytics-react-native' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedDescriptor} 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 {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' @@ -24,7 +23,7 @@ import {logger} from '#/logger' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -export const FeedPage = observer(function FeedPageImpl({ +export function FeedPage({ testID, isPageFocused, feed, @@ -32,7 +31,7 @@ export const FeedPage = observer(function FeedPageImpl({ renderEndOfFeed, }: { testID?: string - feed: PostsFeedModel + feed: FeedDescriptor isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -45,36 +44,30 @@ export const FeedPage = observer(function FeedPageImpl({ const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) + // const {appState} = useAppState({ TODO + // onForeground: () => doPoll(true), + // }) const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const hasNew = false // TODOfeed.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], - ) + // TODO + // 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 scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerOffset}) @@ -84,9 +77,9 @@ export const FeedPage = observer(function FeedPageImpl({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - feed.refresh() + // feed.refresh() TODO } - }, [isPageFocused, scrollToTop, feed]) + }, [isPageFocused, scrollToTop]) // fires when page within screen is activated/deactivated // - check for latest @@ -96,19 +89,17 @@ export const FeedPage = observer(function FeedPageImpl({ } const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) + // const pollInterval = setInterval(doPoll, POLL_FREQ) TODO screen('Feed') logger.debug('HomeScreen: Updating feed') - feed.checkForLatest() + // feed.checkForLatest() TODO return () => { - clearInterval(pollInterval) + // clearInterval(pollInterval) TODO softResetSub.remove() - feedCleanup() } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') @@ -117,8 +108,8 @@ export const FeedPage = observer(function FeedPageImpl({ const onPressLoadLatest = React.useCallback(() => { scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + // feed.refresh() TODO + }, [scrollToTop]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -205,4 +196,4 @@ export const FeedPage = observer(function FeedPageImpl({ /> ) -}) +} 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 1ecb149124..8190e54884 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, RefreshControl, @@ -11,7 +10,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 {OnScrollCb} from 'lib/hooks/useOnMainScroll' @@ -21,12 +19,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' +import {FeedDescriptor, 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, style, scrollElRef, @@ -40,7 +40,7 @@ export const Feed = observer(function Feed({ ListHeaderComponent, extraData, }: { - feed: PostsFeedModel + feed: FeedDescriptor style?: StyleProp scrollElRef?: MutableRefObject | null> onScroll?: OnScrollCb @@ -58,31 +58,41 @@ export const Feed = observer(function Feed({ const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (feed.hasLoaded) { - if (feed.hasError) { - feedItems = feedItems.concat([ERROR_ITEM]) + const { + data, + dataUpdatedAt, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feed) + const isEmpty = isFetched && data?.pages[0]?.slices.length === 0 + + const feedItems = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isError && isEmpty) { + arr = arr.concat([ERROR_ITEM]) } - if (feed.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(feed.slices) + 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 // = @@ -91,31 +101,31 @@ export const Feed = observer(function Feed({ track('Feed:onRefresh') setIsRefreshing(true) try { - await feed.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } setIsRefreshing(false) - }, [feed, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded || !feed.hasMore) return + if (isFetching || !hasNextPage) 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, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + refetch() + }, [refetch]) const onPressRetryLoadMore = React.useCallback(() => { - feed.retryLoadMore() - }, [feed]) + fetchNextPage() + }, [fetchNextPage]) // rendering // = @@ -126,7 +136,11 @@ export const Feed = observer(function Feed({ return renderEmptyState() } else if (item === ERROR_ITEM) { return ( - + ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( @@ -138,23 +152,30 @@ 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 FeedFooter = React.useCallback( () => - feed.isLoadingMore ? ( + isFetchingNextPage ? ( - ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + ) : !hasNextPage && !isEmpty && renderEndOfFeed ? ( renderEndOfFeed() ) : ( ), - [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], + [isFetchingNextPage, hasNextPage, isEmpty, renderEndOfFeed], ) return ( @@ -162,7 +183,7 @@ export const Feed = observer(function Feed({ item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} @@ -193,7 +214,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..efb6f6e926 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -12,6 +12,7 @@ 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' const MESSAGES = { [KnownError.Unknown]: '', @@ -27,22 +28,28 @@ 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 || + true /*TODO*/ ) { - return ( - - ) + return } - return + // TODO + // return } function FeedgenErrorMessage({ @@ -120,3 +127,39 @@ function safeParseFeedgenUri(uri: string): [string, string] { return ['', ''] } } + +function detectKnownError( + feedDesc: FeedDescriptor, + error: any, +): KnownError | undefined { + if (!error) { + return undefined + } + 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..d8773bd7ca 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,25 +1,28 @@ 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, + moderatePost, + 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 {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 +30,100 @@ 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, +export function FeedItem({ + post, + record, + reason, + dataUpdatedAt, source, isThreadChild, isThreadLastChild, isThreadParent, }: { - item: PostsFeedItemModel + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | undefined + dataUpdatedAt: number + source?: FeedSourceInfo + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean +}) { + const store = useStores() + const postShadowed = usePostShadow(post, dataUpdatedAt) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const moderation = useMemo( + () => + post ? moderatePost(post, store.preferences.moderationOpts) : undefined, + [post, store], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + + ) + } + return null +} + +function FeedItemLoaded({ + post, + record, + reason, + richText, + moderation, + source, + isThreadChild, + isThreadLastChild, + isThreadParent, +}: { + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | undefined + richText: RichTextAPI + moderation: PostModeration source?: FeedSourceInfo 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 +131,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 +165,11 @@ export const FeedItem = observer(function FeedItemImpl({ isThreadChild ? styles.outerSmallTop : undefined, ] - if (!record || deleted) { - return - } - return ( @@ -214,12 +211,12 @@ export const FeedItem = observer(function FeedItemImpl({ /> - ) : item.reasonRepost ? ( + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( @@ -256,10 +252,10 @@ export const FeedItem = observer(function FeedItemImpl({ {isThreadParent && ( {!isThreadChild && replyAuthorDid !== '' && ( @@ -308,19 +304,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..bde114b744 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,7 +1,7 @@ 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 {FeedPostSlice} from '#/state/queries/post-feed' import {AtUri} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -12,14 +12,17 @@ import {makeProfileLink} from 'lib/routes/links' export const FeedSlice = observer(function FeedSliceImpl({ slice, + dataUpdatedAt, ignoreFilterFor, }: { - slice: PostsFeedSliceModel + slice: FeedPostSlice + dataUpdatedAt: number ignoreFilterFor?: string }) { - if (slice.shouldFilter(ignoreFilterFor)) { - return null - } + // TODO + // if (slice.shouldFilter(ignoreFilterFor)) { + // return null + // } if (slice.isThread && slice.items.length > 3) { const last = slice.items.length - 1 @@ -27,23 +30,32 @@ export const FeedSlice = observer(function FeedSliceImpl({ <> @@ -55,12 +67,15 @@ export const FeedSlice = observer(function FeedSliceImpl({ {slice.items.map((item, i) => ( ))} @@ -68,12 +83,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/screens/Home.tsx b/src/view/screens/Home.tsx index d6603a9369..4c6d50c84a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -4,7 +4,7 @@ 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} 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' @@ -26,7 +26,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 +39,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) @@ -129,14 +127,14 @@ export const HomeScreen = withAuthRequired( key="1" testID="followingFeedPage" isPageFocused={selectedPage === 0} - feed={store.me.mainFeed} + feed="following" 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) @@ -351,7 +344,7 @@ export const ProfileFeedScreenInner = observer( {({onScroll, headerHeight, isScrolledDown}) => ( void headerHeight: number isScrolledDown: boolean @@ -398,13 +391,13 @@ const FeedSection = React.forwardRef( {feed, onScroll, headerHeight, isScrolledDown}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing const scrollElRef = React.useRef(null) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + // feed.refresh() TODO + }, [scrollElRef, headerHeight]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 0bbb512c12..bd285b5591 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -30,7 +30,7 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Haptics} from 'lib/haptics' import {ListModel} from 'state/models/content/list' -import {PostsFeedModel} from 'state/models/feeds/posts' +import {FeedDescriptor} from '#/state/queries/post-feed' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' @@ -112,32 +112,22 @@ export const ProfileListScreenInner = observer( const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const {rkey} = route.params + const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}` const feedSectionRef = React.useRef(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(() => { @@ -146,11 +136,11 @@ export const ProfileListScreenInner = observer( list, onAdd() { if (list.isCuratelist) { - feed.refresh() + // feed.refresh() TODO } }, }) - }, [openModal, list, feed]) + }, [openModal, list]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -179,7 +169,7 @@ export const ProfileListScreenInner = observer( {({onScroll, headerHeight, isScrolledDown}) => ( void headerHeight: number isScrolledDown: boolean @@ -564,13 +554,13 @@ const FeedSection = React.forwardRef( {feed, onScroll, headerHeight, isScrolledDown}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing const scrollElRef = React.useRef(null) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + // feed.refresh() TODO + }, [scrollElRef, headerHeight]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) From ea4f5860c031ee8cf4c1dd55f6dfc1e05ad8afb4 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 07:45:26 -0800 Subject: [PATCH 03/15] Add feed refresh behaviors --- src/view/com/feeds/FeedPage.tsx | 11 +++++++---- src/view/screens/ProfileFeed.tsx | 7 +++++-- src/view/screens/ProfileList.tsx | 14 ++++++++++---- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 2609f7d1e4..b9adaaf8f7 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -4,6 +4,8 @@ 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' @@ -40,6 +42,7 @@ export function FeedPage({ const pal = usePalette('default') const {_} = useLingui() const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() @@ -77,9 +80,9 @@ export function FeedPage({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - // feed.refresh() TODO + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) } - }, [isPageFocused, scrollToTop]) + }, [isPageFocused, scrollToTop, queryClient, feed]) // fires when page within screen is activated/deactivated // - check for latest @@ -108,8 +111,8 @@ export function FeedPage({ const onPressLoadLatest = React.useCallback(() => { scrollToTop() - // feed.refresh() TODO - }, [scrollToTop]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + }, [scrollToTop, feed, queryClient]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 02020cc316..e211bf1737 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -9,6 +9,7 @@ import { import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useNavigation} from '@react-navigation/native' import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -33,6 +34,7 @@ import {EmptyState} from 'view/com/util/EmptyState' import * as Toast from 'view/com/util/Toast' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' @@ -393,11 +395,12 @@ const FeedSection = React.forwardRef( ) { const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing const scrollElRef = React.useRef(null) + const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - // feed.refresh() TODO - }, [scrollElRef, headerHeight]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + }, [scrollElRef, headerHeight, queryClient, feed]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index bd285b5591..3698882e33 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -14,6 +14,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useAnimatedScrollHandler} from 'react-native-reanimated' import {observer} from 'mobx-react-lite' import {RichText as RichTextAPI} from '@atproto/api' +import {useQueryClient} from '@tanstack/react-query' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' @@ -35,6 +36,7 @@ import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' @@ -110,6 +112,7 @@ export const ProfileListScreenInner = observer( }: Props & {listOwnerDid: string}) { const store = useStores() const {_} = useLingui() + const queryClient = useQueryClient() const setMinimalShellMode = useSetMinimalShellMode() const {rkey} = route.params const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}` @@ -136,11 +139,13 @@ export const ProfileListScreenInner = observer( list, onAdd() { if (list.isCuratelist) { - // feed.refresh() TODO + queryClient.invalidateQueries({ + queryKey: FEED_RQKEY(`list|${listUri}`), + }) } }, }) - }, [openModal, list]) + }, [openModal, list, queryClient, listUri]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -554,13 +559,14 @@ const FeedSection = React.forwardRef( {feed, onScroll, headerHeight, isScrolledDown}, ref, ) { + const queryClient = useQueryClient() const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing const scrollElRef = React.useRef(null) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - // feed.refresh() TODO - }, [scrollElRef, headerHeight]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + }, [scrollElRef, headerHeight, queryClient, feed]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) From 408269d5e1a588a03dbcc1f0c893ab2341aca14d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 07:55:34 -0800 Subject: [PATCH 04/15] Only fetch feeds of visible pages --- src/state/queries/post-feed.ts | 6 +++++- src/view/com/feeds/FeedPage.tsx | 1 + src/view/com/posts/Feed.tsx | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index ca47663864..6811496463 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -53,7 +53,10 @@ export interface FeedPage { slices: FeedPostSlice[] } -export function usePostFeedQuery(feedDesc: FeedDescriptor) { +export function usePostFeedQuery( + feedDesc: FeedDescriptor, + opts?: {enabled?: boolean}, +) { const {agent} = useSession() const feedTuners = useFeedTuners(feedDesc) @@ -122,5 +125,6 @@ export function usePostFeedQuery(feedDesc: FeedDescriptor) { }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + enabled: opts?.enabled === false ? false : true, }) } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index b9adaaf8f7..103f45022e 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -174,6 +174,7 @@ export function FeedPage({ scrollElRef?: MutableRefObject | null> onScroll?: OnScrollCb @@ -69,7 +71,7 @@ export function Feed({ hasNextPage, isFetchingNextPage, fetchNextPage, - } = usePostFeedQuery(feed) + } = usePostFeedQuery(feed, {enabled}) const isEmpty = isFetched && data?.pages[0]?.slices.length === 0 const feedItems = React.useMemo(() => { From a04e8f37fcc97d6740e3376ca48423af413204aa Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 08:35:12 -0800 Subject: [PATCH 05/15] Implement polling for latest on feeds --- src/state/queries/post-feed.ts | 82 +++++++++++++++++++++++--------- src/view/com/feeds/FeedPage.tsx | 61 ++++++++++-------------- src/view/com/posts/Feed.tsx | 33 +++++++++++-- src/view/screens/Home.tsx | 18 ------- src/view/screens/ProfileFeed.tsx | 7 ++- src/view/screens/ProfileList.tsx | 7 ++- 6 files changed, 124 insertions(+), 84 deletions(-) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 6811496463..8c25d51cb3 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,4 +1,5 @@ -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +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' @@ -10,6 +11,7 @@ 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 = @@ -59,28 +61,59 @@ export function usePostFeedQuery( ) { const {agent} = useSession() const feedTuners = useFeedTuners(feedDesc) + const store = useStores() + const enabled = opts?.enabled !== false - let api: FeedAPI - if (feedDesc === 'home') { - api = new MergeFeedAPI(agent) - } else if (feedDesc === 'following') { - api = new FollowingFeedAPI(agent) - } else if (feedDesc.startsWith('author')) { - const [_, actor, filter] = feedDesc.split('|') - api = new AuthorFeedAPI(agent, {actor, filter}) - } else if (feedDesc.startsWith('likes')) { - const [_, actor] = feedDesc.split('|') - api = new LikesFeedAPI(agent, {actor}) - } else if (feedDesc.startsWith('feedgen')) { - const [_, feed] = feedDesc.split('|') - api = new CustomFeedAPI(agent, {feed}) - } else if (feedDesc.startsWith('list')) { - const [_, list] = feedDesc.split('|') - api = new ListFeedAPI(agent, {list}) - } - const tuner = new FeedTuner() + const api: FeedAPI = useMemo(() => { + if (feedDesc === 'home') { + return new MergeFeedAPI(agent) + } 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, agent]) + const tuner = useMemo(() => new FeedTuner(), []) - return useInfiniteQuery< + const pollLatest = useCallback(async () => { + if (!enabled) { + return false + } + console.log('polling') + 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, @@ -90,6 +123,9 @@ export function usePostFeedQuery( queryKey: RQKEY(feedDesc), 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 { @@ -125,6 +161,8 @@ export function usePostFeedQuery( }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, - enabled: opts?.enabled === false ? false : true, + enabled, }) + + return {...out, pollLatest} } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 103f45022e..50b9b291a7 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -13,18 +13,17 @@ import {FeedDescriptor} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' import React from 'react' -import {FlatList, View} from 'react-native' +import {FlatList, View, useWindowDimensions} from 'react-native' import {useStores} from 'state/index' -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' +const POLL_FREQ = 30e3 // 30sec + export function FeedPage({ testID, isPageFocused, @@ -47,30 +46,8 @@ export function FeedPage({ const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef(null) - // const {appState} = useAppState({ TODO - // onForeground: () => doPoll(true), - // }) const isScreenFocused = useIsFocused() - const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing - - // TODO - // 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}) @@ -81,25 +58,18 @@ export function FeedPage({ if (isPageFocused) { scrollToTop() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) } - }, [isPageFocused, scrollToTop, queryClient, 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 pollInterval = setInterval(doPoll, POLL_FREQ) TODO - screen('Feed') - logger.debug('HomeScreen: Updating feed') - // feed.checkForLatest() TODO - return () => { - // clearInterval(pollInterval) TODO softResetSub.remove() } }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) @@ -112,7 +82,8 @@ export function FeedPage({ const onPressLoadLatest = React.useCallback(() => { scrollToTop() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) - }, [scrollToTop, feed, queryClient]) + setHasNew(false) + }, [scrollToTop, feed, queryClient, setHasNew]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -175,8 +146,10 @@ export function FeedPage({ testID={testID ? `${testID}-feed` : undefined} feed={feed} enabled={isPageFocused} + pollInterval={POLL_FREQ} scrollElRef={scrollElRef} onScroll={onMainScroll} + onHasNew={setHasNew} scrollEventThrottle={1} renderEmptyState={renderEmptyState} renderEndOfFeed={renderEndOfFeed} @@ -201,3 +174,17 @@ export function FeedPage({ ) } + +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/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1f33337bd7..3bb339d8a5 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -30,8 +30,10 @@ export function Feed({ feed, style, enabled, + pollInterval, scrollElRef, onScroll, + onHasNew, scrollEventThrottle, renderEmptyState, renderEndOfFeed, @@ -42,10 +44,12 @@ export function Feed({ extraData, }: { feed: FeedDescriptor - enabled?: boolean style?: StyleProp + enabled?: boolean + pollInterval?: number scrollElRef?: MutableRefObject | null> onScroll?: OnScrollCb + onHasNew?: (v: boolean) => void scrollEventThrottle?: number renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -71,9 +75,30 @@ export function Feed({ hasNextPage, isFetchingNextPage, fetchNextPage, + pollLatest, } = usePostFeedQuery(feed, {enabled}) const isEmpty = isFetched && data?.pages[0]?.slices.length === 0 + const checkForNew = React.useCallback(async () => { + if (!isFetched || isFetching || !onHasNew) { + return + } + try { + if (await pollLatest()) { + onHasNew(true) + } + } catch (e) { + logger.error('Poll latest failed', {feed, error: String(e)}) + } + }, [feed, isFetched, isFetching, pollLatest, onHasNew]) + + React.useEffect(() => { + const i = setInterval(checkForNew, pollInterval) + return () => { + clearInterval(i) + } + }) + const feedItems = React.useMemo(() => { let arr: any[] = [] if (isFetched) { @@ -104,11 +129,12 @@ export function Feed({ setIsRefreshing(true) try { await refetch() + onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } setIsRefreshing(false) - }, [refetch, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing, onHasNew]) const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage) return @@ -123,7 +149,8 @@ export function Feed({ const onPressTryAgain = React.useCallback(() => { refetch() - }, [refetch]) + onHasNew?.(false) + }, [refetch, onHasNew]) const onPressRetryLoadMore = React.useCallback(() => { fetchNextPage() diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4c6d50c84a..c39c94ecab 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,5 +1,4 @@ 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' @@ -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) { @@ -146,17 +142,3 @@ export const HomeScreen = withAuthRequired( ) }), ) - -export 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/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index e211bf1737..f74c661fce 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -393,14 +393,15 @@ const FeedSection = React.forwardRef( {feed, onScroll, headerHeight, isScrolledDown}, ref, ) { - const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing + const [hasNew, setHasNew] = React.useState(false) const scrollElRef = React.useRef(null) const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) - }, [scrollElRef, headerHeight, queryClient, feed]) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, @@ -415,8 +416,10 @@ const FeedSection = React.forwardRef( ( ref, ) { const queryClient = useQueryClient() - const hasNew = false // TODOfeed.hasNewLatest && !feed.isRefreshing + const [hasNew, setHasNew] = React.useState(false) const scrollElRef = React.useRef(null) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) - }, [scrollElRef, headerHeight, queryClient, feed]) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -581,8 +582,10 @@ const FeedSection = React.forwardRef( Date: Fri, 10 Nov 2023 08:47:18 -0800 Subject: [PATCH 06/15] Add moderation filtering to slices --- src/view/com/posts/FeedItem.tsx | 9 ++------- src/view/com/posts/FeedSlice.tsx | 27 ++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index d8773bd7ca..f170df1948 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,7 +4,6 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - moderatePost, PostModeration, RichText as RichTextAPI, } from '@atproto/api' @@ -40,6 +39,7 @@ export function FeedItem({ post, record, reason, + moderation, dataUpdatedAt, source, isThreadChild, @@ -49,13 +49,13 @@ export function FeedItem({ post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason: AppBskyFeedDefs.ReasonRepost | undefined + moderation: PostModeration dataUpdatedAt: number source?: FeedSourceInfo isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean }) { - const store = useStores() const postShadowed = usePostShadow(post, dataUpdatedAt) const richText = useMemo( () => @@ -65,11 +65,6 @@ export function FeedItem({ }), [record], ) - const moderation = useMemo( - () => - post ? moderatePost(post, store.preferences.moderationOpts) : undefined, - [post, store], - ) if (postShadowed === POST_TOMBSTONE) { return null } diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index bde114b744..ed37b70680 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -2,13 +2,14 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FeedPostSlice} from '#/state/queries/post-feed' -import {AtUri} from '@atproto/api' +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, @@ -19,10 +20,22 @@ export const FeedSlice = observer(function FeedSliceImpl({ dataUpdatedAt: number ignoreFilterFor?: string }) { - // TODO - // 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) { const last = slice.items.length - 1 @@ -33,6 +46,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ post={slice.items[0].post} record={slice.items[0].record} reason={slice.items[0].reason} + moderation={moderations[0]} dataUpdatedAt={dataUpdatedAt} source={slice.source} isThreadParent={isThreadParentAt(slice.items, 0)} @@ -43,6 +57,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ post={slice.items[1].post} record={slice.items[1].record} reason={slice.items[1].reason} + moderation={moderations[1]} dataUpdatedAt={dataUpdatedAt} isThreadParent={isThreadParentAt(slice.items, 1)} isThreadChild={isThreadChildAt(slice.items, 1)} @@ -53,6 +68,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ post={slice.items[last].post} record={slice.items[last].record} reason={slice.items[last].reason} + moderation={moderations[last]} dataUpdatedAt={dataUpdatedAt} isThreadParent={isThreadParentAt(slice.items, last)} isThreadChild={isThreadChildAt(slice.items, last)} @@ -70,6 +86,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ post={slice.items[i].post} record={slice.items[i].record} reason={slice.items[i].reason} + moderation={moderations[i]} dataUpdatedAt={dataUpdatedAt} source={i === 0 ? slice.source : undefined} isThreadParent={isThreadParentAt(slice.items, i)} From b594cb1fdb55e3797d124aeaa44101ef3ce8c646 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 08:57:31 -0800 Subject: [PATCH 07/15] Handle block errors --- src/state/models/feeds/posts.ts | 1 + src/view/com/posts/FeedErrorMessage.tsx | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 0a06c581c7..6e60bbde2b 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -29,6 +29,7 @@ const PAGE_SIZE = 30 type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list' export enum KnownError { + Block, FeedgenDoesNotExist, FeedgenMisconfigured, FeedgenBadResponse, diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index efb6f6e926..0d0f837307 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,6 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' +import { + AppBskyFeedGetAuthorFeed, + AtUri, + AppBskyFeedGetFeed as GetCustomFeed, +} from '@atproto/api' import {PostsFeedModel, KnownError} from 'state/models/feeds/posts' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' @@ -13,6 +17,7 @@ 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' const MESSAGES = { [KnownError.Unknown]: '', @@ -48,6 +53,16 @@ export function FeedErrorMessage({ return } + if (knownError === KnownError.Block) { + return ( + + ) + } + // TODO // return } @@ -135,6 +150,12 @@ function detectKnownError( if (!error) { return undefined } + if ( + error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError || + error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError + ) { + return KnownError.Block + } if (typeof error !== 'string') { error = error.toString() } From c8101a9215411b87079116395745aabfe658c90b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 09:05:23 -0800 Subject: [PATCH 08/15] Update feed error messages --- src/view/com/posts/FeedErrorMessage.tsx | 35 ++++++++++++++----------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 0d0f837307..feb4b1c99c 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,11 +1,6 @@ import React from 'react' import {View} from 'react-native' -import { - AppBskyFeedGetAuthorFeed, - 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' @@ -19,8 +14,19 @@ 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.', @@ -46,11 +52,11 @@ export function FeedErrorMessage({ [feedDesc, error], ) if ( - typeof knownError === 'undefined' || - knownError === KnownError.Unknown || - true /*TODO*/ + typeof knownError !== 'undefined' && + knownError !== KnownError.Unknown && + feedDesc.startsWith('feedgen') ) { - return + return } if (knownError === KnownError.Block) { @@ -63,22 +69,21 @@ export function FeedErrorMessage({ ) } - // TODO - // 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() From f773aa7432775f0973a5884d86cbed1262a0b992 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 09:08:21 -0800 Subject: [PATCH 09/15] Remove old models --- src/state/models/feeds/posts-slice.ts | 91 ------ src/state/models/feeds/posts.ts | 430 ------------------------- src/state/models/me.ts | 10 - src/state/models/ui/profile.ts | 1 - src/view/com/composer/Composer.tsx | 2 +- src/view/com/testing/TestCtrls.e2e.tsx | 4 +- src/view/screens/Profile.tsx | 1 - 7 files changed, 4 insertions(+), 535 deletions(-) delete mode 100644 src/state/models/feeds/posts-slice.ts delete mode 100644 src/state/models/feeds/posts.ts 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 6e60bbde2b..0000000000 --- a/src/state/models/feeds/posts.ts +++ /dev/null @@ -1,430 +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 { - Block, - 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/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/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 2f36609e9b..3c23145da6 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' /** * This utility component is only included in the test simulator @@ -14,6 +15,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { const store = useStores() + const queryClient = useQueryClient() const {openModal} = useModalControls() const onPressSignInAlice = async () => { await store.session.login({ @@ -81,7 +83,7 @@ export function TestCtrls() { /> store.me.mainFeed.refresh()} + onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})} accessibilityRole="button" style={BTN} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index f2aa7f05d2..945a8cc202 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -9,7 +9,6 @@ import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' import {ListCard} from 'view/com/lists/ListCard' From e8612cd233252fb272c8b8821bb6674929765b14 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 09:15:29 -0800 Subject: [PATCH 10/15] Replace simple-feed option with disable-tuner option --- src/lib/api/feed-manip.ts | 11 +++++++++++ src/state/queries/post-feed.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 8f259a910b..f96d953089 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -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/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 8c25d51cb3..5b149f4c5b 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -3,7 +3,7 @@ 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} from 'lib/api/feed-manip' +import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip' import {FeedAPI} from 'lib/api/feed/types' import {FollowingFeedAPI} from 'lib/api/feed/following' import {AuthorFeedAPI} from 'lib/api/feed/author' @@ -57,7 +57,7 @@ export interface FeedPage { export function usePostFeedQuery( feedDesc: FeedDescriptor, - opts?: {enabled?: boolean}, + opts?: {enabled?: boolean; disableTuner?: boolean}, ) { const {agent} = useSession() const feedTuners = useFeedTuners(feedDesc) @@ -86,7 +86,10 @@ export function usePostFeedQuery( return new FollowingFeedAPI(agent) } }, [feedDesc, agent]) - const tuner = useMemo(() => new FeedTuner(), []) + const tuner = useMemo( + () => (opts?.disableTuner ? new NoopFeedTuner() : new FeedTuner()), + [opts?.disableTuner], + ) const pollLatest = useCallback(async () => { if (!enabled) { From 14fd12097c00feb73818821bbdd1cea100a907c0 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 12:01:29 -0800 Subject: [PATCH 11/15] Add missing useMemo --- src/state/preferences/feed-tuners.tsx | 69 ++++++++++++++------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index 84e85a714c..96770055c2 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react' import {FeedTuner} from '#/lib/api/feed-manip' import {FeedDescriptor} from '../queries/post-feed' import {useLanguagePrefs} from './languages' @@ -5,41 +6,43 @@ import {useLanguagePrefs} from './languages' export function useFeedTuners(feedDesc: FeedDescriptor) { const langPrefs = useLanguagePrefs() - 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) + 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 (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.hideReposts*/) { + feedTuners.push(FeedTuner.removeReposts) + } else { + feedTuners.push(FeedTuner.dedupReposts) + } - if (false /*TODOthis.homeFeed.hideQuotePosts*/) { - feedTuners.push(FeedTuner.removeQuotePosts) - } + 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, + }), + ) + }*/ - return feedTuners - } - return [] + if (false /*TODOthis.homeFeed.hideQuotePosts*/) { + feedTuners.push(FeedTuner.removeQuotePosts) + } + + return feedTuners + } + return [] + }, [feedDesc, langPrefs]) } From 6e871302938fb345874d126422d2e4675c408bb8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 14:33:57 -0800 Subject: [PATCH 12/15] Implement the mergefeed and fixes to polling --- src/lib/api/feed-manip.ts | 6 +-- src/lib/api/feed/merge.ts | 68 ++++++++++++++++---------------- src/lib/api/feed/types.ts | 12 +++++- src/state/queries/post-feed.ts | 31 +++++++++------ src/view/com/feeds/FeedPage.tsx | 5 ++- src/view/com/posts/Feed.tsx | 26 ++++++++---- src/view/com/posts/FeedItem.tsx | 21 ++++------ src/view/com/posts/FeedSlice.tsx | 2 - src/view/screens/Home.tsx | 18 ++++++++- 9 files changed, 112 insertions(+), 77 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index f96d953089..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) { diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index cdf91b83e8..7a0f02887c 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -4,7 +4,9 @@ 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 @@ -16,16 +18,30 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor(public agent: BskyAgent) { - this.following = new MergeFeedSource_Following(this.agent) + 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.agent) + 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 { @@ -46,9 +62,6 @@ export class MergeFeedAPI implements FeedAPI { this.reset() } - // we capture here to ensure the data has loaded - this._captureFeedsIfNeeded() - const promises = [] // always keep following topped up @@ -85,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI { } return { - cursor: posts.length ? 'fake' : undefined, + cursor: posts.length ? String(this.itemCursor) : undefined, feed: posts, } } @@ -116,29 +129,15 @@ export class MergeFeedAPI implements FeedAPI { // provide follow return this.following.take(1) } - - _captureFeedsIfNeeded() { - // TODO - // if (!this.agent.preferences.homeFeed.lab_mergeFeedEnabled) { - // return - // } - // if (this.customFeeds.length === 0) { - // this.customFeeds = shuffle( - // this.agent.preferences.savedFeeds.map( - // feedUri => new MergeFeedSource_Custom(this.agent, feedUri), - // ), - // ) - // } - } } class MergeFeedSource { - sourceInfo: FeedSourceInfo | undefined + sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public agent: BskyAgent) {} + constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {} get numReady() { return this.queue.length @@ -202,14 +201,10 @@ class MergeFeedSource_Following extends MergeFeedSource { ): Promise { 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.agent.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 } @@ -218,9 +213,14 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor(public agent: BskyAgent, public feedUri: string) { - super(agent) + constructor( + public agent: BskyAgent, + public feedUri: string, + public feedTuners: FeedTunerFn[], + ) { + super(agent, feedTuners) this.sourceInfo = { + $type: 'reasonFeedSource', displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts index 9acd9a6c66..5d2a90c1db 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -16,7 +16,17 @@ export interface FeedAPI { }): 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/queries/post-feed.ts b/src/state/queries/post-feed.ts index 5b149f4c5b..1a391d5c30 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -4,7 +4,7 @@ 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} from 'lib/api/feed/types' +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' @@ -27,11 +27,16 @@ export type FeedDescriptor = | `feedgen|${FeedUri}` | `likes|${ActorDid}` | `list|${ListUri}` +export interface FeedParams { + disableTuner?: boolean + mergeFeedEnabled?: boolean + mergeFeedSources?: string[] +} type RQPageParam = string | undefined -export function RQKEY(feedDesc: FeedDescriptor) { - return ['post-feed', feedDesc] +export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { + return ['post-feed', feedDesc, params || {}] } export interface FeedPostSliceItem { @@ -39,14 +44,13 @@ export interface FeedPostSliceItem { uri: string post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record - reason?: AppBskyFeedDefs.ReasonRepost + reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource } export interface FeedPostSlice { _reactKey: string rootUri: string isThread: boolean - source: undefined // TODO items: FeedPostSliceItem[] } @@ -57,7 +61,8 @@ export interface FeedPage { export function usePostFeedQuery( feedDesc: FeedDescriptor, - opts?: {enabled?: boolean; disableTuner?: boolean}, + params?: FeedParams, + opts?: {enabled?: boolean}, ) { const {agent} = useSession() const feedTuners = useFeedTuners(feedDesc) @@ -66,7 +71,7 @@ export function usePostFeedQuery( const api: FeedAPI = useMemo(() => { if (feedDesc === 'home') { - return new MergeFeedAPI(agent) + return new MergeFeedAPI(agent, params || {}, feedTuners) } else if (feedDesc === 'following') { return new FollowingFeedAPI(agent) } else if (feedDesc.startsWith('author')) { @@ -85,17 +90,17 @@ export function usePostFeedQuery( // shouldnt happen return new FollowingFeedAPI(agent) } - }, [feedDesc, agent]) + }, [feedDesc, params, feedTuners, agent]) const tuner = useMemo( - () => (opts?.disableTuner ? new NoopFeedTuner() : new FeedTuner()), - [opts?.disableTuner], + () => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()), + [params], ) const pollLatest = useCallback(async () => { if (!enabled) { return false } - console.log('polling') + console.log('poll') const post = await api.peekLatest() if (post) { const slices = tuner.tune([post], feedTuners, { @@ -123,7 +128,7 @@ export function usePostFeedQuery( QueryKey, RQPageParam >({ - queryKey: RQKEY(feedDesc), + queryKey: RQKEY(feedDesc, params), async queryFn({pageParam}: {pageParam: RQPageParam}) { console.log('fetch', feedDesc, pageParam) if (!pageParam) { @@ -153,7 +158,7 @@ export function usePostFeedQuery( uri: item.post.uri, post: item.post, record: item.post.record, - reason: item.reason, + reason: i === 0 && slice.source ? slice.source : item.reason, } } return undefined diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 50b9b291a7..c4b2c42808 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -9,7 +9,7 @@ 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} from '#/state/queries/post-feed' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' import React from 'react' @@ -28,11 +28,13 @@ export function FeedPage({ testID, isPageFocused, feed, + feedParams, renderEmptyState, renderEndOfFeed, }: { testID?: string feed: FeedDescriptor + feedParams?: FeedParams isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -145,6 +147,7 @@ export function FeedPage({ enabled?: boolean pollInterval?: number @@ -63,7 +68,9 @@ export 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, @@ -76,7 +83,7 @@ export function Feed({ isFetchingNextPage, fetchNextPage, pollLatest, - } = usePostFeedQuery(feed, {enabled}) + } = usePostFeedQuery(feed, feedParams, opts) const isEmpty = isFetched && data?.pages[0]?.slices.length === 0 const checkForNew = React.useCallback(async () => { @@ -93,11 +100,14 @@ export function Feed({ }, [feed, isFetched, isFetching, pollLatest, onHasNew]) React.useEffect(() => { - const i = setInterval(checkForNew, pollInterval) - return () => { - clearInterval(i) - } - }) + // 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[] = [] diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index f170df1948..9ddb6cbccd 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -11,7 +11,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -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' @@ -41,17 +41,15 @@ export function FeedItem({ reason, moderation, dataUpdatedAt, - source, isThreadChild, isThreadLastChild, isThreadParent, }: { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record - reason: AppBskyFeedDefs.ReasonRepost | undefined + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined moderation: PostModeration dataUpdatedAt: number - source?: FeedSourceInfo isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -76,7 +74,6 @@ export function FeedItem({ reason={reason} richText={richText} moderation={moderation} - source={source} isThreadChild={isThreadChild} isThreadLastChild={isThreadLastChild} isThreadParent={isThreadParent} @@ -92,17 +89,15 @@ function FeedItemLoaded({ reason, richText, moderation, - source, isThreadChild, isThreadLastChild, isThreadParent, }: { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record - reason: AppBskyFeedDefs.ReasonRepost | undefined + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined richText: RichTextAPI moderation: PostModeration - source?: FeedSourceInfo isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -186,10 +181,10 @@ function FeedItemLoaded({ - {source ? ( + {isReasonFeedSource(reason) ? ( + title={sanitizeDisplayName(reason.displayName)} + href={reason.uri}> diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index ed37b70680..c33c6028d5 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -48,7 +48,6 @@ export const FeedSlice = observer(function FeedSliceImpl({ reason={slice.items[0].reason} moderation={moderations[0]} dataUpdatedAt={dataUpdatedAt} - source={slice.source} isThreadParent={isThreadParentAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)} /> @@ -88,7 +87,6 @@ export const FeedSlice = observer(function FeedSliceImpl({ reason={slice.items[i].reason} moderation={moderations[i]} dataUpdatedAt={dataUpdatedAt} - source={i === 0 ? slice.source : undefined} isThreadParent={isThreadParentAt(slice.items, i)} isThreadChild={isThreadChildAt(slice.items, i)} isThreadLastChild={ diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index c39c94ecab..53813f8225 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -3,7 +3,7 @@ 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 {FeedDescriptor} from '#/state/queries/post-feed' +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' @@ -56,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) @@ -123,7 +136,8 @@ export const HomeScreen = withAuthRequired( key="1" testID="followingFeedPage" isPageFocused={selectedPage === 0} - feed="following" + feed="home" + feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} renderEndOfFeed={FollowingEndOfFeed} /> From 0dfbb92e21b4390f3a567405b349e1bfefe32ee8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 14:46:26 -0800 Subject: [PATCH 13/15] Correctly handle failed load more state --- src/view/com/posts/Feed.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 49a7ba969c..e7e1e3b559 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -147,7 +147,7 @@ export function Feed({ }, [refetch, track, setIsRefreshing, onHasNew]) const onEndReached = React.useCallback(async () => { - if (isFetching || !hasNextPage) return + if (isFetching || !hasNextPage || isError) return track('Feed:onEndReached') try { @@ -155,7 +155,7 @@ export function Feed({ } catch (err) { logger.error('Failed to load more posts', {error: err}) } - }, [isFetching, hasNextPage, fetchNextPage, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { refetch() From f6b63120768ac44b0b8b48854e3b5f8e0f8beded Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 14:55:07 -0800 Subject: [PATCH 14/15] Improve error and empty state behaviors --- src/view/com/posts/Feed.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index e7e1e3b559..82b3618603 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -84,7 +84,7 @@ export function Feed({ fetchNextPage, pollLatest, } = usePostFeedQuery(feed, feedParams, opts) - const isEmpty = isFetched && data?.pages[0]?.slices.length === 0 + const isEmpty = !isFetching && !data?.pages[0]?.slices.length const checkForNew = React.useCallback(async () => { if (!isFetched || isFetching || !onHasNew) { @@ -203,18 +203,20 @@ export function Feed({ ], ) + const shouldRenderEndOfFeed = + !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = React.useCallback( () => isFetchingNextPage ? ( - ) : !hasNextPage && !isEmpty && renderEndOfFeed ? ( + ) : shouldRenderEndOfFeed ? ( renderEndOfFeed() ) : ( ), - [isFetchingNextPage, hasNextPage, isEmpty, renderEndOfFeed], + [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], ) return ( From 426623fa3e0f218ef17826dace1330734a5ab8a1 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 10 Nov 2023 15:21:54 -0800 Subject: [PATCH 15/15] Clearer naming --- src/view/com/posts/FeedItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 9ddb6cbccd..c5a841e311 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -68,7 +68,7 @@ export function FeedItem({ } if (richText && moderation) { return ( -