/* Copyright 2021 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.timeline.viewmodel import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status 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.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch abstract class TimelineViewModel( private val timelineCases: TimelineCases, private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { abstract val statuses: Flow> var kind: Kind = Kind.HOME private set var id: String? = null private set var tags: List = emptyList() private set protected var alwaysShowSensitiveMedia = false private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST fun init( kind: Kind, id: String?, tags: List ) { this.kind = kind this.id = id this.tags = tags filterModel.kind = kind.toFilterKind() if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" filterRemoveReplies = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) filterRemoveReblogs = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler viewModelScope.launch { eventHub.events .collect { event -> handleEvent(event) } } reloadFilters() } fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.reblog(status.actionableId, reblog).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) } } } fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { val poll = status.status.actionableStatus.poll ?: run { Log.w(TAG, "No poll on status ${status.id}") return@launch } val votedPoll = poll.votedCopy(choices) updatePoll(votedPoll, status) try { timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) } } } abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) abstract fun removeAllByAccountId(accountId: String) abstract fun removeAllByInstance(instance: String) abstract fun removeStatusWithId(id: String) abstract fun loadMore(placeholderId: String) abstract fun handleStatusChangedEvent(status: Status) abstract fun fullReload() abstract fun clearWarning(status: StatusViewData.Concrete) /** Saves the user's reading position so it can be restored later */ abstract fun saveReadingPosition(statusId: String) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE return if ( (status.inReplyToId != null && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) ) { return Filter.Action.HIDE } else { statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) statusViewData.filterAction } } private fun onPreferenceChanged(key: String) { when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = kind == Kind.HOME && !filter if (oldRemoveReplies != filterRemoveReplies) { fullReload() } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = kind == Kind.HOME && !filter if (oldRemoveReblogs != filterRemoveReblogs) { fullReload() } } FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { if (filterContextMatchesKind(kind, listOf(key))) { reloadFilters() } } PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { // it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia } PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) } } } private fun handleEvent(event: Event) { when (event) { is StatusChangedEvent -> handleStatusChangedEvent(event.status) is MuteConversationEvent -> fullReload() is UnfollowEvent -> { if (kind == Kind.HOME) { val id = event.accountId removeAllByAccountId(id) } } is BlockEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val id = event.accountId removeAllByAccountId(id) } } is MuteEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val id = event.accountId removeAllByAccountId(id) } } is DomainMuteEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val instance = event.instance removeAllByInstance(instance) } } is StatusDeletedEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { removeStatusWithId(event.statusId) } } is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } is FilterUpdatedEvent -> { if (filterContextMatchesKind(kind, event.filterContext)) { fullReload() } } } } private fun reloadFilters() { viewModelScope.launch { api.getFilters().fold( { // After the filters are loaded we need to reload displayed content to apply them. // It can happen during the usage or at startup, when we get statuses before filters. invalidate() }, { throwable -> if (throwable.isHttpNotFound()) { // Fallback to client-side filter code val filters = api.getFiltersV1().getOrElse { Log.e(TAG, "Failed to fetch filters", it) return@launch } filterModel.initWithFilters( filters.filter { filterContextMatchesKind(kind, it.context) } ) // After the filters are loaded we need to reload displayed content to apply them. // It can happen during the usage or at startup, when we get statuses before filters. invalidate() } else { Log.e(TAG, "Error getting filters", throwable) } } ) } } companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 fun filterContextMatchesKind( kind: Kind, filterContext: List ): Boolean { return filterContext.contains(kind.toFilterKind().kind) } } enum class Kind { HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS, PUBLIC_TRENDING_STATUSES; fun toFilterKind(): Filter.Kind { return when (valueOf(name)) { HOME, LIST -> Filter.Kind.HOME PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES, PUBLIC_TRENDING_STATUSES -> Filter.Kind.PUBLIC USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT else -> Filter.Kind.PUBLIC } } } }