From 9ac5e4730c1cd1bbad0eb078da6ba00b1f759b2b Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 8 Sep 2026 10:20:56 -0700 Subject: [PATCH] feat(weekly-rotation): make the mix shareable A Weekly Rotation can now be linked. Sharing produces /explore/weekly-rotation/:handle, which shows that listener's mix to anyone (the endpoint is public), and unfurls with a 2x2 collage of the mix's first four tracks rendered by og.audius.co. The bare /explore/weekly-rotation stays the signed-in user's own mix. Under /explore rather than /:handle/weekly-rotation because /:handle/:slug is the track permalink pattern. - common: `weeklyRotation` share type, request and content; the content is just the listener, since there is no entity behind the mix. useWeeklyRotation takes an optional userId. Share analytics get a `weeklyRotation` kind. Copy says Wednesday: the rollover moved. - web: Share button on the page; the share modal builds the link from the handle and copies it directly, there being no social saga to route through. The handle route is registered in both router trees and gets a Vike SSR entry that emits the collage as og:image with the period stamped into the URL as a cache-buster. getWeeklyRotationPeriod mirrors the API's Wednesday-shifted ISO week and carries the same test cases. - mobile: share icon in the top bar, the share drawer offers DM, X, Copy Link and the system sheet (the story paths need a single streamable track, which a mix does not have), the screen takes an optional handle, and explore/weekly-rotation/:handle? is deep-linkable. Co-Authored-By: Claude Fable 5.1 --- .../tan-query/lineups/useWeeklyRotation.ts | 21 ++-- packages/common/src/hooks/useShareContent.ts | 8 ++ packages/common/src/messages/explore.ts | 5 +- packages/common/src/models/Analytics.ts | 4 +- .../common/src/store/ui/share-modal/types.ts | 21 +++- packages/common/src/utils/route.ts | 8 ++ .../NavigationContainer.tsx | 2 + .../components/share-drawer/ShareDrawer.tsx | 31 +++++- .../src/components/share-drawer/messages.ts | 5 +- .../src/components/share-drawer/utils.ts | 11 +- .../src/screens/app-screen/AppTabScreen.tsx | 3 +- .../WeeklyRotationScreen.tsx | 72 +++++++++++-- packages/mobile/src/utils/routes.tsx | 5 + packages/web/src/app/web-player/WebPlayer.tsx | 9 ++ .../src/components/share-modal/ShareModal.tsx | 20 +++- .../src/components/share-modal/messages.ts | 5 +- .../web/src/components/share-modal/utils.ts | 11 +- .../WeeklyRotationPage.tsx | 100 +++++++++++++++--- packages/web/src/ssr/metaTags.ts | 47 +++++++- .../web/src/ssr/weekly-rotation/+Page.tsx | 4 + .../ssr/weekly-rotation/+onBeforeRender.tsx | 53 ++++++++++ .../ssr/weekly-rotation/+onRenderClient.tsx | 20 ++++ .../src/ssr/weekly-rotation/+onRenderHtml.tsx | 62 +++++++++++ .../web/src/ssr/weekly-rotation/+route.ts | 8 ++ packages/web/src/utils/route.ts | 9 ++ .../src/utils/weeklyRotationPeriod.test.ts | 74 +++++++++++++ .../web/src/utils/weeklyRotationPeriod.ts | 52 +++++++++ 27 files changed, 625 insertions(+), 45 deletions(-) create mode 100644 packages/web/src/ssr/weekly-rotation/+Page.tsx create mode 100644 packages/web/src/ssr/weekly-rotation/+onBeforeRender.tsx create mode 100644 packages/web/src/ssr/weekly-rotation/+onRenderClient.tsx create mode 100644 packages/web/src/ssr/weekly-rotation/+onRenderHtml.tsx create mode 100644 packages/web/src/ssr/weekly-rotation/+route.ts create mode 100644 packages/web/src/utils/weeklyRotationPeriod.test.ts create mode 100644 packages/web/src/utils/weeklyRotationPeriod.ts 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))}`