package com.suno.android.ui.screens.home.notifications import android.Manifest import android.content.Intent import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.extensions.xCheckNotificationPermissions import com.suno.android.common_core_utils.extensions.xTruncate import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_networking.remote.entities.NotificationsInner import com.suno.android.common_ui.components.loading.SunoSpinner import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.ui.screens.home.mediaplayer.omniPlayer import com.suno.android.ui.screens.home.notifications.notification_list_items.ClipCommentNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.ClipCreateFolloweeNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.ClipLikeNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.ClipRemixNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.CommentLikeNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.CommentMentionNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.CommentReplyNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.FollowNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.HookCommentLikeNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.HookCommentNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.HookCommentReplyNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.HookLikeNotificationListItem import com.suno.android.ui.screens.home.notifications.notification_list_items.HookMentionNotificationItem import com.suno.android.ui.screens.home.notifications.notification_list_items.PlaylistLikeNotificationListItem import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR const val MAX_CONTENT_LENGTH = 30 enum class NotificationType( val value: String, ) { FOLLOW("follow"), CLIP_LIKE("clip_like"), PLAYLIST_LIKE("playlist_like"), CLIP_COMMENT("clip_comment"), COMMENT_LIKE("comment_like"), COMMENT_REPLY("comment_reply"), CLIP_CREATE_FOLLOWEE("clip_create_followee"), COMMENT_MENTION("comment_mention"), HOOK_LIKE("hook_like"), HOOK_COMMENT("hook_comment"), HOOK_COMMENT_REPLY("hook_comment_reply"), HOOK_COMMENT_MENTION("hook_comment_mention"), HOOK_COMMENT_LIKE("hook_comment_like"), VIDEO_COVER_HOOK_LIKE("video_cover_hook_like"), VIDEO_COVER_HOOK_COMMENT("video_cover_hook_comment"), VIDEO_COVER_HOOK_REPLY("video_cover_hook_comment_reply"), VIDEO_COVER_HOOK_COMMENT_LIKE("video_cover_hook_comment_like"), VIDEO_COVER_HOOK_COMMENT_MENTION("video_cover_hook_comment_mention"), CLIP_REMIX("clip_remix"), } @Composable fun NotificationScreen( vm: NotificationsScreenVM = hiltViewModel(), topAppBarScrollBehavior: TopAppBarScrollBehavior, onProfileClick: (profileHandle: String) -> Unit, onClipClick: (openCommentsSheet: Boolean) -> Unit, onHookClick: (hookId: Id, openCommentsSheet: Boolean) -> Unit, onBackClick: (() -> Unit)? = null, ) { val viewState by vm.state.collectAsStateWithLifecycle() val context = LocalContext.current val notificationsEnabled = context.xCheckNotificationPermissions() var notificationsPermissionBannerDismissed: Boolean by remember { mutableStateOf(false) } val notificationsPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { isGranted -> if (isGranted) { vm.sendEvent(NotificationsScreenEvent.OnAcceptNotificationsBanner) } notificationsPermissionBannerDismissed = true } LaunchedEffect(Unit) { vm.effects.collect { effect -> when (effect) { is NotificationsScreenEffect.MarkNotificationsAsRead -> { vm.markNotificationsAsRead(effect.idList) } } } } NotificationScreenContent( loading = viewState.loading, notificationItems = viewState.notificationItems, followStates = viewState.followStates, hooksNotificationsGateEnabled = viewState.hooksNotificationsGateEnabled, notificationInfoBannerState = viewState.notificationInfoBannerState, notificationsEnabled = notificationsEnabled, notificationsPermissionBannerDismissed = notificationsPermissionBannerDismissed, topAppBarScrollBehavior = topAppBarScrollBehavior, onBackClick = onBackClick, onProfileClick = onProfileClick, navigateToOmniPlayer = onClipClick, onHookClick = onHookClick, onDismissPermissionBanner = { notificationsPermissionBannerDismissed = true }, onRequestNotificationPermission = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { val intent = Intent().apply { action = Settings.ACTION_APP_NOTIFICATION_SETTINGS putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } context.startActivity(intent) } }, onDismissNotificationBanner = { vm.sendEvent(NotificationsScreenEvent.DismissNotificationBannerContent) }, onSongClicked = { clipId, enablePlayback, notificationId -> vm.sendEvent( NotificationsScreenEvent.OnSongClicked( clipId = clipId, enablePlayback = enablePlayback, notificationId = notificationId, ), ) }, onFollowClicked = { followHandle -> vm.sendEvent(NotificationsScreenEvent.OnFollowClicked(followHandle = followHandle)) }, ) } @Composable private fun NotificationScreenContent( loading: Boolean, notificationItems: List?, followStates: List, hooksNotificationsGateEnabled: Boolean, notificationInfoBannerState: NotificationsScreenState.NotificationInfoBannerState?, notificationsEnabled: Boolean, notificationsPermissionBannerDismissed: Boolean, topAppBarScrollBehavior: TopAppBarScrollBehavior, onBackClick: (() -> Unit)?, onProfileClick: (profileHandle: String) -> Unit, navigateToOmniPlayer: (openCommentsSheet: Boolean) -> Unit, onHookClick: (hookId: Id, openCommentsSheet: Boolean) -> Unit, onDismissPermissionBanner: () -> Unit, onRequestNotificationPermission: () -> Unit, onDismissNotificationBanner: () -> Unit, onSongClicked: (clipId: Id, enablePlayback: Boolean, notificationId: String?) -> Unit, onFollowClicked: (followHandle: String) -> Unit, ) { Scaffold( modifier = Modifier .background(ExtendedTheme.colors.backgroundPrimary) .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), topBar = { TopAppBar( scrollBehavior = topAppBarScrollBehavior, colors = TopAppBarDefaults.topAppBarColors( containerColor = ExtendedTheme.colors.backgroundPrimary, scrolledContainerColor = ExtendedTheme.colors.backgroundPrimary, ), navigationIcon = { if (onBackClick != null) { CircleIconButton( vectorDrawableResId = CommonUIR.drawable.arrow_left, contentDescription = stringResource(CommonResR.string.back), onClick = onBackClick, ) } }, title = { Text( text = stringResource(id = CommonResR.string.notifications), style = ExtendedTheme.typography.smallTrackName, ) }, ) }, ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), contentAlignment = Alignment.Center, ) { val isInitialized = notificationItems != null if (loading) { SunoSpinner() } else if (isInitialized) { val notificationItemsList = notificationItems val noNotifications = notificationItemsList.isEmpty() if (noNotifications) { Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Image( modifier = Modifier .width(228.dp) .height(100.dp) .padding(bottom = 8.dp), painter = painterResource(CommonUIR.drawable.empty_notifications), contentDescription = null, ) Text( modifier = Modifier.padding(bottom = 4.dp), text = stringResource(CommonResR.string.no_notifications), style = ExtendedTheme.typography.smallTrackName, color = ExtendedTheme.colors.textPrimary, ) Text( text = stringResource(CommonResR.string.notifications_up_to_date), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, ) } } else { LazyColumn( contentPadding = WindowInsets.omniPlayer.asPaddingValues(), modifier = Modifier .fillMaxSize() .consumeWindowInsets(WindowInsets.omniPlayer), ) { notificationInfoBannerState?.let { bannerState -> item { NotificationsInfoBanner( onDismiss = onDismissNotificationBanner, pillText = bannerState.pillText, headerText = bannerState.headerText, bodyText = bannerState.bodyText, ) } } if (!notificationsEnabled && !notificationsPermissionBannerDismissed) { item { NotificationsPermissionBanner( onDismiss = onDismissPermissionBanner, onClick = onRequestNotificationPermission, ) } } val timeGroupedNotifications = notificationItemsList.xGroupByTimeframe() // todo: use stringresource for localization here val groupOrder = listOf("Today", "Yesterday", "Past Week", "Past Month", "Older") // notification_type?: "clip_like" | "scene_like" | "clip_create_followee" | "playlist_like" | "code_redeem" | "persona_favorite" | "persona_follow" | "persona_used" | "announcement" | "follow" | "invite" | "invite_accepted" | "clip_comment" | "scene_comment" | "playlist_comment" | "comment_like" | "comment_reply" | "delete" | "default"; val whitelistedNotificationTypes = buildList { add(NotificationType.FOLLOW) add(NotificationType.CLIP_LIKE) add(NotificationType.PLAYLIST_LIKE) add(NotificationType.CLIP_COMMENT) add(NotificationType.COMMENT_LIKE) add(NotificationType.COMMENT_REPLY) add(NotificationType.CLIP_CREATE_FOLLOWEE) add(NotificationType.COMMENT_MENTION) add(NotificationType.CLIP_REMIX) // "caption_mention", if (hooksNotificationsGateEnabled) { add(NotificationType.HOOK_LIKE) add(NotificationType.HOOK_COMMENT) add(NotificationType.HOOK_COMMENT_REPLY) add(NotificationType.HOOK_COMMENT_MENTION) add(NotificationType.HOOK_COMMENT_LIKE) add(NotificationType.VIDEO_COVER_HOOK_LIKE) add(NotificationType.VIDEO_COVER_HOOK_REPLY) add(NotificationType.VIDEO_COVER_HOOK_COMMENT) add(NotificationType.VIDEO_COVER_HOOK_COMMENT_LIKE) add(NotificationType.VIDEO_COVER_HOOK_COMMENT_MENTION) } } groupOrder.forEach { groupKey -> timeGroupedNotifications[groupKey]?.let { allNotifications -> val filteredNotifications = allNotifications.filter { notification -> whitelistedNotificationTypes.any { it.value == notification.notificationType } } if (filteredNotifications.isNotEmpty()) { item { Text( text = groupKey, modifier = Modifier.padding(16.dp), style = ExtendedTheme.typography.introCopy, color = ExtendedTheme.colors.textPrimary, ) } items(filteredNotifications) { notificationItem -> val notificationTextStyle = ExtendedTheme.typography.userTitle.copy( color = ExtendedTheme.colors.textPrimary, ) val textLayoutResult = remember { mutableStateOf(null) } val notificationTextModifier = Modifier.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() val offset = down.position val layoutResult = textLayoutResult.value ?: return@awaitEachGesture val position = layoutResult.getOffsetForPosition(offset) notificationItem.userProfiles?.forEach { userProfile -> val handle = userProfile.handle val tag = "user_handle_$handle" val annotation = layoutResult.layoutInput.text.getStringAnnotations( tag, position, position, ).firstOrNull() ?: return@awaitEachGesture down.consume() val up = waitForUpOrCancellation() ?: return@awaitEachGesture up.consume() onProfileClick.invoke( annotation.item, ) } } } when (notificationItem.notificationType) { NotificationType.FOLLOW.value -> { FollowNotificationListItem( notificationItem = notificationItem, isFollowing = followStates.find { it.handle == notificationItem.userProfiles?.first()?.handle }?.isFollowing, onFollowClick = { notificationItem.userProfiles?.first()?.handle?.let { handle -> onFollowClicked(handle) } }, onProfileClick = onProfileClick, notificationTextModifier = notificationTextModifier, notificationTextStyle = notificationTextStyle, ) } NotificationType.CLIP_LIKE.value -> { ClipLikeNotificationListItem( notificationTextModifier = notificationTextModifier, notificationItem = notificationItem, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), true, notificationItem.id) } }, onProfileClick = onProfileClick, notificationTextStyle = notificationTextStyle, ) } NotificationType.PLAYLIST_LIKE.value -> { PlaylistLikeNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, ) } NotificationType.CLIP_COMMENT.value -> { ClipCommentNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), false, notificationItem.id) navigateToOmniPlayer(true) } }, ) } NotificationType.COMMENT_LIKE.value -> { CommentLikeNotificationItem( notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, notificationTextStyle = notificationTextStyle, notificationItem = notificationItem, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), false, notificationItem.id) navigateToOmniPlayer(true) } }, ) } NotificationType.COMMENT_REPLY.value -> { CommentReplyNotificationItem( notificationTextModifier = notificationTextModifier, notificationItem = notificationItem, onProfileClick = onProfileClick, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), false, notificationItem.id) navigateToOmniPlayer(true) } }, notificationTextStyle = notificationTextStyle, ) } NotificationType.CLIP_CREATE_FOLLOWEE.value -> { ClipCreateFolloweeNotificationItem( notificationTextModifier = notificationTextModifier, notificationItem = notificationItem, onProfileClick = onProfileClick, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), true, notificationItem.id) } }, notificationTextStyle = notificationTextStyle, ) } NotificationType.COMMENT_MENTION.value -> { CommentMentionNotificationItem( notificationTextModifier = notificationTextModifier, notificationItem = notificationItem, onProfileClick = onProfileClick, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), false, notificationItem.id) navigateToOmniPlayer(true) } }, notificationTextStyle = notificationTextStyle, ) } NotificationType.CLIP_REMIX.value -> { ClipRemixNotificationItem( notificationTextModifier = notificationTextModifier, notificationItem = notificationItem, onNotificationClick = { notificationItem.contentId?.let { songId -> onSongClicked(Id(songId), false, notificationItem.id) } }, onProfileClick = onProfileClick, notificationTextStyle = notificationTextStyle, ) } NotificationType.HOOK_LIKE.value -> { HookLikeNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onHookClick = onHookClick, notificationText = buildAnnotatedString { append( notificationItem.userProfiles?.xFormatHandlesAnnotated(), ) append(" ") append( stringResource( CommonResR.string.notifications_hook_like_singular, ), ) }, ) } NotificationType.VIDEO_COVER_HOOK_LIKE.value -> { HookLikeNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onHookClick = onHookClick, notificationText = buildAnnotatedString { append( notificationItem.userProfiles?.xFormatHandlesAnnotated(), ) append(" ") append( stringResource( CommonResR .string .notifications_video_cover_hook_like_singular, ), ) }, ) } NotificationType.HOOK_COMMENT.value -> { HookCommentNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onHookClick = onHookClick, notificationText = buildAnnotatedString { append( notificationItem.userProfiles?.xFormatHandlesAnnotated(), ) append(" ") append( stringResource( CommonResR.string.notifications_hook_comment, ), ) append(": ") append( notificationItem.contentMessage?.xTruncate( MAX_CONTENT_LENGTH, ), ) }, ) } NotificationType.VIDEO_COVER_HOOK_COMMENT.value -> { HookCommentNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onHookClick = onHookClick, notificationText = buildAnnotatedString { append( notificationItem.userProfiles?.xFormatHandlesAnnotated(), ) append(" ") append( stringResource( CommonResR .string .notifications_video_cover_hook_comment, ), ) append(": ") append( notificationItem.contentMessage?.xTruncate( MAX_CONTENT_LENGTH, ), ) }, ) } NotificationType.HOOK_COMMENT_REPLY.value, NotificationType.VIDEO_COVER_HOOK_REPLY.value, -> { HookCommentReplyNotificationListItem( notificationItem = notificationItem, notificationTextStyle = notificationTextStyle, notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, onHookClick = onHookClick, ) } NotificationType.HOOK_COMMENT_MENTION.value, NotificationType.VIDEO_COVER_HOOK_COMMENT_MENTION.value, -> { HookMentionNotificationItem( notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, notificationTextStyle = notificationTextStyle, notificationItem = notificationItem, onHookClick = onHookClick, ) } NotificationType.HOOK_COMMENT_LIKE.value, NotificationType.VIDEO_COVER_HOOK_COMMENT_LIKE.value, -> { HookCommentLikeNotificationListItem( notificationTextModifier = notificationTextModifier, onProfileClick = onProfileClick, notificationTextStyle = notificationTextStyle, notificationItem = notificationItem, onHookClick = onHookClick, ) } } if (whitelistedNotificationTypes.any { it.value == notificationItem.notificationType } ) { HorizontalDivider( modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp, color = ExtendedTheme.colors.backgroundQuarternary, ) } } } } } item { Box( modifier = Modifier .fillMaxSize() .padding(vertical = 42.dp), contentAlignment = Alignment.Center, ) { Text( style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textSecondary, text = stringResource(CommonResR.string.notifications_up_to_date), ) } } } } } else { SunoSpinner() } } } } const val NOTIFICATION_MAX_LINES = 3