package com.suno.android.ui.screens.home.notifications import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.common_analytics.listening_source.ListeningSource import com.suno.android.common_analytics.listening_source.ListeningSourceCache import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.environment.UserPrefsDataStoreManager import com.suno.android.common_data.alerts.AlertsRepo import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.entities.RemoteFollowArtistProfileBody import com.suno.android.common_networking.remote.profiles.ProfilesService import com.suno.android.gating.statsig.DynamicConfigName import com.suno.android.gating.statsig.StatsigFeatureDataSource import com.suno.android.hooks.HooksFeatureGateManager import com.suno.android.media.MediaManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject private const val NOTIFICATION_BANNER_ID = "id" private const val NOTIFICATION_BANNER_TAG = "tag" private const val NOTIFICATION_BANNER_TITLE_TEXT = "title_text" private const val NOTIFICATION_BANNER_SUBTITLE_TEXT = "subtitle_text" private const val NOTIFICATION_BANNER_PARAM_TEXT = "text" @HiltViewModel class NotificationsScreenVM @Inject constructor( processorFactory: MviProcessorFactory, private val alertsRepo: AlertsRepo, private val profilesService: ProfilesService, private val mediaManager: MediaManager, private val clipsRepository: ClipsRepository, private val analyticsManager: AnalyticsManager, private val listeningSourceCache: ListeningSourceCache, statsigManager: StatsigFeatureDataSource, hooksFeatureGateManager: HooksFeatureGateManager, private val prefsDataStoreManager: UserPrefsDataStoreManager, ) : MviViewModel( processorFactory = processorFactory, initialState = NotificationsScreenState( hooksNotificationsGateEnabled = hooksFeatureGateManager.isHooksInAppNotificationsEnabled, ), ) { init { alertsRepo.inAppNotificationsStateFlow() .onEach { state -> val alertsList = state.notifications val singleFollowNotifications = alertsList?.filter { it.notificationType == "follow" && it.userProfiles?.size == 1 } val followStates = singleFollowNotifications?.map { FollowState( handle = it.userProfiles?.first()?.handle ?: "", isFollowing = it.userProfiles?.first()?.isFollowing, ) } ?: emptyList() updateState { it.copy( loading = false, notificationItems = alertsList, followStates = followStates, ) } // mark all unread notifications as read upon opening this screen val unreadNotificationsIds = alertsList?.filter { it.isRead == false }?.map { it.id } if (unreadNotificationsIds?.isNotEmpty() == true) { alertsRepo.markNotificationsAsRead(unreadNotificationsIds) } }.catch { exception -> logger.e(exception) }.flowOn(Dispatchers.IO) .launchIn(viewModelScope) viewModelScope.launch { alertsRepo.syncNotifications() } viewModelScope.launch { setupBanner(statsigManager) } } @Suppress("ReturnCount") // early returns private suspend fun setupBanner( statsigManager: StatsigFeatureDataSource, ) { val bannerConfig = statsigManager.fetchDynamicConfig(DynamicConfigName.NOTIFICATIONS_BANNER_CONFIG) .getDictionary("android", null) ?: return val id: Id = bannerConfig.get(NOTIFICATION_BANNER_ID)?.toString() ?.let(::Id) ?: return val dismissedIds = prefsDataStoreManager.getDismissedNotificationBannerIds().firstOrNull() ?: emptySet() if (id.value in dismissedIds) return val tagText = bannerConfig.get(NOTIFICATION_BANNER_TAG)?.let { tag -> (tag as? Map<*, *>?)?.get(NOTIFICATION_BANNER_PARAM_TEXT)?.toString() } ?: return val titleText = bannerConfig.get(NOTIFICATION_BANNER_TITLE_TEXT)?.let { title -> (title as? Map<*, *>?)?.get(NOTIFICATION_BANNER_PARAM_TEXT)?.toString() } ?: return val subtitleText = bannerConfig.get(NOTIFICATION_BANNER_SUBTITLE_TEXT)?.let { subtitle -> (subtitle as? Map<*, *>?)?.get(NOTIFICATION_BANNER_PARAM_TEXT)?.toString() } ?: return sendEvent( Internal.UpdateNotificationBannerContent( id = id, pillText = tagText, headerText = titleText, bodyText = subtitleText, ), ) } fun markNotificationsAsRead( idList: List, ) { viewModelScope.launch { alertsRepo.markNotificationsAsRead(idList) } } override suspend fun reduceEvent( currentState: NotificationsScreenState, event: NotificationsScreenEvent, emitEffect: suspend (NotificationsScreenEffect) -> Unit, ): NotificationsScreenState = when (event) { is NotificationsScreenEvent.OnFollowClicked -> { viewModelScope.launch { val body = RemoteFollowArtistProfileBody( handle = event.followHandle, unfollow = false, ) profilesService.followArtistProfile(body).getOrElse { error -> logger.e(error.toThrowable()) return@launch } } currentState.copy( followStates = currentState.followStates.map { follow -> if (follow.handle == event.followHandle) { follow.copy(isFollowing = true) } else { follow } }, ) } is NotificationsScreenEvent.OnSongClicked -> { viewModelScope.launch { val clip = clipsRepository.getClipById(event.clipId) .getOrElse { error -> logger.e(error) { "Error loading clip for song click" } return@launch } if (clip != null) { // Store listening source for analytics if notification ID is available event.notificationId?.let { notificationId -> listeningSourceCache.put( clipId = clip.clipId.map(), listeningSource = ListeningSource.NotificationSource( notificationId = Id(notificationId), ), ) } mediaManager.setSinglePlayingClipData( localClipData = clip, enablePlayback = event.enablePlayback, ) } } currentState } is NotificationsScreenEvent.OnAcceptNotificationsBanner -> { analyticsManager.trackMviEvent( eventName = NotificationsScreenAnalytics.EventName.ACCEPT_NOTIFICATIONS_BANNER, source = NotificationsScreenAnalytics.SCREEN, ) currentState } is NotificationsScreenEvent.OnHookClicked -> { currentState } NotificationsScreenEvent.DismissNotificationBannerContent -> { val id = currentState.notificationInfoBannerState?.id if (id == null) { currentState } else { prefsDataStoreManager.setBannerIdDismissed(id.value) currentState.copy( notificationInfoBannerState = null, ) } } is Internal.UpdateNotificationBannerContent -> { currentState.copy( notificationInfoBannerState = NotificationsScreenState.NotificationInfoBannerState( id = event.id, pillText = event.pillText, headerText = event.headerText, bodyText = event.bodyText, ), ) } } }