Refactor feeds to use react-query (#1862)

* Update to react-query v5

* Introduce post-feed react query

* Add feed refresh behaviors

* Only fetch feeds of visible pages

* Implement polling for latest on feeds

* Add moderation filtering to slices

* Handle block errors

* Update feed error messages

* Remove old models

* Replace simple-feed option with disable-tuner option

* Add missing useMemo

* Implement the mergefeed and fixes to polling

* Correctly handle failed load more state

* Improve error and empty state behaviors

* Clearer naming
zio/stable
Paul Frazee 2023-11-10 15:34:25 -08:00 committed by GitHub
parent 51f04b9620
commit c8c308e31e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 904 additions and 1081 deletions

View File

@ -63,7 +63,7 @@
"@segment/analytics-react-native": "^2.10.1", "@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5", "@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0", "@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/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3", "@tiptap/extension-hard-break": "^2.0.3",

View File

@ -4,7 +4,7 @@ import {
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord, AppBskyEmbedRecord,
} from '@atproto/api' } from '@atproto/api'
import {FeedSourceInfo} from './feed/types' import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers' import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost 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) return this.items.find(item => '__source' in item && !!item.__source)
?.__source as FeedSourceInfo ?.__source as ReasonFeedSource
} }
containsUri(uri: string) { containsUri(uri: string) {
@ -116,6 +116,17 @@ export class FeedViewPostsSlice {
} }
} }
export class NoopFeedTuner {
reset() {}
tune(
feed: FeedViewPost[],
_tunerFns: FeedTunerFn[] = [],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner { export class FeedTuner {
seenUris: Set<string> = new Set() seenUris: Set<string> = new Set()

View File

@ -1,38 +1,37 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI { export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
constructor( constructor(
public rootStore: RootStoreModel, public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams, public params: GetAuthorFeed.QueryParams,
) {} ) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getAuthorFeed({ const res = await this.agent.getAuthorFeed({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
const res = await this.rootStore.agent.getAuthorFeed({ cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getAuthorFeed({
...this.params, ...this.params,
cursor: this.cursor, cursor,
limit, limit,
}) })
if (res.success) { if (res.success) {
this.cursor = res.data.cursor
return { return {
cursor: res.data.cursor, cursor: res.data.cursor,
feed: this._filter(res.data.feed), feed: this._filter(res.data.feed),

View File

@ -1,38 +1,37 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI { export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
constructor( constructor(
public rootStore: RootStoreModel, public agent: BskyAgent,
public params: GetCustomFeed.QueryParams, public params: GetCustomFeed.QueryParams,
) {} ) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({ const res = await this.agent.app.bsky.feed.getFeed({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
const res = await this.rootStore.agent.app.bsky.feed.getFeed({ cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getFeed({
...this.params, ...this.params,
cursor: this.cursor, cursor,
limit, limit,
}) })
if (res.success) { if (res.success) {
this.cursor = res.data.cursor
// NOTE // NOTE
// some custom feeds fail to enforce the pagination limit // some custom feeds fail to enforce the pagination limit
// so we manually truncate here // so we manually truncate here

View File

@ -1,30 +1,28 @@
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI { export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined constructor(public agent: BskyAgent) {}
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({ const res = await this.agent.getTimeline({
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
const res = await this.rootStore.agent.getTimeline({ cursor,
cursor: this.cursor, limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getTimeline({
cursor,
limit, limit,
}) })
if (res.success) { if (res.success) {
this.cursor = res.data.cursor
return { return {
cursor: res.data.cursor, cursor: res.data.cursor,
feed: res.data.feed, feed: res.data.feed,

View File

@ -1,38 +1,37 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes, AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI { export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
constructor( constructor(
public rootStore: RootStoreModel, public agent: BskyAgent,
public params: GetActorLikes.QueryParams, public params: GetActorLikes.QueryParams,
) {} ) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getActorLikes({ const res = await this.agent.getActorLikes({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
const res = await this.rootStore.agent.getActorLikes({ cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getActorLikes({
...this.params, ...this.params,
cursor: this.cursor, cursor,
limit, limit,
}) })
if (res.success) { if (res.success) {
this.cursor = res.data.cursor
return { return {
cursor: res.data.cursor, cursor: res.data.cursor,
feed: res.data.feed, feed: res.data.feed,

View File

@ -1,38 +1,37 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed, AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI { export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
constructor( constructor(
public rootStore: RootStoreModel, public agent: BskyAgent,
public params: GetListFeed.QueryParams, public params: GetListFeed.QueryParams,
) {} ) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ const res = await this.agent.app.bsky.feed.getListFeed({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params, ...this.params,
cursor: this.cursor, cursor,
limit, limit,
}) })
if (res.success) { if (res.success) {
this.cursor = res.data.cursor
return { return {
cursor: res.data.cursor, cursor: res.data.cursor,
feed: res.data.feed, feed: res.data.feed,

View File

@ -1,11 +1,12 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle' import shuffle from 'lodash.shuffle'
import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout' import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers' import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip' 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 REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0 itemCursor = 0
sampleCursor = 0 sampleCursor = 0
constructor(public rootStore: RootStoreModel) { constructor(
this.following = new MergeFeedSource_Following(this.rootStore) public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
} }
reset() { reset() {
this.following = new MergeFeedSource_Following(this.rootStore) this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0 this.feedCursor = 0
this.itemCursor = 0 this.itemCursor = 0
this.sampleCursor = 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<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({ const res = await this.agent.getTimeline({
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
} }
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { async fetch({
// we capture here to ensure the data has loaded cursor,
this._captureFeedsIfNeeded() limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
const promises = [] const promises = []
@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
} }
return { return {
cursor: posts.length ? 'fake' : undefined, cursor: posts.length ? String(this.itemCursor) : undefined,
feed: posts, feed: posts,
} }
} }
@ -107,28 +129,15 @@ export class MergeFeedAPI implements FeedAPI {
// provide follow // provide follow
return this.following.take(1) return this.following.take(1)
} }
_captureFeedsIfNeeded() {
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
return
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
this.rootStore.preferences.savedFeeds.map(
feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
),
)
}
}
} }
class MergeFeedSource { class MergeFeedSource {
sourceInfo: FeedSourceInfo | undefined sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = [] queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true hasMore = true
constructor(public rootStore: RootStoreModel) {} constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
get numReady() { get numReady() {
return this.queue.length return this.queue.length
@ -190,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined, cursor: string | undefined,
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
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 // run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune( const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
res.data.feed, dryRun: false,
this.rootStore.preferences.getFeedTuners('home'), maintainOrder: true,
{ })
dryRun: false,
maintainOrder: true,
},
)
res.data.feed = slices.map(slice => slice.rootItem) res.data.feed = slices.map(slice => slice.rootItem)
return res return res
} }
@ -208,14 +213,19 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date minDate: Date
constructor(public rootStore: RootStoreModel, public feedUri: string) { constructor(
super(rootStore) public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
this.sourceInfo = { this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '', displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri), uri: feedUriToHref(feedUri),
} }
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
this.rootStore.agent.app.bsky.feed this.agent.app.bsky.feed
.getFeedGenerator({ .getFeedGenerator({
feed: feedUri, feed: feedUri,
}) })
@ -234,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
try { try {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({ const res = await this.agent.app.bsky.feed.getFeed({
cursor, cursor,
limit, limit,
feed: this.feedUri, feed: this.feedUri,

View File

@ -6,12 +6,27 @@ export interface FeedAPIResponse {
} }
export interface FeedAPI { export interface FeedAPI {
reset(): void
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse>
} }
export interface FeedSourceInfo { export interface ReasonFeedSource {
$type: 'reasonFeedSource'
uri: string uri: string
displayName: string displayName: string
} }
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
return (
!!v &&
typeof v === 'object' &&
'$type' in v &&
v.$type === 'reasonFeedSource'
)
}

View File

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

View File

@ -1,429 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedGetTimeline as GetTimeline,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
AppBskyFeedGetFeed as GetCustomFeed,
AppBskyFeedGetActorLikes as GetActorLikes,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {FeedTuner} from 'lib/api/feed-manip'
import {PostsFeedSliceModel} from './posts-slice'
import {track} from 'lib/analytics/analytics'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {logger} from '#/logger'
const PAGE_SIZE = 30
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
export enum KnownError {
FeedgenDoesNotExist,
FeedgenMisconfigured,
FeedgenBadResponse,
FeedgenOffline,
FeedgenUnknown,
Unknown,
}
type Options = {
/**
* Formats the feed in a flat array with no threading of replies, just
* top-level posts.
*/
isSimpleFeed?: boolean
}
type QueryParams =
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
| GetListFeed.QueryParams
export class PostsFeedModel {
// state
isLoading = false
isRefreshing = false
hasNewLatest = false
hasLoaded = false
isBlocking = false
isBlockedBy = false
error = ''
knownError: KnownError | undefined
loadMoreError = ''
params: QueryParams
hasMore = true
pollCursor: string | undefined
api: FeedAPI
tuner = new FeedTuner()
pageSize = PAGE_SIZE
options: Options = {}
// used to linearize async modifications to state
lock = new AwaitLock()
// used to track if a feed is coming up empty
emptyFetches = 0
// data
slices: PostsFeedSliceModel[] = []
constructor(
public rootStore: RootStoreModel,
public feedType: FeedType,
params: QueryParams,
options?: Options,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
this.options = options || {}
if (feedType === 'home') {
this.api = new MergeFeedAPI(rootStore)
} else if (feedType === 'following') {
this.api = new FollowingFeedAPI(rootStore)
} else if (feedType === 'author') {
this.api = new AuthorFeedAPI(
rootStore,
params as GetAuthorFeed.QueryParams,
)
} else if (feedType === 'likes') {
this.api = new LikesFeedAPI(
rootStore,
params as GetActorLikes.QueryParams,
)
} else if (feedType === 'custom') {
this.api = new CustomFeedAPI(
rootStore,
params as GetCustomFeed.QueryParams,
)
} else if (feedType === 'list') {
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
} else {
this.api = new FollowingFeedAPI(rootStore)
}
}
get reactKey() {
if (this.feedType === 'author') {
return (this.params as GetAuthorFeed.QueryParams).actor
}
if (this.feedType === 'custom') {
return (this.params as GetCustomFeed.QueryParams).feed
}
if (this.feedType === 'list') {
return (this.params as GetListFeed.QueryParams).list
}
return this.feedType
}
get hasContent() {
return this.slices.length !== 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get isLoadingMore() {
return this.isLoading && !this.isRefreshing && this.hasContent
}
setHasNewLatest(v: boolean) {
this.hasNewLatest = v
}
// public api
// =
/**
* Nuke all data
*/
clear() {
logger.debug('FeedModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasNewLatest = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.pollCursor = undefined
this.slices = []
this.tuner.reset()
}
/**
* Load for first render
*/
setup = bundleAsync(async (isRefreshing: boolean = false) => {
logger.debug('FeedModel:setup', {isRefreshing})
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
this.api.reset()
this.tuner.reset()
this._xLoading(isRefreshing)
try {
const res = await this.api.fetchNext({limit: this.pageSize})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
} finally {
this.lock.release()
}
})
/**
* Register any event listeners. Returns a cleanup function.
*/
registerListeners() {
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
return () => sub.remove()
}
/**
* Reset and load
*/
async refresh() {
await this.setup(true)
}
/**
* Load more posts to the end of the feed
*/
loadMore = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.hasMore || this.hasError) {
return
}
this._xLoading()
try {
const res = await this.api.fetchNext({
limit: this.pageSize,
})
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(undefined, e)
runInAction(() => {
this.hasMore = false
})
}
} finally {
this.lock.release()
if (this.feedType === 'custom') {
track('CustomFeed:LoadMore')
}
}
})
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
/**
* Check if new posts are available
*/
async checkForLatest() {
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
return
}
const post = await this.api.peekLatest()
if (post) {
const slices = this.tuner.tune(
[post],
this.rootStore.preferences.getFeedTuners(this.feedType),
{
dryRun: true,
maintainOrder: true,
},
)
if (slices[0]) {
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
if (sliceModel.moderation.content.filter) {
return
}
this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
}
}
}
/**
* Updates the UI after the user has created a post
*/
onPostCreated() {
if (!this.slices.length) {
return this.refresh()
} else {
this.setHasNewLatest(true)
}
}
/**
* Removes posts from the feed upon deletion.
*/
onPostDeleted(uri: string) {
let i
do {
i = this.slices.findIndex(slice => slice.containsUri(uri))
if (i !== -1) {
this.slices.splice(i, 1)
}
} while (i !== -1)
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
this.knownError = undefined
}
_xIdle(error?: any, loadMoreError?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
this.error = cleanError(error)
this.knownError = detectKnownError(this.feedType, error)
this.loadMoreError = cleanError(loadMoreError)
if (error) {
logger.error('Posts feed request failed', {error})
}
if (loadMoreError) {
logger.error('Posts feed load-more request failed', {
error: loadMoreError,
})
}
}
// helper functions
// =
async _replaceAll(res: FeedAPIResponse) {
this.pollCursor = res.feed[0]?.post.uri
return this._appendAll(res, true)
}
async _appendAll(res: FeedAPIResponse, replace = false) {
this.hasMore = !!res.cursor && res.feed.length > 0
if (replace) {
this.emptyFetches = 0
}
this.rootStore.me.follows.hydrateMany(
res.feed.map(item => item.post.author),
)
for (const item of res.feed) {
this.rootStore.posts.fromFeedItem(item)
}
const slices = this.options.isSimpleFeed
? res.feed.map(item => new FeedViewPostsSlice([item]))
: this.tuner.tune(
res.feed,
this.rootStore.preferences.getFeedTuners(this.feedType),
)
const toAppend: PostsFeedSliceModel[] = []
for (const slice of slices) {
const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
const dupTest = (item: PostsFeedSliceModel) =>
item._reactKey === sliceModel._reactKey
// sanity check
// if a duplicate _reactKey passes through, the UI breaks hard
if (!replace) {
if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
continue
}
}
toAppend.push(sliceModel)
}
runInAction(() => {
if (replace) {
this.slices = toAppend
} else {
this.slices = this.slices.concat(toAppend)
}
if (toAppend.length === 0) {
this.emptyFetches++
if (this.emptyFetches >= 10) {
this.hasMore = false
}
}
})
}
}
function detectKnownError(
feedType: FeedType,
error: any,
): KnownError | undefined {
if (!error) {
return undefined
}
if (typeof error !== 'string') {
error = error.toString()
}
if (feedType !== 'custom') {
return KnownError.Unknown
}
if (error.includes('could not find feed')) {
return KnownError.FeedgenDoesNotExist
}
if (error.includes('feed unavailable')) {
return KnownError.FeedgenOffline
}
if (error.includes('invalid did document')) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('could not resolve did document')) {
return KnownError.FeedgenMisconfigured
}
if (
error.includes('invalid feed generator service details in did document')
) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('feed provided an invalid response')) {
return KnownError.FeedgenBadResponse
}
return KnownError.FeedgenUnknown
}

View File

@ -4,7 +4,6 @@ import {
ComAtprotoServerListAppPasswords, ComAtprotoServerListAppPasswords,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications' import {NotificationsFeedModel} from './feeds/notifications'
import {MyFeedsUIModel} from './ui/my-feeds' import {MyFeedsUIModel} from './ui/my-feeds'
import {MyFollowsCache} from './cache/my-follows' import {MyFollowsCache} from './cache/my-follows'
@ -22,7 +21,6 @@ export class MeModel {
avatar: string = '' avatar: string = ''
followsCount: number | undefined followsCount: number | undefined
followersCount: number | undefined followersCount: number | undefined
mainFeed: PostsFeedModel
notifications: NotificationsFeedModel notifications: NotificationsFeedModel
myFeeds: MyFeedsUIModel myFeeds: MyFeedsUIModel
follows: MyFollowsCache follows: MyFollowsCache
@ -41,16 +39,12 @@ export class MeModel {
{rootStore: false, serialize: false, hydrate: false}, {rootStore: false, serialize: false, hydrate: false},
{autoBind: true}, {autoBind: true},
) )
this.mainFeed = new PostsFeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsFeedModel(this.rootStore) this.notifications = new NotificationsFeedModel(this.rootStore)
this.myFeeds = new MyFeedsUIModel(this.rootStore) this.myFeeds = new MyFeedsUIModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore) this.follows = new MyFollowsCache(this.rootStore)
} }
clear() { clear() {
this.mainFeed.clear()
this.notifications.clear() this.notifications.clear()
this.myFeeds.clear() this.myFeeds.clear()
this.follows.clear() this.follows.clear()
@ -109,10 +103,6 @@ export class MeModel {
if (sess.hasSession) { if (sess.hasSession) {
this.did = sess.currentSession?.did || '' this.did = sess.currentSession?.did || ''
await this.fetchProfile() 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 => { /* dont await */ this.notifications.setup().catch(e => {
logger.error('Failed to setup notifications model', { logger.error('Failed to setup notifications model', {
error: e, error: e,

View File

@ -1,7 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile' import {ProfileModel} from '../content/profile'
import {PostsFeedModel} from '../feeds/posts'
import {ActorFeedsModel} from '../lists/actor-feeds' import {ActorFeedsModel} from '../lists/actor-feeds'
import {ListsListModel} from '../lists/lists-list' import {ListsListModel} from '../lists/lists-list'
import {logger} from '#/logger' import {logger} from '#/logger'

View File

@ -0,0 +1,48 @@
import {useMemo} from 'react'
import {FeedTuner} from '#/lib/api/feed-manip'
import {FeedDescriptor} from '../queries/post-feed'
import {useLanguagePrefs} from './languages'
export function useFeedTuners(feedDesc: FeedDescriptor) {
const langPrefs = useLanguagePrefs()
return useMemo(() => {
if (feedDesc.startsWith('feedgen')) {
return [
FeedTuner.dedupReposts,
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
]
}
if (feedDesc.startsWith('list')) {
return [FeedTuner.dedupReposts]
}
if (feedDesc === 'home' || feedDesc === 'following') {
const feedTuners = []
if (false /*TODOthis.homeFeed.hideReposts*/) {
feedTuners.push(FeedTuner.removeReposts)
} else {
feedTuners.push(FeedTuner.dedupReposts)
}
if (true /*TODOthis.homeFeed.hideReplies*/) {
feedTuners.push(FeedTuner.removeReplies)
} /* TODO else {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: this.homeFeed.hideRepliesByLikeCount,
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
}),
)
}*/
if (false /*TODOthis.homeFeed.hideQuotePosts*/) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}, [feedDesc, langPrefs])
}

View File

@ -0,0 +1,176 @@
import {useCallback, useMemo} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {useStores} from '../models/root-store'
type ActorDid = string
type AuthorFilter =
| 'posts_with_replies'
| 'posts_no_replies'
| 'posts_with_media'
type FeedUri = string
type ListUri = string
export type FeedDescriptor =
| 'home'
| 'following'
| `author|${ActorDid}|${AuthorFilter}`
| `feedgen|${FeedUri}`
| `likes|${ActorDid}`
| `list|${ListUri}`
export interface FeedParams {
disableTuner?: boolean
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
}
type RQPageParam = string | undefined
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return ['post-feed', feedDesc, params || {}]
}
export interface FeedPostSliceItem {
_reactKey: string
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
}
export interface FeedPostSlice {
_reactKey: string
rootUri: string
isThread: boolean
items: FeedPostSliceItem[]
}
export interface FeedPage {
cursor: string | undefined
slices: FeedPostSlice[]
}
export function usePostFeedQuery(
feedDesc: FeedDescriptor,
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const {agent} = useSession()
const feedTuners = useFeedTuners(feedDesc)
const store = useStores()
const enabled = opts?.enabled !== false
const api: FeedAPI = useMemo(() => {
if (feedDesc === 'home') {
return new MergeFeedAPI(agent, params || {}, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI(agent)
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI(agent, {actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI(agent, {actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI(agent, {feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI(agent, {list})
} else {
// shouldnt happen
return new FollowingFeedAPI(agent)
}
}, [feedDesc, params, feedTuners, agent])
const tuner = useMemo(
() => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()),
[params],
)
const pollLatest = useCallback(async () => {
if (!enabled) {
return false
}
console.log('poll')
const post = await api.peekLatest()
if (post) {
const slices = tuner.tune([post], feedTuners, {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
if (
!moderatePost(
slices[0].items[0].post,
store.preferences.moderationOpts,
).content.filter
) {
return true
}
}
}
return false
}, [api, tuner, feedTuners, store.preferences.moderationOpts, enabled])
const out = useInfiniteQuery<
FeedPage,
Error,
InfiniteData<FeedPage>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
console.log('fetch', feedDesc, pageParam)
if (!pageParam) {
tuner.reset()
}
const res = await api.fetch({cursor: pageParam, limit: 30})
const slices = tuner.tune(res.feed, feedTuners)
return {
cursor: res.cursor,
slices: slices.map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item => item.post.author.did === slice.items[0].post.author.did,
),
source: undefined, // TODO
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason: i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
}
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
})
return {...out, pollLatest}
}

View File

@ -57,17 +57,17 @@ export type ThreadNode =
export function usePostThreadQuery(uri: string | undefined) { export function usePostThreadQuery(uri: string | undefined) {
const {agent} = useSession() const {agent} = useSession()
return useQuery<ThreadNode, Error>( return useQuery<ThreadNode, Error>({
RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async () => { async queryFn() {
const res = await agent.getPostThread({uri: uri!}) const res = await agent.getPostThread({uri: uri!})
if (res.success) { if (res.success) {
return responseToThreadNodes(res.data.thread) return responseToThreadNodes(res.data.thread)
} }
return {type: 'unknown', uri: uri!} return {type: 'unknown', uri: uri!}
}, },
{enabled: !!uri}, enabled: !!uri,
) })
} }
export function sortThread( export function sortThread(

View File

@ -7,9 +7,9 @@ export const RQKEY = (postUri: string) => ['post', postUri]
export function usePostQuery(uri: string | undefined) { export function usePostQuery(uri: string | undefined) {
const {agent} = useSession() const {agent} = useSession()
return useQuery<AppBskyFeedDefs.PostView>( return useQuery<AppBskyFeedDefs.PostView>({
RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async () => { async queryFn() {
const res = await agent.getPosts({uris: [uri!]}) const res = await agent.getPosts({uris: [uri!]})
if (res.success && res.data.posts[0]) { if (res.success && res.data.posts[0]) {
return res.data.posts[0] return res.data.posts[0]
@ -17,10 +17,8 @@ export function usePostQuery(uri: string | undefined) {
throw new Error('No data') throw new Error('No data')
}, },
{ enabled: !!uri,
enabled: !!uri, })
},
)
} }
export function usePostLikeMutation() { export function usePostLikeMutation() {
@ -29,7 +27,8 @@ export function usePostLikeMutation() {
{uri: string}, // responds with the uri of the like {uri: string}, // responds with the uri of the like
Error, Error,
{uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes {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) { onMutate(variables) {
// optimistically update the post-shadow // optimistically update the post-shadow
updatePostShadow(variables.uri, { updatePostShadow(variables.uri, {
@ -59,27 +58,25 @@ export function usePostUnlikeMutation() {
void, void,
Error, Error,
{postUri: string; likeUri: string; likeCount: number} {postUri: string; likeUri: string; likeCount: number}
>( >({
async ({likeUri}) => { mutationFn: async ({likeUri}) => {
await agent.deleteLike(likeUri) await agent.deleteLike(likeUri)
}, },
{ onMutate(variables) {
onMutate(variables) { // optimistically update the post-shadow
// optimistically update the post-shadow updatePostShadow(variables.postUri, {
updatePostShadow(variables.postUri, { likeCount: variables.likeCount - 1,
likeCount: variables.likeCount - 1, likeUri: undefined,
likeUri: undefined, })
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount,
likeUri: variables.likeUri,
})
},
}, },
) onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount,
likeUri: variables.likeUri,
})
},
})
} }
export function usePostRepostMutation() { export function usePostRepostMutation() {
@ -88,7 +85,8 @@ export function usePostRepostMutation() {
{uri: string}, // responds with the uri of the repost {uri: string}, // responds with the uri of the repost
Error, Error,
{uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts {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) { onMutate(variables) {
// optimistically update the post-shadow // optimistically update the post-shadow
updatePostShadow(variables.uri, { updatePostShadow(variables.uri, {
@ -118,39 +116,35 @@ export function usePostUnrepostMutation() {
void, void,
Error, Error,
{postUri: string; repostUri: string; repostCount: number} {postUri: string; repostUri: string; repostCount: number}
>( >({
async ({repostUri}) => { mutationFn: async ({repostUri}) => {
await agent.deleteRepost(repostUri) await agent.deleteRepost(repostUri)
}, },
{ onMutate(variables) {
onMutate(variables) { // optimistically update the post-shadow
// optimistically update the post-shadow updatePostShadow(variables.postUri, {
updatePostShadow(variables.postUri, { repostCount: variables.repostCount - 1,
repostCount: variables.repostCount - 1, repostUri: undefined,
repostUri: undefined, })
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount,
repostUri: variables.repostUri,
})
},
}, },
) onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount,
repostUri: variables.repostUri,
})
},
})
} }
export function usePostDeleteMutation() { export function usePostDeleteMutation() {
const {agent} = useSession() const {agent} = useSession()
return useMutation<void, Error, {uri: string}>( return useMutation<void, Error, {uri: string}>({
async ({uri}) => { mutationFn: async ({uri}) => {
await agent.deletePost(uri) await agent.deletePost(uri)
}, },
{ onSuccess(data, variables) {
onSuccess(data, variables) { updatePostShadow(variables.uri, {isDeleted: true})
updatePostShadow(variables.uri, {isDeleted: true})
},
}, },
) })
} }

View File

@ -6,12 +6,15 @@ export const RQKEY = (uri: string) => ['resolved-uri', uri]
export function useResolveUriQuery(uri: string) { export function useResolveUriQuery(uri: string) {
const {agent} = useSession() const {agent} = useSession()
return useQuery<string | undefined, Error>(RQKEY(uri), async () => { return useQuery<string | undefined, Error>({
const urip = new AtUri(uri) queryKey: RQKEY(uri),
if (!urip.host.startsWith('did:')) { async queryFn() {
const res = await agent.resolveHandle({handle: urip.host}) const urip = new AtUri(uri)
urip.host = res.data.did if (!urip.host.startsWith('did:')) {
} const res = await agent.resolveHandle({handle: urip.host})
return urip.toString() urip.host = res.data.did
}
return urip.toString()
},
}) })
} }

View File

@ -245,7 +245,7 @@ export const ComposePost = observer(function ComposePost({
if (replyTo && replyTo.uri) track('Post:Reply') if (replyTo && replyTo.uri) track('Post:Reply')
} }
if (!replyTo) { if (!replyTo) {
store.me.mainFeed.onPostCreated() // TODO onPostCreated
} }
setLangPrefs.savePostLanguageToHistory() setLangPrefs.savePostLanguageToHistory()
onPost?.() onPost?.()

View File

@ -4,36 +4,38 @@ import {
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {useIsFocused} from '@react-navigation/native' import {useIsFocused} from '@react-navigation/native'
import {useAnalytics} from '@segment/analytics-react-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 {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {colors, s} from 'lib/styles' import {colors, s} from 'lib/styles'
import {observer} from 'mobx-react-lite'
import React from 'react' import React from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View, useWindowDimensions} from 'react-native'
import {useStores} from 'state/index' 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 {Feed} from '../posts/Feed'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {FAB} from '../util/fab/FAB' import {FAB} from '../util/fab/FAB'
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
import useAppState from 'react-native-appstate-hook'
import {logger} from '#/logger'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
export const FeedPage = observer(function FeedPageImpl({ const POLL_FREQ = 30e3 // 30sec
export function FeedPage({
testID, testID,
isPageFocused, isPageFocused,
feed, feed,
feedParams,
renderEmptyState, renderEmptyState,
renderEndOfFeed, renderEndOfFeed,
}: { }: {
testID?: string testID?: string
feed: PostsFeedModel feed: FeedDescriptor
feedParams?: FeedParams
isPageFocused: boolean isPageFocused: boolean
renderEmptyState: () => JSX.Element renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
@ -43,40 +45,13 @@ export const FeedPage = observer(function FeedPageImpl({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const headerOffset = useHeaderOffset() const headerOffset = useHeaderOffset()
const scrollElRef = React.useRef<FlatList>(null) const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
const hasNew = feed.hasNewLatest && !feed.isRefreshing const [hasNew, setHasNew] = React.useState(false)
React.useEffect(() => {
// called on first load
if (!feed.hasLoaded && isPageFocused) {
feed.setup()
}
}, [isPageFocused, feed])
const doPoll = React.useCallback(
(knownActive = false) => {
if (
(!knownActive && appState !== 'active') ||
!isScreenFocused ||
!isPageFocused
) {
return
}
if (feed.isLoading) {
return
}
logger.debug('HomeScreen: Polling for new posts')
feed.checkForLatest()
},
[appState, isScreenFocused, isPageFocused, feed],
)
const scrollToTop = React.useCallback(() => { const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerOffset}) scrollElRef.current?.scrollToOffset({offset: -headerOffset})
@ -86,31 +61,22 @@ export const FeedPage = observer(function FeedPageImpl({
const onSoftReset = React.useCallback(() => { const onSoftReset = React.useCallback(() => {
if (isPageFocused) { if (isPageFocused) {
scrollToTop() scrollToTop()
feed.refresh() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
setHasNew(false)
} }
}, [isPageFocused, scrollToTop, feed]) }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew])
// fires when page within screen is activated/deactivated // fires when page within screen is activated/deactivated
// - check for latest
React.useEffect(() => { React.useEffect(() => {
if (!isPageFocused || !isScreenFocused) { if (!isPageFocused || !isScreenFocused) {
return return
} }
const softResetSub = store.onScreenSoftReset(onSoftReset) const softResetSub = store.onScreenSoftReset(onSoftReset)
const feedCleanup = feed.registerListeners()
const pollInterval = setInterval(doPoll, POLL_FREQ)
screen('Feed') screen('Feed')
logger.debug('HomeScreen: Updating feed')
feed.checkForLatest()
return () => { return () => {
clearInterval(pollInterval)
softResetSub.remove() softResetSub.remove()
feedCleanup()
} }
}, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
const onPressCompose = React.useCallback(() => { const onPressCompose = React.useCallback(() => {
track('HomeScreen:PressCompose') track('HomeScreen:PressCompose')
@ -119,8 +85,9 @@ export const FeedPage = observer(function FeedPageImpl({
const onPressLoadLatest = React.useCallback(() => { const onPressLoadLatest = React.useCallback(() => {
scrollToTop() scrollToTop()
feed.refresh() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
}, [feed, scrollToTop]) setHasNew(false)
}, [scrollToTop, feed, queryClient, setHasNew])
const ListHeaderComponent = React.useCallback(() => { const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) { if (isDesktop) {
@ -191,8 +158,12 @@ export const FeedPage = observer(function FeedPageImpl({
<Feed <Feed
testID={testID ? `${testID}-feed` : undefined} testID={testID ? `${testID}-feed` : undefined}
feed={feed} feed={feed}
feedParams={feedParams}
enabled={isPageFocused}
pollInterval={POLL_FREQ}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onScroll={onMainScroll} onScroll={onMainScroll}
onHasNew={setHasNew}
scrollEventThrottle={1} scrollEventThrottle={1}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed} renderEndOfFeed={renderEndOfFeed}
@ -216,4 +187,18 @@ export const FeedPage = observer(function FeedPageImpl({
/> />
</View> </View>
) )
}) }
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
}

View File

@ -69,15 +69,11 @@ export function PostThreadItem({
const postShadowed = usePostShadow(post, dataUpdatedAt) const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo( const richText = useMemo(
() => () =>
post && new RichTextAPI({
AppBskyFeedPost.isRecord(post?.record) && text: record.text,
AppBskyFeedPost.validateRecord(post?.record).success facets: record.facets,
? new RichTextAPI({ }),
text: post.record.text, [record],
facets: post.record.facets,
})
: undefined,
[post],
) )
const moderation = useMemo( const moderation = useMemo(
() => () =>

View File

@ -1,5 +1,4 @@
import React, {MutableRefObject} from 'react' import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@ -12,7 +11,6 @@ import {
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {FeedErrorMessage} from './FeedErrorMessage' import {FeedErrorMessage} from './FeedErrorMessage'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice' import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
@ -21,17 +19,26 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {
FeedDescriptor,
FeedParams,
usePostFeedQuery,
} from '#/state/queries/post-feed'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const Feed = observer(function Feed({ export function Feed({
feed, feed,
feedParams,
style, style,
enabled,
pollInterval,
scrollElRef, scrollElRef,
onScroll, onScroll,
onHasNew,
scrollEventThrottle, scrollEventThrottle,
renderEmptyState, renderEmptyState,
renderEndOfFeed, renderEndOfFeed,
@ -41,9 +48,13 @@ export const Feed = observer(function Feed({
ListHeaderComponent, ListHeaderComponent,
extraData, extraData,
}: { }: {
feed: PostsFeedModel feed: FeedDescriptor
feedParams?: FeedParams
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
enabled?: boolean
pollInterval?: number
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onHasNew?: (v: boolean) => void
onScroll?: OnScrollHandler onScroll?: OnScrollHandler
scrollEventThrottle?: number scrollEventThrottle?: number
renderEmptyState: () => JSX.Element renderEmptyState: () => JSX.Element
@ -58,32 +69,68 @@ export const Feed = observer(function Feed({
const theme = useTheme() const theme = useTheme()
const {track} = useAnalytics() const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false) const [isRefreshing, setIsRefreshing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null)
const data = React.useMemo(() => { const opts = React.useMemo(() => ({enabled}), [enabled])
let feedItems: any[] = [] const {
if (feed.hasLoaded) { data,
if (feed.hasError) { dataUpdatedAt,
feedItems = feedItems.concat([ERROR_ITEM]) isFetching,
isFetched,
isError,
error,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
pollLatest,
} = usePostFeedQuery(feed, feedParams, opts)
const isEmpty = !isFetching && !data?.pages[0]?.slices.length
const checkForNew = React.useCallback(async () => {
if (!isFetched || isFetching || !onHasNew) {
return
}
try {
if (await pollLatest()) {
onHasNew(true)
} }
if (feed.isEmpty) { } catch (e) {
feedItems = feedItems.concat([EMPTY_FEED_ITEM]) logger.error('Poll latest failed', {feed, error: String(e)})
} else { }
feedItems = feedItems.concat(feed.slices) }, [feed, isFetched, isFetching, pollLatest, onHasNew])
React.useEffect(() => {
// we store the interval handler in a ref to avoid needless
// reassignments of the interval
checkForNewRef.current = checkForNew
}, [checkForNew])
React.useEffect(() => {
const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
return () => clearInterval(i)
}, [pollInterval])
const feedItems = React.useMemo(() => {
let arr: any[] = []
if (isFetched) {
if (isError && isEmpty) {
arr = arr.concat([ERROR_ITEM])
} }
if (feed.loadMoreError) { if (isEmpty) {
feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) arr = arr.concat([EMPTY_FEED_ITEM])
} else if (data) {
for (const page of data?.pages) {
arr = arr.concat(page.slices)
}
}
if (isError && !isEmpty) {
arr = arr.concat([LOAD_MORE_ERROR_ITEM])
} }
} else { } else {
feedItems.push(LOADING_ITEM) arr.push(LOADING_ITEM)
} }
return feedItems return arr
}, [ }, [isFetched, isError, isEmpty, data])
feed.hasError,
feed.hasLoaded,
feed.isEmpty,
feed.slices,
feed.loadMoreError,
])
// events // events
// = // =
@ -92,31 +139,33 @@ export const Feed = observer(function Feed({
track('Feed:onRefresh') track('Feed:onRefresh')
setIsRefreshing(true) setIsRefreshing(true)
try { try {
await feed.refresh() await refetch()
onHasNew?.(false)
} catch (err) { } catch (err) {
logger.error('Failed to refresh posts feed', {error: err}) logger.error('Failed to refresh posts feed', {error: err})
} }
setIsRefreshing(false) setIsRefreshing(false)
}, [feed, track, setIsRefreshing]) }, [refetch, track, setIsRefreshing, onHasNew])
const onEndReached = React.useCallback(async () => { const onEndReached = React.useCallback(async () => {
if (!feed.hasLoaded || !feed.hasMore) return if (isFetching || !hasNextPage || isError) return
track('Feed:onEndReached') track('Feed:onEndReached')
try { try {
await feed.loadMore() await fetchNextPage()
} catch (err) { } catch (err) {
logger.error('Failed to load more posts', {error: err}) logger.error('Failed to load more posts', {error: err})
} }
}, [feed, track]) }, [isFetching, hasNextPage, isError, fetchNextPage, track])
const onPressTryAgain = React.useCallback(() => { const onPressTryAgain = React.useCallback(() => {
feed.refresh() refetch()
}, [feed]) onHasNew?.(false)
}, [refetch, onHasNew])
const onPressRetryLoadMore = React.useCallback(() => { const onPressRetryLoadMore = React.useCallback(() => {
feed.retryLoadMore() fetchNextPage()
}, [feed]) }, [fetchNextPage])
// rendering // rendering
// = // =
@ -127,7 +176,11 @@ export const Feed = observer(function Feed({
return renderEmptyState() return renderEmptyState()
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
return ( return (
<FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} /> <FeedErrorMessage
feedDesc={feed}
error={error}
onPressTryAgain={onPressTryAgain}
/>
) )
} else if (item === LOAD_MORE_ERROR_ITEM) { } else if (item === LOAD_MORE_ERROR_ITEM) {
return ( return (
@ -139,23 +192,32 @@ export const Feed = observer(function Feed({
} else if (item === LOADING_ITEM) { } else if (item === LOADING_ITEM) {
return <PostFeedLoadingPlaceholder /> return <PostFeedLoadingPlaceholder />
} }
return <FeedSlice slice={item} /> return <FeedSlice slice={item} dataUpdatedAt={dataUpdatedAt} />
}, },
[feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], [
feed,
dataUpdatedAt,
error,
onPressTryAgain,
onPressRetryLoadMore,
renderEmptyState,
],
) )
const shouldRenderEndOfFeed =
!hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
const FeedFooter = React.useCallback( const FeedFooter = React.useCallback(
() => () =>
feed.isLoadingMore ? ( isFetchingNextPage ? (
<View style={styles.feedFooter}> <View style={styles.feedFooter}>
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( ) : shouldRenderEndOfFeed ? (
renderEndOfFeed() renderEndOfFeed()
) : ( ) : (
<View /> <View />
), ),
[feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed],
) )
const scrollHandler = useAnimatedScrollHandler(onScroll || {}) const scrollHandler = useAnimatedScrollHandler(onScroll || {})
@ -164,7 +226,7 @@ export const Feed = observer(function Feed({
<FlatList <FlatList
testID={testID ? `${testID}-flatlist` : undefined} testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef} ref={scrollElRef}
data={data} data={feedItems}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
ListFooterComponent={FeedFooter} ListFooterComponent={FeedFooter}
@ -197,7 +259,7 @@ export const Feed = observer(function Feed({
/> />
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
feedFooter: {paddingTop: 20}, feedFooter: {paddingTop: 20},

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
import {PostsFeedModel, KnownError} from 'state/models/feeds/posts'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -12,9 +11,22 @@ import {NavigationProp} from 'lib/routes/types'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals' 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 = { const MESSAGES = {
[KnownError.Unknown]: '', [KnownError.Unknown]: '',
[KnownError.Block]: '',
[KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`, [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`,
[KnownError.FeedgenMisconfigured]: [KnownError.FeedgenMisconfigured]:
'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.', 'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.',
@ -27,36 +39,51 @@ const MESSAGES = {
} }
export function FeedErrorMessage({ export function FeedErrorMessage({
feed, feedDesc,
error,
onPressTryAgain, onPressTryAgain,
}: { }: {
feed: PostsFeedModel feedDesc: FeedDescriptor
error: any
onPressTryAgain: () => void onPressTryAgain: () => void
}) { }) {
const knownError = React.useMemo(
() => detectKnownError(feedDesc, error),
[feedDesc, error],
)
if ( if (
typeof feed.knownError === 'undefined' || typeof knownError !== 'undefined' &&
feed.knownError === KnownError.Unknown knownError !== KnownError.Unknown &&
feedDesc.startsWith('feedgen')
) { ) {
return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} />
}
if (knownError === KnownError.Block) {
return ( return (
<ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> <EmptyState
icon="ban"
message="Posts hidden"
style={{paddingVertical: 40}}
/>
) )
} }
return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} /> return <ErrorMessage message={error} onPressTryAgain={onPressTryAgain} />
} }
function FeedgenErrorMessage({ function FeedgenErrorMessage({
feed, feedDesc,
knownError, knownError,
}: { }: {
feed: PostsFeedModel feedDesc: FeedDescriptor
knownError: KnownError knownError: KnownError
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError] const msg = MESSAGES[knownError]
const uri = (feed.params as GetCustomFeed.QueryParams).feed const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri) const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
@ -120,3 +147,45 @@ function safeParseFeedgenUri(uri: string): [string, string] {
return ['', ''] return ['', '']
} }
} }
function detectKnownError(
feedDesc: FeedDescriptor,
error: any,
): KnownError | undefined {
if (!error) {
return undefined
}
if (
error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError ||
error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError
) {
return KnownError.Block
}
if (typeof error !== 'string') {
error = error.toString()
}
if (!feedDesc.startsWith('feedgen')) {
return KnownError.Unknown
}
if (error.includes('could not find feed')) {
return KnownError.FeedgenDoesNotExist
}
if (error.includes('feed unavailable')) {
return KnownError.FeedgenOffline
}
if (error.includes('invalid did document')) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('could not resolve did document')) {
return KnownError.FeedgenMisconfigured
}
if (
error.includes('invalid feed generator service details in did document')
) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('feed provided an invalid response')) {
return KnownError.FeedgenBadResponse
}
return KnownError.FeedgenUnknown
}

View File

@ -1,25 +1,27 @@
import React, {useMemo, useState} from 'react' import React, {useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native'
import {Linking, StyleSheet, View} from 'react-native' import {
import Clipboard from '@react-native-clipboard/clipboard' AppBskyFeedDefs,
import {AtUri} from '@atproto/api' AppBskyFeedPost,
AtUri,
PostModeration,
RichText as RichTextAPI,
} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {PostsFeedItemModel} from 'state/models/feeds/post' import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types'
import {FeedSourceInfo} from 'lib/api/feed/types'
import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link' import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' 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 {PostEmbeds} from '../util/post-embeds'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts' import {PostAlerts} from '../util/moderation/PostAlerts'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {PostSandboxWarning} from '../util/PostSandboxWarning' import {PostSandboxWarning} from '../util/PostSandboxWarning'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -27,47 +29,91 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {getTranslatorLink} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {isEmbedByEmbedder} from 'lib/embeds' import {isEmbedByEmbedder} from 'lib/embeds'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {countLines} from 'lib/strings/helpers' import {countLines} from 'lib/strings/helpers'
import {logger} from '#/logger' import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
export const FeedItem = observer(function FeedItemImpl({ export function FeedItem({
item, post,
source, record,
reason,
moderation,
dataUpdatedAt,
isThreadChild, isThreadChild,
isThreadLastChild, isThreadLastChild,
isThreadParent, isThreadParent,
}: { }: {
item: PostsFeedItemModel post: AppBskyFeedDefs.PostView
source?: FeedSourceInfo record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
moderation: PostModeration
dataUpdatedAt: number
isThreadChild?: boolean
isThreadLastChild?: boolean
isThreadParent?: boolean
}) {
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
if (postShadowed === POST_TOMBSTONE) {
return null
}
if (richText && moderation) {
return (
<FeedItemInner
post={postShadowed}
record={record}
reason={reason}
richText={richText}
moderation={moderation}
isThreadChild={isThreadChild}
isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent}
/>
)
}
return null
}
function FeedItemInner({
post,
record,
reason,
richText,
moderation,
isThreadChild,
isThreadLastChild,
isThreadParent,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
richText: RichTextAPI
moderation: PostModeration
isThreadChild?: boolean isThreadChild?: boolean
isThreadLastChild?: boolean isThreadLastChild?: boolean
isThreadParent?: boolean isThreadParent?: boolean
showReplyLine?: boolean
}) { }) {
const store = useStores() const store = useStores()
const langPrefs = useLanguagePrefs()
const pal = usePalette('default') const pal = usePalette('default')
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const {track} = useAnalytics() const {track} = useAnalytics()
const [deleted, setDeleted] = useState(false)
const [limitLines, setLimitLines] = useState( 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 href = useMemo(() => {
const itemCid = item.post.cid const urip = new AtUri(post.uri)
const itemHref = useMemo(() => { return makeProfileLink(post.author, 'post', urip.rkey)
const urip = new AtUri(item.post.uri) }, [post.uri, post.author])
return makeProfileLink(item.post.author, 'post', urip.rkey)
}, [item.post.uri, item.post.author])
const itemTitle = `Post by ${item.post.author.handle}`
const replyAuthorDid = useMemo(() => { const replyAuthorDid = useMemo(() => {
if (!record?.reply) { if (!record?.reply) {
return '' return ''
@ -75,77 +121,22 @@ export const FeedItem = observer(function FeedItemImpl({
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
return urip.hostname return urip.hostname
}, [record?.reply]) }, [record?.reply])
const translatorUrl = getTranslatorLink(
record?.text || '',
langPrefs.primaryLanguage,
)
const onPressReply = React.useCallback(() => { const onPressReply = React.useCallback(() => {
track('FeedItem:PostReply') track('FeedItem:PostReply')
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: item.post.uri, uri: post.uri,
cid: item.post.cid, cid: post.cid,
text: record?.text || '', text: record.text || '',
author: { author: {
handle: item.post.author.handle, handle: post.author.handle,
displayName: item.post.author.displayName, displayName: post.author.displayName,
avatar: item.post.author.avatar, avatar: post.author.avatar,
}, },
}, },
}) })
}, [item, track, record, store]) }, [post, record, track, 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])
const onPressShowMore = React.useCallback(() => { const onPressShowMore = React.useCallback(() => {
setLimitLines(false) setLimitLines(false)
@ -164,15 +155,11 @@ export const FeedItem = observer(function FeedItemImpl({
isThreadChild ? styles.outerSmallTop : undefined, isThreadChild ? styles.outerSmallTop : undefined,
] ]
if (!record || deleted) {
return <View />
}
return ( return (
<Link <Link
testID={`feedItem-by-${item.post.author.handle}`} testID={`feedItem-by-${post.author.handle}`}
style={outerStyles} style={outerStyles}
href={itemHref} href={href}
noFeedback noFeedback
accessible={false}> accessible={false}>
<PostSandboxWarning /> <PostSandboxWarning />
@ -194,10 +181,10 @@ export const FeedItem = observer(function FeedItemImpl({
</View> </View>
<View style={{paddingTop: 12, flexShrink: 1}}> <View style={{paddingTop: 12, flexShrink: 1}}>
{source ? ( {isReasonFeedSource(reason) ? (
<Link <Link
title={sanitizeDisplayName(source.displayName)} title={sanitizeDisplayName(reason.displayName)}
href={source.uri}> href={reason.uri}>
<Text <Text
type="sm-bold" type="sm-bold"
style={pal.textLight} style={pal.textLight}
@ -209,17 +196,17 @@ export const FeedItem = observer(function FeedItemImpl({
style={pal.textLight} style={pal.textLight}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1} numberOfLines={1}
text={sanitizeDisplayName(source.displayName)} text={sanitizeDisplayName(reason.displayName)}
href={source.uri} href={reason.uri}
/> />
</Text> </Text>
</Link> </Link>
) : item.reasonRepost ? ( ) : AppBskyFeedDefs.isReasonRepost(reason) ? (
<Link <Link
style={styles.includeReason} style={styles.includeReason}
href={makeProfileLink(item.reasonRepost.by)} href={makeProfileLink(reason.by)}
title={`Reposted by ${sanitizeDisplayName( title={`Reposted by ${sanitizeDisplayName(
item.reasonRepost.by.displayName || item.reasonRepost.by.handle, reason.by.displayName || reason.by.handle,
)}`}> )}`}>
<FontAwesomeIcon <FontAwesomeIcon
icon="retweet" icon="retweet"
@ -241,10 +228,9 @@ export const FeedItem = observer(function FeedItemImpl({
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1} numberOfLines={1}
text={sanitizeDisplayName( text={sanitizeDisplayName(
item.reasonRepost.by.displayName || reason.by.displayName || sanitizeHandle(reason.by.handle),
sanitizeHandle(item.reasonRepost.by.handle),
)} )}
href={makeProfileLink(item.reasonRepost.by)} href={makeProfileLink(reason.by)}
/> />
</Text> </Text>
</Link> </Link>
@ -256,10 +242,10 @@ export const FeedItem = observer(function FeedItemImpl({
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={52} size={52}
did={item.post.author.did} did={post.author.did}
handle={item.post.author.handle} handle={post.author.handle}
avatar={item.post.author.avatar} avatar={post.author.avatar}
moderation={item.moderation.avatar} moderation={moderation.avatar}
/> />
{isThreadParent && ( {isThreadParent && (
<View <View
@ -276,10 +262,10 @@ export const FeedItem = observer(function FeedItemImpl({
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<PostMeta <PostMeta
author={item.post.author} author={post.author}
authorHasWarning={!!item.post.author.labels?.length} authorHasWarning={!!post.author.labels?.length}
timestamp={item.post.indexedAt} timestamp={post.indexedAt}
postHref={itemHref} postHref={href}
/> />
{!isThreadChild && replyAuthorDid !== '' && ( {!isThreadChild && replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@ -308,19 +294,16 @@ export const FeedItem = observer(function FeedItemImpl({
)} )}
<ContentHider <ContentHider
testID="contentHider-post" testID="contentHider-post"
moderation={item.moderation.content} moderation={moderation.content}
ignoreMute ignoreMute
childContainerStyle={styles.contentHiderChild}> childContainerStyle={styles.contentHiderChild}>
<PostAlerts <PostAlerts moderation={moderation.content} style={styles.alert} />
moderation={item.moderation.content} {richText.text ? (
style={styles.alert}
/>
{item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
testID="postText" testID="postText"
type="post-text" type="post-text"
richText={item.richText} richText={richText}
lineHeight={1.3} lineHeight={1.3}
numberOfLines={limitLines ? MAX_POST_LINES : undefined} numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={s.flex1} style={s.flex1}
@ -335,50 +318,22 @@ export const FeedItem = observer(function FeedItemImpl({
href="#" href="#"
/> />
) : undefined} ) : undefined}
{item.post.embed ? ( {post.embed ? (
<ContentHider <ContentHider
testID="contentHider-embed" testID="contentHider-embed"
moderation={item.moderation.embed} moderation={moderation.embed}
ignoreMute={isEmbedByEmbedder( ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
item.post.embed,
item.post.author.did,
)}
style={styles.embed}> style={styles.embed}>
<PostEmbeds <PostEmbeds embed={post.embed} moderation={moderation.embed} />
embed={item.post.embed}
moderation={item.moderation.embed}
/>
</ContentHider> </ContentHider>
) : null} ) : null}
</ContentHider> </ContentHider>
<PostCtrls <PostCtrls post={post} record={record} onPressReply={onPressReply} />
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
author={item.post.author}
text={item.richText?.text || record.text}
indexedAt={item.post.indexedAt}
isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount}
repostCount={item.post.repostCount}
likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
isThreadMuted={mutedThreads.includes(item.rootUri)}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/>
</View> </View>
</View> </View>
</Link> </Link>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {

View File

@ -1,24 +1,40 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' 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 {AtUri, moderatePost} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import Svg, {Circle, Line} from 'react-native-svg' import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {useStores} from '#/state'
export const FeedSlice = observer(function FeedSliceImpl({ export const FeedSlice = observer(function FeedSliceImpl({
slice, slice,
dataUpdatedAt,
ignoreFilterFor, ignoreFilterFor,
}: { }: {
slice: PostsFeedSliceModel slice: FeedPostSlice
dataUpdatedAt: number
ignoreFilterFor?: string ignoreFilterFor?: string
}) { }) {
if (slice.shouldFilter(ignoreFilterFor)) { const store = useStores()
return null 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) { if (slice.isThread && slice.items.length > 3) {
@ -27,23 +43,34 @@ export const FeedSlice = observer(function FeedSliceImpl({
<> <>
<FeedItem <FeedItem
key={slice.items[0]._reactKey} key={slice.items[0]._reactKey}
item={slice.items[0]} post={slice.items[0].post}
source={slice.source} record={slice.items[0].record}
isThreadParent={slice.isThreadParentAt(0)} reason={slice.items[0].reason}
isThreadChild={slice.isThreadChildAt(0)} moderation={moderations[0]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
/> />
<FeedItem <FeedItem
key={slice.items[1]._reactKey} key={slice.items[1]._reactKey}
item={slice.items[1]} post={slice.items[1].post}
isThreadParent={slice.isThreadParentAt(1)} record={slice.items[1].record}
isThreadChild={slice.isThreadChildAt(1)} reason={slice.items[1].reason}
moderation={moderations[1]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)}
/> />
<ViewFullThread slice={slice} /> <ViewFullThread slice={slice} />
<FeedItem <FeedItem
key={slice.items[last]._reactKey} key={slice.items[last]._reactKey}
item={slice.items[last]} post={slice.items[last].post}
isThreadParent={slice.isThreadParentAt(last)} record={slice.items[last].record}
isThreadChild={slice.isThreadChildAt(last)} reason={slice.items[last].reason}
moderation={moderations[last]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)}
isThreadLastChild isThreadLastChild
/> />
</> </>
@ -55,12 +82,15 @@ export const FeedSlice = observer(function FeedSliceImpl({
{slice.items.map((item, i) => ( {slice.items.map((item, i) => (
<FeedItem <FeedItem
key={item._reactKey} key={item._reactKey}
item={item} post={slice.items[i].post}
source={i === 0 ? slice.source : undefined} record={slice.items[i].record}
isThreadParent={slice.isThreadParentAt(i)} reason={slice.items[i].reason}
isThreadChild={slice.isThreadChildAt(i)} moderation={moderations[i]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, i)}
isThreadChild={isThreadChildAt(slice.items, i)}
isThreadLastChild={ isThreadLastChild={
slice.isThreadChildAt(i) && slice.items.length === i + 1 isThreadChildAt(slice.items, i) && slice.items.length === i + 1
} }
/> />
))} ))}
@ -68,12 +98,12 @@ export const FeedSlice = observer(function FeedSliceImpl({
) )
}) })
function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { function ViewFullThread({slice}: {slice: FeedPostSlice}) {
const pal = usePalette('default') const pal = usePalette('default')
const itemHref = React.useMemo(() => { const itemHref = React.useMemo(() => {
const urip = new AtUri(slice.rootItem.post.uri) const urip = new AtUri(slice.rootUri)
return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
}, [slice.rootItem.post.uri, slice.rootItem.post.author]) }, [slice.rootUri])
return ( return (
<Link <Link
@ -115,3 +145,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
}) })
function isThreadParentAt<T>(arr: Array<T>, i: number) {
if (arr.length === 1) {
return false
}
return i < arr.length - 1
}
function isThreadChildAt<T>(arr: Array<T>, i: number) {
if (arr.length === 1) {
return false
}
return i > 0
}

View File

@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {navigate} from '../../../Navigation' import {navigate} from '../../../Navigation'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useQueryClient} from '@tanstack/react-query'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
/** /**
@ -15,6 +16,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() { export function TestCtrls() {
const store = useStores() const store = useStores()
const queryClient = useQueryClient()
const {logout, login} = useSessionApi() const {logout, login} = useSessionApi()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onPressSignInAlice = async () => { const onPressSignInAlice = async () => {
@ -83,7 +85,7 @@ export function TestCtrls() {
/> />
<Pressable <Pressable
testID="e2eRefreshHome" testID="e2eRefreshHome"
onPress={() => store.me.mainFeed.refresh()} onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})}
accessibilityRole="button" accessibilityRole="button"
style={BTN} style={BTN}
/> />

View File

@ -1,10 +1,9 @@
import React from 'react' import React from 'react'
import {useWindowDimensions} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
@ -12,12 +11,9 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {FeedsTabBar} from '../com/pager/FeedsTabBar'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedPage} from 'view/com/feeds/FeedPage' import {FeedPage} from 'view/com/feeds/FeedPage'
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
export const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired( export const HomeScreen = withAuthRequired(
observer(function HomeScreenImpl({}: Props) { observer(function HomeScreenImpl({}: Props) {
@ -26,7 +22,7 @@ export const HomeScreen = withAuthRequired(
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const pagerRef = React.useRef<PagerRef>(null) const pagerRef = React.useRef<PagerRef>(null)
const [selectedPage, setSelectedPage] = React.useState(0) const [selectedPage, setSelectedPage] = React.useState(0)
const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([]) const [customFeeds, setCustomFeeds] = React.useState<FeedDescriptor[]>([])
const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState< const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState<
string[] string[]
>([]) >([])
@ -39,14 +35,12 @@ export const HomeScreen = withAuthRequired(
return return
} }
const feeds = [] const feeds: FeedDescriptor[] = []
for (const uri of pinned) { for (const uri of pinned) {
if (uri.includes('app.bsky.feed.generator')) { if (uri.includes('app.bsky.feed.generator')) {
const model = new PostsFeedModel(store, 'custom', {feed: uri}) feeds.push(`feedgen|${uri}`)
feeds.push(model)
} else if (uri.includes('app.bsky.graph.list')) { } else if (uri.includes('app.bsky.graph.list')) {
const model = new PostsFeedModel(store, 'list', {list: uri}) feeds.push(`list|${uri}`)
feeds.push(model)
} }
} }
pagerRef.current?.setPage(0) pagerRef.current?.setPage(0)
@ -62,6 +56,19 @@ export const HomeScreen = withAuthRequired(
setRequestedCustomFeeds, setRequestedCustomFeeds,
]) ])
const homeFeedParams = React.useMemo<FeedParams>(() => {
if (!store.preferences.homeFeed.lab_mergeFeedEnabled) {
return {}
}
return {
mergeFeedEnabled: true,
mergeFeedSources: store.preferences.savedFeeds,
}
}, [
store.preferences.homeFeed.lab_mergeFeedEnabled,
store.preferences.savedFeeds,
])
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
setMinimalShellMode(false) setMinimalShellMode(false)
@ -129,14 +136,15 @@ export const HomeScreen = withAuthRequired(
key="1" key="1"
testID="followingFeedPage" testID="followingFeedPage"
isPageFocused={selectedPage === 0} isPageFocused={selectedPage === 0}
feed={store.me.mainFeed} feed="home"
feedParams={homeFeedParams}
renderEmptyState={renderFollowingEmptyState} renderEmptyState={renderFollowingEmptyState}
renderEndOfFeed={FollowingEndOfFeed} renderEndOfFeed={FollowingEndOfFeed}
/> />
{customFeeds.map((f, index) => { {customFeeds.map((f, index) => {
return ( return (
<FeedPage <FeedPage
key={f.reactKey} key={f}
testID="customFeedPage" testID="customFeedPage"
isPageFocused={selectedPage === 1 + index} isPageFocused={selectedPage === 1 + index}
feed={f} feed={f}
@ -148,17 +156,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
}

View File

@ -9,7 +9,6 @@ import {CenteredView} from '../com/util/Views'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {ProfileUiModel, Sections} from 'state/models/ui/profile'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
import {ProfileHeader} from '../com/profile/ProfileHeader' import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedSlice} from '../com/posts/FeedSlice' import {FeedSlice} from '../com/posts/FeedSlice'
import {ListCard} from 'view/com/lists/ListCard' import {ListCard} from 'view/com/lists/ListCard'

View File

@ -8,6 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons' import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -17,7 +18,7 @@ import {colors, s} from 'lib/styles'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {FeedSourceModel} from 'state/models/content/feed-source' import {FeedSourceModel} from 'state/models/content/feed-source'
import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedDescriptor} from '#/state/queries/post-feed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
@ -32,6 +33,7 @@ import {EmptyState} from 'view/com/util/EmptyState'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useCustomFeed} from 'lib/hooks/useCustomFeed' import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
@ -154,13 +156,6 @@ export const ProfileFeedScreenInner = observer(
[rkey, feedOwnerDid], [rkey, feedOwnerDid],
) )
const feedInfo = useCustomFeed(uri) const feedInfo = useCustomFeed(uri)
const feed: PostsFeedModel = useMemo(() => {
const model = new PostsFeedModel(store, 'custom', {
feed: uri,
})
model.setup()
return model
}, [store, uri])
const isPinned = store.preferences.isPinnedFeed(uri) const isPinned = store.preferences.isPinnedFeed(uri)
useSetTitle(feedInfo?.displayName) useSetTitle(feedInfo?.displayName)
@ -352,7 +347,7 @@ export const ProfileFeedScreenInner = observer(
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection <FeedSection
ref={feedSectionRef} ref={feedSectionRef}
feed={feed} feed={`feedgen|${uri}`}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
isScrolledDown={isScrolledDown} isScrolledDown={isScrolledDown}
@ -395,7 +390,7 @@ export const ProfileFeedScreenInner = observer(
) )
interface FeedSectionProps { interface FeedSectionProps {
feed: PostsFeedModel feed: FeedDescriptor
onScroll: OnScrollHandler onScroll: OnScrollHandler
headerHeight: number headerHeight: number
isScrolledDown: boolean isScrolledDown: boolean
@ -406,12 +401,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
{feed, onScroll, headerHeight, isScrolledDown, scrollElRef}, {feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
ref, ref,
) { ) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing const [hasNew, setHasNew] = React.useState(false)
const queryClient = useQueryClient()
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight}) scrollElRef.current?.scrollToOffset({offset: -headerHeight})
feed.refresh() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
}, [feed, scrollElRef, headerHeight]) setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop, scrollToTop: onScrollToTop,
@ -425,7 +422,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
<View> <View>
<Feed <Feed
feed={feed} feed={feed}
pollInterval={30e3}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={5} scrollEventThrottle={5}
renderEmptyState={renderPostsEmpty} renderEmptyState={renderPostsEmpty}

View File

@ -12,6 +12,7 @@ import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {RichText as RichTextAPI} from '@atproto/api' import {RichText as RichTextAPI} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
@ -28,11 +29,12 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB' import {FAB} from 'view/com/util/fab/FAB'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {ListModel} from 'state/models/content/list' 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 {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
@ -109,34 +111,25 @@ export const ProfileListScreenInner = observer(
}: Props & {listOwnerDid: string}) { }: Props & {listOwnerDid: string}) {
const store = useStores() const store = useStores()
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {rkey} = route.params const {rkey} = route.params
const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`
const feedSectionRef = React.useRef<SectionRef>(null) const feedSectionRef = React.useRef<SectionRef>(null)
const aboutSectionRef = React.useRef<SectionRef>(null) const aboutSectionRef = React.useRef<SectionRef>(null)
const {openModal} = useModalControls() const {openModal} = useModalControls()
const list: ListModel = useMemo(() => { const list: ListModel = useMemo(() => {
const model = new ListModel( const model = new ListModel(store, listUri)
store,
`at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
)
return model return model
}, [store, listOwnerDid, rkey]) }, [store, listUri])
const feed = useMemo(
() => new PostsFeedModel(store, 'list', {list: list.uri}),
[store, list],
)
useSetTitle(list.data?.name) useSetTitle(list.data?.name)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
setMinimalShellMode(false) setMinimalShellMode(false)
list.loadMore(true).then(() => { list.loadMore(true)
if (list.isCuratelist) { }, [setMinimalShellMode, list]),
feed.setup()
}
})
}, [setMinimalShellMode, list, feed]),
) )
const onPressAddUser = useCallback(() => { const onPressAddUser = useCallback(() => {
@ -145,11 +138,13 @@ export const ProfileListScreenInner = observer(
list, list,
onAdd() { onAdd() {
if (list.isCuratelist) { if (list.isCuratelist) {
feed.refresh() queryClient.invalidateQueries({
queryKey: FEED_RQKEY(`list|${listUri}`),
})
} }
}, },
}) })
}, [openModal, list, feed]) }, [openModal, list, queryClient, listUri])
const onCurrentPageSelected = React.useCallback( const onCurrentPageSelected = React.useCallback(
(index: number) => { (index: number) => {
@ -178,10 +173,10 @@ export const ProfileListScreenInner = observer(
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection <FeedSection
ref={feedSectionRef} ref={feedSectionRef}
feed={`list|${listUri}`}
scrollElRef={ scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null> scrollElRef as React.MutableRefObject<FlatList<any> | null>
} }
feed={feed}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
isScrolledDown={isScrolledDown} isScrolledDown={isScrolledDown}
@ -562,7 +557,7 @@ const Header = observer(function HeaderImpl({
}) })
interface FeedSectionProps { interface FeedSectionProps {
feed: PostsFeedModel feed: FeedDescriptor
onScroll: OnScrollHandler onScroll: OnScrollHandler
headerHeight: number headerHeight: number
isScrolledDown: boolean isScrolledDown: boolean
@ -573,12 +568,14 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
{feed, scrollElRef, onScroll, headerHeight, isScrolledDown}, {feed, scrollElRef, onScroll, headerHeight, isScrolledDown},
ref, ref,
) { ) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight}) scrollElRef.current?.scrollToOffset({offset: -headerHeight})
feed.refresh() queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
}, [feed, scrollElRef, headerHeight]) setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop, scrollToTop: onScrollToTop,
})) }))
@ -592,7 +589,9 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
<Feed <Feed
testID="listFeed" testID="listFeed"
feed={feed} feed={feed}
pollInterval={30e3}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={1} scrollEventThrottle={1}
renderEmptyState={renderPostsEmpty} renderEmptyState={renderPostsEmpty}

View File

@ -5402,18 +5402,17 @@
"@svgr/plugin-svgo" "^5.5.0" "@svgr/plugin-svgo" "^5.5.0"
loader-utils "^2.0.0" loader-utils "^2.0.0"
"@tanstack/query-core@4.33.0": "@tanstack/query-core@5.8.1":
version "4.33.0" version "5.8.1"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.33.0.tgz#7756da9a75a424e521622b1d84eb55b7a2b33715" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996"
integrity sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g== integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA==
"@tanstack/react-query@^4.33.0": "@tanstack/react-query@^5.8.1":
version "4.33.0" version "5.8.1"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.33.0.tgz#e927b0343a6ecaa948fee59e9ca98fe561062638" resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade"
integrity sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA== integrity sha512-YMagxS8iNPOLg0pK6WOjdSDlAvWKOf69udLOwQrBVmkC2SRLNLko7elo5Ro3ptlJkXvTVHidxC/h5KGi5bH1XQ==
dependencies: dependencies:
"@tanstack/query-core" "4.33.0" "@tanstack/query-core" "5.8.1"
use-sync-external-store "^1.2.0"
"@testing-library/jest-native@^5.4.1": "@testing-library/jest-native@^5.4.1":
version "5.4.2" version "5.4.2"
@ -18944,7 +18943,7 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0" detect-node-es "^1.1.0"
tslib "^2.0.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" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" 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== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==