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

407 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
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 refreshTrigger = MutableStateFlow(0L)
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, ExperimentalCoroutinesApi::class)
val notifications = refreshTrigger.flatMapLatest {
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 { notification ->
val translation = translations[notification.status?.serverId]
notification.toViewData(translation = translation)
}.filter { 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) {
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) {
// refresh the notifications so the new follow notification will be loaded
refreshTrigger.value++
}
},
onFailure = { t ->
Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t)
}
)
}
}
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"
}
}