diff --git a/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts b/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts index f295ce56ba7..cb929d480aa 100644 --- a/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts +++ b/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts @@ -19,19 +19,25 @@ const STALE_TIME_MS = 30 * 60 * 1000 export type UseWeeklyRotationArgs = { limit?: number + /** + * Whose mix. Defaults to the signed-in user; pass another user's id to + * view a shared mix. The endpoint is public, so any user works. + */ + userId?: ID | null } export const getWeeklyRotationQueryKey = ({ userId, limit = DEFAULT_LIMIT -}: UseWeeklyRotationArgs & { userId: ID | null | undefined }) => +}: Omit & { userId: ID | null | undefined }) => [QUERY_KEYS.weeklyRotation, userId, { limit }] as unknown as QueryKey< LineupData[] > /** - * The current user's Weekly Rotation mix: tracks they haven't heard, weighted - * toward artists they don't already follow. + * A user's Weekly Rotation mix: tracks they haven't heard, weighted toward + * artists they don't already follow. The current user's by default; a shared + * link passes the sharer's id. * * Deliberately a plain `useQuery` rather than an infinite one — the mix is a * fixed-size artifact, not a lineup you scroll. There is no page 2. @@ -44,19 +50,20 @@ export const getWeeklyRotationQueryKey = ({ * the request count low while still letting a bad result heal. */ export const useWeeklyRotation = ( - { limit = DEFAULT_LIMIT }: UseWeeklyRotationArgs = {}, + { limit = DEFAULT_LIMIT, userId: userIdArg }: UseWeeklyRotationArgs = {}, options?: QueryOptions ) => { const { audiusSdk } = useQueryContext() const { data: currentUserId } = useCurrentUserId() const queryClient = useQueryClient() + const userId = userIdArg ?? currentUserId const query = useQuery({ - queryKey: getWeeklyRotationQueryKey({ userId: currentUserId, limit }), + queryKey: getWeeklyRotationQueryKey({ userId, limit }), queryFn: async () => { const sdk = await audiusSdk() const { data = [] } = await sdk.users.getWeeklyRotation({ - id: Id.parse(currentUserId), + id: Id.parse(userId), limit, userId: OptionalId.parse(currentUserId) }) @@ -69,7 +76,7 @@ export const useWeeklyRotation = ( }, staleTime: STALE_TIME_MS, ...options, - enabled: options?.enabled !== false && !!currentUserId + enabled: options?.enabled !== false && !!userId }) const data = query.data ?? [] diff --git a/packages/common/src/hooks/useShareContent.ts b/packages/common/src/hooks/useShareContent.ts index 948019f6a43..39a8e6cb23c 100644 --- a/packages/common/src/hooks/useShareContent.ts +++ b/packages/common/src/hooks/useShareContent.ts @@ -15,10 +15,13 @@ export const useShareContent = ( const profileId = request?.type === 'profile' ? request.profileId : null const collectionId = request?.type === 'collection' ? request.collectionId : null + const weeklyRotationUserId = + request?.type === 'weeklyRotation' ? request.userId : null const { data: track } = useTrack(trackId) const { data: profile } = useUser(profileId) const { data: collection } = useCollection(collectionId) + const { data: weeklyRotationUser } = useUser(weeklyRotationUserId) const trackArtistId = track?.owner_id ?? null const collectionOwnerId = collection?.playlist_owner_id ?? null @@ -46,5 +49,10 @@ export const useShareContent = ( return { type: 'playlist', playlist: collection, creator: collectionOwner } } + if (request.type === 'weeklyRotation') { + if (!weeklyRotationUser) return null + return { type: 'weeklyRotation', user: weeklyRotationUser } + } + return null } diff --git a/packages/common/src/messages/explore.ts b/packages/common/src/messages/explore.ts index 2c4a9db6f0b..7d0d7d2f2d3 100644 --- a/packages/common/src/messages/explore.ts +++ b/packages/common/src/messages/explore.ts @@ -28,10 +28,11 @@ export const exploreMessages = { imFeelingLucky: "I'm Feeling Lucky", recentlyPlayed: 'Recently Played', weeklyRotation: 'Your Weekly Rotation', - weeklyRotationSubtitle: 'Updated every Monday', + weeklyRotationFor: (name: string) => `${name}'s Weekly Rotation`, + weeklyRotationSubtitle: 'Updated every Wednesday', weeklyRotationBadge: 'New', weeklyRotationPitch: - 'A fresh mix of tracks picked just for you. Updated every Monday.', + 'A fresh mix of tracks picked just for you. Updated every Wednesday.', weeklyRotationCta: 'Listen Now', weeklyRotationTrackCount: (count: number) => `${count} ${count === 1 ? 'track' : 'tracks'}`, diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index ed736a7579f..b0b437f05f7 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -719,7 +719,7 @@ export enum FollowSource { type Share = { eventName: Name.SHARE - kind: 'profile' | 'album' | 'playlist' | 'track' + kind: 'profile' | 'album' | 'playlist' | 'track' | 'weeklyRotation' source: ShareSource id: string url: string @@ -727,7 +727,7 @@ type Share = { export type ShareToTwitter = { eventName: Name.SHARE_TO_TWITTER - kind: 'profile' | 'album' | 'playlist' | 'track' + kind: 'profile' | 'album' | 'playlist' | 'track' | 'weeklyRotation' source: ShareSource id: number url: string diff --git a/packages/common/src/store/ui/share-modal/types.ts b/packages/common/src/store/ui/share-modal/types.ts index 3cfc2191785..a6446fb20e0 100644 --- a/packages/common/src/store/ui/share-modal/types.ts +++ b/packages/common/src/store/ui/share-modal/types.ts @@ -4,7 +4,13 @@ import { Nullable } from '~/utils/typeUtils' import { ID, ShareSource, Collection, Track, User } from '../../../models' -export type ShareType = 'track' | 'profile' | 'album' | 'playlist' | 'contest' +export type ShareType = + | 'track' + | 'profile' + | 'album' + | 'playlist' + | 'contest' + | 'weeklyRotation' type ShareTrackContent = { type: 'track' @@ -46,18 +52,31 @@ type SharePlaylistContent = { creator: User } +/** + * A listener's Weekly Rotation mix. There is no entity behind it -- the mix + * is computed on demand from the listener's id -- so the only thing the share + * needs is the listener: the link is built from their handle and the card + * from their current mix. + */ +type ShareWeeklyRotationContent = { + type: 'weeklyRotation' + user: User +} + export type ShareContent = | ShareTrackContent | ShareContestContent | ShareProfileContent | ShareAlbumContent | SharePlaylistContent + | ShareWeeklyRotationContent export type ShareModalRequest = | { type: 'track'; trackId: ID } | { type: 'contest'; trackId: ID } | { type: 'profile'; profileId: ID } | { type: 'collection'; collectionId: ID } + | { type: 'weeklyRotation'; userId: ID } export type ShareModalState = { source: Nullable diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index c234fc63489..6296f8df9a2 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -32,6 +32,9 @@ export const EXPLORE_PAGE = '/explore' export const TRENDING_PLAYLISTS_PAGE = '/explore/playlists' export const TRENDING_UNDERGROUND_PAGE = '/explore/underground' export const WEEKLY_ROTATION_PAGE = '/explore/weekly-rotation' +// Someone else's mix. Lives under /explore rather than /:handle/weekly-rotation +// because /:handle/:slug is the track permalink pattern. +export const WEEKLY_ROTATION_USER_PAGE = '/explore/weekly-rotation/:handle' export const CONTESTS_PAGE = '/contests' // DEPRECATED - use /library instead. @@ -303,6 +306,7 @@ export const orderedRoutes = [ TRENDING_PAGE, EXPLORE_PAGE, WEEKLY_ROTATION_PAGE, + WEEKLY_ROTATION_USER_PAGE, CONTESTS_PAGE, EMPTY_PAGE, SEARCH_PAGE, @@ -427,6 +431,10 @@ export const staticRoutes = new Set([ CHATS_PAGE ]) +export const weeklyRotationPage = (handle: string) => { + return `${WEEKLY_ROTATION_PAGE}/${encodeUrlName(handle)}` +} + export const profilePage = (handle: string | null | undefined) => { return `/${encodeUrlName(handle ?? '')}` } diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index ba927cad4e4..f5ad06e67ec 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -123,6 +123,8 @@ const NavigationContainer = (props: NavigationContainerProps) => { MostLoved: 'explore/most-loved', FeelingLucky: 'explore/feeling-lucky', HeavyRotation: 'explore/heavy-rotation', + WeeklyRotationScreen: + 'explore/weekly-rotation/:handle?', ChillPlaylists: 'explore/chill', IntensePlaylists: 'explore/intense', IntimatePlaylists: 'explore/intimate', diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index c02430ccd03..91916a9d8b1 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { useCurrentUserId } from '@audius/common/api' import { useShareAction, useShareContent } from '@audius/common/hooks' +import { exploreMessages } from '@audius/common/messages' import { Name, ShareSource } from '@audius/common/models' import { registerNiceModalId } from '@audius/common/services' import { @@ -31,6 +32,8 @@ import { useToast } from 'app/hooks/useToast' import type { AppTabScreenParamList } from 'app/screens/app-screen' import { make, track } from 'app/services/analytics' import { makeStyles } from 'app/styles' +import { getWeeklyRotationRoute } from 'app/utils/routes' +import share from 'app/utils/share' import { useThemeColors } from 'app/utils/theme' import { ActionDrawerWithoutRedux } from '../action-drawer/ActionDrawerWithoutRedux' @@ -157,6 +160,27 @@ export const ShareDrawer = NiceModal.create(() => { case 'playlist': dispatch(shareCollection(content.playlist.playlist_id, source)) break + case 'weeklyRotation': { + // No entity behind the mix, so no social saga: open the system + // sheet directly with the same link Copy Link uses, and emit the + // same Share event the sagas do. + // `share` prepends AUDIUS_URL itself, so hand it the path. + const url = getWeeklyRotationRoute(content.user) + share({ + url, + message: exploreMessages.weeklyRotationFor(content.user.name) + }) + track( + make({ + eventName: Name.SHARE, + kind: 'weeklyRotation', + id: `${content.user.user_id}`, + url, + source + }) + ) + break + } } }, [dispatch, content, source]) @@ -232,6 +256,10 @@ export const ShareDrawer = NiceModal.create(() => { result.push(shareToInstagramStoriesAction) result.push(shareVideoToTiktokAction) result.push(shareToSnapchatAction) + } else if (content?.type === 'weeklyRotation') { + // The story/video paths need a single streamable track; a mix has + // none, but X works off the link alone. + result.push(shareToXAction) } result.push(copyLinkAction, shareSheetAction) @@ -247,7 +275,8 @@ export const ShareDrawer = NiceModal.create(() => { handleOpenShareSheet, handleShareToSnapchat, handleShareToInstagramStory, - isShareableTrack + isShareableTrack, + content?.type ]) // Trigger share action on mount with new content diff --git a/packages/mobile/src/components/share-drawer/messages.ts b/packages/mobile/src/components/share-drawer/messages.ts index 67bf5d38d04..03e5a109cd7 100644 --- a/packages/mobile/src/components/share-drawer/messages.ts +++ b/packages/mobile/src/components/share-drawer/messages.ts @@ -5,7 +5,8 @@ const shareTypeMap: Record = { profile: 'Profile', album: 'Album', playlist: 'Playlist', - contest: 'Contest' + contest: 'Contest', + weeklyRotation: 'Weekly Rotation' } export const messages = { @@ -30,6 +31,8 @@ export const messages = { `Check out ${albumName} by ${handle} @audius $AUDIO`, playlistShareText: (playlistName: string, handle: string) => `Check out ${playlistName} by ${handle} @audius $AUDIO`, + weeklyRotationShareText: (handle: string) => + `Check out ${handle}'s Weekly Rotation on @audius $AUDIO`, loadingStoryModalTitle: 'Generating Story', loadingInstagramStorySubtitle: 'Preparing to open Instagram', loadingSnapchatSubtitle: 'Preparing to open Snapchat', diff --git a/packages/mobile/src/components/share-drawer/utils.ts b/packages/mobile/src/components/share-drawer/utils.ts index d99f824b6c1..c16aa037f58 100644 --- a/packages/mobile/src/components/share-drawer/utils.ts +++ b/packages/mobile/src/components/share-drawer/utils.ts @@ -5,7 +5,8 @@ import { getCollectionRoute, getContestRoute, getTrackRoute, - getUserRoute + getUserRoute, + getWeeklyRotationRoute } from 'app/utils/routes' import { messages } from './messages' @@ -35,6 +36,10 @@ export const getContentUrl = (content: ShareContent) => { const { playlist } = content return getCollectionRoute(playlist, true) } + case 'weeklyRotation': { + const { user } = content + return getWeeklyRotationRoute(user, true) + } } } @@ -72,6 +77,10 @@ export const getXShareText = async (content: ShareContent) => { } = content return messages.playlistShareText(playlist_name, getXShareHandle(creator)) } + case 'weeklyRotation': { + const { user } = content + return messages.weeklyRotationShareText(getXShareHandle(user)) + } } } diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx index 2622f006e18..98cb1fc3174 100644 --- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx @@ -111,7 +111,8 @@ export type AppTabScreenParamList = { SettingsScreen: undefined AboutScreen: undefined ListeningHistoryScreen: undefined - WeeklyRotationScreen: undefined + // No handle: the signed-in user's own mix. With one: a shared mix. + WeeklyRotationScreen: { handle?: string } | undefined AccountSettingsScreen: undefined ChangeEmail: undefined ChangePassword: undefined diff --git a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx index 1cf8496bd83..95ff87e57fa 100644 --- a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx +++ b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx @@ -1,11 +1,19 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { useWeeklyRotation } from '@audius/common/api' +import { + useCurrentUserId, + useUserByHandle, + useWeeklyRotation +} from '@audius/common/api' import { useAnalytics } from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' import type { ID } from '@audius/common/models' -import { Name } from '@audius/common/models' -import { playbackActions, playbackSelectors } from '@audius/common/store' +import { Name, ShareSource } from '@audius/common/models' +import { + playbackActions, + playbackSelectors, + shareModalUIActions +} from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' import { Image } from 'react-native' import { useDispatch, useSelector } from 'react-redux' @@ -13,17 +21,23 @@ import { useDispatch, useSelector } from 'react-redux' import { Button, Flex, + IconButton, IconPause, IconPlay, + IconShare, Paper, Text } from '@audius/harmony-native' import weeklyRotationArt from 'app/assets/images/weeklyRotation.jpg' import { Screen, ScreenContent } from 'app/components/core' import { TrackLineup } from 'app/components/lineup/TrackLineup' +import { useRoute } from 'app/hooks/useRoute' + +const { requestOpen: requestOpenShareModal } = shareModalUIActions const messages = { - title: 'Weekly Rotation' + title: 'Weekly Rotation', + share: 'Share' } const ART_SIZE = 120 @@ -33,11 +47,25 @@ const WEEKLY_ROTATION_SOURCE = 'WEEKLY_ROTATION_TRACKS' * The full Weekly Rotation mix. Mirrors the web page: artwork header, then the * track list. * + * Without a `handle` param this is the signed-in user's own mix. With one -- + * a shared link, deep-linked from `/explore/weekly-rotation/:handle` -- it is + * that user's. The endpoint is public, so a shared mix loads for anyone. + * * The endpoint returns a fixed 30, so there is no pagination -- hasNextPage is * false and loadNextPage is a no-op. */ export const WeeklyRotationScreen = () => { - const { trackIds, isPending, isFetching } = useWeeklyRotation({ limit: 30 }) + const { params } = useRoute<'WeeklyRotationScreen'>() + const handle = params?.handle + const { data: currentUserId } = useCurrentUserId() + const { data: handleUser } = useUserByHandle(handle, { enabled: !!handle }) + const targetUserId = handle ? handleUser?.user_id : currentUserId + const isOwnMix = !handle || handleUser?.user_id === currentUserId + + const { trackIds, isPending, isFetching } = useWeeklyRotation( + { limit: 30, userId: targetUserId }, + { enabled: !!targetUserId } + ) const { trackEvent } = useAnalytics() const dispatch = useDispatch() @@ -82,6 +110,17 @@ export const WeeklyRotationScreen = () => { ) }, [dispatch, isPlaying, currentPlaybackTrackId, playbackQueue, trackEvent]) + const handleShare = useCallback(() => { + if (!targetUserId) return + dispatch( + requestOpenShareModal({ + type: 'weeklyRotation', + userId: targetUserId, + source: ShareSource.PAGE + }) + ) + }, [dispatch, targetUserId]) + // Fired once the mix resolves, so trackCount is real and a failed load // doesn't register as a page view. const hasTrackedView = useRef(false) @@ -95,6 +134,21 @@ export const WeeklyRotationScreen = () => { }) }, [trackIds.length, trackEvent]) + const title = isOwnMix + ? exploreMessages.weeklyRotation + : exploreMessages.weeklyRotationFor(handleUser?.name ?? handle ?? '') + + const topbarRight = ( + + ) + const header = ( @@ -104,7 +158,7 @@ export const WeeklyRotationScreen = () => { /> - {exploreMessages.weeklyRotation} + {title} {exploreMessages.weeklyRotationSubtitle} @@ -129,7 +183,11 @@ export const WeeklyRotationScreen = () => { ) return ( - + { return fullUrl ? `${AUDIUS_URL}${route}` : route } +export const getWeeklyRotationRoute = (user: UserHandle, fullUrl = false) => { + const route = `/explore/weekly-rotation/${encodeUrlName(user.handle)}` + return fullUrl ? `${AUDIUS_URL}${route}` : route +} + export const getCollectionRoute = ( collection: Pick, fullUrl = false diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index d7d30a848f7..ade608f982f 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -240,6 +240,7 @@ const { NOTIFICATION_USERS_PAGE, EXPLORE_PAGE, WEEKLY_ROTATION_PAGE, + WEEKLY_ROTATION_USER_PAGE, CONTESTS_PAGE, SAVED_PAGE, LIBRARY_PAGE, @@ -932,6 +933,10 @@ const WebPlayer = (props: WebPlayerProps) => { path={WEEKLY_ROTATION_PAGE} element={} /> + } + /> {!isProduction ? ( } /> ) : null} @@ -1414,6 +1419,10 @@ const WebPlayer = (props: WebPlayerProps) => { path={WEEKLY_ROTATION_PAGE} element={} /> + } + /> {!isProduction ? ( } /> ) : null} diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index 2c5ba880753..fe2f9866ed7 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -23,8 +23,10 @@ import { make, useRecord } from 'common/store/analytics/actions' import * as embedModalActions from 'components/embed-modal/store/actions' import { ToastContext } from 'components/toast/ToastContext' import { useIsMobile } from 'hooks/useIsMobile' +import { copyLinkToClipboard } from 'utils/clipboardUtil' import { SHARE_TOAST_TIMEOUT_MILLIS } from 'utils/constants' import { useSelector } from 'utils/reducer' +import { weeklyRotationPage } from 'utils/route' import { openXLink } from 'utils/xShare' import { ShareDialog } from './components/ShareDialog' @@ -105,10 +107,26 @@ export const ShareModal = NiceModal.create(() => { case 'playlist': dispatch(shareCollection(content.playlist.playlist_id, source)) break + case 'weeklyRotation': { + // No entity, so no social saga to route through: the link is a + // function of the handle alone. Same clipboard path and Share + // event the sagas emit. + const link = weeklyRotationPage(content.user.handle) + copyLinkToClipboard(link) + record( + make(Name.SHARE, { + kind: 'weeklyRotation', + id: `${content.user.user_id}`, + url: link, + source + }) + ) + break + } } toast(messages.toast(content.type), SHARE_TOAST_TIMEOUT_MILLIS) onClose() - }, [dispatch, toast, content, source, onClose]) + }, [dispatch, toast, content, source, onClose, record]) const handleEmbed = useCallback(() => { if (content?.type === 'track') { diff --git a/packages/web/src/components/share-modal/messages.ts b/packages/web/src/components/share-modal/messages.ts index 1d6721ad417..4918579c123 100644 --- a/packages/web/src/components/share-modal/messages.ts +++ b/packages/web/src/components/share-modal/messages.ts @@ -5,7 +5,8 @@ const shareTypeMap: Record = { profile: 'Profile', album: 'Album', playlist: 'Playlist', - contest: 'Contest' + contest: 'Contest', + weeklyRotation: 'Weekly Rotation' } export const messages = { @@ -24,6 +25,8 @@ export const messages = { `Check out ${albumName} by ${handle} @audius $AUDIO`, playlistShareText: (playlistName: string, handle: string) => `Check out ${playlistName} by ${handle} @audius $AUDIO`, + weeklyRotationShareText: (handle: string) => + `Check out ${handle}'s Weekly Rotation on @audius $AUDIO`, // TODO: See if you can display my when the account user is the user shareDescription: 'Spread the word! Share with your friends and fans!', hiddenPlaylistShareDescription: diff --git a/packages/web/src/components/share-modal/utils.ts b/packages/web/src/components/share-modal/utils.ts index e25db6ab855..635c263199a 100644 --- a/packages/web/src/components/share-modal/utils.ts +++ b/packages/web/src/components/share-modal/utils.ts @@ -6,7 +6,8 @@ import { fullCollectionPage, fullContestPage, fullProfilePage, - fullTrackPage + fullTrackPage, + fullWeeklyRotationPage } from 'utils/route' import { messages } from './messages' @@ -20,6 +21,7 @@ type ShareMessageConfig = Pick< | 'contestShareText' | 'playlistShareText' | 'albumShareText' + | 'weeklyRotationShareText' > export const getXShareText = async ( @@ -99,6 +101,13 @@ export const getXShareText = async ( analyticsEvent = { kind: 'playlist', id: playlist_id, url: link } break } + case 'weeklyRotation': { + const { user } = content + xText = messageConfig.weeklyRotationShareText(getXShareHandle(user)) + link = fullWeeklyRotationPage(user.handle) + analyticsEvent = { kind: 'weeklyRotation', id: user.user_id, url: link } + break + } } return { xText, link, analyticsEvent } diff --git a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx index c9b406adc76..b1f24a9720e 100644 --- a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx +++ b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx @@ -1,11 +1,19 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useCurrentUserId, useWeeklyRotation } from '@audius/common/api' +import { + useCurrentUserId, + useUserByHandle, + useWeeklyRotation +} from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' -import { ID, Name, PlaybackSource } from '@audius/common/models' +import { ID, Name, PlaybackSource, ShareSource } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' -import { playbackActions, playbackSelectors } from '@audius/common/store' +import { + playbackActions, + playbackSelectors, + shareModalUIActions +} from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' import { route } from '@audius/common/utils' import { @@ -14,10 +22,11 @@ import { Flex, IconPause, IconPlay, + IconShare, Text } from '@audius/harmony' import { useDispatch, useSelector } from 'react-redux' -import { Navigate } from 'react-router' +import { Navigate, useParams } from 'react-router' import weeklyRotationArt from 'assets/img/weeklyRotation.jpg' import { make } from 'common/store/analytics/actions' @@ -26,14 +35,18 @@ import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import { TrackTableLineup, TracksTableColumn } from 'components/tracks-table' import { useIsMobile } from 'hooks/useIsMobile' import { useMainContentRef } from 'pages/MainContentContext' +import { fullWeeklyRotationPage } from 'utils/route' +import { getWeeklyRotationOgImageUrl } from 'utils/weeklyRotationPeriod' const messages = { title: 'Weekly Rotation', description: - 'A fresh mix of tracks picked for you, updated every Monday on Audius.' + 'A fresh mix of tracks picked for you, updated every Wednesday on Audius.', + share: 'Share' } const { EXPLORE_PAGE } = route +const { requestOpen: requestOpenShareModal } = shareModalUIActions const WEEKLY_ROTATION_SOURCE = 'WEEKLY_ROTATION_TRACKS' const PAGE_SIZE = 30 @@ -57,6 +70,12 @@ const columns: TracksTableColumn[] = [ * Artwork is the bundled asset for the same reason: there's no playlist_id to * hang cover art on. * + * Two routes land here. `/explore/weekly-rotation` is the signed-in user's own + * mix; `/explore/weekly-rotation/:handle` is a shared link to someone else's, + * which is what Share produces. The endpoint is public, so the shared page + * works signed out. Opening your own handle's link is the same as the bare + * route. + * * The endpoint returns a fixed 30, so there is no pagination. */ export const WeeklyRotationPage = () => { @@ -65,6 +84,7 @@ export const WeeklyRotationPage = () => { const { trackEvent } = useAnalytics() const mainContentRef = useMainContentRef() const { data: currentUserId } = useCurrentUserId() + const { handle } = useParams<{ handle?: string }>() // The route stays registered while the flag is off -- the URL is public and // shareable, so a link that predates the rollout should land somewhere real @@ -72,9 +92,16 @@ export const WeeklyRotationPage = () => { const { isEnabled: isWeeklyRotationEnabled, isLoaded: isFlagLoaded } = useFeatureFlag(FeatureFlags.WEEKLY_ROTATION) + // With a handle in the URL the mix belongs to that user; otherwise to the + // viewer. Resolving the handle to a user is what the share modal, the + // header, and the query all key off. + const { data: handleUser } = useUserByHandle(handle, { enabled: !!handle }) + const targetUserId = handle ? handleUser?.user_id : currentUserId + const isOwnMix = !handle || handleUser?.user_id === currentUserId + const { trackIds, isPending, isFetching, isLoading } = useWeeklyRotation( - { limit: PAGE_SIZE }, - { enabled: isWeeklyRotationEnabled } + { limit: PAGE_SIZE, userId: targetUserId }, + { enabled: isWeeklyRotationEnabled && !!targetUserId } ) // Fired once the mix resolves rather than on mount, so trackCount is real @@ -150,6 +177,19 @@ export const WeeklyRotationPage = () => { isMobile ]) + // The share modal resolves the owner's handle from the id, so the bare + // route shares the viewer's own mix under their handle. + const handleShare = useCallback(() => { + if (!targetUserId) return + dispatch( + requestOpenShareModal({ + type: 'weeklyRotation', + userId: targetUserId, + source: ShareSource.PAGE + }) + ) + }, [dispatch, targetUserId]) + const isEmpty = !isLoading && trackIds.length === 0 // Nothing until remote config resolves, so an enabled user doesn't get @@ -157,8 +197,24 @@ export const WeeklyRotationPage = () => { if (!isFlagLoaded) return null if (!isWeeklyRotationEnabled) return + const title = isOwnMix + ? exploreMessages.weeklyRotation + : exploreMessages.weeklyRotationFor(handleUser?.name ?? handle ?? '') + + // Only the handle route gets the collage card and a canonical URL: the bare + // route is per-viewer and shouldn't be indexed as anyone's mix. + const metaTags = handle + ? { + title, + description: messages.description, + image: getWeeklyRotationOgImageUrl(handle), + canonicalUrl: fullWeeklyRotationPage(handle), + thumbnail: false + } + : { title: messages.title, description: messages.description } + return ( - + { size='s' textAlign={isMobile ? 'center' : undefined} > - {exploreMessages.weeklyRotation} + {title} {exploreMessages.weeklyRotationSubtitle} @@ -189,14 +245,24 @@ export const WeeklyRotationPage = () => { ? ` · ${exploreMessages.weeklyRotationTrackCount(trackIds.length)}` : ''} - + + + + diff --git a/packages/web/src/ssr/metaTags.ts b/packages/web/src/ssr/metaTags.ts index 9c0cc0761d9..82451eae34c 100644 --- a/packages/web/src/ssr/metaTags.ts +++ b/packages/web/src/ssr/metaTags.ts @@ -3,7 +3,13 @@ * Centralized meta tag generation for both SSR and client-side rendering */ -import { fullCollectionPage, fullProfilePage, fullTrackPage } from 'utils/route' +import { + fullCollectionPage, + fullProfilePage, + fullTrackPage, + fullWeeklyRotationPage +} from 'utils/route' +import { getWeeklyRotationOgImageUrl } from 'utils/weeklyRotationPeriod' // Image URLs - default OG uses the Audius logo on black from og.audius.co export const DEFAULT_IMAGE_URL = 'https://og.audius.co/default' @@ -90,7 +96,15 @@ export const getWebUrl = (path: string): string => { /** * Explore type to metadata mapping */ -export const exploreMap: Record = {} +export const exploreMap: Record = { + 'weekly-rotation': { + title: 'Weekly Rotation', + description: createSeoDescription( + 'A fresh mix of tracks picked for you, updated every Wednesday on Audius' + ), + image: DEFAULT_IMAGE_URL + } +} /** * Get explore info for a given type @@ -333,6 +347,35 @@ export const getSearchContext = () => ({ thumbnail: true }) +/** + * A shared Weekly Rotation (/explore/weekly-rotation/:handle). The card is a + * collage of the mix's first four tracks, rendered by og.audius.co; the + * period is stamped into the image URL so scrapers that cache by URL see a + * new card when the mix rolls over. + */ +export const getWeeklyRotationPageContext = ({ + handle, + userName +}: { + handle: string + userName?: string +}) => { + const displayName = + (userName && String(userName).trim()) || + (handle && String(handle).trim()) || + 'Listener' + return { + title: `${displayName}'s Weekly Rotation`, + description: createSeoDescription( + `A mix of tracks picked for ${displayName}, updated every Wednesday on Audius` + ), + image: getWeeklyRotationOgImageUrl(handle), + imageAlt: `${displayName}'s Weekly Rotation on Audius`, + canonicalUrl: fullWeeklyRotationPage(handle), + thumbnail: false + } +} + /** * SEO Utility functions to generate titles and descriptions * Used by both SSR and client-side rendering diff --git a/packages/web/src/ssr/weekly-rotation/+Page.tsx b/packages/web/src/ssr/weekly-rotation/+Page.tsx new file mode 100644 index 00000000000..761b67a8c09 --- /dev/null +++ b/packages/web/src/ssr/weekly-rotation/+Page.tsx @@ -0,0 +1,4 @@ +// Empty page, everything is handled in +onRenderHtml +export default function render() { + return null +} diff --git a/packages/web/src/ssr/weekly-rotation/+onBeforeRender.tsx b/packages/web/src/ssr/weekly-rotation/+onBeforeRender.tsx new file mode 100644 index 00000000000..7a26a3deaa3 --- /dev/null +++ b/packages/web/src/ssr/weekly-rotation/+onBeforeRender.tsx @@ -0,0 +1,53 @@ +import type { PageContextServer } from 'vike/types' + +// Simple helper to get API URL without importing services/env which pulls in SDK dependencies +const getApiUrl = () => { + const env = process.env.VITE_ENVIRONMENT || 'development' + switch (env) { + case 'production': + return 'https://api.audius.co' + case 'development': + default: + return process.env.VITE_API_URL || 'http://audius-api' + } +} + +// Only the listener's name is needed for the tags; the card itself is +// rendered by og.audius.co from the handle, so the mix is not fetched here. +export async function onBeforeRender(pageContext: PageContextServer) { + const { handle } = pageContext.routeParams + + try { + const requestUrl = `${getApiUrl()}/v1/users/handle/${handle}` + const res = await fetch(requestUrl) + if (res.status !== 200) { + throw new Error(requestUrl) + } + + const json = await res.json() + const raw = json.data + const user = Array.isArray(raw) ? raw[0] : raw + if (!user || typeof user !== 'object') { + throw new Error(`No user found for handle: ${handle}`) + } + + return { + pageContext: { + pageProps: { user } + } + } + } catch (e) { + console.error( + 'Error fetching user for weekly rotation page SSR', + 'handle', + handle, + 'error', + e + ) + return { + pageContext: { + pageProps: {} + } + } + } +} diff --git a/packages/web/src/ssr/weekly-rotation/+onRenderClient.tsx b/packages/web/src/ssr/weekly-rotation/+onRenderClient.tsx new file mode 100644 index 00000000000..7eef81283ba --- /dev/null +++ b/packages/web/src/ssr/weekly-rotation/+onRenderClient.tsx @@ -0,0 +1,20 @@ +// Simple SSR pages use createRoot instead of hydrateRoot +// to avoid hydration mismatches since we only render meta tags during SSR + +import '../polyfills' + +import 'setimmediate' +import { createRoot } from 'react-dom/client' + +import '../../index.css' +import RootWithProviders from 'ssr/RootWithProviders' +import { isMobile as getIsMobile } from 'utils/clientUtil' + +export default function render() { + const container = document.getElementById('root') + if (container) { + const isMobile = getIsMobile() + const root = createRoot(container) + root.render() + } +} diff --git a/packages/web/src/ssr/weekly-rotation/+onRenderHtml.tsx b/packages/web/src/ssr/weekly-rotation/+onRenderHtml.tsx new file mode 100644 index 00000000000..33bef808437 --- /dev/null +++ b/packages/web/src/ssr/weekly-rotation/+onRenderHtml.tsx @@ -0,0 +1,62 @@ +// Weekly Rotation page SSR - meta tags only + +import { renderToString } from 'react-dom/server' +import { Helmet } from 'react-helmet' +import { escapeInject, dangerouslySkipEscape } from 'vike/server' +import type { PageContextServer } from 'vike/types' + +import { MetaTags } from 'components/meta-tags/MetaTags' +import { getIndexHtml } from 'ssr/getIndexHtml' +import { + getAppUrl, + getWebUrl, + getWeeklyRotationPageContext +} from 'ssr/metaTags' + +type WeeklyRotationPageContext = PageContextServer & { + routeParams: { + handle: string + } + pageProps: { + user?: { handle?: string; name?: string } + } +} + +export default function render(pageContext: WeeklyRotationPageContext) { + const { routeParams, pageProps, urlPathname } = pageContext + const { user } = pageProps + // Prefer the API's casing of the handle so the card URL matches what the + // client will request. + const handle = user?.handle ?? routeParams.handle + + const context = getWeeklyRotationPageContext({ + handle, + userName: user?.name + }) + + const pageHtml = renderToString( + <> + +
+ + ) + + const helmet = Helmet.renderStatic() + + const html = getIndexHtml() + .replace(`
`, `
${pageHtml}
`) + .replace( + ``, + ` + ${helmet.title.toString()} + ${helmet.meta.toString()} + ${helmet.link.toString()} + ` + ) + + return escapeInject`${dangerouslySkipEscape(html)}` +} diff --git a/packages/web/src/ssr/weekly-rotation/+route.ts b/packages/web/src/ssr/weekly-rotation/+route.ts new file mode 100644 index 00000000000..7cc94491a99 --- /dev/null +++ b/packages/web/src/ssr/weekly-rotation/+route.ts @@ -0,0 +1,8 @@ +import { makePageRoute } from 'ssr/util' + +// A shared Weekly Rotation. The bare /explore/weekly-rotation stays with the +// explore handler: it's per-viewer and has no card of its own. +export default makePageRoute( + ['/explore/weekly-rotation/@handle'], + 'Weekly Rotation Page' +) diff --git a/packages/web/src/utils/route.ts b/packages/web/src/utils/route.ts index 27a166f9173..4ae9d57fb12 100644 --- a/packages/web/src/utils/route.ts +++ b/packages/web/src/utils/route.ts @@ -112,6 +112,15 @@ export const fullCollectionPage = ( export const fullProfilePage = (handle: string) => { return `${BASE_URL}${profilePage(handle)}` } + +// A listener's Weekly Rotation. Under /explore rather than /:handle/... so it +// can't collide with a track permalink. +export const weeklyRotationPage = (handle: string) => { + return `/explore/weekly-rotation/${encodeUrlName(handle)}` +} +export const fullWeeklyRotationPage = (handle: string) => { + return `${BASE_URL}${weeklyRotationPage(handle)}` +} export const profilePageAiAttributedTracks = (handle: string) => { return `${profilePage(handle)}/ai` } diff --git a/packages/web/src/utils/weeklyRotationPeriod.test.ts b/packages/web/src/utils/weeklyRotationPeriod.test.ts new file mode 100644 index 00000000000..7ebe6330191 --- /dev/null +++ b/packages/web/src/utils/weeklyRotationPeriod.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { + formatWeeklyRotationPeriod, + getWeeklyRotationOgImageUrl, + getWeeklyRotationPeriod +} from './weeklyRotationPeriod' + +const utc = (y: number, m: number, d: number, h = 0) => + new Date(Date.UTC(y, m - 1, d, h)) + +// The same cases as TestWeeklyRotationPeriod in the api repo, so the two +// implementations can't drift apart without one of these failing. +describe('getWeeklyRotationPeriod', () => { + it('opens ISO week 37 at the Wednesday rollover', () => { + // 2026-09-09 is a Wednesday. + expect(getWeeklyRotationPeriod(utc(2026, 9, 9))).toEqual({ + year: 2026, + week: 37 + }) + }) + + it('keeps Monday and Tuesday in the period that started the previous Wednesday', () => { + expect(getWeeklyRotationPeriod(utc(2026, 9, 7, 12))).toEqual({ + year: 2026, + week: 36 + }) + expect(getWeeklyRotationPeriod(utc(2026, 9, 8, 23))).toEqual({ + year: 2026, + week: 36 + }) + }) + + it('works in UTC regardless of the caller timezone offset', () => { + // 2026-09-08 20:00 PDT is 2026-09-09 03:00 UTC. + expect( + getWeeklyRotationPeriod(new Date('2026-09-08T20:00:00-07:00')) + ).toEqual({ year: 2026, week: 37 }) + }) + + it('handles the year boundary', () => { + // ISO week 1 of 2027 starts Monday 2027-01-04, so its period starts + // Wednesday 2027-01-06; the days before belong to 2026's week 53. + expect(getWeeklyRotationPeriod(utc(2027, 1, 6))).toEqual({ + year: 2027, + week: 1 + }) + expect(getWeeklyRotationPeriod(utc(2027, 1, 5, 23))).toEqual({ + year: 2026, + week: 53 + }) + }) +}) + +describe('formatWeeklyRotationPeriod', () => { + it('zero-pads the week', () => { + expect(formatWeeklyRotationPeriod({ year: 2027, week: 1 })).toBe('2027-01') + expect(formatWeeklyRotationPeriod({ year: 2026, week: 37 })).toBe('2026-37') + }) +}) + +describe('getWeeklyRotationOgImageUrl', () => { + it('stamps the period into the card URL', () => { + expect(getWeeklyRotationOgImageUrl('dylan', utc(2026, 9, 9))).toBe( + 'https://og.audius.co/weekly-rotation/dylan?week=2026-37' + ) + }) + + it('encodes the handle', () => { + expect(getWeeklyRotationOgImageUrl('a b', utc(2026, 9, 9))).toBe( + 'https://og.audius.co/weekly-rotation/a%20b?week=2026-37' + ) + }) +}) diff --git a/packages/web/src/utils/weeklyRotationPeriod.ts b/packages/web/src/utils/weeklyRotationPeriod.ts new file mode 100644 index 00000000000..687bdd7abb6 --- /dev/null +++ b/packages/web/src/utils/weeklyRotationPeriod.ts @@ -0,0 +1,52 @@ +/** + * The Weekly Rotation period, as the API defines it: identified by an ISO + * (year, week) pair but rolling over on Wednesday 00:00 UTC rather than + * Monday. Mirrors `weeklyRotationPeriod` in the api repo. + * + * Pure and dependency-free on purpose: the SSR bundle imports it, and SSR + * avoids `@audius/common/utils` because that drags in dayjs and friends. + */ + +const ROLLOVER_OFFSET_DAYS = 2 // ISO Monday -> Wednesday +const MS_PER_DAY = 86_400_000 + +export type WeeklyRotationPeriod = { year: number; week: number } + +export const getWeeklyRotationPeriod = ( + date: Date = new Date() +): WeeklyRotationPeriod => { + // Shift back so a period that started on Wednesday maps onto the ISO week + // whose Monday it belongs to, then do the standard ISO week calculation: + // the ISO week of a date is the week of that date's Thursday. + const d = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ) + d.setUTCDate(d.getUTCDate() - ROLLOVER_OFFSET_DAYS) + const isoWeekday = d.getUTCDay() || 7 + d.setUTCDate(d.getUTCDate() + 4 - isoWeekday) + const year = d.getUTCFullYear() + const yearStart = Date.UTC(year, 0, 1) + const week = Math.ceil(((d.getTime() - yearStart) / MS_PER_DAY + 1) / 7) + return { year, week } +} + +/** `2026-37`: stable, sortable, safe in a query string. */ +export const formatWeeklyRotationPeriod = ({ + year, + week +}: WeeklyRotationPeriod) => `${year}-${String(week).padStart(2, '0')}` + +const OG_BASE_URL = 'https://og.audius.co' + +/** + * The OG card for a listener's current mix. The period is a cache-buster: + * scrapers key their caches on the URL, and the same handle means a new + * card once the week rolls over. + */ +export const getWeeklyRotationOgImageUrl = ( + handle: string, + date: Date = new Date() +) => + `${OG_BASE_URL}/weekly-rotation/${encodeURIComponent( + handle + )}?week=${formatWeeklyRotationPeriod(getWeeklyRotationPeriod(date))}`