Factor our feed source model (#1887)
* Refactor first onboarding step * Replace old FeedSourceCard * Clean up CustomFeedEmbed * Remove discover feeds model * Refactor ProfileFeed screen * Remove useCustomFeed * Delete some unused models * Rip out more prefs * Factor out treeView from thread comp * Improve last commitzio/stable
parent
a01463788d
commit
06eb8b9a4c
|
@ -1,18 +0,0 @@
|
|||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
|
||||
export function useCustomFeed(uri: string): FeedSourceModel | undefined {
|
||||
const store = useStores()
|
||||
const [item, setItem] = useState<FeedSourceModel | undefined>()
|
||||
useEffect(() => {
|
||||
async function buildFeedItem() {
|
||||
const model = new FeedSourceModel(store, uri)
|
||||
await model.setup()
|
||||
setItem(model)
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
return item
|
||||
}
|
|
@ -1,231 +0,0 @@
|
|||
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export class FeedSourceModel {
|
||||
// state
|
||||
_reactKey: string
|
||||
hasLoaded = false
|
||||
error: string | undefined
|
||||
|
||||
// data
|
||||
uri: string
|
||||
cid: string = ''
|
||||
type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
|
||||
avatar: string | undefined = ''
|
||||
displayName: string = ''
|
||||
descriptionRT: RichText | null = null
|
||||
creatorDid: string = ''
|
||||
creatorHandle: string = ''
|
||||
likeCount: number | undefined = 0
|
||||
likeUri: string | undefined = ''
|
||||
|
||||
constructor(public rootStore: RootStoreModel, uri: string) {
|
||||
this._reactKey = uri
|
||||
this.uri = uri
|
||||
|
||||
try {
|
||||
const urip = new AtUri(uri)
|
||||
if (urip.collection === 'app.bsky.feed.generator') {
|
||||
this.type = 'feed-generator'
|
||||
} else if (urip.collection === 'app.bsky.graph.list') {
|
||||
this.type = 'list'
|
||||
}
|
||||
} catch {}
|
||||
this.displayName = uri.split('/').pop() || ''
|
||||
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get href() {
|
||||
const urip = new AtUri(this.uri)
|
||||
const collection =
|
||||
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
|
||||
return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
|
||||
}
|
||||
|
||||
get isSaved() {
|
||||
return this.rootStore.preferences.savedFeeds.includes(this.uri)
|
||||
}
|
||||
|
||||
get isPinned() {
|
||||
return false
|
||||
}
|
||||
|
||||
get isLiked() {
|
||||
return !!this.likeUri
|
||||
}
|
||||
|
||||
get isOwner() {
|
||||
return this.creatorDid === this.rootStore.me.did
|
||||
}
|
||||
|
||||
setup = bundleAsync(async () => {
|
||||
try {
|
||||
if (this.type === 'feed-generator') {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: this.uri,
|
||||
})
|
||||
this.hydrateFeedGenerator(res.data.view)
|
||||
} else if (this.type === 'list') {
|
||||
const res = await this.rootStore.agent.app.bsky.graph.getList({
|
||||
list: this.uri,
|
||||
limit: 1,
|
||||
})
|
||||
this.hydrateList(res.data.list)
|
||||
}
|
||||
} catch (e) {
|
||||
runInAction(() => {
|
||||
this.error = cleanError(e)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
|
||||
this.uri = view.uri
|
||||
this.cid = view.cid
|
||||
this.avatar = view.avatar
|
||||
this.displayName = view.displayName
|
||||
? sanitizeDisplayName(view.displayName)
|
||||
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
|
||||
this.descriptionRT = new RichText({
|
||||
text: view.description || '',
|
||||
facets: (view.descriptionFacets || [])?.slice(),
|
||||
})
|
||||
this.creatorDid = view.creator.did
|
||||
this.creatorHandle = view.creator.handle
|
||||
this.likeCount = view.likeCount
|
||||
this.likeUri = view.viewer?.like
|
||||
this.hasLoaded = true
|
||||
}
|
||||
|
||||
hydrateList(view: AppBskyGraphDefs.ListView) {
|
||||
this.uri = view.uri
|
||||
this.cid = view.cid
|
||||
this.avatar = view.avatar
|
||||
this.displayName = view.name
|
||||
? sanitizeDisplayName(view.name)
|
||||
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`
|
||||
this.descriptionRT = new RichText({
|
||||
text: view.description || '',
|
||||
facets: (view.descriptionFacets || [])?.slice(),
|
||||
})
|
||||
this.creatorDid = view.creator.did
|
||||
this.creatorHandle = view.creator.handle
|
||||
this.likeCount = undefined
|
||||
this.hasLoaded = true
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.rootStore.preferences.addSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Save')
|
||||
}
|
||||
}
|
||||
|
||||
async unsave() {
|
||||
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
|
||||
if (this.type !== 'feed-generator' && this.type !== 'list') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to unsave feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Unsave')
|
||||
}
|
||||
}
|
||||
|
||||
async pin() {
|
||||
try {
|
||||
await this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to pin feed', {error})
|
||||
} finally {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async togglePin() {
|
||||
if (!this.isPinned) {
|
||||
track('CustomFeed:Pin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
return this.rootStore.preferences.addPinnedFeed(this.uri)
|
||||
} else {
|
||||
track('CustomFeed:Unpin', {
|
||||
name: this.displayName,
|
||||
uri: this.uri,
|
||||
})
|
||||
|
||||
if (this.type === 'list') {
|
||||
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
|
||||
return this.unsave()
|
||||
} else {
|
||||
return this.rootStore.preferences.removePinnedFeed(this.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async like() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.likeUri = 'pending'
|
||||
this.likeCount = (this.likeCount || 0) + 1
|
||||
const res = await this.rootStore.agent.like(this.uri, this.cid)
|
||||
this.likeUri = res.uri
|
||||
} catch (e: any) {
|
||||
this.likeUri = undefined
|
||||
this.likeCount = (this.likeCount || 1) - 1
|
||||
logger.error('Failed to like feed', {error: e})
|
||||
} finally {
|
||||
track('CustomFeed:Like')
|
||||
}
|
||||
}
|
||||
|
||||
async unlike() {
|
||||
if (this.type !== 'feed-generator') {
|
||||
return
|
||||
}
|
||||
if (!this.likeUri) {
|
||||
return
|
||||
}
|
||||
const uri = this.likeUri
|
||||
try {
|
||||
this.likeUri = undefined
|
||||
this.likeCount = (this.likeCount || 1) - 1
|
||||
await this.rootStore.agent.deleteLike(uri!)
|
||||
} catch (e: any) {
|
||||
this.likeUri = uri
|
||||
this.likeCount = (this.likeCount || 0) + 1
|
||||
logger.error('Failed to unlike feed', {error: e})
|
||||
} finally {
|
||||
track('CustomFeed:Unlike')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const DEFAULT_LIMIT = 50
|
||||
|
||||
export class FeedsDiscoveryModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
loadMoreCursor: string | undefined = undefined
|
||||
|
||||
// data
|
||||
feeds: FeedSourceModel[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasMore() {
|
||||
if (this.loadMoreCursor) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.feeds.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
refresh = bundleAsync(async () => {
|
||||
this._xLoading()
|
||||
try {
|
||||
const res =
|
||||
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
loadMore = bundleAsync(async () => {
|
||||
if (!this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
try {
|
||||
const res =
|
||||
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||
limit: DEFAULT_LIMIT,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
this._append(res)
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
this._xIdle()
|
||||
})
|
||||
|
||||
search = async (query: string) => {
|
||||
this._xLoading(false)
|
||||
try {
|
||||
const results =
|
||||
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||
limit: DEFAULT_LIMIT,
|
||||
query: query,
|
||||
})
|
||||
this._replaceAll(results)
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
this._xIdle()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.feeds = []
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = true) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch popular feeds', {error: err})
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
|
||||
// 1. set feeds data to empty array
|
||||
this.feeds = []
|
||||
// 2. call this._append()
|
||||
this._append(res)
|
||||
}
|
||||
|
||||
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
|
||||
// 1. push data into feeds array
|
||||
for (const f of res.data.feeds) {
|
||||
const model = new FeedSourceModel(this.rootStore, f.uri)
|
||||
model.hydrateFeedGenerator(f)
|
||||
this.feeds.push(model)
|
||||
}
|
||||
// 2. set loadMoreCursor
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
}
|
||||
}
|
|
@ -1,123 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {FeedSourceModel} from '../content/feed-source'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export class ActorFeedsModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
feeds: FeedSourceModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetActorFeeds.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.feeds.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async refresh() {
|
||||
return this.loadMore(true)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.hasMore = true
|
||||
this.loadMoreCursor = undefined
|
||||
this.feeds = []
|
||||
}
|
||||
|
||||
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||
if (!replace && !this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading(replace)
|
||||
try {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
|
||||
actor: this.params.actor,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
this._appendAll(res)
|
||||
}
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch user followers', {error: err})
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll(res: GetActorFeeds.Response) {
|
||||
this.feeds = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
_appendAll(res: GetActorFeeds.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
for (const f of res.data.feeds) {
|
||||
const model = new FeedSourceModel(this.rootStore, f.uri)
|
||||
model.hydrateFeedGenerator(f)
|
||||
this.feeds.push(model)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -126,33 +126,6 @@ export class PreferencesModel {
|
|||
],
|
||||
}
|
||||
}
|
||||
|
||||
// feeds
|
||||
// =
|
||||
|
||||
isPinnedFeed(uri: string) {
|
||||
return this.pinnedFeeds.includes(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
|
||||
*/
|
||||
async addSavedFeed(_v: string) {}
|
||||
|
||||
/**
|
||||
* @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
|
||||
*/
|
||||
async removeSavedFeed(_v: string) {}
|
||||
|
||||
/**
|
||||
* @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
|
||||
*/
|
||||
async addPinnedFeed(_v: string) {}
|
||||
|
||||
/**
|
||||
* @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
|
||||
*/
|
||||
async removePinnedFeed(_v: string) {}
|
||||
}
|
||||
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
|
|
|
@ -1,255 +0,0 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {ProfileModel} from '../content/profile'
|
||||
import {ActorFeedsModel} from '../lists/actor-feeds'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export enum Sections {
|
||||
PostsNoReplies = 'Posts',
|
||||
PostsWithReplies = 'Posts & replies',
|
||||
PostsWithMedia = 'Media',
|
||||
Likes = 'Likes',
|
||||
CustomAlgorithms = 'Feeds',
|
||||
Lists = 'Lists',
|
||||
}
|
||||
|
||||
export interface ProfileUiParams {
|
||||
user: string
|
||||
}
|
||||
|
||||
export class ProfileUiModel {
|
||||
static LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
static END_ITEM = {_reactKey: '__end__'}
|
||||
static EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
|
||||
isAuthenticatedUser = false
|
||||
|
||||
// data
|
||||
profile: ProfileModel
|
||||
feed: PostsFeedModel
|
||||
algos: ActorFeedsModel
|
||||
lists: ListsListModel
|
||||
|
||||
// ui state
|
||||
selectedViewIndex = 0
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: ProfileUiParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.profile = new ProfileModel(rootStore, {actor: params.user})
|
||||
this.feed = new PostsFeedModel(rootStore, 'author', {
|
||||
actor: params.user,
|
||||
limit: 10,
|
||||
filter: 'posts_no_replies',
|
||||
})
|
||||
this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
|
||||
this.lists = new ListsListModel(rootStore, params.user)
|
||||
}
|
||||
|
||||
get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
|
||||
if (
|
||||
this.selectedView === Sections.PostsNoReplies ||
|
||||
this.selectedView === Sections.PostsWithReplies ||
|
||||
this.selectedView === Sections.PostsWithMedia ||
|
||||
this.selectedView === Sections.Likes
|
||||
) {
|
||||
return this.feed
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
return this.lists
|
||||
}
|
||||
if (this.selectedView === Sections.CustomAlgorithms) {
|
||||
return this.algos
|
||||
}
|
||||
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
||||
}
|
||||
|
||||
get isInitialLoading() {
|
||||
const view = this.currentView
|
||||
return view.isLoading && !view.isRefreshing && !view.hasContent
|
||||
}
|
||||
|
||||
get isRefreshing() {
|
||||
return this.profile.isRefreshing || this.currentView.isRefreshing
|
||||
}
|
||||
|
||||
get selectorItems() {
|
||||
const items = [
|
||||
Sections.PostsNoReplies,
|
||||
Sections.PostsWithReplies,
|
||||
Sections.PostsWithMedia,
|
||||
this.isAuthenticatedUser && Sections.Likes,
|
||||
].filter(Boolean) as string[]
|
||||
if (this.algos.hasLoaded && !this.algos.isEmpty) {
|
||||
items.push(Sections.CustomAlgorithms)
|
||||
}
|
||||
if (this.lists.hasLoaded && !this.lists.isEmpty) {
|
||||
items.push(Sections.Lists)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
get selectedView() {
|
||||
// If, for whatever reason, the selected view index is not available, default back to posts
|
||||
// This can happen when the user was focused on a view but performed an action that caused
|
||||
// the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y)
|
||||
return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies
|
||||
}
|
||||
|
||||
get uiItems() {
|
||||
let arr: any[] = []
|
||||
// if loading, return loading item to show loading spinner
|
||||
if (this.isInitialLoading) {
|
||||
arr = arr.concat([ProfileUiModel.LOADING_ITEM])
|
||||
} else if (this.currentView.hasError) {
|
||||
// if error, return error item to show error message
|
||||
arr = arr.concat([
|
||||
{
|
||||
_reactKey: '__error__',
|
||||
error: this.currentView.error,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
if (
|
||||
this.selectedView === Sections.PostsNoReplies ||
|
||||
this.selectedView === Sections.PostsWithReplies ||
|
||||
this.selectedView === Sections.PostsWithMedia ||
|
||||
this.selectedView === Sections.Likes
|
||||
) {
|
||||
if (this.feed.hasContent) {
|
||||
arr = this.feed.slices.slice()
|
||||
if (!this.feed.hasMore) {
|
||||
arr = arr.concat([ProfileUiModel.END_ITEM])
|
||||
}
|
||||
} else if (this.feed.isEmpty) {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
} else if (this.selectedView === Sections.CustomAlgorithms) {
|
||||
if (this.algos.hasContent) {
|
||||
arr = this.algos.feeds
|
||||
} else if (this.algos.isEmpty) {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
if (this.lists.hasContent) {
|
||||
arr = this.lists.lists
|
||||
} else if (this.lists.isEmpty) {
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
} else {
|
||||
// fallback, add empty item, to show empty message
|
||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
get showLoadingMoreFooter() {
|
||||
if (
|
||||
this.selectedView === Sections.PostsNoReplies ||
|
||||
this.selectedView === Sections.PostsWithReplies ||
|
||||
this.selectedView === Sections.PostsWithMedia ||
|
||||
this.selectedView === Sections.Likes
|
||||
) {
|
||||
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
|
||||
} else if (this.selectedView === Sections.Lists) {
|
||||
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
setSelectedViewIndex(index: number) {
|
||||
// ViewSelector fires onSelectView on mount
|
||||
if (index === this.selectedViewIndex) return
|
||||
|
||||
this.selectedViewIndex = index
|
||||
|
||||
if (
|
||||
this.selectedView === Sections.PostsNoReplies ||
|
||||
this.selectedView === Sections.PostsWithReplies ||
|
||||
this.selectedView === Sections.PostsWithMedia
|
||||
) {
|
||||
let filter = 'posts_no_replies'
|
||||
if (this.selectedView === Sections.PostsWithReplies) {
|
||||
filter = 'posts_with_replies'
|
||||
} else if (this.selectedView === Sections.PostsWithMedia) {
|
||||
filter = 'posts_with_media'
|
||||
}
|
||||
|
||||
this.feed = new PostsFeedModel(
|
||||
this.rootStore,
|
||||
'author',
|
||||
{
|
||||
actor: this.params.user,
|
||||
limit: 10,
|
||||
filter,
|
||||
},
|
||||
{
|
||||
isSimpleFeed: ['posts_with_media'].includes(filter),
|
||||
},
|
||||
)
|
||||
|
||||
this.feed.setup()
|
||||
} else if (this.selectedView === Sections.Likes) {
|
||||
this.feed = new PostsFeedModel(
|
||||
this.rootStore,
|
||||
'likes',
|
||||
{
|
||||
actor: this.params.user,
|
||||
limit: 10,
|
||||
},
|
||||
{
|
||||
isSimpleFeed: true,
|
||||
},
|
||||
)
|
||||
|
||||
this.feed.setup()
|
||||
}
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await Promise.all([
|
||||
this.profile
|
||||
.setup()
|
||||
.catch(err => logger.error('Failed to fetch profile', {error: err})),
|
||||
this.feed
|
||||
.setup()
|
||||
.catch(err => logger.error('Failed to fetch feed', {error: err})),
|
||||
])
|
||||
runInAction(() => {
|
||||
this.isAuthenticatedUser =
|
||||
this.profile.did === this.rootStore.session.currentSession?.did
|
||||
})
|
||||
this.algos.refresh()
|
||||
// HACK: need to use the DID as a param, not the username -prf
|
||||
this.lists.source = this.profile.did
|
||||
this.lists
|
||||
.loadMore()
|
||||
.catch(err => logger.error('Failed to fetch lists', {error: err}))
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
if (
|
||||
!this.currentView.isLoading &&
|
||||
!this.currentView.hasError &&
|
||||
!this.currentView.isEmpty
|
||||
) {
|
||||
await this.currentView.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,39 +21,41 @@ import {sanitizeHandle} from '#/lib/strings/handles'
|
|||
import {useSession} from '#/state/session'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
|
||||
export type FeedSourceInfo =
|
||||
| {
|
||||
type: 'feed'
|
||||
uri: string
|
||||
route: {
|
||||
href: string
|
||||
name: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
cid: string
|
||||
avatar: string | undefined
|
||||
displayName: string
|
||||
description: RichText
|
||||
creatorDid: string
|
||||
creatorHandle: string
|
||||
likeCount: number | undefined
|
||||
likeUri: string | undefined
|
||||
}
|
||||
| {
|
||||
type: 'list'
|
||||
uri: string
|
||||
route: {
|
||||
href: string
|
||||
name: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
cid: string
|
||||
avatar: string | undefined
|
||||
displayName: string
|
||||
description: RichText
|
||||
creatorDid: string
|
||||
creatorHandle: string
|
||||
}
|
||||
export type FeedSourceFeedInfo = {
|
||||
type: 'feed'
|
||||
uri: string
|
||||
route: {
|
||||
href: string
|
||||
name: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
cid: string
|
||||
avatar: string | undefined
|
||||
displayName: string
|
||||
description: RichText
|
||||
creatorDid: string
|
||||
creatorHandle: string
|
||||
likeCount: number | undefined
|
||||
likeUri: string | undefined
|
||||
}
|
||||
|
||||
export type FeedSourceListInfo = {
|
||||
type: 'list'
|
||||
uri: string
|
||||
route: {
|
||||
href: string
|
||||
name: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
cid: string
|
||||
avatar: string | undefined
|
||||
displayName: string
|
||||
description: RichText
|
||||
creatorDid: string
|
||||
creatorHandle: string
|
||||
}
|
||||
|
||||
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
|
||||
|
||||
export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [
|
||||
'getFeedSourceInfo',
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function useLikeMutation() {
|
||||
const {agent} = useSession()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({uri, cid}: {uri: string; cid: string}) => {
|
||||
const res = await agent.like(uri, cid)
|
||||
return {uri: res.uri}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnlikeMutation() {
|
||||
const {agent} = useSession()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({uri}: {uri: string}) => {
|
||||
await agent.deleteLike(uri)
|
||||
},
|
||||
})
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||
import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export const suggestedFeedsQueryKey = ['suggestedFeeds']
|
||||
|
||||
export function useSuggestedFeedsQuery() {
|
||||
const {agent} = useSession()
|
||||
|
||||
return useInfiniteQuery<
|
||||
AppBskyFeedGetSuggestedFeeds.OutputSchema,
|
||||
Error,
|
||||
InfiniteData<AppBskyFeedGetSuggestedFeeds.OutputSchema>,
|
||||
QueryKey,
|
||||
string | undefined
|
||||
>({
|
||||
queryKey: suggestedFeedsQueryKey,
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.feed.getSuggestedFeeds({
|
||||
limit: 10,
|
||||
cursor: pageParam,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
})
|
||||
}
|
|
@ -10,10 +10,8 @@ import {Button} from 'view/com/util/forms/Button'
|
|||
import {RecommendedFeedsItem} from './RecommendedFeedsItem'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
|
@ -21,35 +19,11 @@ type Props = {
|
|||
export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
||||
next,
|
||||
}: Props) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
const {isLoading, data: recommendedFeeds} = useQuery({
|
||||
staleTime: Infinity, // fixed list rn, never refetch
|
||||
queryKey: ['onboarding', 'recommended_feeds'],
|
||||
async queryFn() {
|
||||
try {
|
||||
const {
|
||||
data: {feeds},
|
||||
success,
|
||||
} = await store.agent.app.bsky.feed.getSuggestedFeeds()
|
||||
const {isLoading, data} = useSuggestedFeedsQuery()
|
||||
|
||||
if (!success) {
|
||||
return []
|
||||
}
|
||||
|
||||
return (feeds.length ? feeds : []).map(feed => {
|
||||
const model = new FeedSourceModel(store, feed.uri)
|
||||
model.hydrateFeedGenerator(feed)
|
||||
return model
|
||||
})
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const hasFeeds = recommendedFeeds && recommendedFeeds.length
|
||||
const hasFeeds = data && data?.pages?.[0]?.feeds?.length
|
||||
|
||||
const title = (
|
||||
<>
|
||||
|
@ -118,7 +92,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
|||
contentStyle={{paddingHorizontal: 0}}>
|
||||
{hasFeeds ? (
|
||||
<FlatList
|
||||
data={recommendedFeeds}
|
||||
data={data.pages[0].feeds}
|
||||
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
|
||||
keyExtractor={item => item.uri}
|
||||
style={{flex: 1}}
|
||||
|
@ -146,7 +120,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
|||
|
||||
{hasFeeds ? (
|
||||
<FlatList
|
||||
data={recommendedFeeds}
|
||||
data={data.pages[0].feeds}
|
||||
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
|
||||
keyExtractor={item => item.uri}
|
||||
style={{flex: 1}}
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import {View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {RichText} from 'view/com/util/text/RichText'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
|
@ -11,33 +12,58 @@ import {HeartIcon} from 'lib/icons'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePinFeedMutation,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||
item,
|
||||
}: {
|
||||
item: FeedSourceModel
|
||||
item: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
if (!item) return null
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: pinFeed,
|
||||
variables: pinnedFeed,
|
||||
reset: resetPinFeed,
|
||||
} = usePinFeedMutation()
|
||||
const {
|
||||
mutateAsync: removeFeed,
|
||||
variables: removedFeed,
|
||||
reset: resetRemoveFeed,
|
||||
} = useRemoveFeedMutation()
|
||||
|
||||
if (!item || !preferences) return null
|
||||
|
||||
const isPinned =
|
||||
!removedFeed?.uri &&
|
||||
(pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
|
||||
|
||||
const onToggle = async () => {
|
||||
if (item.isSaved) {
|
||||
if (isPinned) {
|
||||
try {
|
||||
await item.unsave()
|
||||
await removeFeed({uri: item.uri})
|
||||
resetRemoveFeed()
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
console.error('Failed to unsave feed', {e})
|
||||
logger.error('Failed to unsave feed', {error: e})
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await item.pin()
|
||||
await pinFeed({uri: item.uri})
|
||||
resetPinFeed()
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
console.error('Failed to pin feed', {e})
|
||||
logger.error('Failed to pin feed', {error: e})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID={`feed-${item.displayName}`}>
|
||||
<View
|
||||
|
@ -66,10 +92,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
|||
</Text>
|
||||
|
||||
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
|
||||
by {sanitizeHandle(item.creatorHandle, '@')}
|
||||
by {sanitizeHandle(item.creator.handle, '@')}
|
||||
</Text>
|
||||
|
||||
{item.descriptionRT ? (
|
||||
{item.description ? (
|
||||
<RichText
|
||||
type="xl"
|
||||
style={[
|
||||
|
@ -80,7 +106,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
|||
marginBottom: 18,
|
||||
},
|
||||
]}
|
||||
richText={item.descriptionRT}
|
||||
richText={new BskRichText({text: item.description || ''})}
|
||||
numberOfLines={6}
|
||||
/>
|
||||
) : null}
|
||||
|
@ -97,7 +123,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
|||
paddingRight: 2,
|
||||
gap: 6,
|
||||
}}>
|
||||
{item.isSaved ? (
|
||||
{isPinned ? (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
|
|
|
@ -7,7 +7,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
|
@ -23,7 +22,7 @@ import {
|
|||
} from '#/state/queries/preferences'
|
||||
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
|
||||
export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
feedUri,
|
||||
style,
|
||||
showSaveBtn = false,
|
||||
|
@ -162,128 +161,6 @@ export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
|
|||
)
|
||||
})
|
||||
|
||||
export const FeedSourceCard = observer(function FeedSourceCardImpl({
|
||||
item,
|
||||
style,
|
||||
showSaveBtn = false,
|
||||
showDescription = false,
|
||||
showLikes = false,
|
||||
}: {
|
||||
item: FeedSourceModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
showSaveBtn?: boolean
|
||||
showDescription?: boolean
|
||||
showLikes?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
const onToggleSaved = React.useCallback(async () => {
|
||||
if (item.isSaved) {
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: 'Remove from my feeds',
|
||||
message: `Remove ${item.displayName} from my feeds?`,
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await item.unsave()
|
||||
Toast.show('Removed from my feeds')
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
logger.error('Failed to unsave feed', {error: e})
|
||||
}
|
||||
},
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
await item.save()
|
||||
Toast.show('Added to my feeds')
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
logger.error('Failed to save feed', {error: e})
|
||||
}
|
||||
}
|
||||
}, [openModal, item])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={`feed-${item.displayName}`}
|
||||
accessibilityRole="button"
|
||||
style={[styles.container, pal.border, style]}
|
||||
onPress={() => {
|
||||
if (item.type === 'feed-generator') {
|
||||
navigation.push('ProfileFeed', {
|
||||
name: item.creatorDid,
|
||||
rkey: new AtUri(item.uri).rkey,
|
||||
})
|
||||
} else if (item.type === 'list') {
|
||||
navigation.push('ProfileList', {
|
||||
name: item.creatorDid,
|
||||
rkey: new AtUri(item.uri).rkey,
|
||||
})
|
||||
}
|
||||
}}
|
||||
key={item.uri}>
|
||||
<View style={[styles.headerContainer]}>
|
||||
<View style={[s.mr10]}>
|
||||
<UserAvatar type="algo" size={36} avatar={item.avatar} />
|
||||
</View>
|
||||
<View style={[styles.headerTextContainer]}>
|
||||
<Text style={[pal.text, s.bold]} numberOfLines={3}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<Text style={[pal.textLight]} numberOfLines={3}>
|
||||
by {sanitizeHandle(item.creatorHandle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
{showSaveBtn && (
|
||||
<View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
|
||||
}
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSaved}
|
||||
hitSlop={15}
|
||||
style={styles.btn}>
|
||||
{item.isSaved ? (
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'trash-can']}
|
||||
size={19}
|
||||
color={pal.colors.icon}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={18}
|
||||
color={pal.colors.link}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showDescription && item.descriptionRT ? (
|
||||
<RichText
|
||||
style={[pal.textLight, styles.description]}
|
||||
richText={item.descriptionRT}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showLikes ? (
|
||||
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
|
||||
Liked by {item.likeCount || 0}{' '}
|
||||
{pluralize(item.likeCount || 0, 'user')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 18,
|
||||
|
|
|
@ -32,9 +32,12 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
|||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useStores} from '#/state'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
|
||||
|
||||
|
@ -59,11 +62,9 @@ type YieldedItem =
|
|||
export function PostThread({
|
||||
uri,
|
||||
onPressReply,
|
||||
treeView,
|
||||
}: {
|
||||
uri: string | undefined
|
||||
onPressReply: () => void
|
||||
treeView: boolean
|
||||
}) {
|
||||
const {
|
||||
isLoading,
|
||||
|
@ -74,6 +75,7 @@ export function PostThread({
|
|||
data: thread,
|
||||
dataUpdatedAt,
|
||||
} = usePostThreadQuery(uri)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
||||
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
||||
|
||||
|
@ -96,7 +98,7 @@ export function PostThread({
|
|||
if (AppBskyFeedDefs.isBlockedPost(thread)) {
|
||||
return <PostThreadBlocked />
|
||||
}
|
||||
if (!thread || isLoading) {
|
||||
if (!thread || isLoading || !preferences) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<View style={s.p20}>
|
||||
|
@ -110,7 +112,7 @@ export function PostThread({
|
|||
thread={thread}
|
||||
isRefetching={isRefetching}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
treeView={treeView}
|
||||
threadViewPrefs={preferences.threadViewPrefs}
|
||||
onRefresh={refetch}
|
||||
onPressReply={onPressReply}
|
||||
/>
|
||||
|
@ -121,20 +123,19 @@ function PostThreadLoaded({
|
|||
thread,
|
||||
isRefetching,
|
||||
dataUpdatedAt,
|
||||
treeView,
|
||||
threadViewPrefs,
|
||||
onRefresh,
|
||||
onPressReply,
|
||||
}: {
|
||||
thread: ThreadNode
|
||||
isRefetching: boolean
|
||||
dataUpdatedAt: number
|
||||
treeView: boolean
|
||||
threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
|
||||
onRefresh: () => void
|
||||
onPressReply: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {isTablet, isDesktop} = useWebMediaQueries()
|
||||
const ref = useRef<FlatList>(null)
|
||||
// const hasScrolledIntoView = useRef<boolean>(false) TODO
|
||||
|
@ -162,16 +163,14 @@ function PostThreadLoaded({
|
|||
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
|
||||
const posts = React.useMemo(() => {
|
||||
let arr = [TOP_COMPONENT].concat(
|
||||
Array.from(
|
||||
flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
|
||||
),
|
||||
Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
|
||||
)
|
||||
if (arr.length > maxVisible) {
|
||||
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
||||
}
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
return arr
|
||||
}, [thread, maxVisible, store.preferences.thread])
|
||||
}, [thread, maxVisible, threadViewPrefs])
|
||||
|
||||
// TODO
|
||||
/*const onContentSizeChange = React.useCallback(() => {
|
||||
|
@ -297,7 +296,7 @@ function PostThreadLoaded({
|
|||
post={item.post}
|
||||
record={item.record}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
treeView={treeView}
|
||||
treeView={threadViewPrefs.lab_treeViewEnabled}
|
||||
depth={item.ctx.depth}
|
||||
isHighlightedPost={item.ctx.isHighlightedPost}
|
||||
hasMore={item.ctx.hasMore}
|
||||
|
@ -322,7 +321,7 @@ function PostThreadLoaded({
|
|||
pal.colors.border,
|
||||
posts,
|
||||
onRefresh,
|
||||
treeView,
|
||||
threadViewPrefs.lab_treeViewEnabled,
|
||||
dataUpdatedAt,
|
||||
_,
|
||||
],
|
||||
|
|
|
@ -8,12 +8,12 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useStores} from 'state/index'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
||||
|
||||
enum KnownError {
|
||||
Block,
|
||||
|
@ -86,12 +86,12 @@ function FeedgenErrorMessage({
|
|||
knownError: KnownError
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const msg = MESSAGES[knownError]
|
||||
const [_, uri] = feedDesc.split('|')
|
||||
const [ownerDid] = safeParseFeedgenUri(uri)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
|
||||
|
||||
const onViewProfile = React.useCallback(() => {
|
||||
navigation.navigate('Profile', {name: ownerDid})
|
||||
|
@ -104,7 +104,7 @@ function FeedgenErrorMessage({
|
|||
message: 'Remove this feed from your saved feeds?',
|
||||
async onPressConfirm() {
|
||||
try {
|
||||
await store.preferences.removeSavedFeed(uri)
|
||||
await removeFeed({uri})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue removing this feed. Please check your internet connection and try again.',
|
||||
|
@ -116,7 +116,7 @@ function FeedgenErrorMessage({
|
|||
closeModal()
|
||||
},
|
||||
})
|
||||
}, [store, openModal, closeModal, uri])
|
||||
}, [openModal, closeModal, uri, removeFeed])
|
||||
|
||||
return (
|
||||
<View
|
||||
|
|
|
@ -52,6 +52,7 @@ export function Button({
|
|||
accessibilityLabelledBy,
|
||||
onAccessibilityEscape,
|
||||
withLoading = false,
|
||||
disabled = false,
|
||||
}: React.PropsWithChildren<{
|
||||
type?: ButtonType
|
||||
label?: string
|
||||
|
@ -65,6 +66,7 @@ export function Button({
|
|||
accessibilityLabelledBy?: string
|
||||
onAccessibilityEscape?: () => void
|
||||
withLoading?: boolean
|
||||
disabled?: boolean
|
||||
}>) {
|
||||
const theme = useTheme()
|
||||
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
|
||||
|
@ -198,7 +200,7 @@ export function Button({
|
|||
<Pressable
|
||||
style={getStyle}
|
||||
onPress={onPressWrapped}
|
||||
disabled={isLoading}
|
||||
disabled={disabled || isLoading}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import React, {useMemo} from 'react'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
|
||||
export function CustomFeedEmbed({
|
||||
record,
|
||||
}: {
|
||||
record: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const item = useMemo(() => {
|
||||
const model = new FeedSourceModel(store, record.uri)
|
||||
model.hydrateFeedGenerator(record)
|
||||
return model
|
||||
}, [store, record])
|
||||
return (
|
||||
<FeedSourceCard
|
||||
item={item}
|
||||
style={[pal.view, pal.border, styles.customFeedOuter]}
|
||||
showLikes
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
customFeedOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
})
|
|
@ -28,9 +28,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
|||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {CustomFeedEmbed} from './CustomFeedEmbed'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
import {isCauseALabelOnUri} from 'lib/moderation'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
|
@ -72,7 +72,13 @@ export function PostEmbeds({
|
|||
// custom feed embed (i.e. generator view)
|
||||
// =
|
||||
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
||||
return <CustomFeedEmbed record={embed.record} />
|
||||
return (
|
||||
<FeedSourceCard
|
||||
feedUri={embed.record.uri}
|
||||
style={[pal.view, pal.border, styles.customFeedOuter]}
|
||||
showLikes
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// list embed
|
||||
|
@ -206,4 +212,11 @@ const styles = StyleSheet.create({
|
|||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
customFeedOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -23,7 +23,7 @@ import debounce from 'lodash.debounce'
|
|||
import {Text} from 'view/com/util/text/Text'
|
||||
import {FlatList} from 'view/com/util/Views'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
|
@ -412,7 +412,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
|
|||
return <FeedFeedLoadingPlaceholder />
|
||||
} else if (item.type === 'popularFeed') {
|
||||
return (
|
||||
<NewFeedSourceCard
|
||||
<FeedSourceCard
|
||||
feedUri={item.feedUri}
|
||||
showSaveBtn
|
||||
showDescription
|
||||
|
|
|
@ -84,7 +84,6 @@ export const PostThreadScreen = withAuthRequired(
|
|||
<PostThreadComponent
|
||||
uri={resolvedUri?.uri}
|
||||
onPressReply={onPressReply}
|
||||
treeView={!!store.preferences.thread.lab_treeViewEnabled}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -17,7 +17,6 @@ import {makeRecordUri} from 'lib/strings/url-helpers'
|
|||
import {colors, s} from 'lib/styles'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from 'state/index'
|
||||
import {FeedSourceModel} from 'state/models/content/feed-source'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
|
@ -32,7 +31,6 @@ import {FAB} from 'view/com/util/fab/FAB'
|
|||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
|
@ -40,7 +38,6 @@ import {toShareUrl} from 'lib/strings/url-helpers'
|
|||
import {Haptics} from 'lib/haptics'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
|
||||
import {resolveName} from 'lib/api'
|
||||
import {makeCustomFeedLink} from 'lib/routes/links'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {CenteredView, ScrollView} from 'view/com/util/Views'
|
||||
|
@ -53,6 +50,18 @@ import {Trans, msg} from '@lingui/macro'
|
|||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesQuery,
|
||||
useSaveFeedMutation,
|
||||
useRemoveFeedMutation,
|
||||
usePinFeedMutation,
|
||||
useUnpinFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
|
||||
const SECTION_TITLES = ['Posts', 'About']
|
||||
|
||||
|
@ -63,15 +72,17 @@ interface SectionRef {
|
|||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
|
||||
export const ProfileFeedScreen = withAuthRequired(
|
||||
observer(function ProfileFeedScreenImpl(props: Props) {
|
||||
const {rkey, name: handleOrDid} = props.route.params
|
||||
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {name: handleOrDid} = props.route.params
|
||||
|
||||
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
|
||||
const [error, setError] = React.useState<string | undefined>()
|
||||
const uri = useMemo(
|
||||
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
|
||||
[rkey, handleOrDid],
|
||||
)
|
||||
const {error, data: resolvedUri} = useResolveUriQuery(uri)
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
|
@ -81,24 +92,6 @@ export const ProfileFeedScreen = withAuthRequired(
|
|||
}
|
||||
}, [navigation])
|
||||
|
||||
React.useEffect(() => {
|
||||
/*
|
||||
* We must resolve the DID of the feed owner before we can fetch the feed.
|
||||
*/
|
||||
async function fetchDid() {
|
||||
try {
|
||||
const did = await resolveName(store, handleOrDid)
|
||||
setFeedOwnerDid(did)
|
||||
} catch (e) {
|
||||
setError(
|
||||
`We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDid()
|
||||
}, [store, handleOrDid, setFeedOwnerDid])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<CenteredView>
|
||||
|
@ -107,7 +100,7 @@ export const ProfileFeedScreen = withAuthRequired(
|
|||
<Trans>Could not load feed</Trans>
|
||||
</Text>
|
||||
<Text type="md" style={[pal.text, s.mb20]}>
|
||||
{error}
|
||||
{error.toString()}
|
||||
</Text>
|
||||
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
|
@ -127,8 +120,8 @@ export const ProfileFeedScreen = withAuthRequired(
|
|||
)
|
||||
}
|
||||
|
||||
return feedOwnerDid ? (
|
||||
<ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
|
||||
return resolvedUri ? (
|
||||
<ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
|
||||
) : (
|
||||
<CenteredView>
|
||||
<View style={s.p20}>
|
||||
|
@ -139,255 +132,305 @@ export const ProfileFeedScreen = withAuthRequired(
|
|||
}),
|
||||
)
|
||||
|
||||
export const ProfileFeedScreenInner = observer(
|
||||
function ProfileFeedScreenInnerImpl({
|
||||
route,
|
||||
feedOwnerDid,
|
||||
}: Props & {feedOwnerDid: string}) {
|
||||
const {openModal} = useModalControls()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
const {rkey, name: handleOrDid} = route.params
|
||||
const uri = useMemo(
|
||||
() => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
|
||||
[rkey, feedOwnerDid],
|
||||
)
|
||||
const feedInfo = useCustomFeed(uri)
|
||||
const isPinned = store.preferences.isPinnedFeed(uri)
|
||||
useSetTitle(feedInfo?.displayName)
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const onToggleSaved = React.useCallback(async () => {
|
||||
try {
|
||||
Haptics.default()
|
||||
if (feedInfo?.isSaved) {
|
||||
await feedInfo?.unsave()
|
||||
} else {
|
||||
await feedInfo?.save()
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue updating your feeds, please check your internet connection and try again.',
|
||||
)
|
||||
logger.error('Failed up update feeds', {error: err})
|
||||
}
|
||||
}, [feedInfo])
|
||||
|
||||
const onToggleLiked = React.useCallback(async () => {
|
||||
Haptics.default()
|
||||
try {
|
||||
if (feedInfo?.isLiked) {
|
||||
await feedInfo?.unlike()
|
||||
} else {
|
||||
await feedInfo?.like()
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue contacting the server, please check your internet connection and try again.',
|
||||
)
|
||||
logger.error('Failed up toggle like', {error: err})
|
||||
}
|
||||
}, [feedInfo])
|
||||
|
||||
const onTogglePinned = React.useCallback(async () => {
|
||||
Haptics.default()
|
||||
if (feedInfo) {
|
||||
feedInfo.togglePin().catch(e => {
|
||||
Toast.show('There was an issue contacting the server')
|
||||
logger.error('Failed to toggle pinned feed', {error: e})
|
||||
})
|
||||
}
|
||||
}, [feedInfo])
|
||||
|
||||
const onPressShare = React.useCallback(() => {
|
||||
const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
|
||||
shareUrl(url)
|
||||
track('CustomFeed:Share')
|
||||
}, [handleOrDid, rkey, track])
|
||||
|
||||
const onPressReport = React.useCallback(() => {
|
||||
if (!feedInfo) return
|
||||
openModal({
|
||||
name: 'report',
|
||||
uri: feedInfo.uri,
|
||||
cid: feedInfo.cid,
|
||||
})
|
||||
}, [openModal, feedInfo])
|
||||
|
||||
const onCurrentPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === 0) {
|
||||
feedSectionRef.current?.scrollToTop()
|
||||
}
|
||||
},
|
||||
[feedSectionRef],
|
||||
)
|
||||
|
||||
// render
|
||||
// =
|
||||
|
||||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
testID: 'feedHeaderDropdownToggleSavedBtn',
|
||||
label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds',
|
||||
onPress: onToggleSaved,
|
||||
icon: feedInfo?.isSaved
|
||||
? {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
},
|
||||
android: 'ic_delete',
|
||||
web: ['far', 'trash-can'],
|
||||
}
|
||||
: {
|
||||
ios: {
|
||||
name: 'plus',
|
||||
},
|
||||
android: '',
|
||||
web: 'plus',
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'feedHeaderDropdownReportBtn',
|
||||
label: 'Report feed',
|
||||
onPress: onPressReport,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'exclamationmark.triangle',
|
||||
},
|
||||
android: 'ic_menu_report_image',
|
||||
web: 'circle-exclamation',
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'feedHeaderDropdownShareBtn',
|
||||
label: 'Share link',
|
||||
onPress: onPressShare,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'square.and.arrow.up',
|
||||
},
|
||||
android: 'ic_menu_share',
|
||||
web: 'share',
|
||||
},
|
||||
},
|
||||
] as DropdownItem[]
|
||||
}, [feedInfo, onToggleSaved, onPressReport, onPressShare])
|
||||
|
||||
const renderHeader = useCallback(() => {
|
||||
return (
|
||||
<ProfileSubpageHeader
|
||||
isLoading={!feedInfo?.hasLoaded}
|
||||
href={makeCustomFeedLink(feedOwnerDid, rkey)}
|
||||
title={feedInfo?.displayName}
|
||||
avatar={feedInfo?.avatar}
|
||||
isOwner={feedInfo?.isOwner}
|
||||
creator={
|
||||
feedInfo
|
||||
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
|
||||
: undefined
|
||||
}
|
||||
avatarType="algo">
|
||||
{feedInfo && (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
|
||||
onPress={onToggleSaved}
|
||||
style={styles.btn}
|
||||
/>
|
||||
<Button
|
||||
type={isPinned ? 'default' : 'inverted'}
|
||||
label={isPinned ? 'Unpin' : 'Pin to home'}
|
||||
onPress={onTogglePinned}
|
||||
style={styles.btn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NativeDropdown
|
||||
testID="headerDropdownBtn"
|
||||
items={dropdownItems}
|
||||
accessibilityLabel={_(msg`More options`)}
|
||||
accessibilityHint="">
|
||||
<View style={[pal.viewLight, styles.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis"
|
||||
size={20}
|
||||
color={pal.colors.text}
|
||||
/>
|
||||
</View>
|
||||
</NativeDropdown>
|
||||
</ProfileSubpageHeader>
|
||||
)
|
||||
}, [
|
||||
pal,
|
||||
feedOwnerDid,
|
||||
rkey,
|
||||
feedInfo,
|
||||
isPinned,
|
||||
onTogglePinned,
|
||||
onToggleSaved,
|
||||
dropdownItems,
|
||||
_,
|
||||
])
|
||||
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
|
||||
|
||||
if (!preferences || !info) {
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES}
|
||||
isHeaderReady={feedInfo?.hasLoaded ?? false}
|
||||
renderHeader={renderHeader}
|
||||
onCurrentPageSelected={onCurrentPageSelected}>
|
||||
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`feedgen|${uri}`}
|
||||
onScroll={onScroll}
|
||||
headerHeight={headerHeight}
|
||||
isScrolledDown={isScrolledDown}
|
||||
scrollElRef={
|
||||
scrollElRef as React.MutableRefObject<FlatList<any> | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{({onScroll, headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
feedOwnerDid={feedOwnerDid}
|
||||
feedRkey={rkey}
|
||||
feedInfo={feedInfo}
|
||||
headerHeight={headerHeight}
|
||||
onToggleLiked={onToggleLiked}
|
||||
onScroll={onScroll}
|
||||
scrollElRef={
|
||||
scrollElRef as React.MutableRefObject<ScrollView | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => store.shell.openComposer({})}
|
||||
icon={
|
||||
<ComposeIcon2
|
||||
strokeWidth={1.5}
|
||||
size={29}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
<CenteredView>
|
||||
<View style={s.p20}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileFeedScreenInner
|
||||
preferences={preferences}
|
||||
feedInfo={info as FeedSourceFeedInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProfileFeedScreenInner = function ProfileFeedScreenInnerImpl({
|
||||
preferences,
|
||||
feedInfo,
|
||||
}: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
feedInfo: FeedSourceFeedInfo
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
|
||||
const {
|
||||
mutateAsync: saveFeed,
|
||||
variables: savedFeed,
|
||||
reset: resetSaveFeed,
|
||||
isPending: isSavePending,
|
||||
} = useSaveFeedMutation()
|
||||
const {
|
||||
mutateAsync: removeFeed,
|
||||
variables: removedFeed,
|
||||
reset: resetRemoveFeed,
|
||||
isPending: isRemovePending,
|
||||
} = useRemoveFeedMutation()
|
||||
const {
|
||||
mutateAsync: pinFeed,
|
||||
variables: pinnedFeed,
|
||||
reset: resetPinFeed,
|
||||
isPending: isPinPending,
|
||||
} = usePinFeedMutation()
|
||||
const {
|
||||
mutateAsync: unpinFeed,
|
||||
variables: unpinnedFeed,
|
||||
reset: resetUnpinFeed,
|
||||
isPending: isUnpinPending,
|
||||
} = useUnpinFeedMutation()
|
||||
|
||||
const isSaved =
|
||||
!removedFeed &&
|
||||
(!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
|
||||
const isPinned =
|
||||
!unpinnedFeed &&
|
||||
(!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
|
||||
|
||||
useSetTitle(feedInfo?.displayName)
|
||||
|
||||
const onToggleSaved = React.useCallback(async () => {
|
||||
try {
|
||||
Haptics.default()
|
||||
|
||||
if (isSaved) {
|
||||
await removeFeed({uri: feedInfo.uri})
|
||||
resetRemoveFeed()
|
||||
} else {
|
||||
await saveFeed({uri: feedInfo.uri})
|
||||
resetSaveFeed()
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue updating your feeds, please check your internet connection and try again.',
|
||||
)
|
||||
logger.error('Failed up update feeds', {error: err})
|
||||
}
|
||||
}, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed])
|
||||
|
||||
const onTogglePinned = React.useCallback(async () => {
|
||||
try {
|
||||
Haptics.default()
|
||||
|
||||
if (isPinned) {
|
||||
await unpinFeed({uri: feedInfo.uri})
|
||||
resetUnpinFeed()
|
||||
} else {
|
||||
await pinFeed({uri: feedInfo.uri})
|
||||
resetPinFeed()
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting the server')
|
||||
logger.error('Failed to toggle pinned feed', {error: e})
|
||||
}
|
||||
}, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed])
|
||||
|
||||
const onPressShare = React.useCallback(() => {
|
||||
const url = toShareUrl(feedInfo.route.href)
|
||||
shareUrl(url)
|
||||
track('CustomFeed:Share')
|
||||
}, [feedInfo, track])
|
||||
|
||||
const onPressReport = React.useCallback(() => {
|
||||
if (!feedInfo) return
|
||||
openModal({
|
||||
name: 'report',
|
||||
uri: feedInfo.uri,
|
||||
cid: feedInfo.cid,
|
||||
})
|
||||
}, [openModal, feedInfo])
|
||||
|
||||
const onCurrentPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === 0) {
|
||||
feedSectionRef.current?.scrollToTop()
|
||||
}
|
||||
},
|
||||
[feedSectionRef],
|
||||
)
|
||||
|
||||
// render
|
||||
// =
|
||||
|
||||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
testID: 'feedHeaderDropdownToggleSavedBtn',
|
||||
label: isSaved ? 'Remove from my feeds' : 'Add to my feeds',
|
||||
onPress: isSavePending || isRemovePending ? undefined : onToggleSaved,
|
||||
icon: isSaved
|
||||
? {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
},
|
||||
android: 'ic_delete',
|
||||
web: ['far', 'trash-can'],
|
||||
}
|
||||
: {
|
||||
ios: {
|
||||
name: 'plus',
|
||||
},
|
||||
android: '',
|
||||
web: 'plus',
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'feedHeaderDropdownReportBtn',
|
||||
label: 'Report feed',
|
||||
onPress: onPressReport,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'exclamationmark.triangle',
|
||||
},
|
||||
android: 'ic_menu_report_image',
|
||||
web: 'circle-exclamation',
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'feedHeaderDropdownShareBtn',
|
||||
label: 'Share link',
|
||||
onPress: onPressShare,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'square.and.arrow.up',
|
||||
},
|
||||
android: 'ic_menu_share',
|
||||
web: 'share',
|
||||
},
|
||||
},
|
||||
] as DropdownItem[]
|
||||
}, [
|
||||
onToggleSaved,
|
||||
onPressReport,
|
||||
onPressShare,
|
||||
isSaved,
|
||||
isSavePending,
|
||||
isRemovePending,
|
||||
])
|
||||
|
||||
const renderHeader = useCallback(() => {
|
||||
return (
|
||||
<ProfileSubpageHeader
|
||||
isLoading={false}
|
||||
href={feedInfo.route.href}
|
||||
title={feedInfo?.displayName}
|
||||
avatar={feedInfo?.avatar}
|
||||
isOwner={feedInfo.creatorDid === currentAccount?.did}
|
||||
creator={
|
||||
feedInfo
|
||||
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
|
||||
: undefined
|
||||
}
|
||||
avatarType="algo">
|
||||
{feedInfo && (
|
||||
<>
|
||||
<Button
|
||||
disabled={isSavePending || isRemovePending}
|
||||
type="default"
|
||||
label={isSaved ? 'Unsave' : 'Save'}
|
||||
onPress={onToggleSaved}
|
||||
style={styles.btn}
|
||||
/>
|
||||
<Button
|
||||
disabled={isPinPending || isUnpinPending}
|
||||
type={isPinned ? 'default' : 'inverted'}
|
||||
label={isPinned ? 'Unpin' : 'Pin to home'}
|
||||
onPress={onTogglePinned}
|
||||
style={styles.btn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NativeDropdown
|
||||
testID="headerDropdownBtn"
|
||||
items={dropdownItems}
|
||||
accessibilityLabel={_(msg`More options`)}
|
||||
accessibilityHint="">
|
||||
<View style={[pal.viewLight, styles.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis"
|
||||
size={20}
|
||||
color={pal.colors.text}
|
||||
/>
|
||||
</View>
|
||||
</NativeDropdown>
|
||||
</ProfileSubpageHeader>
|
||||
)
|
||||
}, [
|
||||
_,
|
||||
pal,
|
||||
feedInfo,
|
||||
isPinned,
|
||||
onTogglePinned,
|
||||
onToggleSaved,
|
||||
dropdownItems,
|
||||
currentAccount?.did,
|
||||
isPinPending,
|
||||
isRemovePending,
|
||||
isSavePending,
|
||||
isSaved,
|
||||
isUnpinPending,
|
||||
])
|
||||
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}
|
||||
onCurrentPageSelected={onCurrentPageSelected}>
|
||||
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`feedgen|${feedInfo.uri}`}
|
||||
onScroll={onScroll}
|
||||
headerHeight={headerHeight}
|
||||
isScrolledDown={isScrolledDown}
|
||||
scrollElRef={
|
||||
scrollElRef as React.MutableRefObject<FlatList<any> | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{({onScroll, headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
feedOwnerDid={feedInfo.creatorDid}
|
||||
feedRkey={feedInfo.route.params.rkey}
|
||||
feedInfo={feedInfo}
|
||||
headerHeight={headerHeight}
|
||||
onScroll={onScroll}
|
||||
scrollElRef={
|
||||
scrollElRef as React.MutableRefObject<ScrollView | null>
|
||||
}
|
||||
isOwner={feedInfo.creatorDid === currentAccount?.did}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => store.shell.openComposer({})}
|
||||
icon={
|
||||
<ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeedSectionProps {
|
||||
feed: FeedDescriptor
|
||||
|
@ -447,25 +490,49 @@ const AboutSection = observer(function AboutPageImpl({
|
|||
feedRkey,
|
||||
feedInfo,
|
||||
headerHeight,
|
||||
onToggleLiked,
|
||||
onScroll,
|
||||
scrollElRef,
|
||||
isOwner,
|
||||
}: {
|
||||
feedOwnerDid: string
|
||||
feedRkey: string
|
||||
feedInfo: FeedSourceModel | undefined
|
||||
feedInfo: FeedSourceFeedInfo
|
||||
headerHeight: number
|
||||
onToggleLiked: () => void
|
||||
onScroll: OnScrollHandler
|
||||
scrollElRef: React.MutableRefObject<ScrollView | null>
|
||||
isOwner: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const scrollHandler = useAnimatedScrollHandler(onScroll)
|
||||
const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
|
||||
|
||||
if (!feedInfo) {
|
||||
return <View />
|
||||
}
|
||||
const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
|
||||
const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
|
||||
useUnlikeMutation()
|
||||
|
||||
const isLiked = !!likeUri
|
||||
const likeCount =
|
||||
isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount
|
||||
|
||||
const onToggleLiked = React.useCallback(async () => {
|
||||
try {
|
||||
Haptics.default()
|
||||
|
||||
if (isLiked && likeUri) {
|
||||
await unlikeFeed({uri: likeUri})
|
||||
setLikeUri('')
|
||||
} else {
|
||||
const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid})
|
||||
setLikeUri(res.uri)
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue contacting the server, please check your internet connection and try again.',
|
||||
)
|
||||
logger.error('Failed up toggle like', {error: err})
|
||||
}
|
||||
}, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed])
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
@ -486,12 +553,12 @@ const AboutSection = observer(function AboutPageImpl({
|
|||
},
|
||||
pal.border,
|
||||
]}>
|
||||
{feedInfo.descriptionRT ? (
|
||||
{feedInfo.description ? (
|
||||
<RichText
|
||||
testID="listDescription"
|
||||
type="lg"
|
||||
style={pal.text}
|
||||
richText={feedInfo.descriptionRT}
|
||||
richText={feedInfo.description}
|
||||
/>
|
||||
) : (
|
||||
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
|
||||
|
@ -504,28 +571,26 @@ const AboutSection = observer(function AboutPageImpl({
|
|||
testID="toggleLikeBtn"
|
||||
accessibilityLabel={_(msg`Like this feed`)}
|
||||
accessibilityHint=""
|
||||
disabled={isLikePending || isUnlikePending}
|
||||
onPress={onToggleLiked}
|
||||
style={{paddingHorizontal: 10}}>
|
||||
{feedInfo?.isLiked ? (
|
||||
{isLiked ? (
|
||||
<HeartIconSolid size={19} style={styles.liked} />
|
||||
) : (
|
||||
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
|
||||
)}
|
||||
</Button>
|
||||
{typeof feedInfo.likeCount === 'number' && (
|
||||
{typeof likeCount === 'number' && (
|
||||
<TextLink
|
||||
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
|
||||
text={`Liked by ${feedInfo.likeCount} ${pluralize(
|
||||
feedInfo.likeCount,
|
||||
'user',
|
||||
)}`}
|
||||
text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
|
||||
style={[pal.textLight, s.semiBold]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
Created by{' '}
|
||||
{feedInfo.isOwner ? (
|
||||
{isOwner ? (
|
||||
'you'
|
||||
) : (
|
||||
<TextLink
|
||||
|
|
|
@ -21,7 +21,7 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
|
|||
import {ScrollView, CenteredView} from 'view/com/util/Views'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {Haptics} from 'lib/haptics'
|
||||
|
@ -250,7 +250,7 @@ const ListItem = observer(function ListItemImpl({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
<NewFeedSourceCard
|
||||
<FeedSourceCard
|
||||
key={feedUri}
|
||||
feedUri={feedUri}
|
||||
style={styles.noBorder}
|
||||
|
|
Loading…
Reference in New Issue