From b20ecb56dfcfff79f88a21906c53852846654579 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Wed, 23 Sep 2026 16:47:08 -0700 Subject: [PATCH 1/3] fix: cap inbox tab backfill, tighten chat row swipe, weekly rotation play state - Cap the Priority/General tab backfill at 5 pages per tab visit (web and mobile) so a near-empty tab doesn't page through the whole chat history. - Raise the chat row swipe activation offsets to 30pt so diagonal vertical scrolls stay with the list. - OTA banner: stop an in-flight poll from rescheduling after stop/restart. - Weekly Rotation: don't treat a shared mix as your own while its handle is loading or not found, and key playback state per mix owner so your own mix and a shared one don't share the header play button. Co-Authored-By: Claude Opus 5.5 --- .../ota-update-banner/OtaUpdateBanner.tsx | 10 ++++++++- .../src/screens/chat-screen/ChatListItem.tsx | 4 ++++ .../screens/chat-screen/ChatListScreen.tsx | 17 +++++++++++++-- .../WeeklyRotationScreen.tsx | 17 ++++++++------- .../pages/chat-page/components/ChatList.tsx | 18 +++++++++++++--- .../WeeklyRotationPage.tsx | 21 +++++++++---------- 6 files changed, 62 insertions(+), 25 deletions(-) diff --git a/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx b/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx index aa9791d416a..8d57852b2c9 100644 --- a/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx +++ b/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx @@ -77,6 +77,8 @@ export const OtaUpdateBanner = () => { const dismissedRef = useRef(false) const pendingLoggedRef = useRef(false) const pollTimeoutRef = useRef | null>(null) + // Bumped on every stop/restart so an in-flight poll can't reschedule itself. + const pollGenerationRef = useRef(0) /** Re-reads CodePush state; resolves to whether a pending package exists. */ const refresh = useCallback(async (): Promise => { @@ -118,6 +120,7 @@ export const OtaUpdateBanner = () => { }, []) const stopPendingPolls = useCallback(() => { + pollGenerationRef.current += 1 if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current) pollTimeoutRef.current = null @@ -133,13 +136,18 @@ export const OtaUpdateBanner = () => { if (!isOtaEnabled()) { return } + const generation = pollGenerationRef.current const startedAt = Date.now() const tick = () => { pollTimeoutRef.current = null refresh() .catch(() => false) .then((pending) => { - if (pending || Date.now() - startedAt >= PENDING_POLL_WINDOW_MS) { + if ( + generation !== pollGenerationRef.current || + pending || + Date.now() - startedAt >= PENDING_POLL_WINDOW_MS + ) { return } pollTimeoutRef.current = setTimeout(tick, PENDING_POLL_INTERVAL_MS) diff --git a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx index 93dd21ed097..3d7125c612f 100644 --- a/packages/mobile/src/screens/chat-screen/ChatListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatListItem.tsx @@ -88,6 +88,10 @@ export const ChatListItem = ({ chatId }: { chatId: string }) => { { }, [dispatch]) // Backfill the current tab from older pages when it is nearly empty + const [backfillPages, setBackfillPages] = useState(0) + useEffect(() => { + setBackfillPages(0) + }, [currentTab]) + const isBackfillExhausted = backfillPages >= MAX_BACKFILL_PAGES const needsBackfill = - hasMore && nonEmptyChats.length < MIN_VISIBLE_CHATS_PER_TAB + hasMore && + nonEmptyChats.length < MIN_VISIBLE_CHATS_PER_TAB && + !isBackfillExhausted useEffect(() => { if (chatsStatus === Status.SUCCESS && needsBackfill) { + setBackfillPages((pages) => pages + 1) dispatch(fetchMoreChats()) } }, [chatsStatus, needsBackfill, dispatch]) @@ -239,7 +250,9 @@ export const ChatListScreen = () => { renderItem={renderItem} keyExtractor={keyExtractor} ListEmptyComponent={() => - hasMore && chatsStatus !== Status.ERROR ? ( + hasMore && + chatsStatus !== Status.ERROR && + !isBackfillExhausted ? ( // Still backfilling this tab from older pages <> diff --git a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx index 25c24543682..1049fa79521 100644 --- a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx +++ b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx @@ -59,7 +59,10 @@ export const WeeklyRotationScreen = () => { 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 isOwnMix = + !handle || (handleUser != null && handleUser.user_id === currentUserId) + // Per-owner source so your own mix and a shared one don't share play state. + const playbackSource = `${WEEKLY_ROTATION_SOURCE}:${targetUserId ?? ''}` const { trackIds, isPending, isFetching } = useWeeklyRotation( { limit: 30, userId: targetUserId }, @@ -74,11 +77,9 @@ export const WeeklyRotationScreen = () => { ) const currentPlaybackSource = useSelector(playbackSelectors.getCurrentSource) - // The header button reflects -- and controls -- this mix only. Keyed off - // the global playing flag alone it read "Pause" while something else was - // playing, and "Pause" on any track but the first restarted the mix. + // The header button only reflects and controls playback of this mix. const isQueued = - currentPlaybackSource === WEEKLY_ROTATION_SOURCE && + currentPlaybackSource === playbackSource && currentPlaybackTrackId != null && trackIds.includes(currentPlaybackTrackId) const isPlaying = isPlaybackActive && isQueued @@ -87,9 +88,9 @@ export const WeeklyRotationScreen = () => { () => trackIds.map((id) => ({ trackId: id, - source: WEEKLY_ROTATION_SOURCE + source: playbackSource })), - [trackIds] + [trackIds, playbackSource] ) // Mirrors the web page's play-all: toggle when the mix is what's loaded, @@ -199,7 +200,7 @@ export const WeeklyRotationScreen = () => { = { * until the tab has at least this many rows or there is nothing left. */ const MIN_VISIBLE_CHATS_PER_TAB = 10 +// Stop backfilling after this many pages per tab visit, so a mostly empty tab +// doesn't page through the whole chat history. +const MAX_BACKFILL_PAGES = 5 type ChatListProps = { currentChatId?: string @@ -66,9 +69,16 @@ export const ChatList = (props: ChatListProps) => { }, [status, setHasLoadedOnce]) // Backfill the current tab from older pages when it is nearly empty - const needsBackfill = hasMore && chats.length < MIN_VISIBLE_CHATS_PER_TAB + const [backfillPages, setBackfillPages] = useState(0) + useEffect(() => { + setBackfillPages(0) + }, [currentTab]) + const isBackfillExhausted = backfillPages >= MAX_BACKFILL_PAGES + const needsBackfill = + hasMore && chats.length < MIN_VISIBLE_CHATS_PER_TAB && !isBackfillExhausted useEffect(() => { if (status === Status.SUCCESS && needsBackfill) { + setBackfillPages((pages) => pages + 1) dispatch(fetchMoreChats()) } }, [status, needsBackfill, dispatch]) @@ -76,7 +86,9 @@ export const ChatList = (props: ChatListProps) => { // While there are still pages to load, the InfiniteScroll loader (below) // shows skeletons, so only show the empty state once we've run out. const isEmptyTab = - chats.length === 0 && hasLoadedOnce && (!hasMore || status === Status.ERROR) + chats.length === 0 && + hasLoadedOnce && + (!hasMore || status === Status.ERROR || isBackfillExhausted) return (
{ pageStart={0} initialLoad={true} loadMore={handleLoadMoreChats} - hasMore={hasMore} + hasMore={hasMore && !isEmptyTab} useWindow={false} loader={ hasLoadedOnce ? ( diff --git a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx index 199c3a08760..d74d57fde40 100644 --- a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx +++ b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx @@ -92,12 +92,13 @@ 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. + // The mix belongs to the handle's user if present, otherwise the viewer. const { data: handleUser } = useUserByHandle(handle, { enabled: !!handle }) const targetUserId = handle ? handleUser?.user_id : currentUserId - const isOwnMix = !handle || handleUser?.user_id === currentUserId + const isOwnMix = + !handle || (handleUser != null && handleUser.user_id === currentUserId) + // Per-owner source so your own mix and a shared one don't share play state. + const playbackSource = `${WEEKLY_ROTATION_SOURCE}:${targetUserId ?? ''}` const { trackIds, isPending, isFetching, isLoading } = useWeeklyRotation( { limit: PAGE_SIZE, userId: targetUserId }, @@ -123,11 +124,9 @@ export const WeeklyRotationPage = () => { ) const currentPlaybackSource = useSelector(playbackSelectors.getCurrentSource) - // The header button reflects -- and controls -- this mix only. Keyed off - // the global playing flag alone it read "Pause" while something else was - // playing, and "Pause" on any track but the first restarted the mix. + // The header button only reflects and controls playback of this mix. const isQueued = - currentPlaybackSource === WEEKLY_ROTATION_SOURCE && + currentPlaybackSource === playbackSource && currentPlaybackTrackId != null && trackIds.includes(currentPlaybackTrackId) const isPlaying = isPlaybackActive && isQueued @@ -136,9 +135,9 @@ export const WeeklyRotationPage = () => { () => trackIds.map((id) => ({ trackId: id, - source: WEEKLY_ROTATION_SOURCE + source: playbackSource })), - [trackIds] + [trackIds, playbackSource] ) // Toggle when the mix is what's loaded, otherwise start the queue from the @@ -278,7 +277,7 @@ export const WeeklyRotationPage = () => { Date: Wed, 23 Sep 2026 16:47:12 -0700 Subject: [PATCH 2/3] chore: simplify weekly rotation, chat category, and OTA banner comments Shorten comments to plain statements, fix stale api references in the weekly rotation period helper, and align the weekly rotation notification body with the push copy. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/messages/notifications.ts | 2 +- .../services/remote-config/feature-flags.ts | 6 ++--- .../src/store/pages/chat/category.test.ts | 2 +- .../common/src/store/pages/chat/selectors.ts | 5 ++-- .../common/src/store/ui/share-modal/types.ts | 6 ++--- packages/common/src/utils/route.ts | 3 +-- .../ota-update-banner/OtaUpdateBanner.tsx | 20 +++++----------- .../components/share-drawer/ShareDrawer.tsx | 17 ++++---------- .../WeeklyRotationNotification.tsx | 5 ++-- .../WeeklyRotationScreen.tsx | 8 +++---- .../edit-mode/InlineAlbumPriceAndAudience.tsx | 6 ++--- .../WeeklyRotationNotification.tsx | 8 ++----- .../src/components/share-modal/ShareModal.tsx | 5 ++-- .../WeeklyRotationPage.tsx | 23 ++++++++----------- packages/web/src/ssr/metaTags.ts | 6 ++--- packages/web/src/utils/route.ts | 3 +-- .../src/utils/weeklyRotationPeriod.test.ts | 3 +-- .../web/src/utils/weeklyRotationPeriod.ts | 12 +++++----- 18 files changed, 50 insertions(+), 90 deletions(-) diff --git a/packages/common/src/messages/notifications.ts b/packages/common/src/messages/notifications.ts index 38cc870e934..fa8d3a2b662 100644 --- a/packages/common/src/messages/notifications.ts +++ b/packages/common/src/messages/notifications.ts @@ -6,5 +6,5 @@ export const listenStreakReminderMessages = { export const weeklyRotationNotificationMessages = { title: 'Your Weekly Rotation Is Ready', - body: 'A fresh mix of tracks picked just for you. Give it a spin before it rotates next Wednesday.' + body: 'A fresh mix of tracks picked just for you. Updates every Wednesday.' } diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 740fcb30b00..8fe3d7d1037 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -51,9 +51,7 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.LAUNCHPAD_VERIFICATION]: true, [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: false, [FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false, - // Launched 2026-09-13 at 100%. Defaulting on means a shared - // /explore/weekly-rotation/:handle link still renders when the Optimizely - // datafile hasn't loaded (a first visit on a cold cache) instead of - // bouncing the visitor to Explore; the remote flag stays the kill switch. + // Default on so shared links render before the Optimizely datafile loads; + // the remote flag is the kill switch. [FeatureFlags.WEEKLY_ROTATION]: true } diff --git a/packages/common/src/store/pages/chat/category.test.ts b/packages/common/src/store/pages/chat/category.test.ts index ed6dba9b738..c383757279d 100644 --- a/packages/common/src/store/pages/chat/category.test.ts +++ b/packages/common/src/store/pages/chat/category.test.ts @@ -364,7 +364,7 @@ describe('chat categories', () => { expect(getHasUnreadMessages(asCommonState(seeded))).toBe(true) }) - it('goes quiet once the General chat is read', () => { + it('clears once the General chat is read', () => { const seeded = withServerCounts( seed([ makeChat('general-1', { diff --git a/packages/common/src/store/pages/chat/selectors.ts b/packages/common/src/store/pages/chat/selectors.ts index 050a79526f9..421b1196258 100644 --- a/packages/common/src/store/pages/chat/selectors.ts +++ b/packages/common/src/store/pages/chat/selectors.ts @@ -197,9 +197,8 @@ export const getHasUnreadMessages = (state: CommonState) => { if (getUnreadMessagesCount(state) > 0) { return true } - // This really shouldn't be necessary since the above should be kept in sync. - // Blasts never carry unread counts, so skip them rather than stopping at the - // first one (they sort to the top on ties and would hide a later unread). + // Fallback if the count is out of sync. Blasts have no unread count; skip + // them. const chats = getChats(state) for (const chat of chats) { if (chat.is_blast) continue diff --git a/packages/common/src/store/ui/share-modal/types.ts b/packages/common/src/store/ui/share-modal/types.ts index a6446fb20e0..bceeb8837f4 100644 --- a/packages/common/src/store/ui/share-modal/types.ts +++ b/packages/common/src/store/ui/share-modal/types.ts @@ -53,10 +53,8 @@ type SharePlaylistContent = { } /** - * 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. + * A user's Weekly Rotation. Not backed by an entity; the link is built from + * the user's handle. */ type ShareWeeklyRotationContent = { type: 'weeklyRotation' diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index 6296f8df9a2..ee512882fd5 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -32,8 +32,7 @@ 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. +// Under /explore to avoid colliding with /:handle/:slug track permalinks. export const WEEKLY_ROTATION_USER_PAGE = '/explore/weekly-rotation/:handle' export const CONTESTS_PAGE = '/contests' diff --git a/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx b/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx index 8d57852b2c9..e0111411eb4 100644 --- a/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx +++ b/packages/mobile/src/components/ota-update-banner/OtaUpdateBanner.tsx @@ -51,10 +51,8 @@ function syncStatusLabel(status: CodePush.SyncStatus): string { type BannerPhase = 'none' | 'pending' /** - * How long to keep polling for a pending package after mount/foreground, and - * how often. A production bundle takes tens of seconds to download on a - * phone, and the download is usually owned by the CodePush root HOC's own - * sync (ota-root), not by this component's -- see `prefetchUpdate`. + * Poll for a pending package after mount/foreground. The download usually + * takes tens of seconds and is done by ota-root's sync, not `prefetchUpdate`. */ const PENDING_POLL_INTERVAL_MS = 2000 const PENDING_POLL_WINDOW_MS = 3 * 60 * 1000 @@ -64,12 +62,9 @@ const PENDING_POLL_WINDOW_MS = 3 * 60 * 1000 * ON_NEXT_RESTART. We only surface UI when an update is already downloaded * and installed as pending — then the user restarts when they want. * - * One instance mounts per root tab header, so each tab that is open polls on - * its own. The first tab (Feed) mounts while the root HOC's sync is already - * running, so its own `CodePush.sync` returns SYNC_IN_PROGRESS immediately - * and never observes the install; it has to find the pending package by - * polling. Before the poll window was long enough, the banner only ever - * appeared on a tab mounted *after* the download finished (Trending). + * One instance per root tab. A tab mounted during ota-root's sync gets + * SYNC_IN_PROGRESS from its own sync, so it detects the pending package by + * polling. */ export const OtaUpdateBanner = () => { const { color, spacing } = useTheme() @@ -127,10 +122,7 @@ export const OtaUpdateBanner = () => { } }, []) - // Poll until a pending package shows up or the window closes. Polling - // (rather than a fixed handful of early checks) is what lets the tab that - // was open during the download -- normally Feed, the initial tab -- show - // the banner instead of only the tab opened afterwards. + // Poll until a pending package appears or the window closes. const schedulePendingPolls = useCallback(() => { stopPendingPolls() if (!isOtaEnabled()) { diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index 22d33521c09..cc910b29ace 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -161,10 +161,8 @@ export const ShareDrawer = NiceModal.create(() => { 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. + // No saga for weekly rotation shares; open the share sheet directly. + // share() prepends AUDIUS_URL. const url = getWeeklyRotationRoute(content.user) share({ url, @@ -184,12 +182,8 @@ export const ShareDrawer = NiceModal.create(() => { } }, [dispatch, content, source]) - // The story platforms build a video out of the track's audio, so a track with - // nothing to play cannot be shared to them. `is_streamable` is false for an - // upload that was indexed without its track_cid: the stream URL 404s, ffmpeg - // fails on it, and the user gets a bare "something went wrong" with no idea - // why. Compare against `false` explicitly - an absent field means the source - // did not populate it, not that the track is broken. + // Story sharing renders the track's audio, so skip tracks the API marks + // unstreamable (undefined = not populated). const isShareableTrack = content?.type === 'track' && !content.track.is_unlisted && @@ -264,8 +258,7 @@ export const ShareDrawer = NiceModal.create(() => { 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. + // Story/video shares need a single track; X only needs the link. result.push(shareToXAction) } diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx index a28e6a5bf2a..0160bea0c2a 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx @@ -20,9 +20,8 @@ type WeeklyRotationNotificationProps = { } /** - * "Your Weekly Rotation is ready", sent every Wednesday. Opens the viewer's - * own mix; there is no entity to resolve. Hidden while the feature flag is - * off, since the screen it opens is gated too. + * Weekly Rotation ready notification (sent Wednesdays); opens the viewer's + * mix. Hidden when the flag is off. */ export const WeeklyRotationNotification = ( props: WeeklyRotationNotificationProps diff --git a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx index 1049fa79521..05fe4f15a2d 100644 --- a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx +++ b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx @@ -46,12 +46,10 @@ 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. + * Shows the signed-in user's mix, or the `handle` param's user's mix (deep + * link `/explore/weekly-rotation/:handle`). * - * The endpoint returns a fixed 30, so there is no pagination -- hasNextPage is - * false and loadNextPage is a no-op. + * The endpoint returns a fixed 30, so there is no pagination. */ export const WeeklyRotationScreen = () => { const { params } = useRoute<'WeeklyRotationScreen'>() diff --git a/packages/web/src/components/collection/desktop/edit-mode/InlineAlbumPriceAndAudience.tsx b/packages/web/src/components/collection/desktop/edit-mode/InlineAlbumPriceAndAudience.tsx index 7a84371be73..7b7fe6264f5 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/InlineAlbumPriceAndAudience.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/InlineAlbumPriceAndAudience.tsx @@ -63,10 +63,8 @@ type InlineAlbumPriceAndAudienceProps = { } /** - * Renders the album "Price & Audience" settings (free vs. pay-to-unlock) - * inside the collection page's inline edit mode. The dedicated /edit page - * has always offered this; the inline editor replaced the pencil link to that - * page, so albums need it here too. + * Album "Price & Audience" settings (free vs. pay-to-unlock) for the inline + * collection editor. */ export const InlineAlbumPriceAndAudience = ( props: InlineAlbumPriceAndAudienceProps diff --git a/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx b/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx index d0d8551b4c8..3fda89a9a63 100644 --- a/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx +++ b/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx @@ -25,12 +25,8 @@ type WeeklyRotationNotificationProps = { } /** - * "Your Weekly Rotation is ready", sent every Wednesday. Opens the viewer's - * own mix; there is no entity to resolve. - * - * Hidden while the feature flag is off: the server fans the row out to every - * active listener regardless, and a tile that links to a page which bounces - * back to Explore would be worse than no tile. + * Weekly Rotation ready notification (sent Wednesdays); opens the viewer's + * mix. Hidden when the flag is off, since the server sends it regardless. */ export const WeeklyRotationNotification = ( props: WeeklyRotationNotificationProps diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index fe2f9866ed7..3b19f47493b 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -108,9 +108,8 @@ export const ShareModal = NiceModal.create(() => { 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. + // No saga for weekly rotation shares; copy the link and record the + // event here. const link = weeklyRotationPage(content.user.handle) copyLinkToClipboard(link) record( diff --git a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx index d74d57fde40..8f8a044fb20 100644 --- a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx +++ b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx @@ -64,17 +64,15 @@ const columns: TracksTableColumn[] = [ /** * The full Weekly Rotation mix. * - * Structured like a collection page -- artwork, title, play-all, track list -- - * but it isn't backed by a collection entity, so it's assembled from the same + * Structured like a collection page (artwork, title, play-all, track list) but + * it isn't backed by a collection entity, so it's assembled from the same * pieces the History page uses rather than reusing the collection page. * 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. + * `/explore/weekly-rotation` shows the signed-in user's mix; + * `/explore/weekly-rotation/:handle` shows that user's mix (public, works + * signed out). * * The endpoint returns a fixed 30, so there is no pagination. */ @@ -86,9 +84,8 @@ export const WeeklyRotationPage = () => { 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 - // rather than 404. + // The route stays registered while the flag is off so shared links redirect + // instead of 404ing. const { isEnabled: isWeeklyRotationEnabled, isLoaded: isFlagLoaded } = useFeatureFlag(FeatureFlags.WEEKLY_ROTATION) @@ -187,8 +184,6 @@ 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( @@ -211,8 +206,8 @@ export const WeeklyRotationPage = () => { ? 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. + // Only the handle route gets an OG card and canonical URL; the bare route is + // per-viewer. const metaTags = handle ? { title, diff --git a/packages/web/src/ssr/metaTags.ts b/packages/web/src/ssr/metaTags.ts index 82451eae34c..421428056ec 100644 --- a/packages/web/src/ssr/metaTags.ts +++ b/packages/web/src/ssr/metaTags.ts @@ -348,10 +348,8 @@ export const getSearchContext = () => ({ }) /** - * 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. + * Meta tags for /explore/weekly-rotation/:handle. The OG image comes from + * og.audius.co. */ export const getWeeklyRotationPageContext = ({ handle, diff --git a/packages/web/src/utils/route.ts b/packages/web/src/utils/route.ts index 4ae9d57fb12..f20d90434f2 100644 --- a/packages/web/src/utils/route.ts +++ b/packages/web/src/utils/route.ts @@ -113,8 +113,7 @@ 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. +// Under /explore to avoid colliding with /:handle/:slug track permalinks. export const weeklyRotationPage = (handle: string) => { return `/explore/weekly-rotation/${encodeUrlName(handle)}` } diff --git a/packages/web/src/utils/weeklyRotationPeriod.test.ts b/packages/web/src/utils/weeklyRotationPeriod.test.ts index 7ebe6330191..15c0131916c 100644 --- a/packages/web/src/utils/weeklyRotationPeriod.test.ts +++ b/packages/web/src/utils/weeklyRotationPeriod.test.ts @@ -9,8 +9,7 @@ import { 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. +// Same cases as TestPeriod in the api repo (weeklyrotation/period_test.go). describe('getWeeklyRotationPeriod', () => { it('opens ISO week 37 at the Wednesday rollover', () => { // 2026-09-09 is a Wednesday. diff --git a/packages/web/src/utils/weeklyRotationPeriod.ts b/packages/web/src/utils/weeklyRotationPeriod.ts index 687bdd7abb6..e00f0a715ea 100644 --- a/packages/web/src/utils/weeklyRotationPeriod.ts +++ b/packages/web/src/utils/weeklyRotationPeriod.ts @@ -1,10 +1,11 @@ /** * 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. + * Monday. Mirrors `weeklyrotation.Period` in the api repo + * (weeklyrotation/period.go). * - * Pure and dependency-free on purpose: the SSR bundle imports it, and SSR - * avoids `@audius/common/utils` because that drags in dayjs and friends. + * Kept dependency-free because the SSR bundle imports it (no + * `@audius/common/utils` or dayjs). */ const ROLLOVER_OFFSET_DAYS = 2 // ISO Monday -> Wednesday @@ -39,9 +40,8 @@ export const formatWeeklyRotationPeriod = ({ 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. + * OG card URL for a user's current mix. The period param busts scraper caches + * each week. */ export const getWeeklyRotationOgImageUrl = ( handle: string, From 2e6591599a39891b351ec03953a02a04c8cad754 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Wed, 23 Sep 2026 17:18:50 -0700 Subject: [PATCH 3/3] feat(weekly-rotation): instrument plays, shares and notifications for usage analytics - Add PlaybackSource/FavoriteSource/RepostSource WEEKLY_ROTATION and use them on the web page and mobile screen lineups and header play. - Passive plays from a mix carry weeklyRotationOwnerId, derived from the queue source. - Weekly Rotation banner, page view and play-all events carry period, isOwnMix and ownerUserId; page view adds status and isSignedIn and now fires for empty mixes and fetch errors. - Share modal opens record Modal Opened (name Share) with kind and id. Mobile copy-link and X shares for weeklyRotation record Share and Share to Twitter; Share gains a channel property and Chat Entry Point a kind. - Mobile notification tile tap records Notifications: Clicked Tile, and push opens record the payload type and id. - Web banner CTA no longer fires the click event twice (the button click also bubbled to the card). - Move the period helper and queue source into common. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/models/Analytics.ts | 55 ++++++++---- .../src/store/ui/share-modal/sagas.test.ts | 37 ++++++++ .../common/src/store/ui/share-modal/sagas.ts | 23 ++++- packages/common/src/utils/index.ts | 1 + .../common/src/utils/weeklyRotation.test.ts | 78 +++++++++++++++++ packages/common/src/utils/weeklyRotation.ts | 57 ++++++++++++ .../src/components/lineup-tile/TrackTile.tsx | 10 ++- .../src/components/lineup-tile/types.ts | 11 ++- .../src/components/lineup/TrackLineup.tsx | 31 ++++++- .../components/share-drawer/ShareDrawer.tsx | 38 +++++++- packages/mobile/src/notifications.ts | 16 ++-- .../components/WeeklyRotation.tsx | 34 +++++--- .../WeeklyRotationNotification.tsx | 12 ++- .../WeeklyRotationScreen.tsx | 86 +++++++++++++++---- .../web/src/common/store/playback/sagas.ts | 17 +++- .../src/components/share-modal/ShareModal.tsx | 7 +- .../tracks-table/TrackTableLineup.tsx | 24 +++--- .../weekly-rotation/WeeklyRotationBanner.tsx | 37 +++++--- .../WeeklyRotationPage.tsx | 69 +++++++++++---- .../src/utils/weeklyRotationPeriod.test.ts | 55 +----------- .../web/src/utils/weeklyRotationPeriod.ts | 44 ++-------- 21 files changed, 539 insertions(+), 203 deletions(-) create mode 100644 packages/common/src/store/ui/share-modal/sagas.test.ts create mode 100644 packages/common/src/utils/weeklyRotation.test.ts create mode 100644 packages/common/src/utils/weeklyRotation.ts diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index dffbd56160e..52199970bac 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -685,7 +685,8 @@ export enum RepostSource { LIBRARY_PAGE = 'library page', OVERFLOW = 'overflow', TRACK_LIST = 'track list', - PURCHASE = 'purchase' + PURCHASE = 'purchase', + WEEKLY_ROTATION = 'weekly rotation' } export enum FavoriteSource { TILE = 'tile', @@ -703,7 +704,8 @@ export enum FavoriteSource { // you had a smart collection and it was favorited so it // shows in your left-nav. IMPLICIT = 'implicit', - NAVIGATOR = 'navigator' + NAVIGATOR = 'navigator', + WEEKLY_ROTATION = 'weekly rotation' } export enum FollowSource { INBOX_UNAVAILABLE_MODAL = 'inbox unavailable modal', @@ -725,6 +727,7 @@ type Share = { source: ShareSource id: string url: string + channel?: 'copyLink' | 'shareSheet' } export type ShareToTwitter = { @@ -1064,6 +1067,10 @@ type NotificationsOpenPushNotification = { title?: string body?: string notificationCampaignId?: string + /** The push payload's `data.type`, e.g. `WeeklyRotation` */ + type?: string + /** The push payload's `data.id`, when it has one */ + id?: string } type NotificationsClickTile = { eventName: Name.NOTIFICATIONS_CLICK_TILE @@ -1172,7 +1179,8 @@ export enum PlaybackSource { CHAT_TRACK = 'chat_track', CHAT_PLAYLIST_TRACK = 'chat_playlist_track', SEARCH_PAGE = 'search page', - EXCLUSIVE_TRACKS_PAGE = 'exclusive tracks page' + EXCLUSIVE_TRACKS_PAGE = 'exclusive tracks page', + WEEKLY_ROTATION = 'weekly rotation' } type PlaybackPlay = { @@ -1184,6 +1192,9 @@ type PlaybackPlay = { // Which feed view the play originated from (matches FEED_CHANGE_VIEW's // `view` values). Only present for plays coming from the feed lineup. feed_type?: FeedTab + // Owner of the Weekly Rotation mix the track was queued from. Set on + // passive plays so auto-advance through a mix is attributable to it. + weeklyRotationOwnerId?: string } type PlaybackPause = { eventName: Name.PLAYBACK_PAUSE @@ -1280,7 +1291,7 @@ export enum ModalSource { // Modals type ModalOpened = { eventName: Name.MODAL_OPENED - source: ModalSource + source: ModalSource | ShareSource name: string } & Record // For passing state values @@ -1363,31 +1374,35 @@ type ExploreSectionClick = { */ export type WeeklyRotationSurface = 'explore' | 'feed' -type WeeklyRotationBannerView = { - eventName: Name.WEEKLY_ROTATION_BANNER_VIEW - surface: WeeklyRotationSurface +type WeeklyRotationMixProperties = { source: 'web' | 'mobile' trackCount: number + /** The mix's period, e.g. `2026-38` (see getWeeklyRotationPeriod) */ + period: string + /** Whether the viewer is looking at their own mix rather than a shared one */ + isOwnMix: boolean + ownerUserId?: string } +type WeeklyRotationBannerView = { + eventName: Name.WEEKLY_ROTATION_BANNER_VIEW + surface: WeeklyRotationSurface +} & WeeklyRotationMixProperties + type WeeklyRotationBannerClick = { eventName: Name.WEEKLY_ROTATION_BANNER_CLICK surface: WeeklyRotationSurface - source: 'web' | 'mobile' - trackCount: number -} +} & WeeklyRotationMixProperties type WeeklyRotationPageView = { eventName: Name.WEEKLY_ROTATION_PAGE_VIEW - source: 'web' | 'mobile' - trackCount: number -} + status: 'success' | 'empty' | 'error' + isSignedIn: boolean +} & WeeklyRotationMixProperties type WeeklyRotationPlayAll = { eventName: Name.WEEKLY_ROTATION_PLAY_ALL - source: 'web' | 'mobile' - trackCount: number -} +} & WeeklyRotationMixProperties type BrowserNotificationSetting = { eventName: Name.BROWSER_NOTIFICATION_SETTINGS @@ -2085,6 +2100,14 @@ type ChatReportUser = { type ChatEntryPoint = { eventName: Name.CHAT_ENTRY_POINT source: 'banner' | 'navmenu' | 'share' | 'profile' + /** What was shared, when source is `share` */ + kind?: + | 'profile' + | 'album' + | 'playlist' + | 'track' + | 'contest' + | 'weeklyRotation' } type ChatWebsocketError = { diff --git a/packages/common/src/store/ui/share-modal/sagas.test.ts b/packages/common/src/store/ui/share-modal/sagas.test.ts new file mode 100644 index 00000000000..7bd873957da --- /dev/null +++ b/packages/common/src/store/ui/share-modal/sagas.test.ts @@ -0,0 +1,37 @@ +import { expectSaga } from 'redux-saga-test-plan' +import * as matchers from 'redux-saga-test-plan/matchers' +import { describe, it, vitest } from 'vitest' + +import { Name, ShareSource } from '~/models/Analytics' + +import { setVisibility } from '../modals/parentSlice' + +import sagas from './sagas' +import { requestOpen } from './slice' + +describe('share modal sagas', () => { + it('opens the modal and records what is being shared', async () => { + const track = vitest.fn() + const make = vitest.fn((event) => event) + const [watchRequestOpen] = sagas() + + await expectSaga(watchRequestOpen) + .provide([[matchers.getContext('analytics'), { track, make }]]) + .dispatch( + requestOpen({ + type: 'weeklyRotation', + userId: 7, + source: ShareSource.PAGE + }) + ) + .put(setVisibility({ modal: 'Share', visible: true })) + .call(track, { + eventName: Name.MODAL_OPENED, + name: 'Share', + source: ShareSource.PAGE, + kind: 'weeklyRotation', + userId: 7 + }) + .silentRun() + }) +}) diff --git a/packages/common/src/store/ui/share-modal/sagas.ts b/packages/common/src/store/ui/share-modal/sagas.ts index 770282f7a7b..c5b61a6473c 100644 --- a/packages/common/src/store/ui/share-modal/sagas.ts +++ b/packages/common/src/store/ui/share-modal/sagas.ts @@ -1,4 +1,7 @@ -import { takeEvery, put } from 'typed-redux-saga' +import { takeEvery, put, call } from 'typed-redux-saga' + +import { Name } from '~/models/Analytics' +import { getContext } from '~/store/effects' import { setVisibility } from '../modals/parentSlice' @@ -7,9 +10,23 @@ import { requestOpen } from './slice' // The previous saga fetched track/user/collection data before opening the // modal. That work now lives in `useShareContent` so the modal loads its own // data via TanStack Query. All that remains is to flip the nice-modal -// visibility once a share request lands in the slice. -function* handleRequestOpen() { +// visibility and record the open once a share request lands in the slice. +function* handleRequestOpen({ payload }: ReturnType) { yield put(setVisibility({ modal: 'Share', visible: true })) + + // `ids` is the request's entity id, e.g. `trackId` or `userId`. + const { source, type, ...ids } = payload + const { track, make } = yield* getContext('analytics') + yield* call( + track, + make({ + eventName: Name.MODAL_OPENED, + name: 'Share', + source, + kind: type, + ...ids + }) + ) } function* watchHandleRequestOpen() { diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 50ab9d84c28..126e2a704cb 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -38,6 +38,7 @@ export { default as dayjs } from './dayjs' export { Dayjs } from './dayjs' export * from './promiseWithTimeout' export * from './handleReservedStatus' +export * from './weeklyRotation' export * from './email' export * from './commonPasswordCheck' export * from './restrictedHandles' diff --git a/packages/common/src/utils/weeklyRotation.test.ts b/packages/common/src/utils/weeklyRotation.test.ts new file mode 100644 index 00000000000..55f2a9ac04c --- /dev/null +++ b/packages/common/src/utils/weeklyRotation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { + formatWeeklyRotationPeriod, + getWeeklyRotationOwnerIdFromQueueSource, + getWeeklyRotationPeriod, + getWeeklyRotationQueueSource +} from './weeklyRotation' + +const utc = (y: number, m: number, d: number, h = 0) => + new Date(Date.UTC(y, m - 1, d, h)) + +// Same cases as TestPeriod in the api repo (weeklyrotation/period_test.go). +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('weekly rotation queue source', () => { + it('round-trips the owner id', () => { + const source = getWeeklyRotationQueueSource(123) + expect(source).toBe('WEEKLY_ROTATION_TRACKS:123') + expect(getWeeklyRotationOwnerIdFromQueueSource(source)).toBe(123) + }) + + it('returns null for other sources and a missing owner', () => { + expect(getWeeklyRotationOwnerIdFromQueueSource('trending-week')).toBeNull() + expect(getWeeklyRotationOwnerIdFromQueueSource(null)).toBeNull() + expect( + getWeeklyRotationOwnerIdFromQueueSource( + getWeeklyRotationQueueSource(undefined) + ) + ).toBeNull() + }) +}) diff --git a/packages/common/src/utils/weeklyRotation.ts b/packages/common/src/utils/weeklyRotation.ts new file mode 100644 index 00000000000..89264e0d28b --- /dev/null +++ b/packages/common/src/utils/weeklyRotation.ts @@ -0,0 +1,57 @@ +/** + * 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 `weeklyrotation.Period` in the api repo + * (weeklyrotation/period.go). + * + * Kept dependency-free because the web SSR bundle imports it. + */ + +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 WEEKLY_ROTATION_QUEUE_SOURCE = 'WEEKLY_ROTATION_TRACKS' + +/** + * Playback queue source for a user's mix. Per owner so your own mix and a + * shared one don't share play state. + */ +export const getWeeklyRotationQueueSource = ( + ownerUserId: number | null | undefined +) => `${WEEKLY_ROTATION_QUEUE_SOURCE}:${ownerUserId ?? ''}` + +/** The mix owner's id if `source` is a Weekly Rotation queue source. */ +export const getWeeklyRotationOwnerIdFromQueueSource = ( + source: string | null | undefined +): number | null => { + const prefix = `${WEEKLY_ROTATION_QUEUE_SOURCE}:` + if (!source?.startsWith(prefix)) return null + const ownerUserId = Number(source.slice(prefix.length)) + return Number.isInteger(ownerUserId) && ownerUserId > 0 ? ownerUserId : null +} diff --git a/packages/mobile/src/components/lineup-tile/TrackTile.tsx b/packages/mobile/src/components/lineup-tile/TrackTile.tsx index 6ebdda6f62c..ae53d8830c1 100644 --- a/packages/mobile/src/components/lineup-tile/TrackTile.tsx +++ b/packages/mobile/src/components/lineup-tile/TrackTile.tsx @@ -64,6 +64,8 @@ const TrackTileComponent = (props: TrackTileProps) => { variant, style, showArtistPick = false, + favoriteSource = FavoriteSource.TILE, + repostSource = RepostSource.TILE, ...lineupTileProps } = props @@ -261,17 +263,17 @@ const TrackTileComponent = (props: TrackTileProps) => { const handlePressSave = useToggleFavoriteTrack({ trackId: track?.track_id as number, - source: FavoriteSource.TILE + source: favoriteSource }) const handlePressRepost = useCallback(() => { if (!track) return if (track.has_current_user_reposted) { - dispatch(undoRepostTrack(track.track_id, RepostSource.TILE)) + dispatch(undoRepostTrack(track.track_id, repostSource)) } else { - dispatch(repostTrack(track.track_id, RepostSource.TILE)) + dispatch(repostTrack(track.track_id, repostSource)) } - }, [track, dispatch]) + }, [track, dispatch, repostSource]) const publish = useCallback(() => { if (!track) return diff --git a/packages/mobile/src/components/lineup-tile/types.ts b/packages/mobile/src/components/lineup-tile/types.ts index f5794ff8416..a85a665cee3 100644 --- a/packages/mobile/src/components/lineup-tile/types.ts +++ b/packages/mobile/src/components/lineup-tile/types.ts @@ -1,6 +1,13 @@ import type { ReactNode } from 'react' -import type { PlaybackSource, Collection, ID, UID } from '@audius/common/models' +import type { + PlaybackSource, + Collection, + FavoriteSource, + ID, + RepostSource, + UID +} from '@audius/common/models' import type { EnhancedCollectionTrack } from '@audius/common/store' import type { StyleProp, ViewStyle } from 'react-native' @@ -37,6 +44,8 @@ export type TrackTileProps = { style?: StyleProp source?: LineupTileSource showArtistPick?: boolean + favoriteSource?: FavoriteSource + repostSource?: RepostSource } export type CollectionTileProps = { diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx index 6170c7b2922..a6db86295b3 100644 --- a/packages/mobile/src/components/lineup/TrackLineup.tsx +++ b/packages/mobile/src/components/lineup/TrackLineup.tsx @@ -12,8 +12,11 @@ import type { LineupData } from '@audius/common/api' import { useCollections } from '@audius/common/api' import { Kind, + Name, + type FavoriteSource, type ID, type PlaybackSource, + type RepostSource, type UID } from '@audius/common/models' import { playbackActions, playbackSelectors } from '@audius/common/store' @@ -36,6 +39,7 @@ import { LineupTileSkeleton } from 'app/components/lineup-tile' import { useScrollToTop } from 'app/hooks/useScrollToTop' +import { make, track } from 'app/services/analytics' const { makeGetCurrent } = playbackSelectors const { getPlaying } = playbackSelectors @@ -123,7 +127,10 @@ export type TrackLineupProps = { pullToRefresh?: boolean disableTopTabScroll?: boolean onPressItem?: (id: ID) => void + /** When set, tile plays and pauses record Playback events with it */ playbackSource?: PlaybackSource + favoriteSource?: FavoriteSource + repostSource?: RepostSource /** * Map of indices (into `trackIds`) to JSX elements rendered after the @@ -167,6 +174,9 @@ export const TrackLineup = ({ pullToRefresh, disableTopTabScroll, onPressItem, + playbackSource, + favoriteSource, + repostSource, delineatorMap }: TrackLineupProps) => { const dispatch = useDispatch() @@ -234,12 +244,25 @@ export const TrackLineup = ({ const currentTrackId = currentLegacy?.trackId ?? null const currentSource = currentLegacy?.source ?? null const isSameTile = currentTrackId === id && currentSource === source + const recordPlayback = (play: boolean) => { + if (!playbackSource) return + const properties = { id: `${id}`, source: playbackSource } + track( + make( + play + ? { eventName: Name.PLAYBACK_PLAY, ...properties } + : { eventName: Name.PLAYBACK_PAUSE, ...properties } + ) + ) + } if (isSameTile && isPlaying) { dispatch(playbackActions.togglePlay()) + recordPlayback(false) return } if (isSameTile && !isPlaying) { dispatch(playbackActions.play()) + recordPlayback(true) return } // For a collection tile, locate the track within that collection's queue @@ -260,6 +283,7 @@ export const TrackLineup = ({ querySource: querySource ?? null }) ) + recordPlayback(true) }, [ dispatch, @@ -268,7 +292,8 @@ export const TrackLineup = ({ currentLegacy?.trackId, currentLegacy?.source, source, - isPlaying + isPlaying, + playbackSource ] ) @@ -347,6 +372,8 @@ export const TrackLineup = ({ togglePlay={togglePlay} onPress={onPressItem} showArtistPick={showArtistPick} + favoriteSource={favoriteSource} + repostSource={repostSource} /> ) : ( { presetMessage: getContentUrl(content), defaultUserList: 'chats' }) - track(make({ eventName: Name.CHAT_ENTRY_POINT, source: 'share' })) + track( + make({ + eventName: Name.CHAT_ENTRY_POINT, + source: 'share', + kind: content.type + }) + ) if (source === ShareSource.NOW_PLAYING) { onCloseNowPlaying() } @@ -123,7 +129,18 @@ export const ShareDrawer = NiceModal.create(() => { } else { console.error(`Can't open: ${xShareUrl}`) } - }, [content]) + if (content.type === 'weeklyRotation' && source) { + track( + make({ + eventName: Name.SHARE_TO_TWITTER, + kind: 'weeklyRotation', + id: content.user.user_id, + url: getContentUrl(content), + source + }) + ) + } + }, [content, source]) const { handleShareToStoryStickerLoad, @@ -143,7 +160,19 @@ export const ShareDrawer = NiceModal.create(() => { type: 'info', timeout: shareToastTimeout }) - }, [toast, content, shareType]) + if (content.type === 'weeklyRotation' && source) { + track( + make({ + eventName: Name.SHARE, + kind: 'weeklyRotation', + id: `${content.user.user_id}`, + url: link, + source, + channel: 'copyLink' + }) + ) + } + }, [toast, content, shareType, source]) const handleOpenShareSheet = useCallback(() => { if (!source || !content) return @@ -174,7 +203,8 @@ export const ShareDrawer = NiceModal.create(() => { kind: 'weeklyRotation', id: `${content.user.user_id}`, url, - source + source, + channel: 'shareSheet' }) ) break diff --git a/packages/mobile/src/notifications.ts b/packages/mobile/src/notifications.ts index 163f67e459a..036638238f6 100644 --- a/packages/mobile/src/notifications.ts +++ b/packages/mobile/src/notifications.ts @@ -114,12 +114,20 @@ class PushNotifications { const { title, body, payload } = notification const notificationCampaignId = extractNotificationCampaignIdFromPayload(payload) + let data = payload?.data?.data ?? payload?.data ?? payload + // On Android, FCM delivers all data values as strings, breaking + // numeric ID fields and nested objects. Parse them back. + if (Platform.OS === MobileOS.ANDROID && data && typeof data === 'object') { + data = parseAndroidNotificationData(data) + } track( make({ eventName: EventNames.NOTIFICATIONS_OPEN_PUSH_NOTIFICATION, title, body, - notificationCampaignId + notificationCampaignId, + type: typeof data?.type === 'string' ? data.type : undefined, + id: data?.id != null ? `${data.id}` : undefined }) ) if (notificationCampaignId) { @@ -127,12 +135,6 @@ class PushNotifications { reportNotificationCampaignPushOpen(notificationCampaignId) ).catch(() => {}) } - let data = payload?.data?.data ?? payload?.data ?? payload - // On Android, FCM delivers all data values as strings, breaking - // numeric ID fields and nested objects. Parse them back. - if (Platform.OS === MobileOS.ANDROID && data && typeof data === 'object') { - data = parseAndroidNotificationData(data) - } this.navigation?.navigate(data) } diff --git a/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx b/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx index 9b6e4a65f3a..cdcc5794481 100644 --- a/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx +++ b/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx @@ -1,10 +1,14 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { useWeeklyRotation } from '@audius/common/api' +import { useCurrentUserId, useWeeklyRotation } from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' import { Name, type WeeklyRotationSurface } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod +} from '@audius/common/utils' import { Image } from 'react-native' import { Flex, Paper, Text } from '@audius/harmony-native' @@ -34,6 +38,7 @@ export const WeeklyRotation = ({ const { InViewWrapper, inView } = useExploreSectionTracking('Weekly Rotation') const navigation = useNavigation() const { trackEvent } = useAnalytics() + const { data: currentUserId } = useCurrentUserId() const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( FeatureFlags.WEEKLY_ROTATION ) @@ -42,6 +47,19 @@ export const WeeklyRotation = ({ { enabled: inView && isWeeklyRotationEnabled } ) + // The banner always promotes the viewer's own mix. + const mixProperties = useMemo( + () => ({ + surface, + source: 'mobile' as const, + trackCount: trackIds.length, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix: true, + ownerUserId: currentUserId ? `${currentUserId}` : undefined + }), + [surface, trackIds.length, currentUserId] + ) + // Fire the impression once, and only once there's a real mix behind it. const hasTrackedView = useRef(false) useEffect(() => { @@ -49,21 +67,17 @@ export const WeeklyRotation = ({ hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_VIEW, - surface, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties }) - }, [inView, trackIds.length, surface, trackEvent]) + }, [inView, trackIds.length, mixProperties, trackEvent]) const handlePress = useCallback(() => { trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_CLICK, - surface, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties }) navigation.navigate('WeeklyRotationScreen') - }, [navigation, trackEvent, surface, trackIds.length]) + }, [navigation, trackEvent, mixProperties]) // The flag check sits with the empty/error case so both surfaces that render // this banner -- Explore and the feed -- are gated by this one return. diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx index 0160bea0c2a..70f9f267a35 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx @@ -2,8 +2,11 @@ import { useCallback } from 'react' import { useFeatureFlag } from '@audius/common/hooks' import { weeklyRotationNotificationMessages as messages } from '@audius/common/messages' +import { Name } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' import type { WeeklyRotationNotification as WeeklyRotationNotificationType } from '@audius/common/store' +import { route } from '@audius/common/utils' +import { make, useRecord } from 'common/store/analytics/actions' import { IconArrowRotate } from '@audius/harmony-native' import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation' @@ -28,13 +31,20 @@ export const WeeklyRotationNotification = ( ) => { const { notification } = props const navigation = useNotificationNavigation() + const record = useRecord() const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( FeatureFlags.WEEKLY_ROTATION ) const handlePress = useCallback(() => { navigation.navigate(notification) - }, [navigation, notification]) + record( + make(Name.NOTIFICATIONS_CLICK_TILE, { + kind: notification.type, + link_to: route.WEEKLY_ROTATION_PAGE + }) + ) + }, [navigation, notification, record]) if (!isWeeklyRotationEnabled) return null diff --git a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx index 05fe4f15a2d..ae83c7d2819 100644 --- a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx +++ b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx @@ -7,13 +7,24 @@ import { } from '@audius/common/api' import { useAnalytics } from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' -import { Name, ShareSource } from '@audius/common/models' +import { + FavoriteSource, + Name, + PlaybackSource, + RepostSource, + ShareSource +} from '@audius/common/models' import { playbackActions, playbackSelectors, shareModalUIActions } from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod, + getWeeklyRotationQueueSource +} from '@audius/common/utils' import { Image } from 'react-native' import { useDispatch, useSelector } from 'react-redux' @@ -31,6 +42,7 @@ 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' +import { make, track } from 'app/services/analytics' const { requestOpen: requestOpenShareModal } = shareModalUIActions @@ -40,7 +52,6 @@ const messages = { } const ART_SIZE = 120 -const WEEKLY_ROTATION_SOURCE = 'WEEKLY_ROTATION_TRACKS' /** * The full Weekly Rotation mix. Mirrors the web page: artwork header, then the @@ -59,16 +70,26 @@ export const WeeklyRotationScreen = () => { const targetUserId = handle ? handleUser?.user_id : currentUserId const isOwnMix = !handle || (handleUser != null && handleUser.user_id === currentUserId) - // Per-owner source so your own mix and a shared one don't share play state. - const playbackSource = `${WEEKLY_ROTATION_SOURCE}:${targetUserId ?? ''}` + const playbackSource = getWeeklyRotationQueueSource(targetUserId) - const { trackIds, isPending, isFetching } = useWeeklyRotation( - { limit: 30, userId: targetUserId }, - { enabled: !!targetUserId } - ) + const { trackIds, isPending, isFetching, isSuccess, isError } = + useWeeklyRotation( + { limit: 30, userId: targetUserId }, + { enabled: !!targetUserId } + ) const { trackEvent } = useAnalytics() const dispatch = useDispatch() + const mixProperties = useMemo( + () => ({ + source: 'mobile' as const, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix, + ownerUserId: targetUserId ? `${targetUserId}` : undefined + }), + [isOwnMix, targetUserId] + ) + const isPlaybackActive = useSelector(playbackSelectors.getPlaying) const currentPlaybackTrackId = useSelector( playbackSelectors.getCurrentTrackId @@ -100,12 +121,19 @@ export const WeeklyRotationScreen = () => { dispatch( isPlaying ? playbackActions.togglePlay() : playbackActions.play() ) + track( + make({ + eventName: isPlaying ? Name.PLAYBACK_PAUSE : Name.PLAYBACK_PLAY, + id: `${currentPlaybackTrackId}`, + source: PlaybackSource.WEEKLY_ROTATION + }) + ) return } trackEvent({ eventName: Name.WEEKLY_ROTATION_PLAY_ALL, - source: 'mobile', + ...mixProperties, trackCount: playbackQueue.length }) dispatch( @@ -115,7 +143,22 @@ export const WeeklyRotationScreen = () => { querySource: null }) ) - }, [dispatch, isPlaying, isQueued, playbackQueue, trackEvent]) + track( + make({ + eventName: Name.PLAYBACK_PLAY, + id: `${playbackQueue[0].trackId}`, + source: PlaybackSource.WEEKLY_ROTATION + }) + ) + }, [ + dispatch, + isPlaying, + isQueued, + currentPlaybackTrackId, + playbackQueue, + trackEvent, + mixProperties + ]) const handleShare = useCallback(() => { if (!targetUserId) return @@ -128,18 +171,26 @@ export const WeeklyRotationScreen = () => { ) }, [dispatch, targetUserId]) - // Fired once the mix resolves, so trackCount is real and a failed load - // doesn't register as a page view. + // Fired once the mix resolves, so trackCount and status are real. const hasTrackedView = useRef(false) useEffect(() => { - if (hasTrackedView.current || !trackIds.length) return + if (hasTrackedView.current || (!isSuccess && !isError)) return hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_PAGE_VIEW, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties, + trackCount: trackIds.length, + status: isError ? 'error' : trackIds.length ? 'success' : 'empty', + isSignedIn: !!currentUserId }) - }, [trackIds.length, trackEvent]) + }, [ + isSuccess, + isError, + trackIds.length, + mixProperties, + currentUserId, + trackEvent + ]) const title = isOwnMix ? exploreMessages.weeklyRotation @@ -199,6 +250,9 @@ export const WeeklyRotationScreen = () => { { onCancelAction: setVisibility({ modal: 'Share', visible: true }), defaultUserList: 'chats' }) - dispatch(make(Name.CHAT_ENTRY_POINT, { source: 'share' })) + dispatch( + make(Name.CHAT_ENTRY_POINT, { source: 'share', kind: content.type }) + ) }, [openCreateChatModal, dispatch, onClose, content]) const handleShareToX = useCallback(async () => { @@ -117,7 +119,8 @@ export const ShareModal = NiceModal.create(() => { kind: 'weeklyRotation', id: `${content.user.user_id}`, url: link, - source + source, + channel: 'copyLink' }) ) break diff --git a/packages/web/src/components/tracks-table/TrackTableLineup.tsx b/packages/web/src/components/tracks-table/TrackTableLineup.tsx index 44e95c007f4..95f0347f2e2 100644 --- a/packages/web/src/components/tracks-table/TrackTableLineup.tsx +++ b/packages/web/src/components/tracks-table/TrackTableLineup.tsx @@ -35,6 +35,8 @@ type TrackTableLineupProps = Omit< | 'data' > & { playingSource?: PlaybackSource + favoriteSource?: FavoriteSource + repostSource?: RepostSource // Source tag for the playback queue (also used for stable UID generation). source: string // Ordered list of track IDs to display. @@ -50,6 +52,8 @@ type TrackTableLineupProps = Omit< export const TrackTableLineup = ({ playingSource = PlaybackSource.TRACK_TILE, + favoriteSource = FavoriteSource.TRACK_PAGE, + repostSource = RepostSource.TRACK_PAGE, source, trackIds, isInitialLoading, @@ -116,32 +120,24 @@ export const TrackTableLineup = ({ (track: TrackWithUID) => { const trackId = track.track_id if (!track.has_current_user_saved) { - dispatch( - tracksSocialActions.saveTrack(trackId, FavoriteSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.saveTrack(trackId, favoriteSource)) } else { - dispatch( - tracksSocialActions.unsaveTrack(trackId, FavoriteSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.unsaveTrack(trackId, favoriteSource)) } }, - [dispatch] + [dispatch, favoriteSource] ) const onClickRepost = useCallback( (track: TrackWithUID) => { const trackId = track.track_id if (!track.has_current_user_reposted) { - dispatch( - tracksSocialActions.repostTrack(trackId, RepostSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.repostTrack(trackId, repostSource)) } else { - dispatch( - tracksSocialActions.undoRepostTrack(trackId, RepostSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.undoRepostTrack(trackId, repostSource)) } }, - [dispatch] + [dispatch, repostSource] ) const onClickRow = useCallback( diff --git a/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx b/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx index 2d1210e507d..cd9a9a74aaf 100644 --- a/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx +++ b/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx @@ -1,11 +1,15 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useWeeklyRotation } from '@audius/common/api' +import { useCurrentUserId, useWeeklyRotation } from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' import { Name, type WeeklyRotationSurface } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' -import { route } from '@audius/common/utils' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod, + route +} from '@audius/common/utils' import { Artwork, Button, @@ -46,6 +50,7 @@ export const WeeklyRotationBanner = ({ const navigate = useNavigate() const isMobile = useIsMobile() const { trackEvent } = useAnalytics() + const { data: currentUserId } = useCurrentUserId() const { ref, inView } = useInView({ threshold: 0, @@ -63,6 +68,19 @@ export const WeeklyRotationBanner = ({ { enabled: inView && isWeeklyRotationEnabled } ) + // The banner always promotes the viewer's own mix. + const mixProperties = useMemo( + () => ({ + surface, + source: isMobile ? ('mobile' as const) : ('web' as const), + trackCount: trackIds.length, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix: true, + ownerUserId: currentUserId ? `${currentUserId}` : undefined + }), + [surface, isMobile, trackIds.length, currentUserId] + ) + // Fire the impression once, and only once there's a real mix behind it -- // an impression for a banner that then hides itself would inflate the // denominator on click-through. @@ -72,21 +90,17 @@ export const WeeklyRotationBanner = ({ hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_VIEW, - surface, - source: isMobile ? 'mobile' : 'web', - trackCount: trackIds.length + ...mixProperties }) - }, [inView, trackIds.length, surface, isMobile, trackEvent]) + }, [inView, trackIds.length, mixProperties, trackEvent]) const handleClick = useCallback(() => { trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_CLICK, - surface, - source: isMobile ? 'mobile' : 'web', - trackCount: trackIds.length + ...mixProperties }) navigate(WEEKLY_ROTATION_PAGE) - }, [navigate, trackEvent, surface, isMobile, trackIds.length]) + }, [navigate, trackEvent, mixProperties]) // Hidden entirely when there's no mix to promote -- a banner advertising an // empty page is worse than no banner. The flag check sits alongside it so @@ -137,7 +151,6 @@ export const WeeklyRotationBanner = ({