Skip to content

Commit

Permalink
change: Implement more of FiltersRepository (#816)
Browse files Browse the repository at this point in the history
The previous code had a number of problems, including:

- Calls to the filters API were scattered through UI and viewmodel code.
- Repeated places where the differences between the v1 and v2 Mastodon
filters API had to be handled.
- UI and viewmodel code using the network filter classes, which tied
them to the API implementation.
- Error handling was inconsistent.

Fix this.

## FiltersRepository

- All filter management now goes through `FiltersRepository`.
- `FiltersRepository` exposes the current set of filters as a
`StateFlow`, and automatically updates it when the current server
changes or any changes to filters are made. This makes
`FilterChangeEvent` obsolete.
- Other operations on filters are exposed through `FiltersRepository` as
functions for viewmodels to call.
- Within the bulk of the app a new `Filter` class is used to represent a
filter; handling the differences between the v1 and v2 APIs is
encapsulated in `FiltersRepository`.
- Represent errors when handling filters as subclasses of `PachliError`,
and use `Result<V, E>` throughout, including using `ApiResult` for all
filter API results.
- Provide different types to distinguish between new-and-unsaved
filters, new-and-unsaved keywords, and in-progress edits to filters.

## Editing filters

- Accept an optional complete filter, or filter ID, as parameters in the
intent that launches `EditFilterActivity`. Pass those to the viewmodel
using assisted injection so the viewmodel has the info immediately.
- In the viewmodel use a new `FilterViewData` type to model the data
used to display and edit the filter.
- Start using the UiSuccess/UiError model. Refrain from cutting over to
full the action implementation as that would be a much larger change.
- Use `FiltersRepository` instead of making any API calls directly.

## Listing filters

- Use `FiltersRepository` instead of making any API calls directly.

## EventHub

- Remove `FilterChangedEvent`. Update everywhere that used it to use the
flow from `FiltersRepository`.
  • Loading branch information
nikclayton authored Jul 14, 2024
1 parent 1177948 commit 00a2cd3
Show file tree
Hide file tree
Showing 49 changed files with 2,582 additions and 920 deletions.
187 changes: 55 additions & 132 deletions app/src/main/java/app/pachli/TimelineActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,31 @@ import androidx.core.view.MenuProvider
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent
import app.pachli.appstore.MainTabsChangedEvent
import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.util.unsafeLazy
import app.pachli.core.data.repository.ServerRepository
import app.pachli.core.data.model.Filter
import app.pachli.core.data.model.NewFilterKeyword
import app.pachli.core.data.repository.FilterEdit
import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.data.repository.NewFilter
import app.pachli.core.model.Timeline
import app.pachli.core.navigation.TimelineActivityIntent
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.databinding.ActivityTimelineBinding
import app.pachli.interfaces.ActionButtonActivity
import app.pachli.interfaces.AppBarLayoutHost
import at.connyduck.calladapter.networkresult.fold
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import io.github.z4kn4fein.semver.constraints.toConstraint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber

/**
Expand All @@ -64,7 +62,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
lateinit var eventHub: EventHub

@Inject
lateinit var serverRepository: ServerRepository
lateinit var filtersRepository: FiltersRepository

private val binding: ActivityTimelineBinding by viewBinding(ActivityTimelineBinding::inflate)
private lateinit var timeline: Timeline
Expand All @@ -85,7 +83,6 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
private var unmuteTagItem: MenuItem? = null

/** The filter muting hashtag, null if unknown or hashtag is not filtered */
private var mutedFilterV1: FilterV1? = null
private var mutedFilter: Filter? = null

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -238,14 +235,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
private fun updateMuteTagMenuItems() {
val tagWithHash = hashtag?.let { "#$it" } ?: return

// If there's no server info, or the server can't filter then it's impossible
// to mute hashtags, so disable the functionality.
val server = serverRepository.flow.value.getOrElse { null }
if (server == null || (
!server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) &&
!server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint())
)
) {
// If the server can't filter then it's impossible to mute hashtags, so disable
// the functionality.
if (!filtersRepository.canFilter()) {
muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = false
return
Expand All @@ -256,33 +248,15 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
unmuteTagItem?.isVisible = false

lifecycleScope.launch {
mastodonApi.getFilters().fold(
{ filters ->
mutedFilter = filters.firstOrNull { filter ->
filter.contexts.contains(FilterContext.HOME) && filter.keywords.any {
it.keyword == tagWithHash
}
filtersRepository.filters.collect { result ->
result.onSuccess { filters ->
mutedFilter = filters?.filters?.firstOrNull { filter ->
filter.contexts.contains(FilterContext.HOME) &&
filter.keywords.any { it.keyword == tagWithHash }
}
updateTagMuteState(mutedFilter != null)
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
tagWithHash == filter.phrase && filter.contexts.contains(FilterContext.HOME)
}
updateTagMuteState(mutedFilterV1 != null)
},
{ throwable ->
Timber.e(throwable, "Error getting filters")
},
)
} else {
Timber.e(throwable, "Error getting filters")
}
},
)
}
}
}
}

