Tusky-App-Android/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

410 lines
17 KiB
Kotlin

/* Copyright 2024 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.filter
import androidx.paging.map
import androidx.room.withTransaction
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.map
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.EmptyPagingSource
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import retrofit2.HttpException
class NotificationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val api: MastodonApi,
eventHub: EventHub,
private val accountManager: AccountManager,
private val preferences: SharedPreferences,
private val filterModel: FilterModel,
private val db: AppDatabase,
) : ViewModel() {
private val _filters = MutableStateFlow(
accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet()
)
val filters: StateFlow<Set<Notification.Type>> = _filters.asStateFlow()
/** Map from notification id to translation. */
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, filters.value)
private var readingOrder: ReadingOrder =
ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
@OptIn(ExperimentalPagingApi::class)
val notifications = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
remoteMediator = remoteMediator,
pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyPagingSource()
} else {
db.notificationsDao().getNotifications(activeAccount.id)
}
}
).flow
.cachedIn(viewModelScope)
.combine(translations) { pagingData, translations ->
pagingData.map(Dispatchers.Default.asExecutor()) { notification ->
val translation = translations[notification.status?.serverId]
notification.toViewData(translation = translation)
}.filter(Dispatchers.Default.asExecutor()) { notificationViewData ->
shouldFilterStatus(notificationViewData) != Filter.Action.HIDE
}
}
.flowOn(Dispatchers.Default)
init {
viewModelScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
}
}
}
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {
if (newFilters != _filters.value) {
val account = accountManager.activeAccount
if (account != null) {
viewModelScope.launch {
account.notificationsFilter = serialize(newFilters)
accountManager.saveAccount(account)
remoteMediator.excludes = newFilters
// clear the cache to trigger a reload
db.notificationsDao().cleanupNotifications(account.id, 0)
_filters.value = newFilters
}
}
}
}
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
Notification.Type.MENTION, Notification.Type.STATUS, Notification.Type.POLL -> {
notificationViewData.statusViewData?.let { statusViewData ->
statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable)
return statusViewData.filterAction
}
Filter.Action.NONE
}
else -> Filter.Action.NONE
}
}
fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String): Flow<Boolean> {
return callbackFlow {
viewModelScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.fold(
onSuccess = {
// since the follow request has been responded, the notification can be deleted. The Ui will update automatically.
db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId)
if (accept) {
// Accepting a follow request will generate a new follow notification.
// For it to show up, notifications need to be refreshed which is done easiest by refreshing the adapter in the Fragment.
// We use this boolean to signal the need for refreshing to the ui.
send(true)
}
},
onFailure = { t ->
Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t)
}
)
}
awaitClose()
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog).onFailure { t ->
ifExpected(t) {
Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
}
}
}
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.favourite(status.actionableId, favorite).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.bookmark(status.actionableId, bookmark).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
}
}
}
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete) = viewModelScope.launch {
val poll = status.status.actionableStatus.poll ?: run {
Log.d(TAG, "No poll on status ${status.id}")
return@launch
}
timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
}
}
}
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setExpanded(accountManager.activeAccount!!.id, status.id, expanded)
}
}
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
}
}
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
}
}
fun remove(notificationId: String) {
viewModelScope.launch {
db.notificationsDao().delete(accountManager.activeAccount!!.id, notificationId)
}
}
fun clearWarning(status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId)
}
}
fun clearNotifications() {
viewModelScope.launch {
api.clearNotifications().fold(
{
db.notificationsDao().cleanupNotifications(accountManager.activeAccount!!.id, 0)
},
{ t ->
Log.w(TAG, "failed to clear notifications", t)
}
)
}
}
suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
translations.value += (status.id to TranslationViewData.Loading)
return timelineCases.translate(status.actionableId)
.map { translation ->
translations.value += (status.id to TranslationViewData.Loaded(translation))
}
.onFailure {
translations.value -= status.id
}
}
fun untranslate(status: StatusViewData.Concrete) {
translations.value -= status.id
}
fun loadMore(placeholderId: String) {
viewModelScope.launch {
try {
val notificationsDao = db.notificationsDao()
val activeAccount = accountManager.activeAccount!!
notificationsDao.insertNotification(
Placeholder(placeholderId, loading = true).toNotificationEntity(
activeAccount.id
)
)
val response = db.withTransaction {
val idAbovePlaceholder = notificationsDao.getIdAbove(activeAccount.id, placeholderId)
val idBelowPlaceholder = notificationsDao.getIdBelow(activeAccount.id, placeholderId)
when (readingOrder) {
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
// after minId and no larger than maxId
ReadingOrder.OLDEST_FIRST -> api.notifications(
maxId = idAbovePlaceholder,
minId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE
)
// Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before
// maxId, and no smaller than minId.
ReadingOrder.NEWEST_FIRST -> api.notifications(
maxId = idAbovePlaceholder,
sinceId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE
)
}
}
val notifications = response.body()
if (!response.isSuccessful || notifications == null) {
loadMoreFailed(placeholderId, HttpException(response))
return@launch
}
val statusDao = db.timelineStatusDao()
val accountDao = db.timelineAccountDao()
db.withTransaction {
notificationsDao.delete(activeAccount.id, placeholderId)
val overlappedNotifications = if (notifications.isNotEmpty()) {
notificationsDao.deleteRange(
activeAccount.id,
notifications.last().id,
notifications.first().id
)
} else {
0
}
for (notification in notifications) {
accountDao.insert(notification.account.toEntity(activeAccount.id))
notification.report?.let { report ->
accountDao.insert(report.targetAccount.toEntity(activeAccount.id))
notificationsDao.insertReport(report.toEntity(activeAccount.id))
}
notification.status?.let { status ->
accountDao.insert(status.account.toEntity(activeAccount.id))
statusDao.insert(
status.toEntity(
tuskyAccountId = activeAccount.id,
expanded = activeAccount.alwaysOpenSpoiler,
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.sensitive,
contentCollapsed = true
)
)
}
notificationsDao.insertNotification(
notification.toEntity(
activeAccount.id
)
)
}
/* In case we loaded a whole page and there was no overlap with existing notifications,
we insert a placeholder because there might be even more unknown notifications */
if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) {
/* This overrides the first/last of the newly loaded notifications with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
val idToConvert = when (readingOrder) {
ReadingOrder.OLDEST_FIRST -> notifications.first().id
ReadingOrder.NEWEST_FIRST -> notifications.last().id
}
notificationsDao.insertNotification(
Placeholder(
idToConvert,
loading = false
).toNotificationEntity(activeAccount.id)
)
}
}
} catch (e: Exception) {
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w(TAG, "failed loading notifications", e)
val activeAccount = accountManager.activeAccount!!
db.notificationsDao()
.insertNotification(
Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id)
)
}
private fun onPreferenceChanged(key: String) {
when (key) {
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(
preferences.getString(PrefKeys.READING_ORDER, null)
)
}
}
}
companion object {
private const val LOAD_AT_ONCE = 30
private const val TAG = "NotificationsViewModel"
}
}