From adbe856964e46606485e4d1d16844f67eb892141 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Sat, 4 Apr 2026 17:19:24 -0700 Subject: [PATCH] Add unified fan club feed --- .../api/tan-query/comments/useFanClubFeed.ts | 5 +- .../components/FanClubFeedSection.tsx | 254 ++++++++++++------ 2 files changed, 168 insertions(+), 91 deletions(-) diff --git a/packages/common/src/api/tan-query/comments/useFanClubFeed.ts b/packages/common/src/api/tan-query/comments/useFanClubFeed.ts index 4e0bda99b98..6440ee73299 100644 --- a/packages/common/src/api/tan-query/comments/useFanClubFeed.ts +++ b/packages/common/src/api/tan-query/comments/useFanClubFeed.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' +import { OptionalHashId } from '@audius/sdk' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { useDispatch } from 'react-redux' @@ -81,9 +82,9 @@ export const useFanClubFeed = ({ feedItems.push({ itemType: 'text_post', commentId: comment.id }) } } else if (item.item_type === 'track') { - const trackId = item.track?.id + const trackId = OptionalHashId.parse(item.track?.id) if (trackId) { - feedItems.push({ itemType: 'track', trackId: Number(trackId) }) + feedItems.push({ itemType: 'track', trackId }) } } } diff --git a/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx b/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx index 697d7e2a83b..32ddb854790 100644 --- a/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx +++ b/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx @@ -1,22 +1,41 @@ -import { useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { - useExclusiveTracks, - useExclusiveTracksCount, - useArtistCoin, useFanClubFeed, - type FanClubFeedItem + type FanClubFeedItem, + mapLineupDataToFullLineupItems } from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' +import { + Name, + PlaybackSource, + UID, + ID, + ModalSource +} from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' -import { exclusiveTracksPageLineupActions } from '@audius/common/store' +import { + exclusiveTracksPageLineupActions, + exclusiveTracksPageSelectors, + playerSelectors, + queueSelectors +} from '@audius/common/store' import { Button, Flex, LoadingSpinner, Text } from '@audius/harmony' +import { EntityType } from '@audius/sdk' +import { useQueryClient } from '@tanstack/react-query' +import { useDispatch, useSelector } from 'react-redux' -import { TanQueryLineup } from 'components/lineup/TanQueryLineup' -import { LineupVariant } from 'components/lineup/types' +import { make } from 'common/store/analytics/actions' +import { TrackTile as TrackTileDesktop } from 'components/track/desktop/TrackTile' +import { TrackTile as MobileTrackTile } from 'components/track/mobile/TrackTile' +import { TrackTileSize } from 'components/track/types' +import { useIsMobile } from 'hooks/useIsMobile' import { TextPostCard } from './TextPostCard' +const { getBuffering } = playerSelectors +const { makeGetCurrent } = queueSelectors + const messages = { title: 'Fan Club Feed', loadMore: 'Load More' @@ -29,65 +48,113 @@ type FanClubFeedSectionProps = { } export const FanClubFeedSection = ({ mint }: FanClubFeedSectionProps) => { - const { data: coin } = useArtistCoin(mint) - const ownerId = coin?.ownerId + const dispatch = useDispatch() + const queryClient = useQueryClient() + const isMobile = useIsMobile() const { isEnabled: isTextPostsEnabled } = useFeatureFlag( FeatureFlags.FAN_CLUB_TEXT_POST_POSTING ) - // Exclusive tracks lineup - const { - data: tracksData, - isFetching: isTracksFetching, - isPending: isTracksPending, - isError: isTracksError, - hasNextPage: hasNextTracksPage, - play, - pause, - loadNextPage: loadNextTracksPage, - isPlaying, - lineup, - pageSize - } = useExclusiveTracks({ - userId: ownerId, - pageSize: FEED_PAGE_SIZE, - initialPageSize: FEED_PAGE_SIZE - }) - - const { data: totalTrackCount = 0 } = useExclusiveTracksCount({ - userId: ownerId - }) - - // Fan club feed (text posts) + // Single chronological feed for both tracks and text posts const { data: feedItems, isPending: isFeedPending, - hasNextPage: hasNextFeedPage, - fetchNextPage: fetchNextFeedPage, + hasNextPage, + fetchNextPage, isFetchingNextPage } = useFanClubFeed({ mint, sortMethod: 'newest', pageSize: FEED_PAGE_SIZE, - enabled: !!mint && isTextPostsEnabled + enabled: !!mint }) - const textPosts = feedItems?.filter( - (item): item is Extract => - item.itemType === 'text_post' + // Extract track items for lineup (playback ordering) + const trackLineupData = useMemo( + () => + feedItems + ?.filter( + (item): item is Extract => + item.itemType === 'track' + ) + .map((item) => ({ id: item.trackId, type: EntityType.TRACK })) ?? [], + [feedItems] ) - const handleLoadMoreFeed = useCallback(() => { - if (hasNextFeedPage) { - fetchNextFeedPage() + // Lineup state for sequential track playback + const lineup = useSelector(exclusiveTracksPageSelectors.getLineup) + const isPlaying = useSelector(playerSelectors.getPlaying) + const getCurrentQueueItem = useMemo(() => makeGetCurrent(), []) + const currentQueueItem = useSelector(getCurrentQueueItem) + const isBuffering = useSelector(getBuffering) + const playingUid = currentQueueItem?.uid + const playingSource = currentQueueItem?.source + + // Sync lineup with track data from the feed + const prevTrackCount = useRef(0) + useEffect(() => { + if (trackLineupData.length !== prevTrackCount.current) { + dispatch(exclusiveTracksPageLineupActions.reset()) + if (trackLineupData.length > 0) { + const fullTracks = mapLineupDataToFullLineupItems( + trackLineupData, + queryClient, + () => {}, + lineup.prefix + ) + dispatch( + exclusiveTracksPageLineupActions.fetchLineupMetadatas( + 0, + fullTracks.length, + false, + { items: fullTracks } + ) + ) + } + prevTrackCount.current = trackLineupData.length } - }, [hasNextFeedPage, fetchNextFeedPage]) + }, [trackLineupData, dispatch, queryClient, lineup.prefix]) + + // Playback controls + const togglePlay = useCallback( + (uid: UID, id: ID) => { + if (uid !== playingUid || (uid === playingUid && !isPlaying)) { + dispatch(exclusiveTracksPageLineupActions.play(uid)) + dispatch( + make(Name.PLAYBACK_PLAY, { + id, + source: PlaybackSource.EXCLUSIVE_TRACKS_PAGE + }) + ) + } else { + dispatch(exclusiveTracksPageLineupActions.pause()) + dispatch( + make(Name.PLAYBACK_PAUSE, { + id, + source: PlaybackSource.EXCLUSIVE_TRACKS_PAGE + }) + ) + } + }, + [playingUid, isPlaying, dispatch] + ) + + const TrackTile = isMobile ? MobileTrackTile : TrackTileDesktop + + // Precompute mapping from feed index to lineup entry index for tracks + const feedItemsWithLineupIndex = useMemo(() => { + let trackIdx = 0 + return ( + feedItems?.map((item) => ({ + ...item, + lineupIndex: item.itemType === 'track' ? trackIdx++ : -1 + })) ?? [] + ) + }, [feedItems]) - const hasTextPosts = textPosts && textPosts.length > 0 - const hasTracks = totalTrackCount > 0 - const shouldShowSection = hasTextPosts || hasTracks + const hasContent = feedItemsWithLineupIndex.length > 0 - if (!shouldShowSection) return null + if (!hasContent && !isFeedPending) return null return ( @@ -97,53 +164,62 @@ export const FanClubFeedSection = ({ mint }: FanClubFeedSectionProps) => { - {/* Text posts (feature-flagged) */} - {!isTextPostsEnabled ? null : isFeedPending ? ( + {isFeedPending ? ( - ) : hasTextPosts ? ( + ) : ( - {textPosts.map((item) => ( - - ))} - {hasNextFeedPage ? ( - + {feedItemsWithLineupIndex.map((item) => { + if (item.itemType === 'text_post') { + if (!isTextPostsEnabled) return null + return ( + + ) + } + + // Track item — render using lineup entry for playback state + const lineupEntry: any = lineup.entries[item.lineupIndex] + if (!lineupEntry) return null + + return ( + + ) + })} + + {hasNextPage ? ( + + + ) : null} - ) : null} - - {/* Exclusive tracks */} - {hasTracks && ownerId ? ( - - ) : null} + )} ) }