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 commit
zio/stable
Eric Bailey 2023-11-13 15:53:57 -06:00 committed by GitHub
parent a01463788d
commit 06eb8b9a4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 526 additions and 1356 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf

View File

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

View File

@ -21,39 +21,41 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
export type FeedSourceInfo = export type FeedSourceFeedInfo = {
| { type: 'feed'
type: 'feed' uri: string
uri: string route: {
route: { href: string
href: string name: string
name: string params: Record<string, string>
params: Record<string, string> }
} cid: string
cid: string avatar: string | undefined
avatar: string | undefined displayName: string
displayName: string description: RichText
description: RichText creatorDid: string
creatorDid: string creatorHandle: string
creatorHandle: string likeCount: number | undefined
likeCount: number | undefined likeUri: string | undefined
likeUri: string | undefined }
}
| { export type FeedSourceListInfo = {
type: 'list' type: 'list'
uri: string uri: string
route: { route: {
href: string href: string
name: string name: string
params: Record<string, string> params: Record<string, string>
} }
cid: string cid: string
avatar: string | undefined avatar: string | undefined
displayName: string displayName: string
description: RichText description: RichText
creatorDid: string creatorDid: string
creatorHandle: string creatorHandle: string
} }
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [ export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [
'getFeedSourceInfo', 'getFeedSourceInfo',

View File

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

View File

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

View File

@ -10,10 +10,8 @@ import {Button} from 'view/com/util/forms/Button'
import {RecommendedFeedsItem} from './RecommendedFeedsItem' import {RecommendedFeedsItem} from './RecommendedFeedsItem'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette' 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 {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
type Props = { type Props = {
next: () => void next: () => void
@ -21,35 +19,11 @@ type Props = {
export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
next, next,
}: Props) { }: Props) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries() const {isTabletOrMobile} = useWebMediaQueries()
const {isLoading, data: recommendedFeeds} = useQuery({ const {isLoading, data} = useSuggestedFeedsQuery()
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()
if (!success) { const hasFeeds = data && data?.pages?.[0]?.feeds?.length
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 title = ( const title = (
<> <>
@ -118,7 +92,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
contentStyle={{paddingHorizontal: 0}}> contentStyle={{paddingHorizontal: 0}}>
{hasFeeds ? ( {hasFeeds ? (
<FlatList <FlatList
data={recommendedFeeds} data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />} renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri} keyExtractor={item => item.uri}
style={{flex: 1}} style={{flex: 1}}
@ -146,7 +120,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
{hasFeeds ? ( {hasFeeds ? (
<FlatList <FlatList
data={recommendedFeeds} data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />} renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri} keyExtractor={item => item.uri}
style={{flex: 1}} style={{flex: 1}}

View File

@ -2,6 +2,7 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {RichText} from 'view/com/util/text/RichText' import {RichText} from 'view/com/util/text/RichText'
import {Button} from 'view/com/util/forms/Button' import {Button} from 'view/com/util/forms/Button'
@ -11,33 +12,58 @@ import {HeartIcon} from 'lib/icons'
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 {sanitizeHandle} from 'lib/strings/handles' 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({ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
item, item,
}: { }: {
item: FeedSourceModel item: AppBskyFeedDefs.GeneratorView
}) { }) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') 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 () => { const onToggle = async () => {
if (item.isSaved) { if (isPinned) {
try { try {
await item.unsave() await removeFeed({uri: item.uri})
resetRemoveFeed()
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') 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 { } else {
try { try {
await item.pin() await pinFeed({uri: item.uri})
resetPinFeed()
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') 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 ( return (
<View testID={`feed-${item.displayName}`}> <View testID={`feed-${item.displayName}`}>
<View <View
@ -66,10 +92,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
</Text> </Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
by {sanitizeHandle(item.creatorHandle, '@')} by {sanitizeHandle(item.creator.handle, '@')}
</Text> </Text>
{item.descriptionRT ? ( {item.description ? (
<RichText <RichText
type="xl" type="xl"
style={[ style={[
@ -80,7 +106,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
marginBottom: 18, marginBottom: 18,
}, },
]} ]}
richText={item.descriptionRT} richText={new BskRichText({text: item.description || ''})}
numberOfLines={6} numberOfLines={6}
/> />
) : null} ) : null}
@ -97,7 +123,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
paddingRight: 2, paddingRight: 2,
gap: 6, gap: 6,
}}> }}>
{item.isSaved ? ( {isPinned ? (
<> <>
<FontAwesomeIcon <FontAwesomeIcon
icon="check" icon="check"

View File

@ -7,7 +7,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
@ -23,7 +22,7 @@ import {
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useFeedSourceInfoQuery} from '#/state/queries/feed' import {useFeedSourceInfoQuery} from '#/state/queries/feed'
export const NewFeedSourceCard = observer(function FeedSourceCardImpl({ export const FeedSourceCard = observer(function FeedSourceCardImpl({
feedUri, feedUri,
style, style,
showSaveBtn = false, 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({ const styles = StyleSheet.create({
container: { container: {
paddingHorizontal: 18, paddingHorizontal: 18,

View File

@ -32,9 +32,12 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useStores} from '#/state'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO // const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
@ -59,11 +62,9 @@ type YieldedItem =
export function PostThread({ export function PostThread({
uri, uri,
onPressReply, onPressReply,
treeView,
}: { }: {
uri: string | undefined uri: string | undefined
onPressReply: () => void onPressReply: () => void
treeView: boolean
}) { }) {
const { const {
isLoading, isLoading,
@ -74,6 +75,7 @@ export function PostThread({
data: thread, data: thread,
dataUpdatedAt, dataUpdatedAt,
} = usePostThreadQuery(uri) } = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
@ -96,7 +98,7 @@ export function PostThread({
if (AppBskyFeedDefs.isBlockedPost(thread)) { if (AppBskyFeedDefs.isBlockedPost(thread)) {
return <PostThreadBlocked /> return <PostThreadBlocked />
} }
if (!thread || isLoading) { if (!thread || isLoading || !preferences) {
return ( return (
<CenteredView> <CenteredView>
<View style={s.p20}> <View style={s.p20}>
@ -110,7 +112,7 @@ export function PostThread({
thread={thread} thread={thread}
isRefetching={isRefetching} isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt} dataUpdatedAt={dataUpdatedAt}
treeView={treeView} threadViewPrefs={preferences.threadViewPrefs}
onRefresh={refetch} onRefresh={refetch}
onPressReply={onPressReply} onPressReply={onPressReply}
/> />
@ -121,20 +123,19 @@ function PostThreadLoaded({
thread, thread,
isRefetching, isRefetching,
dataUpdatedAt, dataUpdatedAt,
treeView, threadViewPrefs,
onRefresh, onRefresh,
onPressReply, onPressReply,
}: { }: {
thread: ThreadNode thread: ThreadNode
isRefetching: boolean isRefetching: boolean
dataUpdatedAt: number dataUpdatedAt: number
treeView: boolean threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
onRefresh: () => void onRefresh: () => void
onPressReply: () => void onPressReply: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {isTablet, isDesktop} = useWebMediaQueries() const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null) const ref = useRef<FlatList>(null)
// const hasScrolledIntoView = useRef<boolean>(false) TODO // const hasScrolledIntoView = useRef<boolean>(false) TODO
@ -162,16 +163,14 @@ function PostThreadLoaded({
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat( let arr = [TOP_COMPONENT].concat(
Array.from( Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
),
) )
if (arr.length > maxVisible) { if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
} }
arr.push(BOTTOM_COMPONENT) arr.push(BOTTOM_COMPONENT)
return arr return arr
}, [thread, maxVisible, store.preferences.thread]) }, [thread, maxVisible, threadViewPrefs])
// TODO // TODO
/*const onContentSizeChange = React.useCallback(() => { /*const onContentSizeChange = React.useCallback(() => {
@ -297,7 +296,7 @@ function PostThreadLoaded({
post={item.post} post={item.post}
record={item.record} record={item.record}
dataUpdatedAt={dataUpdatedAt} dataUpdatedAt={dataUpdatedAt}
treeView={treeView} treeView={threadViewPrefs.lab_treeViewEnabled}
depth={item.ctx.depth} depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost} isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore} hasMore={item.ctx.hasMore}
@ -322,7 +321,7 @@ function PostThreadLoaded({
pal.colors.border, pal.colors.border,
posts, posts,
onRefresh, onRefresh,
treeView, threadViewPrefs.lab_treeViewEnabled,
dataUpdatedAt, dataUpdatedAt,
_, _,
], ],

View File

@ -8,12 +8,12 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
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 {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useRemoveFeedMutation} from '#/state/queries/preferences'
enum KnownError { enum KnownError {
Block, Block,
@ -86,12 +86,12 @@ function FeedgenErrorMessage({
knownError: KnownError knownError: KnownError
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError] const msg = MESSAGES[knownError]
const [_, uri] = feedDesc.split('|') const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri) const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
const onViewProfile = React.useCallback(() => { const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid}) navigation.navigate('Profile', {name: ownerDid})
@ -104,7 +104,7 @@ function FeedgenErrorMessage({
message: 'Remove this feed from your saved feeds?', message: 'Remove this feed from your saved feeds?',
async onPressConfirm() { async onPressConfirm() {
try { try {
await store.preferences.removeSavedFeed(uri) await removeFeed({uri})
} catch (err) { } catch (err) {
Toast.show( Toast.show(
'There was an an issue removing this feed. Please check your internet connection and try again.', 'There was an an issue removing this feed. Please check your internet connection and try again.',
@ -116,7 +116,7 @@ function FeedgenErrorMessage({
closeModal() closeModal()
}, },
}) })
}, [store, openModal, closeModal, uri]) }, [openModal, closeModal, uri, removeFeed])
return ( return (
<View <View

View File

@ -52,6 +52,7 @@ export function Button({
accessibilityLabelledBy, accessibilityLabelledBy,
onAccessibilityEscape, onAccessibilityEscape,
withLoading = false, withLoading = false,
disabled = false,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
type?: ButtonType type?: ButtonType
label?: string label?: string
@ -65,6 +66,7 @@ export function Button({
accessibilityLabelledBy?: string accessibilityLabelledBy?: string
onAccessibilityEscape?: () => void onAccessibilityEscape?: () => void
withLoading?: boolean withLoading?: boolean
disabled?: boolean
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -198,7 +200,7 @@ export function Button({
<Pressable <Pressable
style={getStyle} style={getStyle}
onPress={onPressWrapped} onPress={onPressWrapped}
disabled={isLoading} disabled={disabled || isLoading}
testID={testID} testID={testID}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}

View File

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

View File

@ -28,9 +28,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import {MaybeQuoteEmbed} from './QuoteEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage' import {AutoSizedImage} from '../images/AutoSizedImage'
import {CustomFeedEmbed} from './CustomFeedEmbed'
import {ListEmbed} from './ListEmbed' import {ListEmbed} from './ListEmbed'
import {isCauseALabelOnUri} from 'lib/moderation' import {isCauseALabelOnUri} from 'lib/moderation'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
type Embed = type Embed =
| AppBskyEmbedRecord.View | AppBskyEmbedRecord.View
@ -72,7 +72,13 @@ export function PostEmbeds({
// custom feed embed (i.e. generator view) // custom feed embed (i.e. generator view)
// = // =
if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 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 // list embed
@ -206,4 +212,11 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
fontWeight: 'bold', fontWeight: 'bold',
}, },
customFeedOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
paddingHorizontal: 12,
paddingVertical: 12,
},
}) })

View File

@ -23,7 +23,7 @@ import debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {FlatList} from 'view/com/util/Views' import {FlatList} from 'view/com/util/Views'
import {useFocusEffect} from '@react-navigation/native' 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 {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
@ -412,7 +412,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
return <FeedFeedLoadingPlaceholder /> return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') { } else if (item.type === 'popularFeed') {
return ( return (
<NewFeedSourceCard <FeedSourceCard
feedUri={item.feedUri} feedUri={item.feedUri}
showSaveBtn showSaveBtn
showDescription showDescription

View File

@ -84,7 +84,6 @@ export const PostThreadScreen = withAuthRequired(
<PostThreadComponent <PostThreadComponent
uri={resolvedUri?.uri} uri={resolvedUri?.uri}
onPressReply={onPressReply} onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled}
/> />
)} )}
</View> </View>

View File

@ -17,7 +17,6 @@ import {makeRecordUri} from 'lib/strings/url-helpers'
import {colors, s} from 'lib/styles' 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 {FeedDescriptor} from '#/state/queries/post-feed' 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'
@ -32,7 +31,6 @@ import {FAB} from 'view/com/util/fab/FAB'
import {EmptyState} from 'view/com/util/EmptyState' 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 {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 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'
@ -40,7 +38,6 @@ import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {resolveName} from 'lib/api'
import {makeCustomFeedLink} from 'lib/routes/links' import {makeCustomFeedLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {CenteredView, ScrollView} from 'view/com/util/Views' import {CenteredView, ScrollView} from 'view/com/util/Views'
@ -53,6 +50,18 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 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'] const SECTION_TITLES = ['Posts', 'About']
@ -63,15 +72,17 @@ interface SectionRef {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
export const ProfileFeedScreen = withAuthRequired( export const ProfileFeedScreen = withAuthRequired(
observer(function ProfileFeedScreenImpl(props: Props) { observer(function ProfileFeedScreenImpl(props: Props) {
const {rkey, name: handleOrDid} = props.route.params
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params const uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() [rkey, handleOrDid],
const [error, setError] = React.useState<string | undefined>() )
const {error, data: resolvedUri} = useResolveUriQuery(uri)
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@ -81,24 +92,6 @@ export const ProfileFeedScreen = withAuthRequired(
} }
}, [navigation]) }, [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) { if (error) {
return ( return (
<CenteredView> <CenteredView>
@ -107,7 +100,7 @@ export const ProfileFeedScreen = withAuthRequired(
<Trans>Could not load feed</Trans> <Trans>Could not load feed</Trans>
</Text> </Text>
<Text type="md" style={[pal.text, s.mb20]}> <Text type="md" style={[pal.text, s.mb20]}>
{error} {error.toString()}
</Text> </Text>
<View style={{flexDirection: 'row'}}> <View style={{flexDirection: 'row'}}>
@ -127,8 +120,8 @@ export const ProfileFeedScreen = withAuthRequired(
) )
} }
return feedOwnerDid ? ( return resolvedUri ? (
<ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
) : ( ) : (
<CenteredView> <CenteredView>
<View style={s.p20}> <View style={s.p20}>
@ -139,255 +132,305 @@ export const ProfileFeedScreen = withAuthRequired(
}), }),
) )
export const ProfileFeedScreenInner = observer( function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
function ProfileFeedScreenInnerImpl({ const {data: preferences} = usePreferencesQuery()
route, const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
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,
_,
])
if (!preferences || !info) {
return ( return (
<View style={s.hContentRegion}> <CenteredView>
<PagerWithHeader <View style={s.p20}>
items={SECTION_TITLES} <ActivityIndicator size="large" />
isHeaderReady={feedInfo?.hasLoaded ?? false} </View>
renderHeader={renderHeader} </CenteredView>
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>
) )
}, }
)
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 { interface FeedSectionProps {
feed: FeedDescriptor feed: FeedDescriptor
@ -447,25 +490,49 @@ const AboutSection = observer(function AboutPageImpl({
feedRkey, feedRkey,
feedInfo, feedInfo,
headerHeight, headerHeight,
onToggleLiked,
onScroll, onScroll,
scrollElRef, scrollElRef,
isOwner,
}: { }: {
feedOwnerDid: string feedOwnerDid: string
feedRkey: string feedRkey: string
feedInfo: FeedSourceModel | undefined feedInfo: FeedSourceFeedInfo
headerHeight: number headerHeight: number
onToggleLiked: () => void
onScroll: OnScrollHandler onScroll: OnScrollHandler
scrollElRef: React.MutableRefObject<ScrollView | null> scrollElRef: React.MutableRefObject<ScrollView | null>
isOwner: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const scrollHandler = useAnimatedScrollHandler(onScroll) const scrollHandler = useAnimatedScrollHandler(onScroll)
const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
if (!feedInfo) { const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
return <View /> 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 ( return (
<ScrollView <ScrollView
@ -486,12 +553,12 @@ const AboutSection = observer(function AboutPageImpl({
}, },
pal.border, pal.border,
]}> ]}>
{feedInfo.descriptionRT ? ( {feedInfo.description ? (
<RichText <RichText
testID="listDescription" testID="listDescription"
type="lg" type="lg"
style={pal.text} style={pal.text}
richText={feedInfo.descriptionRT} richText={feedInfo.description}
/> />
) : ( ) : (
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
@ -504,28 +571,26 @@ const AboutSection = observer(function AboutPageImpl({
testID="toggleLikeBtn" testID="toggleLikeBtn"
accessibilityLabel={_(msg`Like this feed`)} accessibilityLabel={_(msg`Like this feed`)}
accessibilityHint="" accessibilityHint=""
disabled={isLikePending || isUnlikePending}
onPress={onToggleLiked} onPress={onToggleLiked}
style={{paddingHorizontal: 10}}> style={{paddingHorizontal: 10}}>
{feedInfo?.isLiked ? ( {isLiked ? (
<HeartIconSolid size={19} style={styles.liked} /> <HeartIconSolid size={19} style={styles.liked} />
) : ( ) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)} )}
</Button> </Button>
{typeof feedInfo.likeCount === 'number' && ( {typeof likeCount === 'number' && (
<TextLink <TextLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
text={`Liked by ${feedInfo.likeCount} ${pluralize( text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`}
feedInfo.likeCount,
'user',
)}`}
style={[pal.textLight, s.semiBold]} style={[pal.textLight, s.semiBold]}
/> />
)} )}
</View> </View>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
Created by{' '} Created by{' '}
{feedInfo.isOwner ? ( {isOwner ? (
'you' 'you'
) : ( ) : (
<TextLink <TextLink

View File

@ -21,7 +21,7 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {ScrollView, CenteredView} from 'view/com/util/Views' import {ScrollView, CenteredView} from 'view/com/util/Views'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {s, colors} from 'lib/styles' 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
@ -250,7 +250,7 @@ const ListItem = observer(function ListItemImpl({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null} ) : null}
<NewFeedSourceCard <FeedSourceCard
key={feedUri} key={feedUri}
feedUri={feedUri} feedUri={feedUri}
style={styles.noBorder} style={styles.noBorder}