diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts deleted file mode 100644 index 04201b9a16..0000000000 --- a/src/lib/hooks/useCustomFeed.ts +++ /dev/null @@ -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() - useEffect(() => { - async function buildFeedItem() { - const model = new FeedSourceModel(store, uri) - await model.setup() - setItem(model) - } - buildFeedItem() - }, [store, uri]) - - return item -} diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts deleted file mode 100644 index cd8c08b56a..0000000000 --- a/src/state/models/content/feed-source.ts +++ /dev/null @@ -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') - } - } -} diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts deleted file mode 100644 index a7c94e40d2..0000000000 --- a/src/state/models/discovery/feeds.ts +++ /dev/null @@ -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 - } -} diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts deleted file mode 100644 index 29c01e536e..0000000000 --- a/src/state/models/lists/actor-feeds.ts +++ /dev/null @@ -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) - } - } -} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 1068ac6516..a4c3517cc2 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -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 diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts deleted file mode 100644 index d6ea0c0841..0000000000 --- a/src/state/models/ui/profile.ts +++ /dev/null @@ -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() - } - } -} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index dde37315d7..4ec82c6fb5 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -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 - } - 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 - } - 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 + } + 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 + } + 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', diff --git a/src/state/queries/like.ts b/src/state/queries/like.ts new file mode 100644 index 0000000000..187d8fb824 --- /dev/null +++ b/src/state/queries/like.ts @@ -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) + }, + }) +} diff --git a/src/state/queries/suggested-feeds.ts b/src/state/queries/suggested-feeds.ts new file mode 100644 index 0000000000..e148c97c32 --- /dev/null +++ b/src/state/queries/suggested-feeds.ts @@ -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, + 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, + }) +} diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 400b836d0c..d134dae9ff 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -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 ? ( } keyExtractor={item => item.uri} style={{flex: 1}} @@ -146,7 +120,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ {hasFeeds ? ( } keyExtractor={item => item.uri} style={{flex: 1}} diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index bee23c953f..2eaf3cf2db 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -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 ( - by {sanitizeHandle(item.creatorHandle, '@')} + by {sanitizeHandle(item.creator.handle, '@')} - {item.descriptionRT ? ( + {item.description ? ( ) : null} @@ -97,7 +123,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ paddingRight: 2, gap: 6, }}> - {item.isSaved ? ( + {isPinned ? ( <> - showSaveBtn?: boolean - showDescription?: boolean - showLikes?: boolean -}) { - const pal = usePalette('default') - const navigation = useNavigation() - 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 ( - { - 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}> - - - - - - - {item.displayName} - - - by {sanitizeHandle(item.creatorHandle, '@')} - - - {showSaveBtn && ( - - - {item.isSaved ? ( - - ) : ( - - )} - - - )} - - - {showDescription && item.descriptionRT ? ( - - ) : null} - - {showLikes ? ( - - Liked by {item.likeCount || 0}{' '} - {pluralize(item.likeCount || 0, 'user')} - - ) : null} - - ) -}) - const styles = StyleSheet.create({ container: { paddingHorizontal: 18, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 1e85b3e31a..b0e6f1a31d 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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 } - if (!thread || isLoading) { + if (!thread || isLoading || !preferences) { return ( @@ -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(null) // const hasScrolledIntoView = useRef(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, _, ], diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index e29b35f8a9..0ace06e9a4 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -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() 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 ( void withLoading?: boolean + disabled?: boolean }>) { const theme = useTheme() const typeOuterStyle = choose>( @@ -198,7 +200,7 @@ export function Button({ { - const model = new FeedSourceModel(store, record.uri) - model.hydrateFeedGenerator(record) - return model - }, [store, record]) - return ( - - ) -} - -const styles = StyleSheet.create({ - customFeedOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - paddingHorizontal: 12, - paddingVertical: 12, - }, -}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6c13bc2bb4..b4c7c45ae6 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -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 + return ( + + ) } // 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, + }, }) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 5f60322bd8..9c3d890053 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -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 } else if (item.type === 'popularFeed') { return ( - )} diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 4c56b6674c..537fe73626 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -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 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() - const {name: handleOrDid} = props.route.params - - const [feedOwnerDid, setFeedOwnerDid] = React.useState() - const [error, setError] = React.useState() + 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 ( @@ -107,7 +100,7 @@ export const ProfileFeedScreen = withAuthRequired( Could not load feed - {error} + {error.toString()} @@ -127,8 +120,8 @@ export const ProfileFeedScreen = withAuthRequired( ) } - return feedOwnerDid ? ( - + return resolvedUri ? ( + ) : ( @@ -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(null) - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!preferences || !info) { + return ( + + + + + ) - 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 () => { + return ( + + ) +} + +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(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() - 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}) + + if (isSaved) { + await removeFeed({uri: feedInfo.uri}) + resetRemoveFeed() + } else { + await saveFeed({uri: feedInfo.uri}) + resetSaveFeed() } - }, [feedInfo]) + } 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 () => { + const onTogglePinned = React.useCallback(async () => { + try { 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}) - }) + + if (isPinned) { + await unpinFeed({uri: feedInfo.uri}) + resetUnpinFeed() + } else { + await pinFeed({uri: feedInfo.uri}) + resetPinFeed() } - }, [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], - ) + } 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: 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', + // 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', }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', + }, + { + 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', + }, + { + 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 ( - - {feedInfo && ( - <> - - {typeof feedInfo.likeCount === 'number' && ( + {typeof likeCount === 'number' && ( )} Created by{' '} - {feedInfo.isOwner ? ( + {isOwner ? ( 'you' ) : ( ) : null} -