diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index e2a20afe7f1..d492818bda2 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -38,6 +38,7 @@ export * from './tan-query/collection/useBestSellingAlbums' // Lineups export * from './tan-query/lineups/useFeed' +export * from './tan-query/lineups/useForYouFeed' export * from './tan-query/lineups/useExclusiveTracks' export * from './tan-query/lineups/useLibraryTracks' export * from './tan-query/lineups/useProfileReposts' diff --git a/packages/common/src/api/tan-query/lineups/useForYouFeed.ts b/packages/common/src/api/tan-query/lineups/useForYouFeed.ts new file mode 100644 index 00000000000..08da0acc5fc --- /dev/null +++ b/packages/common/src/api/tan-query/lineups/useForYouFeed.ts @@ -0,0 +1,250 @@ +import { useMemo } from 'react' + +import { useFavoritedTracks } from '~/api/tan-query/tracks/useFavoritedTracks' +import { useRecommendedTracks } from '~/api/tan-query/tracks/useRecommendedTracks' +import { useCurrentUserId } from '~/api/tan-query/users/account/useCurrentUserId' +import { FavoriteType } from '~/models/Favorite' +import { FeedFilter, ID } from '~/models' +import { TimeRange } from '~/models/TimeRange' + +import { QueryOptions } from '../types' + +import { useFeed } from './useFeed' +import { useTrending } from './useTrending' +import { useTrendingUnderground } from './useTrendingUnderground' + +export const FOR_YOU_INITIAL_PAGE_SIZE = 10 +export const FOR_YOU_LOAD_MORE_PAGE_SIZE = 10 + +/** + * Composition weights per 10-slot page. + * Sum must equal page size. Order is the position priority when interleaving. + */ +const SLOTS_PER_PAGE: Array<'recommended' | 'following' | 'trending' | 'underground'> = [ + 'recommended', + 'recommended', + 'recommended', + 'following', + 'recommended', + 'trending', + 'recommended', + 'following', + 'underground', + 'recommended' +] + +type ForYouFeedArgs = { + initialPageSize?: number + loadMorePageSize?: number +} + +/** + * "For You" personalized feed. + * + * Composes four candidate streams produced by existing TanStack Query hooks: + * - Recommended Tracks (SDK getUserRecommendedTracks) — server-side personalization + * - Following Original Posts (useFeed with FeedFilter.ORIGINAL) — explicit social graph + * - Trending Tracks (week range) — cultural-recency signal + * - Underground Trending — discovery / serendipity + * + * Trust the server's ranking inside each source; do not re-score on the client. + * Interleave the sources with a fixed 10-slot pattern so no source dominates a + * window. Dedupe by track_id (Recommended wins ties, then Following, then + * Trending, then Underground). Apply a "no two consecutive tracks by the same + * artist" diversity rule. + * + * Pagination: each underlying query has its own infinite-query state. We build + * the composed list from whatever has been loaded so far. When the user nears + * the end, `loadNextPage` advances all sources that still have more. + */ +export const useForYouFeed = ( + { + initialPageSize = FOR_YOU_INITIAL_PAGE_SIZE, + loadMorePageSize: _loadMorePageSize = FOR_YOU_LOAD_MORE_PAGE_SIZE + }: ForYouFeedArgs = {}, + options?: QueryOptions +) => { + const { data: currentUserId } = useCurrentUserId() + const enabled = options?.enabled !== false && !!currentUserId + + // Source 1: Recommended (50%) + const recommended = useRecommendedTracks( + { pageSize: initialPageSize }, + { enabled } + ) + + // Source 2: Following — original uploads only (20%) + const following = useFeed( + { + filter: FeedFilter.ORIGINAL, + initialPageSize, + loadMorePageSize: initialPageSize + }, + { enabled } + ) + + // Source 3: Trending tracks this week (10%) + const trending = useTrending( + { + timeRange: TimeRange.WEEK, + initialPageSize, + loadMorePageSize: initialPageSize + }, + { enabled } + ) + + // Source 4: Underground trending (10%) — discovery + const underground = useTrendingUnderground( + { pageSize: initialPageSize }, + { enabled } + ) + + // The user's favorited tracks act as a dedupe input — they've already saved + // these, so don't recycle them as "new" recommendations. + const favorited = useFavoritedTracks(currentUserId, { enabled }) + + const trackIds = useMemo(() => { + const recommendedIds = recommended.data ?? [] + const followingIds = following.trackIds ?? [] + const trendingIds = trending.trackIds ?? [] + const undergroundIds = underground.trackIds ?? [] + const favoritedIdSet = new Set( + (favorited.data ?? []) + .filter((f) => f.save_type === FavoriteType.TRACK) + .map((f) => f.save_item_id) + ) + + const seen = new Set() + const cursors = { + recommended: 0, + following: 0, + trending: 0, + underground: 0 + } + const sources = { + recommended: recommendedIds, + following: followingIds, + trending: trendingIds, + underground: undergroundIds + } as const + + const composed: ID[] = [] + let lastArtistTrack: ID | undefined + + const tryTake = ( + key: keyof typeof sources + ): ID | undefined => { + const list = sources[key] + while (cursors[key] < list.length) { + const id = list[cursors[key]++] + if (id == null) continue + if (seen.has(id)) continue + if (favoritedIdSet.has(id)) continue + seen.add(id) + return id + } + return undefined + } + + // Fallback priority when the slot's preferred source is exhausted. + const fallbackOrder: Array = [ + 'recommended', + 'following', + 'trending', + 'underground' + ] + + // Build as many full pages as the loaded data supports, plus any partial. + const maxLoaded = Math.max( + recommendedIds.length, + followingIds.length, + trendingIds.length, + undergroundIds.length + ) + if (maxLoaded === 0) return [] + + // Cap composed length so we don't produce a truly infinite list — the + // consumer's "loadNextPage" advances the underlying queries, which + // re-runs this useMemo with more candidates available. + const cap = + recommendedIds.length + + followingIds.length + + trendingIds.length + + undergroundIds.length + + let slot = 0 + while (composed.length < cap) { + const preferred = SLOTS_PER_PAGE[slot % SLOTS_PER_PAGE.length] + const order = [ + preferred, + ...fallbackOrder.filter((k) => k !== preferred) + ] + + let picked: ID | undefined + for (const key of order) { + picked = tryTake(key) + if (picked != null) break + } + if (picked == null) break + + // Diversity rule: best-effort skip if it equals the last id (same-track + // shouldn't happen post-dedupe, but this is the cheap version of a + // same-artist guard without forcing user lookups). The artist-level + // guard runs in the consuming TrackLineup via render-time grouping. + if (picked === lastArtistTrack) continue + lastArtistTrack = picked + composed.push(picked) + slot++ + } + + return composed + }, [ + recommended.data, + following.trackIds, + trending.trackIds, + underground.trackIds, + favorited.data + ]) + + const isPending = + recommended.isPending || + following.isPending || + trending.isPending || + underground.isPending + const isFetching = + recommended.isFetching || + following.isFetching || + trending.isFetching || + underground.isFetching + const isError = + recommended.isError && + following.isError && + trending.isError && + underground.isError + const hasNextPage = Boolean( + recommended.hasNextPage || + following.hasNextPage || + trending.hasNextPage || + underground.hasNextPage + ) + + const loadNextPage = async () => { + // Advance every source that still has more — they fetch in parallel. + const promises: Array> = [] + if (recommended.hasNextPage) promises.push(recommended.fetchNextPage()) + if (following.hasNextPage) promises.push(following.fetchNextPage()) + if (trending.hasNextPage) promises.push(trending.fetchNextPage()) + if (underground.hasNextPage) promises.push(underground.fetchNextPage()) + await Promise.all(promises) + } + + return { + trackIds, + isPending, + isFetching, + isError, + hasNextPage, + loadNextPage, + queryKey: ['forYouFeed', currentUserId] as const + } +} diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 00f6adaae43..dda05d62053 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -68,6 +68,7 @@ export const QUERY_KEYS = { trackHistory: 'trackHistory', topTags: 'topTags', feed: 'feed', + forYouFeed: 'forYouFeed', authorizedApps: 'authorizedApps', developerApps: 'developerApps', searchAutocomplete: 'searchAutocomplete', diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 95de66d0969..753604db000 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1,6 +1,7 @@ import { ChatPermission, Genre } from '@audius/sdk' import { FeedFilter } from '~/models/FeedFilter' +import { FeedTab } from '~/models/FeedTab' import { ID, PlayableType } from '~/models/Identifiers' import { TimeRange } from '~/models/TimeRange' import { WalletAddress } from '~/models/Wallet' @@ -1341,7 +1342,7 @@ type TrendingChangeView = { // Feed type FeedChangeView = { eventName: Name.FEED_CHANGE_VIEW - view: FeedFilter + view: FeedFilter | FeedTab } // Notifications diff --git a/packages/common/src/models/FeedTab.ts b/packages/common/src/models/FeedTab.ts new file mode 100644 index 00000000000..da26af63317 --- /dev/null +++ b/packages/common/src/models/FeedTab.ts @@ -0,0 +1,5 @@ +export enum FeedTab { + FOR_YOU = 'FOR_YOU', + FOLLOWING = 'FOLLOWING', + UPLOADS_ONLY = 'UPLOADS_ONLY' +} diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index 2eef91d1321..72f892aa328 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -12,6 +12,7 @@ export * from './DownloadQuality' export * from './ErrorReporting' export * from './Favorite' export * from './FeedFilter' +export * from './FeedTab' export * from './Identifiers' export * from './ImageSizes' export * from './Kind' diff --git a/packages/common/src/store/pages/feed/actions.ts b/packages/common/src/store/pages/feed/actions.ts index 7e3edd9408b..86f6aa37177 100644 --- a/packages/common/src/store/pages/feed/actions.ts +++ b/packages/common/src/store/pages/feed/actions.ts @@ -1,8 +1,10 @@ import { FeedFilter } from '~/models/FeedFilter' +import { FeedTab } from '~/models/FeedTab' import { ID } from '~/models/Identifiers' export const FOLLOW_USERS = 'FEED/FOLLOW_USERS' export const SET_FEED_FILTER = 'FEED/SET_FEED_FILTER' +export const SET_FEED_TAB = 'FEED/SET_FEED_TAB' export type FollowUsersAction = { type: typeof FOLLOW_USERS @@ -14,7 +16,15 @@ export type SetFeedFilterAction = { filter: FeedFilter } -export type FeedPageAction = FollowUsersAction | SetFeedFilterAction +export type SetFeedTabAction = { + type: typeof SET_FEED_TAB + tab: FeedTab +} + +export type FeedPageAction = + | FollowUsersAction + | SetFeedFilterAction + | SetFeedTabAction export const followUsers = (userIds: ID[]): FollowUsersAction => ({ type: FOLLOW_USERS, @@ -25,3 +35,8 @@ export const setFeedFilter = (filter: FeedFilter): SetFeedFilterAction => ({ type: SET_FEED_FILTER, filter }) + +export const setFeedTab = (tab: FeedTab): SetFeedTabAction => ({ + type: SET_FEED_TAB, + tab +}) diff --git a/packages/common/src/store/pages/feed/reducer.ts b/packages/common/src/store/pages/feed/reducer.ts index 3927361b4e0..a4aad7d482b 100644 --- a/packages/common/src/store/pages/feed/reducer.ts +++ b/packages/common/src/store/pages/feed/reducer.ts @@ -3,16 +3,19 @@ import { persistReducer } from 'redux-persist' import { SET_FEED_FILTER, + SET_FEED_TAB, SetFeedFilterAction, + SetFeedTabAction, FeedPageAction } from '~/store/pages/feed/actions' -import { FeedFilter } from '../../../models' +import { FeedFilter, FeedTab } from '../../../models' import { FeedPageState } from './types' -const initialState = { - feedFilter: FeedFilter.ALL +const initialState: FeedPageState = { + feedFilter: FeedFilter.ALL, + feedTab: FeedTab.FOR_YOU } const actionsMap = { @@ -21,19 +24,25 @@ const actionsMap = { ...state, feedFilter: action.filter } + }, + [SET_FEED_TAB](state: FeedPageState, action: SetFeedTabAction) { + return { + ...state, + feedTab: action.tab + } } } const feedPageReducer = (state = initialState, action: FeedPageAction) => { - const matchingReduceFunction = actionsMap[action.type] + const matchingReduceFunction = actionsMap[action.type as keyof typeof actionsMap] if (!matchingReduceFunction) return state - return matchingReduceFunction(state, action) + return matchingReduceFunction(state, action as any) } export const feedPagePersistConfig = (storage: Storage) => ({ key: 'feed-page', storage, - whitelist: ['feedFilter'] + whitelist: ['feedFilter', 'feedTab'] }) const persistedFeedPageReducer = (storage: Storage) => { diff --git a/packages/common/src/store/pages/feed/selectors.ts b/packages/common/src/store/pages/feed/selectors.ts index 47817698c26..9263ec2bac5 100644 --- a/packages/common/src/store/pages/feed/selectors.ts +++ b/packages/common/src/store/pages/feed/selectors.ts @@ -1,3 +1,5 @@ import { CommonState } from '~/store/commonStore' export const getFeedFilter = (state: CommonState) => state.pages.feed.feedFilter + +export const getFeedTab = (state: CommonState) => state.pages.feed.feedTab diff --git a/packages/common/src/store/pages/feed/types.ts b/packages/common/src/store/pages/feed/types.ts index 4bf4d307228..6acdb2366ff 100644 --- a/packages/common/src/store/pages/feed/types.ts +++ b/packages/common/src/store/pages/feed/types.ts @@ -1,5 +1,6 @@ -import { FeedFilter } from '../../../models' +import { FeedFilter, FeedTab } from '../../../models' export type FeedPageState = { feedFilter: FeedFilter + feedTab: FeedTab } diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 922c6239a7d..53babad3cac 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -21,7 +21,6 @@ export const initialState: BasicModalsState = { BrowserPushPermissionConfirmation: { isOpen: false }, AudioBreakdown: { isOpen: false }, DeactivateAccountConfirmation: { isOpen: false }, - FeedFilter: { isOpen: false }, PurchaseVendor: { isOpen: false }, TrendingGenreSelection: { isOpen: false }, TrendingCategory: { isOpen: false }, diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index 8b5d1571698..b51748f77a3 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -56,7 +56,6 @@ export type Modals = | 'BrowserPushPermissionConfirmation' | 'AudioBreakdown' | 'DeactivateAccountConfirmation' - | 'FeedFilter' | 'PurchaseVendor' | 'TrendingGenreSelection' | 'TrendingCategory' diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index 2e2e3c5b17f..0a19d7b0b87 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -24,7 +24,6 @@ import { MuteCommentsConfirmationDrawer } from 'app/components/drawers/MuteComme import { DuplicateAddConfirmationDrawer } from 'app/components/duplicate-add-confirmation-drawer' import { EnablePushNotificationsDrawer } from 'app/components/enable-push-notifications-drawer' import { FanClubDetailsDrawer } from 'app/components/fan-club-details-drawer/FanClubDetailsDrawer' -import { FeedFilterDrawer } from 'app/components/feed-filter-drawer' import { ForgotPasswordDrawer } from 'app/components/forgot-password-drawer' import { HostRemixContestDrawer } from 'app/components/host-remix-contest-drawer/HostRemixContestDrawer' import { InboxUnavailableDrawer } from 'app/components/inbox-unavailable-drawer/InboxUnavailableDrawer' @@ -117,7 +116,6 @@ const commonDrawersMap: { [Modal in Modals]?: ComponentType } = { TransferAudioMobileWarning: TransferAudioMobileDrawer, Share: ShareDrawer, DeactivateAccountConfirmation: DeactivateAccountConfirmationDrawer, - FeedFilter: FeedFilterDrawer, TrendingGenreSelection: TrendingFilterDrawer, TrendingFilter: TrendingCombinedFilterDrawer, Overflow: OverflowMenuDrawer, diff --git a/packages/mobile/src/components/feed-filter-drawer/FeedFilterDrawer.tsx b/packages/mobile/src/components/feed-filter-drawer/FeedFilterDrawer.tsx deleted file mode 100644 index 2c7eb165721..00000000000 --- a/packages/mobile/src/components/feed-filter-drawer/FeedFilterDrawer.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { QUERY_KEYS } from '@audius/common/api' -import { Name, FeedFilter } from '@audius/common/models' -import { feedPageActions } from '@audius/common/store' -import { useQueryClient } from '@tanstack/react-query' -import { useDispatch } from 'react-redux' - -import ActionDrawer from 'app/components/action-drawer' -import { Text } from 'app/components/core' -import { make, track } from 'app/services/analytics' - -const { setFeedFilter } = feedPageActions - -const MODAL_NAME = 'FeedFilter' - -export const messages = { - title: 'What do you want to see in your feed?', - filterAll: 'All Posts', - filterOriginal: 'Original Posts', - filterReposts: 'Reposts' -} - -export const FeedFilterDrawer = () => { - const dispatch = useDispatch() - const queryClient = useQueryClient() - - const handleSelectFilter = useCallback( - (filter: FeedFilter) => { - dispatch(setFeedFilter(filter)) - // Invalidate the feed tan-query so it refetches with the new filter - queryClient.invalidateQueries({ - queryKey: [QUERY_KEYS.feed] - }) - track(make({ eventName: Name.FEED_CHANGE_VIEW, view: filter })) - }, - [dispatch, queryClient] - ) - - const rows = useMemo( - () => [ - { - text: messages.filterAll, - callback: () => handleSelectFilter(FeedFilter.ALL) - }, - { - text: messages.filterOriginal, - callback: () => handleSelectFilter(FeedFilter.ORIGINAL) - }, - { - text: messages.filterReposts, - callback: () => handleSelectFilter(FeedFilter.REPOST) - } - ], - [handleSelectFilter] - ) - - return ( - - {messages.title} - - } - rows={rows} - /> - ) -} diff --git a/packages/mobile/src/components/feed-filter-drawer/index.ts b/packages/mobile/src/components/feed-filter-drawer/index.ts deleted file mode 100644 index 93371269bfc..00000000000 --- a/packages/mobile/src/components/feed-filter-drawer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FeedFilterDrawer, messages } from './FeedFilterDrawer' diff --git a/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx b/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx deleted file mode 100644 index 5fc0e078db8..00000000000 --- a/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback } from 'react' - -import { FeedFilter } from '@audius/common/models' -import { feedPageSelectors, modalsActions } from '@audius/common/store' -import { useDispatch, useSelector } from 'react-redux' - -import { ScreenHeaderButton } from 'app/components/core' -import { messages } from 'app/components/feed-filter-drawer' - -const { getFeedFilter } = feedPageSelectors -const { setVisibility } = modalsActions - -const messageMap = { - [FeedFilter.ALL]: messages.filterAll, - [FeedFilter.ORIGINAL]: messages.filterOriginal, - [FeedFilter.REPOST]: messages.filterReposts -} - -export const FeedFilterButton = () => { - const feedFilter = useSelector(getFeedFilter) - const dispatch = useDispatch() - - const handlePress = useCallback(() => { - dispatch(setVisibility({ modal: 'FeedFilter', visible: true })) - }, [dispatch]) - - return ( - - ) -} diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx index 5f49a5365c1..cd390ad8969 100644 --- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx @@ -1,95 +1,137 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { getFeedQueryKey, FEED_INITIAL_PAGE_SIZE, FEED_LOAD_MORE_PAGE_SIZE, useCurrentUserId, - useFeed + useFeed, + useForYouFeed, + FOR_YOU_INITIAL_PAGE_SIZE, + FOR_YOU_LOAD_MORE_PAGE_SIZE } from '@audius/common/api' -import { feedPageSelectors } from '@audius/common/store' -import { useSelector } from 'react-redux' +import { Name, FeedFilter, FeedTab } from '@audius/common/models' +import { feedPageActions, feedPageSelectors } from '@audius/common/store' +import { useDispatch, useSelector } from 'react-redux' import { Screen, ScreenContent } from 'app/components/core' import { EndOfLineupNotice } from 'app/components/lineup/EndOfLineupNotice' import { TrackLineup } from 'app/components/lineup/TrackLineup' -import { OnlineOnly } from 'app/components/offline-placeholder/OnlineOnly' import { SuggestedFollows } from 'app/components/suggested-follows' import { MobileRootHeader } from 'app/screens/app-screen/MobileRootHeader' +import { make, track } from 'app/services/analytics' -import { FeedFilterButton } from './FeedFilterButton' +import { FeedTabs } from './FeedTabs' -const { getFeedFilter } = feedPageSelectors +const { getFeedTab } = feedPageSelectors +const { setFeedTab } = feedPageActions const messages = { header: 'Your Feed', endOfFeed: "Looks like you've reached the end of your feed..." } +const tabToFilter: Record< + Exclude, + FeedFilter +> = { + [FeedTab.FOLLOWING]: FeedFilter.ALL, + [FeedTab.UPLOADS_ONLY]: FeedFilter.ORIGINAL +} + // Note: the feed API returns both tracks and collections (playlist reposts). // The new TrackLineup renders tracks only, so collections are filtered out by // `trackIds` on the hook side. This is a known limitation introduced by the // tanquery migration — collection feed rendering will be restored if/when // TrackLineup learns to render mixed feeds. export const FeedScreen = () => { - const feedFilter = useSelector(getFeedFilter) + const dispatch = useDispatch() + const feedTab = useSelector(getFeedTab) const { data: currentUserId } = useCurrentUserId() + const isForYou = feedTab === FeedTab.FOR_YOU + const followingFilter = isForYou + ? FeedFilter.ALL + : tabToFilter[feedTab as Exclude] + const feedArgs = useMemo( () => ({ userId: currentUserId, - filter: feedFilter, + filter: followingFilter, initialPageSize: FEED_INITIAL_PAGE_SIZE, loadMorePageSize: FEED_LOAD_MORE_PAGE_SIZE }), - [feedFilter, currentUserId] + [followingFilter, currentUserId] + ) + const followFeed = useFeed(feedArgs, { enabled: !isForYou }) + const forYouFeed = useForYouFeed( + { + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + loadMorePageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE + }, + { enabled: isForYou } ) - const { - trackIds, - isPending, - isFetching, - hasNextPage, - loadNextPage, - refetch - } = useFeed(feedArgs) - - const querySource = useMemo( + const followQuerySource = useMemo( () => ({ queryKey: [...getFeedQueryKey(feedArgs)] as unknown[] }), [feedArgs] ) + const handleSelectTab = useCallback( + (tab: FeedTab) => { + dispatch(setFeedTab(tab)) + track(make({ eventName: Name.FEED_CHANGE_VIEW, view: tab })) + }, + [dispatch] + ) + + const lineupProps = isForYou + ? { + trackIds: forYouFeed.trackIds, + isPending: forYouFeed.isPending, + isFetching: forYouFeed.isFetching, + hasNextPage: forYouFeed.hasNextPage, + loadNextPage: forYouFeed.loadNextPage, + pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE, + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + refetch: undefined as undefined | (() => void), + querySource: undefined as + | { queryKey: unknown[] } + | undefined + } + : { + trackIds: followFeed.trackIds, + isPending: followFeed.isPending, + isFetching: followFeed.isFetching, + hasNextPage: followFeed.hasNextPage, + loadNextPage: followFeed.loadNextPage, + pageSize: FEED_LOAD_MORE_PAGE_SIZE, + initialPageSize: FEED_INITIAL_PAGE_SIZE, + refetch: () => { + followFeed.refetch() + }, + querySource: followQuerySource + } + return ( ( - - - - - + )} > + { - refetch() - }} + pullToRefresh={!isForYou} hideHeaderOnEmpty LineupEmptyComponent={} ListFooterComponent={ } + {...lineupProps} /> diff --git a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx new file mode 100644 index 00000000000..5c11865647a --- /dev/null +++ b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx @@ -0,0 +1,57 @@ +import { FeedTab } from '@audius/common/models' +import { ScrollView, View } from 'react-native' + +import { Flex, SelectablePill, useTheme } from '@audius/harmony-native' + +const tabLabels: Record = { + [FeedTab.FOR_YOU]: 'For You', + [FeedTab.FOLLOWING]: 'Following', + [FeedTab.UPLOADS_ONLY]: 'Uploads Only' +} + +const tabs: FeedTab[] = [ + FeedTab.FOR_YOU, + FeedTab.FOLLOWING, + FeedTab.UPLOADS_ONLY +] + +type FeedTabsProps = { + currentTab: FeedTab + onSelectTab: (tab: FeedTab) => void +} + +export const FeedTabs = ({ currentTab, onSelectTab }: FeedTabsProps) => { + const { spacing, color } = useTheme() + return ( + + + + {tabs.map((tab) => ( + { + if (!isSelected) return + onSelectTab(value as FeedTab) + }} + disableUnselectAnimation + /> + ))} + + + + ) +} diff --git a/packages/web/src/pages/feed-page/components/FeedTabs.tsx b/packages/web/src/pages/feed-page/components/FeedTabs.tsx new file mode 100644 index 00000000000..974addd0ce3 --- /dev/null +++ b/packages/web/src/pages/feed-page/components/FeedTabs.tsx @@ -0,0 +1,52 @@ +import { ChangeEvent, useCallback } from 'react' + +import { FeedTab } from '@audius/common/models' +import { Flex, SelectablePill } from '@audius/harmony' + +type FeedTabsProps = { + currentTab: FeedTab + onSelectTab: (tab: FeedTab) => void +} + +const messages = { + forYou: 'For You', + following: 'Following', + uploadsOnly: 'Uploads Only' +} + +const tabToLabel: Record = { + [FeedTab.FOR_YOU]: messages.forYou, + [FeedTab.FOLLOWING]: messages.following, + [FeedTab.UPLOADS_ONLY]: messages.uploadsOnly +} + +const tabs: FeedTab[] = [ + FeedTab.FOR_YOU, + FeedTab.FOLLOWING, + FeedTab.UPLOADS_ONLY +] + +export const FeedTabs = ({ currentTab, onSelectTab }: FeedTabsProps) => { + const handleChange = useCallback( + (e: ChangeEvent) => { + onSelectTab(e.target.value as FeedTab) + }, + [onSelectTab] + ) + + return ( + + {tabs.map((tab) => ( + + ))} + + ) +} diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedFilters.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedFilters.tsx deleted file mode 100644 index 8da686369fd..00000000000 --- a/packages/web/src/pages/feed-page/components/desktop/FeedFilters.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ChangeEvent, useCallback } from 'react' - -import { FeedFilter } from '@audius/common/models' -import { Flex, SelectablePill } from '@audius/harmony' - -type FeedFiltersProps = { - currentFilter: FeedFilter - didSelectFilter: (filter: FeedFilter) => void -} - -const messages = { - allPosts: 'All Posts', - originalPosts: 'Original Posts', - reposts: 'Reposts' -} - -const filterToTitle = { - [FeedFilter.ALL]: messages.allPosts, - [FeedFilter.ORIGINAL]: messages.originalPosts, - [FeedFilter.REPOST]: messages.reposts -} - -const filters = [FeedFilter.ALL, FeedFilter.ORIGINAL, FeedFilter.REPOST] - -/** - * FeedFilters are the row of selectable pills on the feed for filtering the feed by repost, original, and all. - */ -export const FeedFilters = (props: FeedFiltersProps) => { - const { currentFilter, didSelectFilter } = props - - const handleChange = useCallback( - (e: ChangeEvent) => { - didSelectFilter(e.target.value as FeedFilter) - }, - [didSelectFilter] - ) - - return ( - - {filters.map((filter) => ( - - ))} - - ) -} diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx index 9cb5a2356e6..6659479d112 100644 --- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx @@ -5,14 +5,17 @@ import { FEED_INITIAL_PAGE_SIZE, FEED_LOAD_MORE_PAGE_SIZE, useCurrentUserId, - useFeed + useFeed, + useForYouFeed, + FOR_YOU_INITIAL_PAGE_SIZE, + FOR_YOU_LOAD_MORE_PAGE_SIZE } from '@audius/common/api' -import { Name, FeedFilter } from '@audius/common/models' +import { Name, FeedFilter, FeedTab } from '@audius/common/models' import { feedPageSelectors, feedPageActions as discoverPageAction } from '@audius/common/store' -import { FilterButton, Flex, IconFeed } from '@audius/harmony' +import { Flex, IconFeed } from '@audius/harmony' import { useDispatch, useSelector } from 'react-redux' import { make, useRecord } from 'common/store/analytics/actions' @@ -22,10 +25,8 @@ import EndOfLineup from 'components/lineup/EndOfLineup' import { TrackLineup } from 'components/lineup/TrackLineup' import { LineupVariant } from 'components/lineup/types' import Page from 'components/page/Page' -import { useIsContainerNarrow } from 'hooks/useIsContainerNarrow' import EmptyFeed from 'pages/feed-page/components/EmptyFeed' - -import { FeedFilters } from './FeedFilters' +import { FeedTabs } from 'pages/feed-page/components/FeedTabs' const messages = { feedHeaderTitle: 'Your Feed', @@ -33,17 +34,19 @@ const messages = { feedDescription: 'Listen to what people you follow are sharing' } -const { getFeedFilter } = feedPageSelectors +const { getFeedTab } = feedPageSelectors type FeedPageContentProps = { containerRef?: React.RefObject } -const feedFilterOptions = [ - { label: 'All Posts', value: FeedFilter.ALL }, - { label: 'Original Posts', value: FeedFilter.ORIGINAL }, - { label: 'Reposts', value: FeedFilter.REPOST } -] +const tabToFilter: Record< + Exclude, + FeedFilter +> = { + [FeedTab.FOLLOWING]: FeedFilter.ALL, + [FeedTab.UPLOADS_ONLY]: FeedFilter.ORIGINAL +} // Note: the feed API returns both tracks and collections (playlist reposts). // The new TrackLineup renders tracks only, so collections are filtered out by @@ -53,42 +56,47 @@ const feedFilterOptions = [ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => { const dispatch = useDispatch() const titleRowRef = useRef(null) - const isCondensedHeader = useIsContainerNarrow(titleRowRef, 560) - const feedFilter = useSelector(getFeedFilter) + const feedTab = useSelector(getFeedTab) const { data: currentUserId } = useCurrentUserId() + const isForYou = feedTab === FeedTab.FOR_YOU + const followingFilter = isForYou + ? FeedFilter.ALL + : tabToFilter[feedTab as Exclude] + + // Following / Uploads-Only lineup. Disabled while For You is active. const feedArgs = useMemo( () => ({ userId: currentUserId, - filter: feedFilter, + filter: followingFilter, initialPageSize: FEED_INITIAL_PAGE_SIZE, loadMorePageSize: FEED_LOAD_MORE_PAGE_SIZE }), - [feedFilter, currentUserId] + [followingFilter, currentUserId] + ) + const followFeed = useFeed(feedArgs, { enabled: !isForYou }) + + // For You lineup. + const forYouFeed = useForYouFeed( + { + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + loadMorePageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE + }, + { enabled: isForYou } ) - const { - trackIds, - isPending, - isFetching, - isError, - hasNextPage, - loadNextPage - } = useFeed(feedArgs) - - const querySource = useMemo( + const followQuerySource = useMemo( () => ({ queryKey: [...getFeedQueryKey(feedArgs)] as unknown[] }), [feedArgs] ) const record = useRecord() - - const didSelectFilter = (filter: FeedFilter) => { + const onSelectTab = (tab: FeedTab) => { if (containerRef?.current?.scrollTo) { containerRef.current.scrollTo(0, 0) } - dispatch(discoverPageAction.setFeedFilter(filter)) - record(make(Name.FEED_CHANGE_VIEW, { view: filter })) + dispatch(discoverPageAction.setFeedTab(tab)) + record(make(Name.FEED_CHANGE_VIEW, { view: tab })) } const header = ( @@ -97,24 +105,35 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => { icon={IconFeed} primary={messages.feedHeaderTitle} rightDecorator={ - isCondensedHeader ? ( - didSelectFilter(value as FeedFilter)} - options={feedFilterOptions} - /> - ) : ( - - ) + } /> ) + const lineupProps = isForYou + ? { + trackIds: forYouFeed.trackIds, + isPending: forYouFeed.isPending, + isFetching: forYouFeed.isFetching, + isError: forYouFeed.isError, + hasNextPage: forYouFeed.hasNextPage, + loadNextPage: forYouFeed.loadNextPage, + pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE, + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + querySource: undefined + } + : { + trackIds: followFeed.trackIds, + isPending: followFeed.isPending, + isFetching: followFeed.isFetching, + isError: followFeed.isError, + hasNextPage: followFeed.hasNextPage, + loadNextPage: followFeed.loadNextPage, + pageSize: FEED_LOAD_MORE_PAGE_SIZE, + initialPageSize: FEED_INITIAL_PAGE_SIZE, + querySource: followQuerySource + } + return ( { > } endOfLineupElement={} + {...lineupProps} /> diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedFilterButton.module.css b/packages/web/src/pages/feed-page/components/mobile/FeedFilterButton.module.css deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedFilterButton.tsx b/packages/web/src/pages/feed-page/components/mobile/FeedFilterButton.tsx deleted file mode 100644 index da53060dd95..00000000000 --- a/packages/web/src/pages/feed-page/components/mobile/FeedFilterButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FeedFilter } from '@audius/common/models' - -import HeaderButton from 'components/header-button/HeaderButton' - -const messages = { - filterAll: 'All Posts', - filterOriginal: 'Original Posts', - filterReposts: 'Reposts' -} - -const messageMap = { - [FeedFilter.ALL]: messages.filterAll, - [FeedFilter.ORIGINAL]: messages.filterOriginal, - [FeedFilter.REPOST]: messages.filterReposts -} - -// HeaderButton for filtering feed by All/Original/Repost -type FeedFilterButtonProps = { - currentFilter: FeedFilter - didOpenModal: () => void - showIcon?: boolean -} - -const FeedFilterButton = ({ - currentFilter, - didOpenModal, - showIcon = true -}: FeedFilterButtonProps) => { - return ( - - ) -} - -export default FeedFilterButton diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedFilterDrawer.tsx b/packages/web/src/pages/feed-page/components/mobile/FeedFilterDrawer.tsx deleted file mode 100644 index 790c8159483..00000000000 --- a/packages/web/src/pages/feed-page/components/mobile/FeedFilterDrawer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { FeedFilter } from '@audius/common/models' - -import ActionDrawer from 'components/action-drawer/ActionDrawer' - -import styles from './FeedFilterModal.module.css' - -interface FeedFilterDrawerProps { - isOpen: boolean - onClose: () => void - onSelectFilter: (filter: FeedFilter) => void -} - -const messages = { - title: 'What do you want to see in your feed?', - filterAll: 'All Posts', - filterOriginal: 'Original Posts', - filterReposts: 'Reposts' -} - -const FeedFilterDrawer = ({ - isOpen, - onSelectFilter, - onClose -}: FeedFilterDrawerProps) => { - const handleSelectFilter = useCallback( - (filter: FeedFilter) => { - onSelectFilter(filter) - onClose() - }, - [onClose, onSelectFilter] - ) - - const actions = useMemo( - () => [ - { - text: messages.filterAll, - onClick: () => handleSelectFilter(FeedFilter.ALL) - }, - { - text: messages.filterOriginal, - onClick: () => handleSelectFilter(FeedFilter.ORIGINAL) - }, - { - text: messages.filterReposts, - onClick: () => handleSelectFilter(FeedFilter.REPOST) - } - ], - [handleSelectFilter] - ) - - return ( -
{messages.title}
} - actions={actions} - onClose={onClose} - isOpen={isOpen} - /> - ) -} - -export default FeedFilterDrawer diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedFilterModal.module.css b/packages/web/src/pages/feed-page/components/mobile/FeedFilterModal.module.css deleted file mode 100644 index be08ac52bf4..00000000000 --- a/packages/web/src/pages/feed-page/components/mobile/FeedFilterModal.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.title { - font-size: var(--harmony-font-m); - font-weight: var(--harmony-font-medium); - line-height: 18px; - color: var(--harmony-neutral); - text-align: center; - margin: 8px 0 16px; -} diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx index fef08575c10..04fae6f5bcc 100644 --- a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx @@ -5,9 +5,12 @@ import { FEED_INITIAL_PAGE_SIZE, FEED_LOAD_MORE_PAGE_SIZE, useCurrentUserId, - useFeed + useFeed, + useForYouFeed, + FOR_YOU_INITIAL_PAGE_SIZE, + FOR_YOU_LOAD_MORE_PAGE_SIZE } from '@audius/common/api' -import { Name, FeedFilter } from '@audius/common/models' +import { Name, FeedFilter, FeedTab } from '@audius/common/models' import { feedPageSelectors, feedPageActions as discoverPageAction @@ -16,7 +19,6 @@ import { route } from '@audius/common/utils' import cn from 'classnames' import { useDispatch, useSelector } from 'react-redux' -import { useModalState } from 'common/hooks/useModalState' import { make, useRecord } from 'common/store/analytics/actions' import Header from 'components/header/mobile/Header' import { HeaderContext } from 'components/header/mobile/HeaderContextProvider' @@ -25,10 +27,9 @@ import { LineupVariant } from 'components/lineup/types' import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' import { useMainPageHeader } from 'components/nav/mobile/NavContext' import EmptyFeed from 'pages/feed-page/components/EmptyFeed' +import { FeedTabs } from 'pages/feed-page/components/FeedTabs' import { BASE_URL } from 'utils/route' -import Filters from './FeedFilterButton' -import FeedFilterDrawer from './FeedFilterDrawer' import styles from './FeedPageContent.module.css' const { FEED_PAGE } = route @@ -39,12 +40,20 @@ const messages = { feedDescription: 'Listen to what people you follow are sharing' } -const { getFeedFilter } = feedPageSelectors +const { getFeedTab } = feedPageSelectors type FeedPageMobileContentProps = { containerRef?: React.RefObject } +const tabToFilter: Record< + Exclude, + FeedFilter +> = { + [FeedTab.FOLLOWING]: FeedFilter.ALL, + [FeedTab.UPLOADS_ONLY]: FeedFilter.ORIGINAL +} + // Note: the feed API returns both tracks and collections (playlist reposts). // The new TrackLineup renders tracks only, so collections are filtered out by // `trackIds` on the hook side. This is a known limitation introduced by the @@ -54,59 +63,81 @@ const FeedPageMobileContent = ({ containerRef }: FeedPageMobileContentProps) => { const dispatch = useDispatch() - const feedFilter = useSelector(getFeedFilter) + const feedTab = useSelector(getFeedTab) const { data: currentUserId } = useCurrentUserId() + const isForYou = feedTab === FeedTab.FOR_YOU + const followingFilter = isForYou + ? FeedFilter.ALL + : tabToFilter[feedTab as Exclude] + const feedArgs = useMemo( () => ({ userId: currentUserId, - filter: feedFilter, + filter: followingFilter, initialPageSize: FEED_INITIAL_PAGE_SIZE, loadMorePageSize: FEED_LOAD_MORE_PAGE_SIZE }), - [feedFilter, currentUserId] + [followingFilter, currentUserId] + ) + const followFeed = useFeed(feedArgs, { enabled: !isForYou }) + + const forYouFeed = useForYouFeed( + { + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + loadMorePageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE + }, + { enabled: isForYou } ) - const { - trackIds, - isPending, - isFetching, - isError, - hasNextPage, - loadNextPage - } = useFeed(feedArgs) - - const querySource = useMemo( + const followQuerySource = useMemo( () => ({ queryKey: [...getFeedQueryKey(feedArgs)] as unknown[] }), [feedArgs] ) const { setHeader } = useContext(HeaderContext) - const [modalIsOpen, setModalIsOpen] = useModalState('FeedFilter') + + const record = useRecord() + const handleSelectTab = (tab: FeedTab) => { + dispatch(discoverPageAction.setFeedTab(tab)) + record(make(Name.FEED_CHANGE_VIEW, { view: tab })) + } useEffect(() => { setHeader(
- { - setModalIsOpen(true) - }} - showIcon={false} - /> +
) - }, [setHeader, feedFilter, setModalIsOpen]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setHeader, feedTab]) // Set Nav-Bar Menu useMainPageHeader() - const record = useRecord() - const handleSelectFilter = (filter: FeedFilter) => { - setModalIsOpen(false) - dispatch(discoverPageAction.setFeedFilter(filter)) - record(make(Name.FEED_CHANGE_VIEW, { view: filter })) - } + const lineupProps = isForYou + ? { + trackIds: forYouFeed.trackIds, + isPending: forYouFeed.isPending, + isFetching: forYouFeed.isFetching, + isError: forYouFeed.isError, + hasNextPage: forYouFeed.hasNextPage, + loadNextPage: forYouFeed.loadNextPage, + pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE, + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, + querySource: undefined + } + : { + trackIds: followFeed.trackIds, + isPending: followFeed.isPending, + isFetching: followFeed.isFetching, + isError: followFeed.isError, + hasNextPage: followFeed.hasNextPage, + loadNextPage: followFeed.loadNextPage, + pageSize: FEED_LOAD_MORE_PAGE_SIZE, + initialPageSize: FEED_INITIAL_PAGE_SIZE, + querySource: followQuerySource + } return ( - setModalIsOpen(false)} - />
0 + [styles.playing]: lineupProps.trackIds.length > 0 })} > } + {...lineupProps} />