ff8dd37855
* Replace "warn"-filtered posts in timelines and thread view with placeholders * Adapt hashtag muting interface * Rework filter UI * Add icon for account preferences * Clean up UI * WIP: Use chips instead of a list. Adjust padding * Scroll the filter edit activity Nested scrolling views (e.g., an activity that scrolls with an embedded list that also scrolls) can be difficult UI. Since the list of contexts is fixed, replace it with a fixed collection of switches, so there's no need to scroll the list. Since the list of actions is only two (warn, hide), and are mutually exclusive, replace the spinner with two radio buttons. Use the accent colour and title styles on the different heading titles in the layout, to match the presentation in Preferences. Add an explicit "Cancel" button. The layout is a straightforward LinearLayout, so use that instead of ConstraintLayout, and remove some unncessary IDs. Update EditFilterActivity to handle the new layout. * Cleanup * Add more information to the filter list view * First pass on code review comments * Add view model to filters activity * Add view model to edit filters activity * Only use the status wrapper for filtered statuses * Relint --------- Co-authored-by: Nik Clayton <nik@ngo.org.uk>
546 lines
20 KiB
Kotlin
546 lines
20 KiB
Kotlin
/* Copyright 2022 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.viewthread
|
|
|
|
import android.util.Log
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import at.connyduck.calladapter.networkresult.fold
|
|
import at.connyduck.calladapter.networkresult.getOrElse
|
|
import com.google.gson.Gson
|
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
|
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
|
import com.keylesspalace.tusky.appstore.EventHub
|
|
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
|
import com.keylesspalace.tusky.appstore.PinEvent
|
|
import com.keylesspalace.tusky.appstore.ReblogEvent
|
|
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
|
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
|
import com.keylesspalace.tusky.appstore.StatusEditedEvent
|
|
import com.keylesspalace.tusky.components.timeline.toViewData
|
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
|
import com.keylesspalace.tusky.db.AccountManager
|
|
import com.keylesspalace.tusky.db.AppDatabase
|
|
import com.keylesspalace.tusky.entity.Filter
|
|
import com.keylesspalace.tusky.entity.FilterV1
|
|
import com.keylesspalace.tusky.entity.Status
|
|
import com.keylesspalace.tusky.network.FilterModel
|
|
import com.keylesspalace.tusky.network.MastodonApi
|
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
|
import com.keylesspalace.tusky.util.toViewData
|
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.channels.BufferOverflow
|
|
import kotlinx.coroutines.flow.Flow
|
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.rx3.asFlow
|
|
import kotlinx.coroutines.rx3.await
|
|
import retrofit2.HttpException
|
|
import javax.inject.Inject
|
|
|
|
class ViewThreadViewModel @Inject constructor(
|
|
private val api: MastodonApi,
|
|
private val filterModel: FilterModel,
|
|
private val timelineCases: TimelineCases,
|
|
eventHub: EventHub,
|
|
accountManager: AccountManager,
|
|
private val db: AppDatabase,
|
|
private val gson: Gson
|
|
) : ViewModel() {
|
|
|
|
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
|
|
val uiState: Flow<ThreadUiState>
|
|
get() = _uiState
|
|
|
|
private val _errors = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
|
val errors: Flow<Throwable>
|
|
get() = _errors
|
|
|
|
var isInitialLoad: Boolean = true
|
|
|
|
private val alwaysShowSensitiveMedia: Boolean
|
|
private val alwaysOpenSpoiler: Boolean
|
|
|
|
init {
|
|
val activeAccount = accountManager.activeAccount
|
|
alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
|
alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
|
|
|
viewModelScope.launch {
|
|
eventHub.events
|
|
.asFlow()
|
|
.collect { event ->
|
|
when (event) {
|
|
is FavoriteEvent -> handleFavEvent(event)
|
|
is ReblogEvent -> handleReblogEvent(event)
|
|
is BookmarkEvent -> handleBookmarkEvent(event)
|
|
is PinEvent -> handlePinEvent(event)
|
|
is BlockEvent -> removeAllByAccountId(event.accountId)
|
|
is StatusComposedEvent -> handleStatusComposedEvent(event)
|
|
is StatusDeletedEvent -> handleStatusDeletedEvent(event)
|
|
is StatusEditedEvent -> handleStatusEditedEvent(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
loadFilters()
|
|
}
|
|
|
|
fun loadThread(id: String) {
|
|
_uiState.value = ThreadUiState.Loading
|
|
|
|
viewModelScope.launch {
|
|
Log.d(TAG, "Finding status with: $id")
|
|
val contextCall = async { api.statusContext(id) }
|
|
val timelineStatus = db.timelineDao().getStatus(id)
|
|
|
|
var detailedStatus = if (timelineStatus != null) {
|
|
Log.d(TAG, "Loaded status from local timeline")
|
|
val viewData = timelineStatus.toViewData(
|
|
gson,
|
|
isDetailed = true
|
|
) as StatusViewData.Concrete
|
|
|
|
// Return the correct status, depending on which one matched. If you do not do
|
|
// this the status IDs will be different between the status that's displayed with
|
|
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
|
|
// status content is the same. Then the status flickers as it is drawn twice.
|
|
if (viewData.actionableId == id) {
|
|
viewData.actionable.toViewData(isDetailed = true)
|
|
} else {
|
|
viewData
|
|
}
|
|
} else {
|
|
Log.d(TAG, "Loaded status from network")
|
|
val result = api.status(id).getOrElse { exception ->
|
|
_uiState.value = ThreadUiState.Error(exception)
|
|
return@launch
|
|
}
|
|
result.toViewData(isDetailed = true)
|
|
}
|
|
|
|
_uiState.value = ThreadUiState.LoadingThread(
|
|
statusViewDatum = detailedStatus,
|
|
revealButton = detailedStatus.getRevealButtonState()
|
|
)
|
|
|
|
// If the detailedStatus was loaded from the database it might be out-of-date
|
|
// compared to the remote one. Now the user has a working UI do a background fetch
|
|
// for the status. Ignore errors, the user still has a functioning UI if the fetch
|
|
// failed.
|
|
if (timelineStatus != null) {
|
|
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true)
|
|
if (viewData != null) { detailedStatus = viewData }
|
|
}
|
|
|
|
val contextResult = contextCall.await()
|
|
|
|
contextResult.fold({ statusContext ->
|
|
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
|
|
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
|
|
val statuses = ancestors + detailedStatus + descendants
|
|
|
|
_uiState.value = ThreadUiState.Success(
|
|
statusViewData = statuses,
|
|
detailedStatusPosition = ancestors.size,
|
|
revealButton = statuses.getRevealButtonState()
|
|
)
|
|
}, { throwable ->
|
|
_errors.emit(throwable)
|
|
_uiState.value = ThreadUiState.Success(
|
|
statusViewData = listOf(detailedStatus),
|
|
detailedStatusPosition = 0,
|
|
revealButton = RevealButtonState.NO_BUTTON,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
fun retry(id: String) {
|
|
_uiState.value = ThreadUiState.Loading
|
|
loadThread(id)
|
|
}
|
|
|
|
fun refresh(id: String) {
|
|
_uiState.value = ThreadUiState.Refreshing
|
|
loadThread(id)
|
|
}
|
|
|
|
fun detailedStatus(): StatusViewData.Concrete? {
|
|
return when (val uiState = _uiState.value) {
|
|
is ThreadUiState.Success -> uiState.statusViewData.find { status ->
|
|
status.isDetailed
|
|
}
|
|
is ThreadUiState.LoadingThread -> uiState.statusViewDatum
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
|
try {
|
|
timelineCases.reblog(status.actionableId, reblog).await()
|
|
} 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).await()
|
|
} 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).await()
|
|
} catch (t: Exception) {
|
|
ifExpected(t) {
|
|
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun voteInPoll(choices: List<Int>, 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)
|
|
updateStatus(status.id) { status ->
|
|
status.copy(poll = votedPoll)
|
|
}
|
|
|
|
try {
|
|
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await()
|
|
} catch (t: Exception) {
|
|
ifExpected(t) {
|
|
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun removeStatus(statusToRemove: StatusViewData.Concrete) {
|
|
updateSuccess { uiState ->
|
|
uiState.copy(
|
|
statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove }
|
|
)
|
|
}
|
|
}
|
|
|
|
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
|
updateSuccess { uiState ->
|
|
val statuses = uiState.statusViewData.map { viewData ->
|
|
if (viewData.id == status.id) {
|
|
viewData.copy(isExpanded = expanded)
|
|
} else {
|
|
viewData
|
|
}
|
|
}
|
|
uiState.copy(
|
|
statusViewData = statuses,
|
|
revealButton = statuses.getRevealButtonState()
|
|
)
|
|
}
|
|
}
|
|
|
|
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
|
updateStatusViewData(status.id) { viewData ->
|
|
viewData.copy(isShowingContent = isShowing)
|
|
}
|
|
}
|
|
|
|
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
|
updateStatusViewData(status.id) { viewData ->
|
|
viewData.copy(isCollapsed = isCollapsed)
|
|
}
|
|
}
|
|
|
|
private fun handleFavEvent(event: FavoriteEvent) {
|
|
updateStatus(event.statusId) { status ->
|
|
status.copy(favourited = event.favourite)
|
|
}
|
|
}
|
|
|
|
private fun handleReblogEvent(event: ReblogEvent) {
|
|
updateStatus(event.statusId) { status ->
|
|
status.copy(reblogged = event.reblog)
|
|
}
|
|
}
|
|
|
|
private fun handleBookmarkEvent(event: BookmarkEvent) {
|
|
updateStatus(event.statusId) { status ->
|
|
status.copy(bookmarked = event.bookmark)
|
|
}
|
|
}
|
|
|
|
private fun handlePinEvent(event: PinEvent) {
|
|
updateStatus(event.statusId) { status ->
|
|
status.copy(pinned = event.pinned)
|
|
}
|
|
}
|
|
|
|
private fun removeAllByAccountId(accountId: String) {
|
|
updateSuccess { uiState ->
|
|
uiState.copy(
|
|
statusViewData = uiState.statusViewData.filter { viewData ->
|
|
viewData.status.account.id != accountId
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun handleStatusComposedEvent(event: StatusComposedEvent) {
|
|
val eventStatus = event.status
|
|
updateSuccess { uiState ->
|
|
val statuses = uiState.statusViewData
|
|
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
|
|
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
|
|
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
|
// there is a new reply to the detailed status or below -> display it
|
|
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
|
eventStatus.toViewData() +
|
|
statuses.subList(repliedIndex + 1, statuses.size)
|
|
uiState.copy(statusViewData = newStatuses)
|
|
} else {
|
|
uiState
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun handleStatusEditedEvent(event: StatusEditedEvent) {
|
|
updateSuccess { uiState ->
|
|
uiState.copy(
|
|
statusViewData = uiState.statusViewData.map { status ->
|
|
if (status.actionableId == event.originalId) {
|
|
event.status.toViewData()
|
|
} else {
|
|
status
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
|
|
updateSuccess { uiState ->
|
|
uiState.copy(
|
|
statusViewData = uiState.statusViewData.filter { status ->
|
|
status.id != event.statusId
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
fun toggleRevealButton() {
|
|
updateSuccess { uiState ->
|
|
when (uiState.revealButton) {
|
|
RevealButtonState.HIDE -> uiState.copy(
|
|
statusViewData = uiState.statusViewData.map { viewData ->
|
|
viewData.copy(isExpanded = false)
|
|
},
|
|
revealButton = RevealButtonState.REVEAL
|
|
)
|
|
RevealButtonState.REVEAL -> uiState.copy(
|
|
statusViewData = uiState.statusViewData.map { viewData ->
|
|
viewData.copy(isExpanded = true)
|
|
},
|
|
revealButton = RevealButtonState.HIDE
|
|
)
|
|
else -> uiState
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState {
|
|
val hasWarnings = status.spoilerText.isNotEmpty()
|
|
|
|
return if (hasWarnings) {
|
|
if (isExpanded) {
|
|
RevealButtonState.HIDE
|
|
} else {
|
|
RevealButtonState.REVEAL
|
|
}
|
|
} else {
|
|
RevealButtonState.NO_BUTTON
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the reveal button state based on the state of all the statuses in the list.
|
|
*
|
|
* - If any status sets it to REVEAL, use REVEAL
|
|
* - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE
|
|
* - Otherwise use NO_BUTTON
|
|
*/
|
|
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
|
|
var seenHide = false
|
|
|
|
forEach {
|
|
when (val state = it.getRevealButtonState()) {
|
|
RevealButtonState.NO_BUTTON -> return@forEach
|
|
RevealButtonState.REVEAL -> return state
|
|
RevealButtonState.HIDE -> seenHide = true
|
|
}
|
|
}
|
|
|
|
if (seenHide) {
|
|
return RevealButtonState.HIDE
|
|
}
|
|
|
|
return RevealButtonState.NO_BUTTON
|
|
}
|
|
|
|
private fun loadFilters() {
|
|
viewModelScope.launch {
|
|
api.getFilters().fold(
|
|
{
|
|
filterModel.kind = Filter.Kind.THREAD
|
|
updateStatuses()
|
|
},
|
|
{ throwable ->
|
|
if (throwable is HttpException && throwable.code() == 404) {
|
|
val filters = api.getFiltersV1().getOrElse {
|
|
Log.w(TAG, "Failed to fetch filters", it)
|
|
return@launch
|
|
}
|
|
|
|
filterModel.initWithFilters(
|
|
filters.filter { filter -> filter.context.contains(FilterV1.THREAD) }
|
|
)
|
|
updateStatuses()
|
|
} else {
|
|
Log.e(TAG, "Error getting filters", throwable)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun updateStatuses() {
|
|
updateSuccess { uiState ->
|
|
val statuses = uiState.statusViewData.filter()
|
|
uiState.copy(
|
|
statusViewData = statuses,
|
|
revealButton = statuses.getRevealButtonState()
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
|
|
return filter { status ->
|
|
if (status.isDetailed) {
|
|
true
|
|
} else {
|
|
status.filterAction = filterModel.shouldFilterStatus(status.status)
|
|
status.filterAction != Filter.Action.HIDE
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun Status.toViewData(
|
|
isDetailed: Boolean = false
|
|
): StatusViewData.Concrete {
|
|
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id }
|
|
return toViewData(
|
|
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
|
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
|
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
|
isDetailed = oldStatus?.isDetailed ?: isDetailed
|
|
)
|
|
}
|
|
|
|
private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) {
|
|
_uiState.update { uiState ->
|
|
if (uiState is ThreadUiState.Success) {
|
|
updater(uiState)
|
|
} else {
|
|
uiState
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) {
|
|
updateSuccess { uiState ->
|
|
uiState.copy(
|
|
statusViewData = uiState.statusViewData.map { viewData ->
|
|
if (viewData.id == statusId) {
|
|
updater(viewData)
|
|
} else {
|
|
viewData
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun updateStatus(statusId: String, updater: (Status) -> Status) {
|
|
updateStatusViewData(statusId) { viewData ->
|
|
viewData.copy(
|
|
status = updater(viewData.status)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun clearWarning(viewData: StatusViewData.Concrete) {
|
|
updateStatus(viewData.id) { status ->
|
|
status.copy(filtered = null)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "ViewThreadViewModel"
|
|
}
|
|
}
|
|
|
|
sealed interface ThreadUiState {
|
|
/** The initial load of the detailed status for this thread */
|
|
object Loading : ThreadUiState
|
|
|
|
/** Loading the detailed status has completed, now loading ancestors/descendants */
|
|
data class LoadingThread(
|
|
val statusViewDatum: StatusViewData.Concrete?,
|
|
val revealButton: RevealButtonState
|
|
) : ThreadUiState
|
|
|
|
/** An error occurred at any point */
|
|
class Error(val throwable: Throwable) : ThreadUiState
|
|
|
|
/** Successfully loaded the full thread */
|
|
data class Success(
|
|
val statusViewData: List<StatusViewData.Concrete>,
|
|
val revealButton: RevealButtonState,
|
|
val detailedStatusPosition: Int
|
|
) : ThreadUiState
|
|
|
|
/** Refreshing the thread with a swipe */
|
|
object Refreshing : ThreadUiState
|
|
}
|
|
|
|
enum class RevealButtonState {
|
|
NO_BUTTON, REVEAL, HIDE
|
|
}
|