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 namingzio/stable
parent
51f04b9620
commit
c8c308e31e
|
@ -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",
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,91 +0,0 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
|
||||||
import {RootStoreModel} from '../root-store'
|
|
||||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
|
||||||
import {PostsFeedItemModel} from './post'
|
|
||||||
import {FeedSourceInfo} from 'lib/api/feed/types'
|
|
||||||
|
|
||||||
export class PostsFeedSliceModel {
|
|
||||||
// ui state
|
|
||||||
_reactKey: string = ''
|
|
||||||
|
|
||||||
// data
|
|
||||||
items: PostsFeedItemModel[] = []
|
|
||||||
source: FeedSourceInfo | undefined
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
|
|
||||||
this._reactKey = slice._reactKey
|
|
||||||
this.source = slice.source
|
|
||||||
for (let i = 0; i < slice.items.length; i++) {
|
|
||||||
this.items.push(
|
|
||||||
new PostsFeedItemModel(
|
|
||||||
rootStore,
|
|
||||||
`${this._reactKey} - ${i}`,
|
|
||||||
slice.items[i],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
makeAutoObservable(this, {rootStore: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
get uri() {
|
|
||||||
if (this.isReply) {
|
|
||||||
return this.items[1].post.uri
|
|
||||||
}
|
|
||||||
return this.items[0].post.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
get isThread() {
|
|
||||||
return (
|
|
||||||
this.items.length > 1 &&
|
|
||||||
this.items.every(
|
|
||||||
item => item.post.author.did === this.items[0].post.author.did,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get isReply() {
|
|
||||||
return this.items.length > 1 && !this.isThread
|
|
||||||
}
|
|
||||||
|
|
||||||
get rootItem() {
|
|
||||||
if (this.isReply) {
|
|
||||||
return this.items[1]
|
|
||||||
}
|
|
||||||
return this.items[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
get moderation() {
|
|
||||||
// prefer the most stringent item
|
|
||||||
const topItem = this.items.find(item => item.moderation.content.filter)
|
|
||||||
if (topItem) {
|
|
||||||
return topItem.moderation
|
|
||||||
}
|
|
||||||
// otherwise just use the first one
|
|
||||||
return this.items[0].moderation
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldFilter(ignoreFilterForDid: string | undefined): boolean {
|
|
||||||
const mods = this.items
|
|
||||||
.filter(item => item.post.author.did !== ignoreFilterForDid)
|
|
||||||
.map(item => item.moderation)
|
|
||||||
return !!mods.find(mod => mod.content.filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
containsUri(uri: string) {
|
|
||||||
return !!this.items.find(item => item.post.uri === uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
isThreadParentAt(i: number) {
|
|
||||||
if (this.items.length === 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return i < this.items.length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
isThreadChildAt(i: number) {
|
|
||||||
if (this.items.length === 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return i > 0
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,429 +0,0 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
|
||||||
import {
|
|
||||||
AppBskyFeedGetTimeline as GetTimeline,
|
|
||||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
|
||||||
AppBskyFeedGetFeed as GetCustomFeed,
|
|
||||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
|
||||||
AppBskyFeedGetListFeed as GetListFeed,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import AwaitLock from 'await-lock'
|
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
|
||||||
import {RootStoreModel} from '../root-store'
|
|
||||||
import {cleanError} from 'lib/strings/errors'
|
|
||||||
import {FeedTuner} from 'lib/api/feed-manip'
|
|
||||||
import {PostsFeedSliceModel} from './posts-slice'
|
|
||||||
import {track} from 'lib/analytics/analytics'
|
|
||||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
|
||||||
|
|
||||||
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
|
|
||||||
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
|
||||||
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
|
||||||
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|
||||||
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
|
||||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
|
||||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
|
||||||
|
|
||||||
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
|
|
||||||
|
|
||||||
export enum KnownError {
|
|
||||||
FeedgenDoesNotExist,
|
|
||||||
FeedgenMisconfigured,
|
|
||||||
FeedgenBadResponse,
|
|
||||||
FeedgenOffline,
|
|
||||||
FeedgenUnknown,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
/**
|
|
||||||
* Formats the feed in a flat array with no threading of replies, just
|
|
||||||
* top-level posts.
|
|
||||||
*/
|
|
||||||
isSimpleFeed?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueryParams =
|
|
||||||
| GetTimeline.QueryParams
|
|
||||||
| GetAuthorFeed.QueryParams
|
|
||||||
| GetActorLikes.QueryParams
|
|
||||||
| GetCustomFeed.QueryParams
|
|
||||||
| GetListFeed.QueryParams
|
|
||||||
|
|
||||||
export class PostsFeedModel {
|
|
||||||
// state
|
|
||||||
isLoading = false
|
|
||||||
isRefreshing = false
|
|
||||||
hasNewLatest = false
|
|
||||||
hasLoaded = false
|
|
||||||
isBlocking = false
|
|
||||||
isBlockedBy = false
|
|
||||||
error = ''
|
|
||||||
knownError: KnownError | undefined
|
|
||||||
loadMoreError = ''
|
|
||||||
params: QueryParams
|
|
||||||
hasMore = true
|
|
||||||
pollCursor: string | undefined
|
|
||||||
api: FeedAPI
|
|
||||||
tuner = new FeedTuner()
|
|
||||||
pageSize = PAGE_SIZE
|
|
||||||
options: Options = {}
|
|
||||||
|
|
||||||
// used to linearize async modifications to state
|
|
||||||
lock = new AwaitLock()
|
|
||||||
|
|
||||||
// used to track if a feed is coming up empty
|
|
||||||
emptyFetches = 0
|
|
||||||
|
|
||||||
// data
|
|
||||||
slices: PostsFeedSliceModel[] = []
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public rootStore: RootStoreModel,
|
|
||||||
public feedType: FeedType,
|
|
||||||
params: QueryParams,
|
|
||||||
options?: Options,
|
|
||||||
) {
|
|
||||||
makeAutoObservable(
|
|
||||||
this,
|
|
||||||
{
|
|
||||||
rootStore: false,
|
|
||||||
params: false,
|
|
||||||
},
|
|
||||||
{autoBind: true},
|
|
||||||
)
|
|
||||||
this.params = params
|
|
||||||
this.options = options || {}
|
|
||||||
if (feedType === 'home') {
|
|
||||||
this.api = new MergeFeedAPI(rootStore)
|
|
||||||
} else if (feedType === 'following') {
|
|
||||||
this.api = new FollowingFeedAPI(rootStore)
|
|
||||||
} else if (feedType === 'author') {
|
|
||||||
this.api = new AuthorFeedAPI(
|
|
||||||
rootStore,
|
|
||||||
params as GetAuthorFeed.QueryParams,
|
|
||||||
)
|
|
||||||
} else if (feedType === 'likes') {
|
|
||||||
this.api = new LikesFeedAPI(
|
|
||||||
rootStore,
|
|
||||||
params as GetActorLikes.QueryParams,
|
|
||||||
)
|
|
||||||
} else if (feedType === 'custom') {
|
|
||||||
this.api = new CustomFeedAPI(
|
|
||||||
rootStore,
|
|
||||||
params as GetCustomFeed.QueryParams,
|
|
||||||
)
|
|
||||||
} else if (feedType === 'list') {
|
|
||||||
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
|
|
||||||
} else {
|
|
||||||
this.api = new FollowingFeedAPI(rootStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get reactKey() {
|
|
||||||
if (this.feedType === 'author') {
|
|
||||||
return (this.params as GetAuthorFeed.QueryParams).actor
|
|
||||||
}
|
|
||||||
if (this.feedType === 'custom') {
|
|
||||||
return (this.params as GetCustomFeed.QueryParams).feed
|
|
||||||
}
|
|
||||||
if (this.feedType === 'list') {
|
|
||||||
return (this.params as GetListFeed.QueryParams).list
|
|
||||||
}
|
|
||||||
return this.feedType
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasContent() {
|
|
||||||
return this.slices.length !== 0
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasError() {
|
|
||||||
return this.error !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
get isEmpty() {
|
|
||||||
return this.hasLoaded && !this.hasContent
|
|
||||||
}
|
|
||||||
|
|
||||||
get isLoadingMore() {
|
|
||||||
return this.isLoading && !this.isRefreshing && this.hasContent
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNewLatest(v: boolean) {
|
|
||||||
this.hasNewLatest = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// public api
|
|
||||||
// =
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nuke all data
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
logger.debug('FeedModel:clear')
|
|
||||||
this.isLoading = false
|
|
||||||
this.isRefreshing = false
|
|
||||||
this.hasNewLatest = false
|
|
||||||
this.hasLoaded = false
|
|
||||||
this.error = ''
|
|
||||||
this.hasMore = true
|
|
||||||
this.pollCursor = undefined
|
|
||||||
this.slices = []
|
|
||||||
this.tuner.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load for first render
|
|
||||||
*/
|
|
||||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
|
||||||
logger.debug('FeedModel:setup', {isRefreshing})
|
|
||||||
if (isRefreshing) {
|
|
||||||
this.isRefreshing = true // set optimistically for UI
|
|
||||||
}
|
|
||||||
await this.lock.acquireAsync()
|
|
||||||
try {
|
|
||||||
this.setHasNewLatest(false)
|
|
||||||
this.api.reset()
|
|
||||||
this.tuner.reset()
|
|
||||||
this._xLoading(isRefreshing)
|
|
||||||
try {
|
|
||||||
const res = await this.api.fetchNext({limit: this.pageSize})
|
|
||||||
await this._replaceAll(res)
|
|
||||||
this._xIdle()
|
|
||||||
} catch (e: any) {
|
|
||||||
this._xIdle(e)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.lock.release()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any event listeners. Returns a cleanup function.
|
|
||||||
*/
|
|
||||||
registerListeners() {
|
|
||||||
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
|
|
||||||
return () => sub.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset and load
|
|
||||||
*/
|
|
||||||
async refresh() {
|
|
||||||
await this.setup(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load more posts to the end of the feed
|
|
||||||
*/
|
|
||||||
loadMore = bundleAsync(async () => {
|
|
||||||
await this.lock.acquireAsync()
|
|
||||||
try {
|
|
||||||
if (!this.hasMore || this.hasError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this._xLoading()
|
|
||||||
try {
|
|
||||||
const res = await this.api.fetchNext({
|
|
||||||
limit: this.pageSize,
|
|
||||||
})
|
|
||||||
await this._appendAll(res)
|
|
||||||
this._xIdle()
|
|
||||||
} catch (e: any) {
|
|
||||||
this._xIdle(undefined, e)
|
|
||||||
runInAction(() => {
|
|
||||||
this.hasMore = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.lock.release()
|
|
||||||
if (this.feedType === 'custom') {
|
|
||||||
track('CustomFeed:LoadMore')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to load more again after a failure
|
|
||||||
*/
|
|
||||||
async retryLoadMore() {
|
|
||||||
this.loadMoreError = ''
|
|
||||||
this.hasMore = true
|
|
||||||
return this.loadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if new posts are available
|
|
||||||
*/
|
|
||||||
async checkForLatest() {
|
|
||||||
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const post = await this.api.peekLatest()
|
|
||||||
if (post) {
|
|
||||||
const slices = this.tuner.tune(
|
|
||||||
[post],
|
|
||||||
this.rootStore.preferences.getFeedTuners(this.feedType),
|
|
||||||
{
|
|
||||||
dryRun: true,
|
|
||||||
maintainOrder: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (slices[0]) {
|
|
||||||
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
|
|
||||||
if (sliceModel.moderation.content.filter) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the UI after the user has created a post
|
|
||||||
*/
|
|
||||||
onPostCreated() {
|
|
||||||
if (!this.slices.length) {
|
|
||||||
return this.refresh()
|
|
||||||
} else {
|
|
||||||
this.setHasNewLatest(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes posts from the feed upon deletion.
|
|
||||||
*/
|
|
||||||
onPostDeleted(uri: string) {
|
|
||||||
let i
|
|
||||||
do {
|
|
||||||
i = this.slices.findIndex(slice => slice.containsUri(uri))
|
|
||||||
if (i !== -1) {
|
|
||||||
this.slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
} while (i !== -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// state transitions
|
|
||||||
// =
|
|
||||||
|
|
||||||
_xLoading(isRefreshing = false) {
|
|
||||||
this.isLoading = true
|
|
||||||
this.isRefreshing = isRefreshing
|
|
||||||
this.error = ''
|
|
||||||
this.knownError = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
_xIdle(error?: any, loadMoreError?: any) {
|
|
||||||
this.isLoading = false
|
|
||||||
this.isRefreshing = false
|
|
||||||
this.hasLoaded = true
|
|
||||||
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
|
|
||||||
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
|
|
||||||
this.error = cleanError(error)
|
|
||||||
this.knownError = detectKnownError(this.feedType, error)
|
|
||||||
this.loadMoreError = cleanError(loadMoreError)
|
|
||||||
if (error) {
|
|
||||||
logger.error('Posts feed request failed', {error})
|
|
||||||
}
|
|
||||||
if (loadMoreError) {
|
|
||||||
logger.error('Posts feed load-more request failed', {
|
|
||||||
error: loadMoreError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper functions
|
|
||||||
// =
|
|
||||||
|
|
||||||
async _replaceAll(res: FeedAPIResponse) {
|
|
||||||
this.pollCursor = res.feed[0]?.post.uri
|
|
||||||
return this._appendAll(res, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async _appendAll(res: FeedAPIResponse, replace = false) {
|
|
||||||
this.hasMore = !!res.cursor && res.feed.length > 0
|
|
||||||
if (replace) {
|
|
||||||
this.emptyFetches = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rootStore.me.follows.hydrateMany(
|
|
||||||
res.feed.map(item => item.post.author),
|
|
||||||
)
|
|
||||||
for (const item of res.feed) {
|
|
||||||
this.rootStore.posts.fromFeedItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const slices = this.options.isSimpleFeed
|
|
||||||
? res.feed.map(item => new FeedViewPostsSlice([item]))
|
|
||||||
: this.tuner.tune(
|
|
||||||
res.feed,
|
|
||||||
this.rootStore.preferences.getFeedTuners(this.feedType),
|
|
||||||
)
|
|
||||||
|
|
||||||
const toAppend: PostsFeedSliceModel[] = []
|
|
||||||
for (const slice of slices) {
|
|
||||||
const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
|
|
||||||
const dupTest = (item: PostsFeedSliceModel) =>
|
|
||||||
item._reactKey === sliceModel._reactKey
|
|
||||||
// sanity check
|
|
||||||
// if a duplicate _reactKey passes through, the UI breaks hard
|
|
||||||
if (!replace) {
|
|
||||||
if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toAppend.push(sliceModel)
|
|
||||||
}
|
|
||||||
runInAction(() => {
|
|
||||||
if (replace) {
|
|
||||||
this.slices = toAppend
|
|
||||||
} else {
|
|
||||||
this.slices = this.slices.concat(toAppend)
|
|
||||||
}
|
|
||||||
if (toAppend.length === 0) {
|
|
||||||
this.emptyFetches++
|
|
||||||
if (this.emptyFetches >= 10) {
|
|
||||||
this.hasMore = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectKnownError(
|
|
||||||
feedType: FeedType,
|
|
||||||
error: any,
|
|
||||||
): KnownError | undefined {
|
|
||||||
if (!error) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if (typeof error !== 'string') {
|
|
||||||
error = error.toString()
|
|
||||||
}
|
|
||||||
if (feedType !== 'custom') {
|
|
||||||
return KnownError.Unknown
|
|
||||||
}
|
|
||||||
if (error.includes('could not find feed')) {
|
|
||||||
return KnownError.FeedgenDoesNotExist
|
|
||||||
}
|
|
||||||
if (error.includes('feed unavailable')) {
|
|
||||||
return KnownError.FeedgenOffline
|
|
||||||
}
|
|
||||||
if (error.includes('invalid did document')) {
|
|
||||||
return KnownError.FeedgenMisconfigured
|
|
||||||
}
|
|
||||||
if (error.includes('could not resolve did document')) {
|
|
||||||
return KnownError.FeedgenMisconfigured
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
error.includes('invalid feed generator service details in did document')
|
|
||||||
) {
|
|
||||||
return KnownError.FeedgenMisconfigured
|
|
||||||
}
|
|
||||||
if (error.includes('feed provided an invalid response')) {
|
|
||||||
return KnownError.FeedgenBadResponse
|
|
||||||
}
|
|
||||||
return KnownError.FeedgenUnknown
|
|
||||||
}
|
|
|
@ -4,7 +4,6 @@ import {
|
||||||
ComAtprotoServerListAppPasswords,
|
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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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])
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {useCallback, useMemo} from 'react'
|
||||||
|
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
|
||||||
|
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||||
|
import {useSession} from '../session'
|
||||||
|
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||||
|
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
|
||||||
|
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
|
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
||||||
|
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
||||||
|
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||||
|
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
||||||
|
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||||
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||||
|
import {useStores} from '../models/root-store'
|
||||||
|
|
||||||
|
type ActorDid = string
|
||||||
|
type AuthorFilter =
|
||||||
|
| 'posts_with_replies'
|
||||||
|
| 'posts_no_replies'
|
||||||
|
| 'posts_with_media'
|
||||||
|
type FeedUri = string
|
||||||
|
type ListUri = string
|
||||||
|
export type FeedDescriptor =
|
||||||
|
| 'home'
|
||||||
|
| 'following'
|
||||||
|
| `author|${ActorDid}|${AuthorFilter}`
|
||||||
|
| `feedgen|${FeedUri}`
|
||||||
|
| `likes|${ActorDid}`
|
||||||
|
| `list|${ListUri}`
|
||||||
|
export interface FeedParams {
|
||||||
|
disableTuner?: boolean
|
||||||
|
mergeFeedEnabled?: boolean
|
||||||
|
mergeFeedSources?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RQPageParam = string | undefined
|
||||||
|
|
||||||
|
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
|
||||||
|
return ['post-feed', feedDesc, params || {}]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPostSliceItem {
|
||||||
|
_reactKey: string
|
||||||
|
uri: string
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
record: AppBskyFeedPost.Record
|
||||||
|
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPostSlice {
|
||||||
|
_reactKey: string
|
||||||
|
rootUri: string
|
||||||
|
isThread: boolean
|
||||||
|
items: FeedPostSliceItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPage {
|
||||||
|
cursor: string | undefined
|
||||||
|
slices: FeedPostSlice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePostFeedQuery(
|
||||||
|
feedDesc: FeedDescriptor,
|
||||||
|
params?: FeedParams,
|
||||||
|
opts?: {enabled?: boolean},
|
||||||
|
) {
|
||||||
|
const {agent} = useSession()
|
||||||
|
const feedTuners = useFeedTuners(feedDesc)
|
||||||
|
const store = useStores()
|
||||||
|
const enabled = opts?.enabled !== false
|
||||||
|
|
||||||
|
const api: FeedAPI = useMemo(() => {
|
||||||
|
if (feedDesc === 'home') {
|
||||||
|
return new MergeFeedAPI(agent, params || {}, feedTuners)
|
||||||
|
} else if (feedDesc === 'following') {
|
||||||
|
return new FollowingFeedAPI(agent)
|
||||||
|
} else if (feedDesc.startsWith('author')) {
|
||||||
|
const [_, actor, filter] = feedDesc.split('|')
|
||||||
|
return new AuthorFeedAPI(agent, {actor, filter})
|
||||||
|
} else if (feedDesc.startsWith('likes')) {
|
||||||
|
const [_, actor] = feedDesc.split('|')
|
||||||
|
return new LikesFeedAPI(agent, {actor})
|
||||||
|
} else if (feedDesc.startsWith('feedgen')) {
|
||||||
|
const [_, feed] = feedDesc.split('|')
|
||||||
|
return new CustomFeedAPI(agent, {feed})
|
||||||
|
} else if (feedDesc.startsWith('list')) {
|
||||||
|
const [_, list] = feedDesc.split('|')
|
||||||
|
return new ListFeedAPI(agent, {list})
|
||||||
|
} else {
|
||||||
|
// shouldnt happen
|
||||||
|
return new FollowingFeedAPI(agent)
|
||||||
|
}
|
||||||
|
}, [feedDesc, params, feedTuners, agent])
|
||||||
|
const tuner = useMemo(
|
||||||
|
() => (params?.disableTuner ? new NoopFeedTuner() : new FeedTuner()),
|
||||||
|
[params],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pollLatest = useCallback(async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
console.log('poll')
|
||||||
|
const post = await api.peekLatest()
|
||||||
|
if (post) {
|
||||||
|
const slices = tuner.tune([post], feedTuners, {
|
||||||
|
dryRun: true,
|
||||||
|
maintainOrder: true,
|
||||||
|
})
|
||||||
|
if (slices[0]) {
|
||||||
|
if (
|
||||||
|
!moderatePost(
|
||||||
|
slices[0].items[0].post,
|
||||||
|
store.preferences.moderationOpts,
|
||||||
|
).content.filter
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [api, tuner, feedTuners, store.preferences.moderationOpts, enabled])
|
||||||
|
|
||||||
|
const out = useInfiniteQuery<
|
||||||
|
FeedPage,
|
||||||
|
Error,
|
||||||
|
InfiniteData<FeedPage>,
|
||||||
|
QueryKey,
|
||||||
|
RQPageParam
|
||||||
|
>({
|
||||||
|
queryKey: RQKEY(feedDesc, params),
|
||||||
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||||
|
console.log('fetch', feedDesc, pageParam)
|
||||||
|
if (!pageParam) {
|
||||||
|
tuner.reset()
|
||||||
|
}
|
||||||
|
const res = await api.fetch({cursor: pageParam, limit: 30})
|
||||||
|
const slices = tuner.tune(res.feed, feedTuners)
|
||||||
|
return {
|
||||||
|
cursor: res.cursor,
|
||||||
|
slices: slices.map(slice => ({
|
||||||
|
_reactKey: slice._reactKey,
|
||||||
|
rootUri: slice.rootItem.post.uri,
|
||||||
|
isThread:
|
||||||
|
slice.items.length > 1 &&
|
||||||
|
slice.items.every(
|
||||||
|
item => item.post.author.did === slice.items[0].post.author.did,
|
||||||
|
),
|
||||||
|
source: undefined, // TODO
|
||||||
|
items: slice.items
|
||||||
|
.map((item, i) => {
|
||||||
|
if (
|
||||||
|
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||||
|
AppBskyFeedPost.validateRecord(item.post.record).success
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
_reactKey: `${slice._reactKey}-${i}`,
|
||||||
|
uri: item.post.uri,
|
||||||
|
post: item.post,
|
||||||
|
record: item.post.record,
|
||||||
|
reason: i === 0 && slice.source ? slice.source : item.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
.filter(Boolean) as FeedPostSliceItem[],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: undefined,
|
||||||
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {...out, pollLatest}
|
||||||
|
}
|
|
@ -57,17 +57,17 @@ export type ThreadNode =
|
||||||
|
|
||||||
export function usePostThreadQuery(uri: string | undefined) {
|
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(
|
||||||
|
|
|
@ -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})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?.()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -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},
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
21
yarn.lock
21
yarn.lock
|
@ -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==
|
||||||
|
|
Loading…
Reference in New Issue