diff --git a/packages/common/src/adapters/notification.ts b/packages/common/src/adapters/notification.ts index 79381514d23..5738299f2f4 100644 --- a/packages/common/src/adapters/notification.ts +++ b/packages/common/src/adapters/notification.ts @@ -619,5 +619,13 @@ export const notificationFromSDK = ( ...formatBaseNotification(notification) } } + case 'listen_streak_reminder': { + const data = notification.actions[0].data + return { + type: NotificationType.ListenStreakReminder, + streak: data.streak, + ...formatBaseNotification(notification) + } + } } } diff --git a/packages/common/src/messages/index.ts b/packages/common/src/messages/index.ts index d503c4c7e26..e2a95372703 100644 --- a/packages/common/src/messages/index.ts +++ b/packages/common/src/messages/index.ts @@ -6,3 +6,4 @@ export * from './edit' export * from './comments' export * from './settings' export * from './trackPage' +export * from './notifications' diff --git a/packages/common/src/messages/notifications.ts b/packages/common/src/messages/notifications.ts new file mode 100644 index 00000000000..65af126dd3b --- /dev/null +++ b/packages/common/src/messages/notifications.ts @@ -0,0 +1,5 @@ +export const listenStreakReminderMessages = { + title: 'Keep Your Streak Going!', + body: (streak: number) => + `Your ${streak} day listening streak will end in 6 hours! Keep listening to earn daily rewards!` +} diff --git a/packages/common/src/store/notifications/types.ts b/packages/common/src/store/notifications/types.ts index 9c24dbdce4f..5045498d438 100644 --- a/packages/common/src/store/notifications/types.ts +++ b/packages/common/src/store/notifications/types.ts @@ -43,7 +43,8 @@ export enum NotificationType { Comment = 'Comment', CommentThread = 'CommentThread', CommentMention = 'CommentMention', - CommentReaction = 'CommentReaction' + CommentReaction = 'CommentReaction', + ListenStreakReminder = 'ListenStreakReminder' } export enum PushNotificationType { @@ -91,7 +92,8 @@ export enum PushNotificationType { Comment = 'Comment', CommentThread = 'CommentThread', CommentMention = 'CommentMention', - CommentReaction = 'CommentReaction' + CommentReaction = 'CommentReaction', + ListenStreakReminder = 'ListenStreakReminder' } export enum Entity { @@ -688,6 +690,11 @@ export type CommentReactionNotification = BaseNotification & { entityType: Entity.Playlist | Entity.Album | Entity.Track } +export type ListenStreakReminderNotification = BaseNotification & { + type: NotificationType.ListenStreakReminder + streak: number +} + export type Notification = | AnnouncementNotification | UserSubscriptionNotification @@ -722,6 +729,7 @@ export type Notification = | CommentThreadNotification | CommentMentionNotification | CommentReactionNotification + | ListenStreakReminderNotification export type IdentityNotification = Omit & { timestamp: string diff --git a/packages/discovery-provider/integration_tests/tasks/test_create_listen_streak_reminder_notifications.py b/packages/discovery-provider/integration_tests/tasks/test_create_listen_streak_reminder_notifications.py new file mode 100644 index 00000000000..9c0b6709f80 --- /dev/null +++ b/packages/discovery-provider/integration_tests/tasks/test_create_listen_streak_reminder_notifications.py @@ -0,0 +1,156 @@ +import logging +from datetime import datetime, timedelta + +from integration_tests.utils import populate_mock_db +from src.models.notifications.notification import Notification +from src.tasks.create_listen_streak_reminder_notifications import ( + LISTEN_STREAK_REMINDER, + _create_listen_streak_reminder_notifications, + get_listen_streak_notification_group_id, +) +from src.utils.db_session import get_db + +logger = logging.getLogger(__name__) + +TEST_USER_ID = 1 +TEST_STREAK = 3 +TEST_DATE = (datetime.now() - timedelta(hours=18, minutes=30)).date() +TEST_GROUP_ID = get_listen_streak_notification_group_id(TEST_USER_ID, TEST_DATE) + + +def test_create_listen_streak_reminder_notification(app): + """Test basic creation of listen streak notification""" + with app.app_context(): + db = get_db() + + now = datetime.now() + # Place listen time clearly within the window (between 18-19 hours ago) + listen_time = now - timedelta(hours=18, minutes=30) + + entities = { + "challenge_listen_streaks": [ + { + "user_id": TEST_USER_ID, + "listen_streak": TEST_STREAK, + "last_listen_date": listen_time, + } + ] + } + + populate_mock_db(db, entities) + with db.scoped_session() as session: + _create_listen_streak_reminder_notifications(session) + all_notifications = session.query(Notification).all() + + assert len(all_notifications) == 1 + notification = all_notifications[0] + assert notification.specifier == str(TEST_USER_ID) + assert notification.group_id == TEST_GROUP_ID + assert notification.data == {"streak": TEST_STREAK} + assert notification.type == LISTEN_STREAK_REMINDER + assert notification.user_ids == [TEST_USER_ID] + + +def test_ignore_outside_time_window(app): + """Test that streaks outside the notification window are ignored""" + with app.app_context(): + db = get_db() + + now = datetime.now() + too_recent = now - timedelta(hours=17) # Too recent (< 18 hours ago) + too_old = now - timedelta(hours=20) # Too old (> 19 hours ago) + + entities = { + "challenge_listen_streaks": [ + { + "user_id": TEST_USER_ID, + "listen_streak": TEST_STREAK, + "last_listen_date": too_recent, + }, + { + "user_id": TEST_USER_ID + 1, + "listen_streak": TEST_STREAK, + "last_listen_date": too_old, + }, + ] + } + + populate_mock_db(db, entities) + with db.scoped_session() as session: + _create_listen_streak_reminder_notifications(session) + all_notifications = session.query(Notification).all() + assert len(all_notifications) == 0 + + +def test_ignore_existing_notification(app): + """Test that duplicate notifications are not created""" + with app.app_context(): + db = get_db() + + now = datetime.now() + listen_time = now - timedelta(hours=18, minutes=30) + + entities = { + "challenge_listen_streaks": [ + { + "user_id": TEST_USER_ID, + "listen_streak": TEST_STREAK, + "last_listen_date": listen_time, + } + ], + "notifications": [ + { + "specifier": str(TEST_USER_ID), + "group_id": TEST_GROUP_ID, + "type": LISTEN_STREAK_REMINDER, + "user_ids": [TEST_USER_ID], + "data": {"streak": TEST_STREAK}, + "timestamp": listen_time, + } + ], + } + + populate_mock_db(db, entities) + with db.scoped_session() as session: + _create_listen_streak_reminder_notifications(session) + all_notifications = session.query(Notification).all() + assert len(all_notifications) == 1 # Only the existing notification + + +def test_multiple_streaks(app): + """Test handling multiple eligible listen streaks""" + with app.app_context(): + db = get_db() + + now = datetime.now() + listen_time = now - timedelta(hours=18, minutes=30) + + entities = { + "challenge_listen_streaks": [ + { + "user_id": TEST_USER_ID, + "listen_streak": TEST_STREAK, + "last_listen_date": listen_time, + }, + { + "user_id": TEST_USER_ID + 1, + "listen_streak": TEST_STREAK + 1, + "last_listen_date": listen_time, + }, + ] + } + + populate_mock_db(db, entities) + with db.scoped_session() as session: + _create_listen_streak_reminder_notifications(session) + all_notifications = session.query(Notification).all() + + assert len(all_notifications) == 2 + for notification in all_notifications: + assert notification.type == LISTEN_STREAK_REMINDER + user_id = int(notification.specifier) + assert notification.user_ids == [user_id] + expected_streak = ( + TEST_STREAK if user_id == TEST_USER_ID else TEST_STREAK + 1 + ) + assert notification.data == {"streak": expected_streak} diff --git a/packages/discovery-provider/integration_tests/utils.py b/packages/discovery-provider/integration_tests/utils.py index 680ce63255e..eab6cf80938 100644 --- a/packages/discovery-provider/integration_tests/utils.py +++ b/packages/discovery-provider/integration_tests/utils.py @@ -24,6 +24,7 @@ from src.models.playlists.playlist_track import PlaylistTrack from src.models.rewards.challenge import Challenge from src.models.rewards.challenge_disbursement import ChallengeDisbursement +from src.models.rewards.listen_streak_challenge import ChallengeListenStreak from src.models.rewards.reward_manager import RewardManagerTransaction from src.models.rewards.user_challenge import UserChallenge from src.models.social.aggregate_monthly_plays import AggregateMonthlyPlay @@ -154,6 +155,7 @@ def populate_mock_db(db, entities, block_offset=None): stems = entities.get("stems", []) challenges = entities.get("challenges", []) user_challenges = entities.get("user_challenges", []) + challenge_listen_streaks = entities.get("challenge_listen_streaks", []) plays = entities.get("plays", []) aggregate_plays = entities.get("aggregate_plays", []) aggregate_track = entities.get("aggregate_track", []) @@ -932,4 +934,14 @@ def populate_mock_db(db, entities, block_offset=None): ) session.add(collectible_data_record) + for i, challenge_listen_streak in enumerate(challenge_listen_streaks): + streak = ChallengeListenStreak( + user_id=challenge_listen_streak.get("user_id", i), + last_listen_date=challenge_listen_streak.get( + "last_listen_date", datetime.now() + ), + listen_streak=challenge_listen_streak.get("listen_streak", 1), + ) + session.add(streak) + session.commit() diff --git a/packages/discovery-provider/scripts/lint.sh b/packages/discovery-provider/scripts/lint.sh index 4cd60d08d6e..c5c1778305c 100755 --- a/packages/discovery-provider/scripts/lint.sh +++ b/packages/discovery-provider/scripts/lint.sh @@ -1,4 +1,4 @@ -isort --diff . --skip ./src/tasks/core/gen --skip ./plugins +isort . --skip ./src/tasks/core/gen --skip ./plugins flake8 . --exclude=./src/tasks/core/gen,./plugins -black --diff . --exclude './src/tasks/core/gen|./plugins' +black . --exclude './src/tasks/core/gen|./plugins' mypy . --exclude './src/tasks/core/gen|./plugins' \ No newline at end of file diff --git a/packages/discovery-provider/src/api/v1/models/notifications.py b/packages/discovery-provider/src/api/v1/models/notifications.py index 889a3ba09f7..c02046e063e 100644 --- a/packages/discovery-provider/src/api/v1/models/notifications.py +++ b/packages/discovery-provider/src/api/v1/models/notifications.py @@ -910,6 +910,31 @@ def format(self, value): }, ) +listen_streak_reminder_notification_action_data = ns.model( + "listen_streak_reminder_notification_action_data", + { + "streak": fields.Integer(required=True), + }, +) +listen_streak_reminder_notification_action = ns.clone( + "listen_streak_reminder_notification_action", + notification_action_base, + { + "data": fields.Nested( + listen_streak_reminder_notification_action_data, required=True + ) + }, +) +listen_streak_reminder_notification = ns.clone( + "listen_streak_reminder_notification", + notification_base, + { + "actions": fields.List( + fields.Nested(listen_streak_reminder_notification_action, required=True), + required=True, + ) + }, +) notification = ns.add_model( "notification", @@ -949,6 +974,7 @@ def format(self, value): "comment_thread": comment_thread_notification, "comment_mention": comment_mention_notification, "comment_reaction": comment_reaction_notification, + "listen_streak_reminder": listen_streak_reminder_notification, }, discriminator="type", ), diff --git a/packages/discovery-provider/src/api/v1/utils/extend_notification.py b/packages/discovery-provider/src/api/v1/utils/extend_notification.py index f5561bf645c..ef8d3cbfb36 100644 --- a/packages/discovery-provider/src/api/v1/utils/extend_notification.py +++ b/packages/discovery-provider/src/api/v1/utils/extend_notification.py @@ -16,6 +16,7 @@ CreateTrackNotification, FollowerMilestoneNotification, FollowNotification, + ListenStreakReminderNotification, Notification, NotificationAction, NotificationData, @@ -742,6 +743,22 @@ def extend_comment_reaction(action: NotificationAction): } +def extend_listen_streak_reminder(action: NotificationAction): + data: ListenStreakReminderNotification = action["data"] # type: ignore + return { + "specifier": encode_int_id(int(action["specifier"])), + "type": action["type"], + "timestamp": ( + datetime.timestamp(action["timestamp"]) + if action["timestamp"] + else action["timestamp"] + ), + "data": { + "streak": data["streak"], + }, + } + + notification_action_handler = { "follow": extend_follow, "repost": extend_repost, @@ -776,4 +793,5 @@ def extend_comment_reaction(action: NotificationAction): "comment_thread": extend_comment_thread, "comment_mention": extend_comment_mention, "comment_reaction": extend_comment_reaction, + "listen_streak_reminder": extend_listen_streak_reminder, } diff --git a/packages/discovery-provider/src/app.py b/packages/discovery-provider/src/app.py index aef695935fc..32b1787951a 100644 --- a/packages/discovery-provider/src/app.py +++ b/packages/discovery-provider/src/app.py @@ -326,6 +326,7 @@ def configure_celery(celery, test_config=None): "src.tasks.cache_entity_counts", "src.tasks.publish_scheduled_releases", "src.tasks.create_engagement_notifications", + "src.tasks.create_listen_streak_reminder_notifications", "src.tasks.index_core", ], beat_schedule={ @@ -425,6 +426,10 @@ def configure_celery(celery, test_config=None): "task": "create_engagement_notifications", "schedule": timedelta(minutes=10), }, + "create_listen_streak_reminder_notifications": { + "task": "create_listen_streak_reminder_notifications", + "schedule": timedelta(seconds=10), + }, "repair_audio_analyses": { "task": "repair_audio_analyses", "schedule": timedelta(minutes=3), diff --git a/packages/discovery-provider/src/queries/get_notifications.py b/packages/discovery-provider/src/queries/get_notifications.py index 3ee9696d44f..ef84c0a13cf 100644 --- a/packages/discovery-provider/src/queries/get_notifications.py +++ b/packages/discovery-provider/src/queries/get_notifications.py @@ -173,6 +173,7 @@ class NotificationType(str, Enum): COMMENT_THREAD = "comment_thread" COMMENT_MENTION = "comment_mention" COMMENT_REACTION = "comment_reaction" + LISTEN_STREAK_REMINDER = "listen_streak_reminder" def __str__(self) -> str: return str.__str__(self) @@ -477,6 +478,10 @@ class CommentReactionNotification(TypedDict): reacter_user_id: int +class ListenStreakReminderNotification(TypedDict): + streak: int + + NotificationData = Union[ AnnouncementNotification, FollowNotification, @@ -508,6 +513,7 @@ class CommentReactionNotification(TypedDict): CommentThreadNotification, CommentMentionNotification, CommentReactionNotification, + ListenStreakReminderNotification, ] diff --git a/packages/discovery-provider/src/tasks/create_listen_streak_reminder_notifications.py b/packages/discovery-provider/src/tasks/create_listen_streak_reminder_notifications.py new file mode 100644 index 00000000000..87463a6cbc6 --- /dev/null +++ b/packages/discovery-provider/src/tasks/create_listen_streak_reminder_notifications.py @@ -0,0 +1,108 @@ +from datetime import datetime, timedelta + +from sqlalchemy import String, and_, cast, func + +from src.models.notifications.notification import Notification +from src.models.rewards.listen_streak_challenge import ChallengeListenStreak +from src.tasks.celery_app import celery +from src.utils.config import shared_config +from src.utils.structured_logger import StructuredLogger, log_duration + +logger = StructuredLogger(__name__) +env = shared_config["discprov"]["env"] + +LISTEN_STREAK_REMINDER = "listen_streak_reminder" +HOURS_PER_DAY = 24 +LISTEN_STREAK_BUFFER = 6 +LAST_LISTEN_HOURS_AGO = HOURS_PER_DAY - LISTEN_STREAK_BUFFER + + +def get_listen_streak_notification_group_id(user_id, date): + return f"{LISTEN_STREAK_REMINDER}:{user_id}:{date}" + + +@log_duration(logger) +def _create_listen_streak_reminder_notifications(session): + now = datetime.now() + window_end = now - timedelta(hours=LAST_LISTEN_HOURS_AGO) + window_start = now - timedelta(hours=LAST_LISTEN_HOURS_AGO + 1) + if env == "stage": + window_end = now - timedelta(minutes=1) + window_start = now - timedelta(minutes=2) + + # Find listen streaks that need reminder notifications + listen_streaks = ( + session.query(ChallengeListenStreak) + .outerjoin( + Notification, + and_( + Notification.user_ids.any(ChallengeListenStreak.user_id), + Notification.group_id + == func.concat( + LISTEN_STREAK_REMINDER, + ":", + cast(ChallengeListenStreak.user_id, String), + ":", + func.to_char(ChallengeListenStreak.last_listen_date, "YYYY-MM-DD"), + ), + Notification.timestamp >= window_start, + ), + ) + .filter( + ChallengeListenStreak.last_listen_date.between(window_start, window_end), + Notification.id.is_(None), # No notification sent yet in this window + ) + .all() + ) + + new_notifications = [] + for streak in listen_streaks: + new_notification = Notification( + specifier=str(streak.user_id), + group_id=get_listen_streak_notification_group_id( + streak.user_id, + streak.last_listen_date.date(), + ), + blocknumber=None, # Not blockchain related + user_ids=[streak.user_id], + type=LISTEN_STREAK_REMINDER, + data={ + "streak": streak.listen_streak, + }, + timestamp=datetime.now(), + ) + new_notifications.append(new_notification) + + logger.debug( + f"Inserting {len(new_notifications)} listen streak reminder notifications" + ) + session.add_all(new_notifications) + session.commit() + + +# ####### CELERY TASKS ####### # +@celery.task(name="create_listen_streak_reminder_notifications", bind=True) +def create_listen_streak_reminder_notifications(self): + redis = create_listen_streak_reminder_notifications.redis + db = create_listen_streak_reminder_notifications.db + + # Define lock acquired boolean + have_lock = False + # Define redis lock object + update_lock = redis.lock( + "create_listen_streak_reminder_notifications_lock", + blocking_timeout=25, + timeout=600, + ) + try: + have_lock = update_lock.acquire(blocking=False) + if have_lock: + with db.scoped_session() as session: + _create_listen_streak_reminder_notifications(session) + else: + logger.info("Failed to acquire lock") + except Exception as e: + logger.error(f"Error creating listen streak reminder notifications: {e}") + finally: + if have_lock: + update_lock.release() diff --git a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx index c00ef414831..a825d80d086 100644 --- a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx +++ b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx @@ -37,6 +37,7 @@ import { CommentMentionNotification, CommentReactionNotification } from './Notifications' +import { ListenStreakReminderNotification } from './Notifications/ListenStreakReminderNotification' type NotificationListItemProps = { notification: Notification @@ -125,6 +126,8 @@ export const NotificationListItem = (props: NotificationListItemProps) => { return case NotificationType.CommentReaction: return + case NotificationType.ListenStreakReminder: + return default: return null } diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/ListenStreakReminderNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/ListenStreakReminderNotification.tsx new file mode 100644 index 00000000000..ecec7db7e6a --- /dev/null +++ b/packages/mobile/src/screens/notifications-screen/Notifications/ListenStreakReminderNotification.tsx @@ -0,0 +1,42 @@ +import { useCallback } from 'react' + +import { listenStreakReminderMessages as messages } from '@audius/common/messages' +import type { ListenStreakReminderNotification as ListenStreakReminderNotificationType } from '@audius/common/store' +import { Text } from 'react-native' + +import { useNavigation } from 'app/hooks/useNavigation' + +import { + NotificationTile, + NotificationHeader, + NotificationText, + NotificationTitle +} from '../Notification' + +const IconStreakFire = () => { + return 🔥 +} + +type ListenStreakReminderNotificationProps = { + notification: ListenStreakReminderNotificationType +} + +export const ListenStreakReminderNotification = ( + props: ListenStreakReminderNotificationProps +) => { + const { notification } = props + const navigation = useNavigation() + + const handlePress = useCallback(() => { + navigation.navigate('AudioScreen') + }, [navigation]) + + return ( + + + {messages.title} + + {messages.body(notification.streak)} + + ) +} diff --git a/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES b/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES index c4b32937ab2..08497826793 100644 --- a/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES +++ b/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES @@ -99,6 +99,9 @@ models/FullUserResponse.ts models/GetTipsResponse.ts models/Grant.ts models/HistoryResponseFull.ts +models/ListenStreakReminderNotification.ts +models/ListenStreakReminderNotificationAction.ts +models/ListenStreakReminderNotificationActionData.ts models/ManagedUser.ts models/ManagedUsersResponse.ts models/ManagersResponse.ts diff --git a/packages/sdk/src/sdk/api/generated/full/apis/NotificationsApi.ts b/packages/sdk/src/sdk/api/generated/full/apis/NotificationsApi.ts index 8e47f461360..171e46b345d 100644 --- a/packages/sdk/src/sdk/api/generated/full/apis/NotificationsApi.ts +++ b/packages/sdk/src/sdk/api/generated/full/apis/NotificationsApi.ts @@ -161,6 +161,7 @@ export const GetNotificationsValidTypesEnum = { Comment: 'comment', CommentThread: 'comment_thread', CommentMention: 'comment_mention', - CommentReaction: 'comment_reaction' + CommentReaction: 'comment_reaction', + ListenStreakReminder: 'listen_streak_reminder' } as const; export type GetNotificationsValidTypesEnum = typeof GetNotificationsValidTypesEnum[keyof typeof GetNotificationsValidTypesEnum]; diff --git a/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotification.ts b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotification.ts new file mode 100644 index 00000000000..095bf6a119d --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotification.ts @@ -0,0 +1,109 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ListenStreakReminderNotificationAction } from './ListenStreakReminderNotificationAction'; +import { + ListenStreakReminderNotificationActionFromJSON, + ListenStreakReminderNotificationActionFromJSONTyped, + ListenStreakReminderNotificationActionToJSON, +} from './ListenStreakReminderNotificationAction'; + +/** + * + * @export + * @interface ListenStreakReminderNotification + */ +export interface ListenStreakReminderNotification { + /** + * + * @type {string} + * @memberof ListenStreakReminderNotification + */ + type: string; + /** + * + * @type {string} + * @memberof ListenStreakReminderNotification + */ + groupId: string; + /** + * + * @type {boolean} + * @memberof ListenStreakReminderNotification + */ + isSeen: boolean; + /** + * + * @type {number} + * @memberof ListenStreakReminderNotification + */ + seenAt?: number; + /** + * + * @type {Array} + * @memberof ListenStreakReminderNotification + */ + actions: Array; +} + +/** + * Check if a given object implements the ListenStreakReminderNotification interface. + */ +export function instanceOfListenStreakReminderNotification(value: object): value is ListenStreakReminderNotification { + let isInstance = true; + isInstance = isInstance && "type" in value && value["type"] !== undefined; + isInstance = isInstance && "groupId" in value && value["groupId"] !== undefined; + isInstance = isInstance && "isSeen" in value && value["isSeen"] !== undefined; + isInstance = isInstance && "actions" in value && value["actions"] !== undefined; + + return isInstance; +} + +export function ListenStreakReminderNotificationFromJSON(json: any): ListenStreakReminderNotification { + return ListenStreakReminderNotificationFromJSONTyped(json, false); +} + +export function ListenStreakReminderNotificationFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenStreakReminderNotification { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'type': json['type'], + 'groupId': json['group_id'], + 'isSeen': json['is_seen'], + 'seenAt': !exists(json, 'seen_at') ? undefined : json['seen_at'], + 'actions': ((json['actions'] as Array).map(ListenStreakReminderNotificationActionFromJSON)), + }; +} + +export function ListenStreakReminderNotificationToJSON(value?: ListenStreakReminderNotification | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'type': value.type, + 'group_id': value.groupId, + 'is_seen': value.isSeen, + 'seen_at': value.seenAt, + 'actions': ((value.actions as Array).map(ListenStreakReminderNotificationActionToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationAction.ts b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationAction.ts new file mode 100644 index 00000000000..83e9f5a1677 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationAction.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ListenStreakReminderNotificationActionData } from './ListenStreakReminderNotificationActionData'; +import { + ListenStreakReminderNotificationActionDataFromJSON, + ListenStreakReminderNotificationActionDataFromJSONTyped, + ListenStreakReminderNotificationActionDataToJSON, +} from './ListenStreakReminderNotificationActionData'; + +/** + * + * @export + * @interface ListenStreakReminderNotificationAction + */ +export interface ListenStreakReminderNotificationAction { + /** + * + * @type {string} + * @memberof ListenStreakReminderNotificationAction + */ + specifier: string; + /** + * + * @type {string} + * @memberof ListenStreakReminderNotificationAction + */ + type: string; + /** + * + * @type {number} + * @memberof ListenStreakReminderNotificationAction + */ + timestamp: number; + /** + * + * @type {ListenStreakReminderNotificationActionData} + * @memberof ListenStreakReminderNotificationAction + */ + data: ListenStreakReminderNotificationActionData; +} + +/** + * Check if a given object implements the ListenStreakReminderNotificationAction interface. + */ +export function instanceOfListenStreakReminderNotificationAction(value: object): value is ListenStreakReminderNotificationAction { + let isInstance = true; + isInstance = isInstance && "specifier" in value && value["specifier"] !== undefined; + isInstance = isInstance && "type" in value && value["type"] !== undefined; + isInstance = isInstance && "timestamp" in value && value["timestamp"] !== undefined; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function ListenStreakReminderNotificationActionFromJSON(json: any): ListenStreakReminderNotificationAction { + return ListenStreakReminderNotificationActionFromJSONTyped(json, false); +} + +export function ListenStreakReminderNotificationActionFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenStreakReminderNotificationAction { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'specifier': json['specifier'], + 'type': json['type'], + 'timestamp': json['timestamp'], + 'data': ListenStreakReminderNotificationActionDataFromJSON(json['data']), + }; +} + +export function ListenStreakReminderNotificationActionToJSON(value?: ListenStreakReminderNotificationAction | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'specifier': value.specifier, + 'type': value.type, + 'timestamp': value.timestamp, + 'data': ListenStreakReminderNotificationActionDataToJSON(value.data), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationActionData.ts b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationActionData.ts new file mode 100644 index 00000000000..2e1470c0591 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/ListenStreakReminderNotificationActionData.ts @@ -0,0 +1,67 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface ListenStreakReminderNotificationActionData + */ +export interface ListenStreakReminderNotificationActionData { + /** + * + * @type {number} + * @memberof ListenStreakReminderNotificationActionData + */ + streak: number; +} + +/** + * Check if a given object implements the ListenStreakReminderNotificationActionData interface. + */ +export function instanceOfListenStreakReminderNotificationActionData(value: object): value is ListenStreakReminderNotificationActionData { + let isInstance = true; + isInstance = isInstance && "streak" in value && value["streak"] !== undefined; + + return isInstance; +} + +export function ListenStreakReminderNotificationActionDataFromJSON(json: any): ListenStreakReminderNotificationActionData { + return ListenStreakReminderNotificationActionDataFromJSONTyped(json, false); +} + +export function ListenStreakReminderNotificationActionDataFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenStreakReminderNotificationActionData { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'streak': json['streak'], + }; +} + +export function ListenStreakReminderNotificationActionDataToJSON(value?: ListenStreakReminderNotificationActionData | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'streak': value.streak, + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/Notification.ts b/packages/sdk/src/sdk/api/generated/full/models/Notification.ts index a4b2c8abe6e..9a229c5a9bf 100644 --- a/packages/sdk/src/sdk/api/generated/full/models/Notification.ts +++ b/packages/sdk/src/sdk/api/generated/full/models/Notification.ts @@ -90,6 +90,13 @@ import { FollowNotificationFromJSONTyped, FollowNotificationToJSON, } from './FollowNotification'; +import { + ListenStreakReminderNotification, + instanceOfListenStreakReminderNotification, + ListenStreakReminderNotificationFromJSON, + ListenStreakReminderNotificationFromJSONTyped, + ListenStreakReminderNotificationToJSON, +} from './ListenStreakReminderNotification'; import { MilestoneNotification, instanceOfMilestoneNotification, @@ -243,7 +250,7 @@ import { * * @export */ -export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'follow' } & FollowNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification; +export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'follow' } & FollowNotification | { type: 'listen_streak_reminder' } & ListenStreakReminderNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification; export function NotificationFromJSON(json: any): Notification { return NotificationFromJSONTyped(json, false); @@ -276,6 +283,8 @@ export function NotificationFromJSONTyped(json: any, ignoreDiscriminator: boolea return {...CreateNotificationFromJSONTyped(json, true), type: 'create'}; case 'follow': return {...FollowNotificationFromJSONTyped(json, true), type: 'follow'}; + case 'listen_streak_reminder': + return {...ListenStreakReminderNotificationFromJSONTyped(json, true), type: 'listen_streak_reminder'}; case 'milestone': return {...MilestoneNotificationFromJSONTyped(json, true), type: 'milestone'}; case 'reaction': @@ -355,6 +364,8 @@ export function NotificationToJSON(value?: Notification | null): any { return CreateNotificationToJSON(value); case 'follow': return FollowNotificationToJSON(value); + case 'listen_streak_reminder': + return ListenStreakReminderNotificationToJSON(value); case 'milestone': return MilestoneNotificationToJSON(value); case 'reaction': diff --git a/packages/sdk/src/sdk/api/generated/full/models/index.ts b/packages/sdk/src/sdk/api/generated/full/models/index.ts index 0ed3ba7011f..3a56735bacf 100644 --- a/packages/sdk/src/sdk/api/generated/full/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/full/models/index.ts @@ -88,6 +88,9 @@ export * from './FullUserResponse'; export * from './GetTipsResponse'; export * from './Grant'; export * from './HistoryResponseFull'; +export * from './ListenStreakReminderNotification'; +export * from './ListenStreakReminderNotificationAction'; +export * from './ListenStreakReminderNotificationActionData'; export * from './ManagedUser'; export * from './ManagedUsersResponse'; export * from './ManagersResponse'; diff --git a/packages/web/src/common/store/notifications/fetchNotifications.ts b/packages/web/src/common/store/notifications/fetchNotifications.ts index c50840bf2cb..e83a3d12b4d 100644 --- a/packages/web/src/common/store/notifications/fetchNotifications.ts +++ b/packages/web/src/common/store/notifications/fetchNotifications.ts @@ -47,7 +47,7 @@ export function* fetchNotifications(config: FetchNotificationsParams) { ValidTypes.CommentThread, ValidTypes.CommentMention, ValidTypes.CommentReaction, - ValidTypes.ClaimableReward + isListenStreakEndlessEnabled ? ValidTypes.ListenStreakReminder : null ].filter(removeNullable) const { data } = yield* call( diff --git a/packages/web/src/components/notification/Notification/ListenStreakReminderNotification.tsx b/packages/web/src/components/notification/Notification/ListenStreakReminderNotification.tsx new file mode 100644 index 00000000000..1bb533ccd9b --- /dev/null +++ b/packages/web/src/components/notification/Notification/ListenStreakReminderNotification.tsx @@ -0,0 +1,52 @@ +import { useCallback } from 'react' + +import { listenStreakReminderMessages as messages } from '@audius/common/messages' +import { Name } from '@audius/common/models' +import { ListenStreakReminderNotification as ListenStreakNotificationType } 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 { IconStreakFire } from './components/icons' + +const { REWARDS_PAGE } = route + +type ListenStreakReminderNotificationProps = { + notification: ListenStreakNotificationType +} + +export const ListenStreakReminderNotification = ( + props: ListenStreakReminderNotificationProps +) => { + const { notification } = props + const record = useRecord() + const dispatch = useDispatch() + const { timeLabel, isViewed } = notification + + const handleClick = useCallback(() => { + dispatch(push(REWARDS_PAGE)) + record( + make(Name.NOTIFICATIONS_CLICK_TILE, { + kind: notification.type, + link_to: REWARDS_PAGE + }) + ) + }, [dispatch, notification, record]) + + return ( + + }> + {messages.title} + + {messages.body(notification.streak)} + + + ) +} diff --git a/packages/web/src/components/notification/Notification/Notification.tsx b/packages/web/src/components/notification/Notification/Notification.tsx index 4430584095a..340714439b9 100644 --- a/packages/web/src/components/notification/Notification/Notification.tsx +++ b/packages/web/src/components/notification/Notification/Notification.tsx @@ -17,6 +17,7 @@ import { CommentThreadNotification } from './CommentThreadNotification' import { FavoriteNotification } from './FavoriteNotification' import { FavoriteOfRepostNotification } from './FavoriteOfRepostNotification' import { FollowNotification } from './FollowNotification' +import { ListenStreakReminderNotification } from './ListenStreakReminderNotification' import { MilestoneNotification } from './MilestoneNotification' import { RemixCosignNotification } from './RemixCosignNotification' import { RemixCreateNotification } from './RemixCreateNotification' @@ -149,6 +150,9 @@ export const Notification = (props: NotificationProps) => { case NotificationType.CommentReaction: { return } + case NotificationType.ListenStreakReminder: { + return + } default: { return null } diff --git a/packages/web/src/components/notification/Notification/components/icons.tsx b/packages/web/src/components/notification/Notification/components/icons.tsx index 88ab87f929e..f56498f1418 100644 --- a/packages/web/src/components/notification/Notification/components/icons.tsx +++ b/packages/web/src/components/notification/Notification/components/icons.tsx @@ -78,3 +78,11 @@ export const IconAddTrackToPlaylist = () => { ) } + +export const IconStreakFire = () => { + return ( + + 🔥 + + ) +}