Expand All @@ -298,108 +272,57 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
}
}

private fun muteTag(): Boolean {
val tagWithHash = hashtag?.let { "#$it" } ?: return true
private fun muteTag() {
val tagWithHash = hashtag?.let { "#$it" } ?: return

lifecycleScope.launch {
mastodonApi.createFilter(
val newFilter = NewFilter(
title = tagWithHash,
context = listOf(FilterContext.HOME),
filterAction = Filter.Action.WARN,
expiresInSeconds = null,
).fold(
{ filter ->
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tagWithHash, wholeWord = true).isSuccess) {
mutedFilter = filter
updateTagMuteState(true)
eventHub.dispatch(FilterChangedEvent(filter.contexts[0]))
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e("Failed to mute %s", tagWithHash)
}
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.createFilterV1(
tagWithHash,
listOf(FilterContext.HOME),
irreversible = false,
wholeWord = true,
expiresInSeconds = null,
).fold(
{ filter ->
mutedFilterV1 = filter
updateTagMuteState(true)
eventHub.dispatch(FilterChangedEvent(filter.contexts[0]))
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e(throwable, "Failed to mute %s", tagWithHash)
},
)
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e(throwable, "Failed to mute %s", tagWithHash)
}
},
contexts = setOf(FilterContext.HOME),
action = app.pachli.core.network.model.Filter.Action.WARN,
expiresIn = 0,
keywords = listOf(
NewFilterKeyword(
keyword = tagWithHash,
wholeWord = true,
),
),
)
}

return true
filtersRepository.createFilter(newFilter)
.onSuccess {
mutedFilter = it
updateTagMuteState(true)
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
}
.onFailure {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e("Failed to mute %s: %s", tagWithHash, it.fmt(this@TimelineActivity))
}
}
}

private fun unmuteTag(): Boolean {
private fun unmuteTag() {
lifecycleScope.launch {
val tagWithHash = hashtag?.let { "#$it" } ?: return@launch

val result = if (mutedFilter != null) {
val filter = mutedFilter!!
if (filter.contexts.size > 1) {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilter(
id = filter.id,
context = filter.contexts.filter { it != FilterContext.HOME },
)
val result = mutedFilter?.let { filter ->
val newContexts = filter.contexts.filter { it != FilterContext.HOME }
if (newContexts.isEmpty()) {
filtersRepository.deleteFilter(filter.id)
} else {
mastodonApi.deleteFilter(filter.id)
}
} else if (mutedFilterV1 != null) {
mutedFilterV1?.let { filter ->
if (filter.contexts.size > 1) {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.contexts.filter { it != FilterContext.HOME },
irreversible = null,
wholeWord = null,
expiresInSeconds = null,
)
} else {
mastodonApi.deleteFilterV1(filter.id)
}
filtersRepository.updateFilter(filter, FilterEdit(filter.id, contexts = newContexts))
}
} else {
null
}

result?.fold(
{
updateTagMuteState(false)
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show()
eventHub.dispatch(FilterChangedEvent(FilterContext.HOME))
mutedFilterV1 = null
mutedFilter = null
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e(throwable, "Failed to unmute %s", tagWithHash)
},
)
result?.onSuccess {
updateTagMuteState(false)
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show()
mutedFilter = null
}?.onFailure { e ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
Timber.e("Failed to unmute %s: %s", tagWithHash, e.fmt(this@TimelineActivity))
}
}

return true
}
}
2 changes: 0 additions & 2 deletions app/src/main/java/app/pachli/appstore/Events.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package app.pachli.appstore

import app.pachli.core.model.Timeline
import app.pachli.core.network.model.Account
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status

Expand All @@ -21,7 +20,6 @@ data class StatusComposedEvent(val status: Status) : Event
data object StatusScheduledEvent : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class FilterChangedEvent(val filterContext: FilterContext) : Event
data class MainTabsChangedEvent(val newTabs: List<Timeline>) : Event
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class DomainMuteEvent(val instance: String) : Event
Expand Down
Loading

0 comments on commit 00a2cd3

Please sign in to comment.