/* * Copyright 2023 Tusky Contributors * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.components.notifications import android.content.SharedPreferences import android.util.Log import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.util.throttleFirst import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await import retrofit2.HttpException import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime data class UiState( /** Filtered notification types */ val activeFilter: Set = emptySet(), /** True if the UI to filter and clear notifications should be shown */ val showFilterOptions: Boolean = false, /** True if the FAB should be shown while scrolling */ val showFabWhileScrolling: Boolean = true ) /** Preferences the UI reacts to */ data class UiPrefs( val showFabWhileScrolling: Boolean, val showFilter: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( PrefKeys.FAB_HIDE, PrefKeys.SHOW_NOTIFICATIONS_FILTER ) } } /** Parent class for all UI actions, fallible or infallible. */ sealed class UiAction /** Actions the user can trigger from the UI. These actions may fail. */ sealed class FallibleUiAction : UiAction() { /** Clear all notifications */ object ClearNotifications : FallibleUiAction() } /** * Actions the user can trigger from the UI that either cannot fail, or if they do fail, * do not show an error. */ sealed class InfallibleUiAction : UiAction() { /** Apply a new filter to the notification list */ // This saves the list to the local database, which triggers a refresh of the data. // Saving the data can't fail, which is why this is infallible. Refreshing the // data may fail, but that's handled by the paging system / adapter refresh logic. data class ApplyFilter(val filter: Set) : InfallibleUiAction() /** * User is leaving the fragment, save the ID of the visible notification. * * Infallible because if it fails there's nowhere to show the error, and nothing the user * can do. */ data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() /** Ignore the saved reading position, load the page with the newest items */ // Resets the account's `lastNotificationId`, which can't fail, which is why this is // infallible. Reloading the data may fail, but that's handled by the paging system / // adapter refresh logic. object LoadNewest : InfallibleUiAction() } /** Actions the user can trigger on an individual notification. These may fail. */ sealed class NotificationAction : FallibleUiAction() { data class AcceptFollowRequest(val accountId: String) : NotificationAction() data class RejectFollowRequest(val accountId: String) : NotificationAction() } sealed class UiSuccess { // These three are from menu items on the status. Currently they don't come to the // viewModel as actions, they're noticed when events are posted. That will change, // but for the moment we can still report them to the UI. Typically, receiving any // of these three should trigger the UI to refresh. /** A user was blocked */ object Block : UiSuccess() /** A user was muted */ object Mute : UiSuccess() /** A conversation was muted */ object MuteConversation : UiSuccess() } /** The result of a successful action on a notification */ sealed class NotificationActionSuccess( /** String resource with an error message to show the user */ @StringRes val msg: Int, /** * The original action, in case additional information is required from it to display the * message. */ open val action: NotificationAction ) : UiSuccess() { data class AcceptFollowRequest(override val action: NotificationAction) : NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) data class RejectFollowRequest(override val action: NotificationAction) : NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) companion object { fun from(action: NotificationAction) = when (action) { is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) } } } /** Actions the user can trigger on an individual status */ sealed class StatusAction( open val statusViewData: StatusViewData.Concrete ) : FallibleUiAction() { /** Set the bookmark state for a status */ data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : StatusAction(statusViewData) /** Set the favourite state for a status */ data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : StatusAction(statusViewData) /** Set the reblog state for a status */ data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : StatusAction(statusViewData) /** Vote in a poll */ data class VoteInPoll( val poll: Poll, val choices: List, override val statusViewData: StatusViewData.Concrete ) : StatusAction(statusViewData) } /** Changes to a status' visible state after API calls */ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { data class Bookmark(override val action: StatusAction.Bookmark) : StatusActionSuccess(action) data class Favourite(override val action: StatusAction.Favourite) : StatusActionSuccess(action) data class Reblog(override val action: StatusAction.Reblog) : StatusActionSuccess(action) data class VoteInPoll(override val action: StatusAction.VoteInPoll) : StatusActionSuccess(action) companion object { fun from(action: StatusAction) = when (action) { is StatusAction.Bookmark -> Bookmark(action) is StatusAction.Favourite -> Favourite(action) is StatusAction.Reblog -> Reblog(action) is StatusAction.VoteInPoll -> VoteInPoll(action) } } } /** Errors from fallible view model actions that the UI will need to show */ sealed class UiError( /** The exception associated with the error */ open val throwable: Throwable, /** String resource with an error message to show the user */ @StringRes val message: Int, /** The action that failed. Can be resent to retry the action */ open val action: UiAction? = null ) { data class ClearNotifications(override val throwable: Throwable) : UiError( throwable, R.string.ui_error_clear_notifications ) data class Bookmark( override val throwable: Throwable, override val action: StatusAction.Bookmark ) : UiError(throwable, R.string.ui_error_bookmark, action) data class Favourite( override val throwable: Throwable, override val action: StatusAction.Favourite ) : UiError(throwable, R.string.ui_error_favourite, action) data class Reblog( override val throwable: Throwable, override val action: StatusAction.Reblog ) : UiError(throwable, R.string.ui_error_reblog, action) data class VoteInPoll( override val throwable: Throwable, override val action: StatusAction.VoteInPoll ) : UiError(throwable, R.string.ui_error_vote, action) data class AcceptFollowRequest( override val throwable: Throwable, override val action: NotificationAction.AcceptFollowRequest ) : UiError(throwable, R.string.ui_error_accept_follow_request, action) data class RejectFollowRequest( override val throwable: Throwable, override val action: NotificationAction.RejectFollowRequest ) : UiError(throwable, R.string.ui_error_reject_follow_request, action) companion object { fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { is StatusAction.Bookmark -> Bookmark(throwable, action) is StatusAction.Favourite -> Favourite(throwable, action) is StatusAction.Reblog -> Reblog(throwable, action) is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action) is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action) FallibleUiAction.ClearNotifications -> ClearNotifications(throwable) } } } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class) class NotificationsViewModel @Inject constructor( private val repository: NotificationsRepository, private val preferences: SharedPreferences, private val accountManager: AccountManager, private val timelineCases: TimelineCases, private val eventHub: EventHub ) : ViewModel() { /** The account to display notifications for */ val account = accountManager.activeAccount!! val uiState: StateFlow /** Flow of changes to statusDisplayOptions, for use by the UI */ val statusDisplayOptions: StateFlow val pagingData: Flow> /** Flow of user actions received from the UI */ private val uiAction = MutableSharedFlow() /** Flow that can be used to trigger a full reload */ private val reload = MutableStateFlow(0) /** Flow of successful action results */ // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be // retained. A message is shown once to a user and then dismissed. Re-collecting the flow // (e.g., after a device orientation change) should not re-show the most recent success // message, as it will be confusing to the user. val uiSuccess = MutableSharedFlow() /** Channel for error results */ // Errors are sent to a channel to ensure that any errors that occur *before* there are any // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it // was a StateFlow any errors would be retained, and there would need to be an explicit // mechanism to dismiss them. private val _uiErrorChannel = Channel() /** Expose UI errors as a flow */ val uiError = _uiErrorChannel.receiveAsFlow() /** Accept UI actions in to actionStateFlow */ val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } } init { // Handle changes to notification filters val notificationFilter = uiAction .filterIsInstance() .distinctUntilChanged() // Save each change back to the active account .onEach { action -> Log.d(TAG, "notificationFilter: $action") account.notificationsFilter = serialize(action.filter) accountManager.saveAccount(account) } // Load the initial filter from the active account .onStart { emit( InfallibleUiAction.ApplyFilter( filter = deserialize(account.notificationsFilter) ) ) } // Reset the last notification ID to "0" to fetch the newest notifications, and // increment `reload` to trigger creation of a new PagingSource. viewModelScope.launch { uiAction .filterIsInstance() .collectLatest { account.lastNotificationId = "0" accountManager.saveAccount(account) reload.getAndUpdate { it + 1 } } } // Save the visible notification ID viewModelScope.launch { uiAction .filterIsInstance() .distinctUntilChanged() .collectLatest { action -> Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}") account.lastNotificationId = action.visibleId accountManager.saveAccount(account) } } // Set initial status display options from the user's preferences. // // Then collect future preference changes and emit new values in to // statusDisplayOptions if necessary. statusDisplayOptions = MutableStateFlow( StatusDisplayOptions.from( preferences, account ) ) viewModelScope.launch { eventHub.events .filterIsInstance() .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } .map { statusDisplayOptions.value.make( preferences, it.preferenceKey, account ) } .collect { statusDisplayOptions.emit(it) } } // Handle UiAction.ClearNotifications viewModelScope.launch { uiAction.filterIsInstance() .collectLatest { try { repository.clearNotifications().apply { if (this.isSuccessful) { repository.invalidate() } else { _uiErrorChannel.send(UiError.make(HttpException(this), it)) } } } catch (e: Exception) { ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } } } } // Handle NotificationAction.* viewModelScope.launch { uiAction.filterIsInstance() .throttleFirst(THROTTLE_TIMEOUT) .collect { action -> try { when (action) { is NotificationAction.AcceptFollowRequest -> timelineCases.acceptFollowRequest(action.accountId).await() is NotificationAction.RejectFollowRequest -> timelineCases.rejectFollowRequest(action.accountId).await() } uiSuccess.emit(NotificationActionSuccess.from(action)) } catch (e: Exception) { ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } } } } // Handle StatusAction.* viewModelScope.launch { uiAction.filterIsInstance() .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps .collect { action -> try { when (action) { is StatusAction.Bookmark -> timelineCases.bookmark( action.statusViewData.actionableId, action.state ) is StatusAction.Favourite -> timelineCases.favourite( action.statusViewData.actionableId, action.state ) is StatusAction.Reblog -> timelineCases.reblog( action.statusViewData.actionableId, action.state ) is StatusAction.VoteInPoll -> timelineCases.voteInPoll( action.statusViewData.actionableId, action.poll.id, action.choices ) }.getOrThrow() uiSuccess.emit(StatusActionSuccess.from(action)) } catch (t: Throwable) { _uiErrorChannel.send(UiError.make(t, action)) } } } // Handle events that should refresh the list viewModelScope.launch { eventHub.events.collectLatest { when (it) { is BlockEvent -> uiSuccess.emit(UiSuccess.Block) is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) } } } // Re-fetch notifications if either of `notificationFilter` or `reload` flows have // new items. pagingData = combine(notificationFilter, reload) { action, _ -> action } .flatMapLatest { action -> getNotifications(filters = action.filter, initialKey = getInitialKey()) }.cachedIn(viewModelScope) uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> UiState( activeFilter = filter.filter, showFilterOptions = prefs.showFilter, showFabWhileScrolling = prefs.showFabWhileScrolling ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), initialValue = UiState() ) } private fun getNotifications( filters: Set, initialKey: String? = null ): Flow> { return repository.getNotificationsStream(filter = filters, initialKey = initialKey) .map { pagingData -> pagingData.map { notification -> notification.toViewData( isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !(notification.status?.actionableStatus?.sensitive ?: false), isExpanded = statusDisplayOptions.value.openSpoiler, isCollapsed = true ) } } } // The database stores "0" as the last notification ID if notifications have not been // fetched. Convert to null to ensure a full fetch in this case private fun getInitialKey(): String? { val initialKey = when (val id = account.lastNotificationId) { "0" -> null else -> id } Log.d(TAG, "Restoring at $initialKey") return initialKey } /** * @return Flow of relevant preferences that change the UI */ // TODO: Preferences should be in a repository private fun getUiPrefs() = eventHub.events .filterIsInstance() .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } .map { toPrefs() } .onStart { emit(toPrefs()) } private fun toPrefs() = UiPrefs( showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) ) companion object { private const val TAG = "NotificationsViewModel" private val THROTTLE_TIMEOUT = 500.milliseconds } }