From 426783d5a2abdb96e2803a710d675ec959253991 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Sun, 13 Sep 2026 14:30:19 -0700 Subject: [PATCH] feat(notifications): render the weekly_rotation notification The api now writes a `weekly_rotation` notification row per active listener every Wednesday, and pedalboard pushes it. This makes the row show up in the notification feed on web and mobile as "Your Weekly Rotation Is Ready", opening the viewer's own mix, and routes the push tap (`type: 'WeeklyRotation'`) to the WeeklyRotationScreen. The SDK's generated union doesn't know the type yet, so the adapter maps it in the same fallback branch remix_contest_update used before its regen. Both tiles hide while the `weekly_rotation` flag is off: the page they open bounces to Explore in that state, and the server fans the row out regardless. Co-Authored-By: Claude Fable 5.1 --- packages/common/src/adapters/notification.ts | 9 +++ packages/common/src/messages/notifications.ts | 5 ++ .../common/src/store/notifications/types.ts | 10 +++ .../src/hooks/useNotificationNavigation.ts | 6 ++ .../NotificationListItem.tsx | 3 + .../WeeklyRotationNotification.tsx | 50 ++++++++++++++ .../Notification/Notification.tsx | 4 ++ .../WeeklyRotationNotification.tsx | 67 +++++++++++++++++++ .../Notification/components/icons.tsx | 7 +- 9 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx create mode 100644 packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx diff --git a/packages/common/src/adapters/notification.ts b/packages/common/src/adapters/notification.ts index a3382baaf99..101ea49d2ea 100644 --- a/packages/common/src/adapters/notification.ts +++ b/packages/common/src/adapters/notification.ts @@ -688,6 +688,15 @@ export const notificationFromSDK = ( type: string actions: typeof notification.actions } + if (n.type === 'weekly_rotation') { + const data = n.actions[0].data as unknown as Record + return { + type: NotificationType.WeeklyRotation, + year: data.year, + week: data.week, + ...formatBaseNotification(notification) + } + } if (n.type === 'remix_contest_update') { const data = n.actions[0].data as unknown as Record return { diff --git a/packages/common/src/messages/notifications.ts b/packages/common/src/messages/notifications.ts index 65af126dd3b..38cc870e934 100644 --- a/packages/common/src/messages/notifications.ts +++ b/packages/common/src/messages/notifications.ts @@ -3,3 +3,8 @@ export const listenStreakReminderMessages = { body: (streak: number) => `Your ${streak} day listening streak will end in 6 hours! Keep listening to earn daily rewards!` } + +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.' +} diff --git a/packages/common/src/store/notifications/types.ts b/packages/common/src/store/notifications/types.ts index bb254753f5f..d2c4e5b05d3 100644 --- a/packages/common/src/store/notifications/types.ts +++ b/packages/common/src/store/notifications/types.ts @@ -46,6 +46,7 @@ export enum NotificationType { CommentMention = 'CommentMention', CommentReaction = 'CommentReaction', ListenStreakReminder = 'ListenStreakReminder', + WeeklyRotation = 'WeeklyRotation', ArtistRemixContestEnded = 'ArtistRemixContestEnded', ArtistRemixContestEndingSoon = 'ArtistRemixContestEndingSoon', ArtistRemixContestSubmissions = 'ArtistRemixContestSubmissions', @@ -94,6 +95,7 @@ export enum PushNotificationType { CommentMention = 'CommentMention', CommentReaction = 'CommentReaction', ListenStreakReminder = 'ListenStreakReminder', + WeeklyRotation = 'WeeklyRotation', FanClubTextPost = 'FanClubTextPost' } @@ -604,6 +606,13 @@ export type ListenStreakReminderNotification = BaseNotification & { streak: number } +export type WeeklyRotationNotification = BaseNotification & { + type: NotificationType.WeeklyRotation + /** The rotation period announced, as an ISO (year, week) pair. */ + year: number + week: number +} + export type FanRemixContestStartedNotification = BaseNotification & { type: NotificationType.FanRemixContestStarted entityId: ID @@ -709,6 +718,7 @@ export type Notification = | CommentMentionNotification | CommentReactionNotification | ListenStreakReminderNotification + | WeeklyRotationNotification | ArtistRemixContestEndedNotification | FanRemixContestEndedNotification | FanRemixContestWinnersSelectedNotification diff --git a/packages/mobile/src/hooks/useNotificationNavigation.ts b/packages/mobile/src/hooks/useNotificationNavigation.ts index 1ba3930373d..32ae1ecd96a 100644 --- a/packages/mobile/src/hooks/useNotificationNavigation.ts +++ b/packages/mobile/src/hooks/useNotificationNavigation.ts @@ -272,6 +272,12 @@ export const useNotificationNavigation = () => { ) => { navigation.navigate('RewardsScreen') }, + // Handles both the in-app tile and the push payload (`type: + // 'WeeklyRotation'`); neither carries an entity, the mix is fetched on + // demand for the signed-in user. + [NotificationType.WeeklyRotation]: () => { + navigation.navigate('WeeklyRotationScreen') + }, [PushNotificationType.FavoriteAlbum]: socialActionHandler, [PushNotificationType.FavoritePlaylist]: socialActionHandler, [PushNotificationType.FavoriteTrack]: socialActionHandler, diff --git a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx index 311076349c1..8265ae1ead5 100644 --- a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx +++ b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx @@ -43,6 +43,7 @@ import { FanRemixContestSubmissionNotification } from './Notifications/FanRemixC import { FanRemixContestWinnersSelectedNotification } from './Notifications/FanRemixContestWinnersSelectedNotification' import { ListenStreakReminderNotification } from './Notifications/ListenStreakReminderNotification' import { RemixContestUpdateNotification } from './Notifications/RemixContestUpdateNotification' +import { WeeklyRotationNotification } from './Notifications/WeeklyRotationNotification' type NotificationListItemProps = { notification: Notification @@ -117,6 +118,8 @@ export const NotificationListItem = (props: NotificationListItemProps) => { return case NotificationType.ListenStreakReminder: return + case NotificationType.WeeklyRotation: + return case NotificationType.FanRemixContestEnded: return case NotificationType.FanRemixContestEndingSoon: diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx new file mode 100644 index 00000000000..a28e6a5bf2a --- /dev/null +++ b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx @@ -0,0 +1,50 @@ +import { useCallback } from 'react' + +import { useFeatureFlag } from '@audius/common/hooks' +import { weeklyRotationNotificationMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' +import type { WeeklyRotationNotification as WeeklyRotationNotificationType } from '@audius/common/store' + +import { IconArrowRotate } from '@audius/harmony-native' +import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation' + +import { + NotificationHeader, + NotificationText, + NotificationTile, + NotificationTitle +} from '../Notification' + +type WeeklyRotationNotificationProps = { + notification: WeeklyRotationNotificationType +} + +/** + * "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. + */ +export const WeeklyRotationNotification = ( + props: WeeklyRotationNotificationProps +) => { + const { notification } = props + const navigation = useNotificationNavigation() + const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( + FeatureFlags.WEEKLY_ROTATION + ) + + const handlePress = useCallback(() => { + navigation.navigate(notification) + }, [navigation, notification]) + + if (!isWeeklyRotationEnabled) return null + + return ( + + + {messages.title} + + {messages.body} + + ) +} diff --git a/packages/web/src/components/notification/Notification/Notification.tsx b/packages/web/src/components/notification/Notification/Notification.tsx index a1e5f35faf1..7ed9f404440 100644 --- a/packages/web/src/components/notification/Notification/Notification.tsx +++ b/packages/web/src/components/notification/Notification/Notification.tsx @@ -44,6 +44,7 @@ import { TrendingUndergroundNotification } from './TrendingUndergroundNotificati import { USDCPurchaseBuyerNotification } from './USDCPurchaseBuyerNotification' import { USDCPurchaseSellerNotification } from './USDCPurchaseSellerNotification' import { UserSubscriptionNotification } from './UserSubscriptionNotification' +import { WeeklyRotationNotification } from './WeeklyRotationNotification' type NotificationProps = { notification: Notifications @@ -147,6 +148,9 @@ export const Notification = (props: NotificationProps) => { case NotificationType.ListenStreakReminder: { return } + case NotificationType.WeeklyRotation: { + return + } case NotificationType.FanRemixContestEndingSoon: { return ( diff --git a/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx b/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx new file mode 100644 index 00000000000..d0d8551b4c8 --- /dev/null +++ b/packages/web/src/components/notification/Notification/WeeklyRotationNotification.tsx @@ -0,0 +1,67 @@ +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 { WeeklyRotationNotification as WeeklyRotationNotificationType } from '@audius/common/store' +import { route } from '@audius/common/utils' +import { useDispatch } from 'react-redux' + +import { make, useRecord } from 'common/store/analytics/actions' +import { push } from 'utils/navigation' + +import { NotificationBody } from './components/NotificationBody' +import { NotificationFooter } from './components/NotificationFooter' +import { NotificationHeader } from './components/NotificationHeader' +import { NotificationTile } from './components/NotificationTile' +import { NotificationTitle } from './components/NotificationTitle' +import { IconWeeklyRotation } from './components/icons' + +const { WEEKLY_ROTATION_PAGE } = route + +type WeeklyRotationNotificationProps = { + notification: WeeklyRotationNotificationType +} + +/** + * "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. + */ +export const WeeklyRotationNotification = ( + props: WeeklyRotationNotificationProps +) => { + const { notification } = props + const record = useRecord() + const dispatch = useDispatch() + const { timeLabel, isViewed } = notification + const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( + FeatureFlags.WEEKLY_ROTATION + ) + + const handleClick = useCallback(() => { + dispatch(push(WEEKLY_ROTATION_PAGE)) + record( + make(Name.NOTIFICATIONS_CLICK_TILE, { + kind: notification.type, + link_to: WEEKLY_ROTATION_PAGE + }) + ) + }, [dispatch, notification, record]) + + if (!isWeeklyRotationEnabled) return null + + return ( + + }> + {messages.title} + + {messages.body} + + + ) +} diff --git a/packages/web/src/components/notification/Notification/components/icons.tsx b/packages/web/src/components/notification/Notification/components/icons.tsx index 5bf6621df46..bdaf3a4950c 100644 --- a/packages/web/src/components/notification/Notification/components/icons.tsx +++ b/packages/web/src/components/notification/Notification/components/icons.tsx @@ -12,7 +12,8 @@ import { IconTrending as IconTrendingBase, IconTrophy, IconUser, - IconCart as IconCartBase + IconCart as IconCartBase, + IconArrowRotate } from '@audius/harmony' import styles from './icons.module.css' @@ -81,3 +82,7 @@ export const IconStreakFire = () => { ) } + +export const IconWeeklyRotation = () => { + return +}