refactor: Create V2 filters with one API call (#979)

Previous code didn't encode v2 filter keywords, so created v2 filters by
first creating the filter with no keywords (one API call) then making
1-N API calls to add each keyword to the filter.

Fix this by adding a dedicated converter for the `NewContentFilter` type
that encodes it correctly so the filter can be created with a single API
call.

This necessitates moving some types around,
This commit is contained in:
Nik Clayton 2024-10-05 14:53:23 +02:00 committed by GitHub
parent ec27aa2435
commit 65c73625f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 586 additions and 309 deletions

View File

@ -30,16 +30,16 @@ 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.model.ContentFilter
import app.pachli.core.data.model.NewContentFilterKeyword
import app.pachli.core.data.repository.ContentFilterEdit
import app.pachli.core.data.repository.ContentFiltersError
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.NewContentFilter
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.NewContentFilter
import app.pachli.core.model.NewContentFilterKeyword
import app.pachli.core.model.Timeline
import app.pachli.core.navigation.TimelineActivityIntent
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.databinding.ActivityTimelineBinding
import app.pachli.interfaces.ActionButtonActivity
import app.pachli.interfaces.AppBarLayoutHost

View File

@ -21,8 +21,9 @@ import android.view.View
import androidx.core.text.HtmlCompat
import app.pachli.R
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.interfaces.StatusActionListener
import app.pachli.viewdata.IStatusViewData
@ -53,7 +54,7 @@ open class FilterableStatusViewHolder<T : IStatusViewData>(
return
}
status.actionable.filtered?.find { it.filter.filterAction === FilterAction.WARN }?.let { result ->
status.actionable.filtered?.find { it.filter.filterAction === NetworkFilterAction.WARN }?.let { result ->
this.matchedFilter = result.filter
setPlaceholderVisibility(true)

View File

@ -27,8 +27,8 @@ import app.pachli.core.common.extensions.visible
import app.pachli.core.common.string.unicodeWrap
import app.pachli.core.common.util.formatNumber
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Emoji
import app.pachli.core.network.model.FilterAction
import app.pachli.databinding.ItemStatusBinding
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.SmartLengthInputFilter

View File

@ -12,7 +12,7 @@ import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.model.ContentFilter
import app.pachli.core.navigation.EditContentFilterActivityIntent
import app.pachli.core.ui.BackgroundMessage
import app.pachli.databinding.ActivityContentFiltersBinding

View File

@ -1,6 +1,6 @@
package app.pachli.components.filters
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.model.ContentFilter
interface ContentFiltersListener {
fun deleteContentFilter(contentFilter: ContentFilter)

View File

@ -3,8 +3,8 @@ package app.pachli.components.filters
import android.view.View
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.model.ContentFilter
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.google.android.material.snackbar.Snackbar

View File

@ -22,11 +22,10 @@ import app.pachli.R
import app.pachli.core.activity.BaseActivity
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.data.model.ContentFilterValidationError
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterKeyword
import app.pachli.core.navigation.EditContentFilterActivityIntent
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.ui.extensions.await
import app.pachli.databinding.ActivityEditContentFilterBinding
import app.pachli.databinding.DialogFilterBinding

View File

@ -24,15 +24,14 @@ import androidx.lifecycle.viewModelScope
import app.pachli.R
import app.pachli.core.common.PachliError
import app.pachli.core.common.extensions.mapIfNotNull
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.model.ContentFilterValidationError
import app.pachli.core.data.model.NewContentFilterKeyword
import app.pachli.core.data.repository.ContentFilterEdit
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.NewContentFilter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterKeyword
import app.pachli.core.model.NewContentFilter
import app.pachli.core.model.NewContentFilterKeyword
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result

View File

@ -5,8 +5,7 @@ import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.model.ContentFilterValidationError
import app.pachli.core.model.ContentFilter
import app.pachli.core.ui.BindingHolder
import app.pachli.databinding.ItemRemovableBinding
import app.pachli.util.getRelativeTimeSpanString
@ -67,6 +66,18 @@ class FiltersAdapter(val listener: ContentFiltersListener, val contentFilters: L
}
}
/** Reasons why a filter might be invalid */
enum class ContentFilterValidationError {
/** Filter title is empty or blank */
NO_TITLE,
/** Filter has no keywords */
NO_KEYWORDS,
/** Filter has no contexts */
NO_CONTEXT,
}
/**
* @return String resource containing an error message for this
* validation error.
@ -77,3 +88,13 @@ fun ContentFilterValidationError.stringResource() = when (this) {
ContentFilterValidationError.NO_KEYWORDS -> R.string.error_filter_missing_keyword
ContentFilterValidationError.NO_CONTEXT -> R.string.error_filter_missing_context
}
/**
* @return Set of [ContentFilterValidationError] given the current state of the
* filter. Empty if there are no validation errors.
*/
fun ContentFilter.validate() = buildSet {
if (title.isBlank()) add(ContentFilterValidationError.NO_TITLE)
if (keywords.isEmpty()) add(ContentFilterValidationError.NO_KEYWORDS)
if (contexts.isEmpty()) add(ContentFilterValidationError.NO_CONTEXT)
}

View File

@ -55,9 +55,9 @@ import app.pachli.core.activity.openLink
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.model.FilterAction
import app.pachli.core.navigation.AttachmentViewData.Companion.list
import app.pachli.core.navigation.EditContentFilterActivityIntent
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status

View File

@ -26,7 +26,7 @@ import app.pachli.adapter.FollowRequestViewHolder
import app.pachli.adapter.ReportNotificationViewHolder
import app.pachli.core.common.util.AbsoluteTimeFormatter
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Status
import app.pachli.databinding.ItemFollowBinding

View File

@ -32,11 +32,11 @@ import app.pachli.appstore.MuteConversationEvent
import app.pachli.appstore.MuteEvent
import app.pachli.core.common.extensions.throttleFirst
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.ContentFilterVersion
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.model.ContentFilterVersion
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Poll
import app.pachli.core.preferences.PrefKeys

View File

@ -26,7 +26,7 @@ import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusViewHolder
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.databinding.ItemStatusBinding
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.interfaces.StatusActionListener

View File

@ -33,7 +33,7 @@ import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Poll
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases

View File

@ -33,7 +33,7 @@ import app.pachli.components.timeline.NetworkTimelineRepository
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Poll
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases

View File

@ -43,12 +43,12 @@ import app.pachli.appstore.StatusEditedEvent
import app.pachli.appstore.UnfollowEvent
import app.pachli.core.common.extensions.throttleFirst
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.ContentFilterVersion
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.model.ContentFilterVersion
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.Timeline
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
import app.pachli.core.preferences.PrefKeys

View File

@ -19,7 +19,7 @@ package app.pachli.components.trending.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.network.model.FilterContext
import app.pachli.core.model.FilterContext
import app.pachli.core.network.model.TrendingTag
import app.pachli.core.network.model.end
import app.pachli.core.network.model.start

View File

@ -25,7 +25,7 @@ import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusDetailedViewHolder
import app.pachli.adapter.StatusViewHolder
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.databinding.ItemStatusBinding
import app.pachli.databinding.ItemStatusDetailedBinding
import app.pachli.databinding.ItemStatusWrapperBinding

View File

@ -30,15 +30,15 @@ import app.pachli.appstore.StatusEditedEvent
import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.util.ifExpected
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.data.repository.ContentFilterVersion
import app.pachli.core.data.repository.ContentFiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.dao.TimelineDao
import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.TranslatedStatusEntity
import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.model.ContentFilterVersion
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
import app.pachli.core.network.retrofit.MastodonApi

View File

@ -1,8 +1,10 @@
package app.pachli.network
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.data.model.from
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml
import java.util.Date
@ -49,13 +51,13 @@ class ContentFilterModel(private val filterContext: FilterContext, v1ContentFilt
}
val matchingKind = status.filtered?.filter { result ->
result.filter.contexts.contains(filterContext)
result.filter.contexts.contains(NetworkFilterContext.from(filterContext))
}
return if (matchingKind.isNullOrEmpty()) {
FilterAction.NONE
} else {
matchingKind.maxOf { it.filter.filterAction }
matchingKind.maxOf { FilterAction.from(it.filter.filterAction) }
}
}

View File

@ -20,7 +20,7 @@ package app.pachli.viewdata
import android.text.Spanned
import app.pachli.core.database.model.TranslatedStatusEntity
import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.RelationshipSeveranceEvent
import app.pachli.core.network.model.Report

View File

@ -23,7 +23,7 @@ import app.pachli.core.database.model.ConversationStatusEntity
import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.database.model.TranslatedStatusEntity
import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.model.FilterAction
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml
import app.pachli.core.network.replaceCrashingCharacters

View File

@ -19,10 +19,12 @@ package app.pachli
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.pachli.components.filters.EditContentFilterViewModel.Companion.getSecondsForDurationIndex
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.model.from
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.network.model.Attachment
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.PollOption
@ -47,7 +49,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "badWord",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = false,
@ -55,7 +57,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "badWholeWord",
contexts = setOf(FilterContext.HOME, FilterContext.PUBLIC),
contexts = setOf(NetworkFilterContext.HOME, NetworkFilterContext.PUBLIC),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -63,7 +65,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "@twitter.com",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -71,7 +73,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "#hashtag",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -79,7 +81,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "expired",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = Date.from(Instant.now().minusSeconds(10)),
irreversible = false,
wholeWord = true,
@ -87,7 +89,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "unexpired",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = Date.from(Instant.now().plusSeconds(3600)),
irreversible = false,
wholeWord = true,
@ -95,7 +97,7 @@ class ContentFilterV1Test {
FilterV1(
id = "123",
phrase = "href",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = false,

View File

@ -17,11 +17,11 @@
package app.pachli.components.filters
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.repository.ContentFilterEdit
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterKeyword
import com.google.common.truth.Truth.assertThat
import org.junit.Test

View File

@ -17,98 +17,78 @@
package app.pachli.core.data.model
import android.os.Parcelable
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import java.util.Date
import kotlinx.parcelize.Parcelize
/** Reasons why a filter might be invalid */
enum class ContentFilterValidationError {
/** Filter title is empty or blank */
NO_TITLE,
/** Filter has no keywords */
NO_KEYWORDS,
/** Filter has no contexts */
NO_CONTEXT,
}
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterAction.HIDE
import app.pachli.core.model.FilterAction.NONE
import app.pachli.core.model.FilterAction.WARN
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterContext.ACCOUNT
import app.pachli.core.model.FilterContext.HOME
import app.pachli.core.model.FilterContext.NOTIFICATIONS
import app.pachli.core.model.FilterContext.PUBLIC
import app.pachli.core.model.FilterContext.THREAD
import app.pachli.core.model.FilterKeyword
import app.pachli.core.network.model.Filter as NetworkFilter
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword
import app.pachli.core.network.model.FilterV1 as NetworkFilterV1
/**
* Internal representation of a Mastodon filter, whether v1 or v2.
*
* This is a *content* filter, to distinguish it from filters that operate on
* accounts, domains, or other data.
*
* @param id The server's ID for this filter
* @param title Filter's title (label to use in the UI)
* @param contexts One or more [FilterContext] the filter is applied to
* @param expiresAt Date the filter expires, null if the filter does not expire
* @param filterAction Action to take if the filter matches a status
* @param keywords One or more [FilterKeyword] the filter matches against a status
* Returns a [ContentFilter] from a [v2 Mastodon filter][NetworkFilter].
*/
@Parcelize
data class ContentFilter(
val id: String,
val title: String,
val contexts: Set<FilterContext> = emptySet(),
val expiresAt: Date? = null,
val filterAction: FilterAction,
val keywords: List<FilterKeyword> = emptyList(),
) : Parcelable {
/**
* @return Set of [ContentFilterValidationError] given the current state of the
* filter. Empty if there are no validation errors.
*/
fun validate() = buildSet {
if (title.isBlank()) add(ContentFilterValidationError.NO_TITLE)
if (keywords.isEmpty()) add(ContentFilterValidationError.NO_KEYWORDS)
if (contexts.isEmpty()) add(ContentFilterValidationError.NO_CONTEXT)
}
companion object {
/**
* Returns a [ContentFilter] from a
* [v2 Mastodon filter][app.pachli.core.network.model.Filter].
*/
fun from(filter: app.pachli.core.network.model.Filter) = ContentFilter(
id = filter.id,
title = filter.title,
contexts = filter.contexts,
expiresAt = filter.expiresAt,
filterAction = filter.filterAction,
keywords = filter.keywords,
)
/**
* Returns a [ContentFilter] from a
* [v1 Mastodon filter][app.pachli.core.network.model.Filter].
*
* There are some restrictions imposed by the v1 filter;
* - it can only have a single entry in the [keywords] list
* - the [title] is identical to the keyword
*/
fun from(filter: app.pachli.core.network.model.FilterV1) = ContentFilter(
id = filter.id,
title = filter.phrase,
contexts = filter.contexts,
expiresAt = filter.expiresAt,
filterAction = FilterAction.WARN,
keywords = listOf(
FilterKeyword(
id = filter.id,
keyword = filter.phrase,
wholeWord = filter.wholeWord,
),
),
)
}
}
/** A new filter keyword; has no ID as it has not been saved to the server. */
data class NewContentFilterKeyword(
val keyword: String,
val wholeWord: Boolean,
fun ContentFilter.Companion.from(filter: NetworkFilter) = ContentFilter(
id = filter.id,
title = filter.title,
contexts = filter.contexts.map { FilterContext.from(it) }.toSet(),
expiresAt = filter.expiresAt,
filterAction = FilterAction.from(filter.filterAction),
keywords = filter.keywords.map { FilterKeyword.from(it) },
)
fun FilterContext.Companion.from(networkFilter: NetworkFilterContext) =
when (networkFilter) {
NetworkFilterContext.HOME -> HOME
NetworkFilterContext.NOTIFICATIONS -> NOTIFICATIONS
NetworkFilterContext.PUBLIC -> PUBLIC
NetworkFilterContext.THREAD -> THREAD
NetworkFilterContext.ACCOUNT -> ACCOUNT
}
fun FilterAction.Companion.from(networkAction: NetworkFilterAction) =
when (networkAction) {
NetworkFilterAction.NONE -> NONE
NetworkFilterAction.WARN -> WARN
NetworkFilterAction.HIDE -> HIDE
}
fun FilterKeyword.Companion.from(networkKeyword: NetworkFilterKeyword) =
FilterKeyword(
id = networkKeyword.id,
keyword = networkKeyword.keyword,
wholeWord = networkKeyword.wholeWord,
)
/**
* Returns a [ContentFilter] from a
* [v1 Mastodon filter][app.pachli.core.network.model.Filter].
*
* There are some restrictions imposed by the v1 filter;
* - it can only have a single entry in the [keywords] list
* - the [title] is identical to the keyword
*/
fun ContentFilter.Companion.from(filter: NetworkFilterV1) = ContentFilter(
id = filter.id,
title = filter.phrase,
contexts = filter.contexts.map { FilterContext.from(it) }.toSet(),
expiresAt = filter.expiresAt,
filterAction = WARN,
keywords = listOf(
FilterKeyword(
id = filter.id,
keyword = filter.phrase,
wholeWord = filter.wholeWord,
),
),
)

View File

@ -21,8 +21,7 @@ import androidx.annotation.VisibleForTesting
import app.pachli.core.common.PachliError
import app.pachli.core.common.di.ApplicationScope
import app.pachli.core.data.R
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.model.NewContentFilterKeyword
import app.pachli.core.data.model.from
import app.pachli.core.data.repository.ContentFiltersError.CreateContentFilterError
import app.pachli.core.data.repository.ContentFiltersError.DeleteContentFilterError
import app.pachli.core.data.repository.ContentFiltersError.GetContentFilterError
@ -30,13 +29,17 @@ import app.pachli.core.data.repository.ContentFiltersError.GetContentFiltersErro
import app.pachli.core.data.repository.ContentFiltersError.ServerDoesNotFilter
import app.pachli.core.data.repository.ContentFiltersError.ServerRepositoryError
import app.pachli.core.data.repository.ContentFiltersError.UpdateContentFilterError
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.ContentFilterVersion
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterKeyword
import app.pachli.core.model.NewContentFilter
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
import app.pachli.core.network.Server
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.model.NewContentFilterV1
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.retrofit.MastodonApi
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
@ -56,45 +59,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* A content filter to be created or updated.
*
* Same as [ContentFilter] except a [NewContentFilter] does not have an [id][ContentFilter.id], as it
* has not been created on the server.
*/
data class NewContentFilter(
val title: String,
val contexts: Set<FilterContext>,
val expiresIn: Int,
val filterAction: FilterAction,
val keywords: List<NewContentFilterKeyword>,
) {
fun toNewContentFilterV1() = this.keywords.map { keyword ->
NewContentFilterV1(
phrase = keyword.keyword,
contexts = this.contexts,
expiresIn = this.expiresIn,
irreversible = false,
wholeWord = keyword.wholeWord,
)
}
companion object {
fun from(contentFilter: ContentFilter) = NewContentFilter(
title = contentFilter.title,
contexts = contentFilter.contexts,
expiresIn = -1,
filterAction = contentFilter.filterAction,
keywords = contentFilter.keywords.map {
NewContentFilterKeyword(
keyword = it.keyword,
wholeWord = it.wholeWord,
)
},
)
}
}
/**
* Represents a collection of edits to make to an existing content filter.
*
@ -153,11 +117,6 @@ sealed interface ContentFiltersError : PachliError {
value class DeleteContentFilterError(private val error: PachliError) : ContentFiltersError, PachliError by error
}
enum class ContentFilterVersion {
V1,
V2,
}
// Hack, so that FilterModel can know whether this is V1 or V2 content filters.
// See usage in:
// - TimelineViewModel.getFilters()
@ -248,28 +207,18 @@ class ContentFiltersRepository @Inject constructor(
externalScope.async {
when {
server.canFilterV2() -> {
mastodonApi.createFilter(
title = filter.title,
contexts = filter.contexts,
filterAction = filter.filterAction,
expiresInSeconds = expiresInSeconds,
).andThen { response ->
val filterId = response.body.id
filter.keywords.mapResult {
mastodonApi.addFilterKeyword(
filterId,
keyword = it.keyword,
wholeWord = it.wholeWord,
)
}.map { ContentFilter.from(response.body) }
mastodonApi.createFilter(filter).map {
ContentFilter.from(it.body)
}
}
server.canFilterV1() -> {
val networkContexts =
filter.contexts.map { NetworkFilterContext.from(it) }.toSet()
filter.toNewContentFilterV1().mapResult {
mastodonApi.createFilterV1(
phrase = it.phrase,
context = it.contexts,
context = networkContexts,
irreversible = it.irreversible,
wholeWord = it.wholeWord,
expiresInSeconds = expiresInSeconds,
@ -313,11 +262,18 @@ class ContentFiltersRepository @Inject constructor(
contentFilterEdit.filterAction != null ||
expiresInSeconds != null
) {
val networkContexts = contentFilterEdit.contexts?.map {
NetworkFilterContext.from(it)
}?.toSet()
val networkAction = contentFilterEdit.filterAction?.let {
NetworkFilterAction.from(it)
}
mastodonApi.updateFilter(
id = contentFilterEdit.id,
title = contentFilterEdit.title,
contexts = contentFilterEdit.contexts,
filterAction = contentFilterEdit.filterAction,
contexts = networkContexts,
filterAction = networkAction,
expiresInSeconds = expiresInSeconds,
)
} else {
@ -352,11 +308,18 @@ class ContentFiltersRepository @Inject constructor(
.map { ContentFilter.from(it.body) }
}
server.canFilterV1() -> {
val networkContexts = contentFilterEdit.contexts?.map {
NetworkFilterContext.from(it)
}?.toSet() ?: originalContentFilter.contexts.map {
NetworkFilterContext.from(
it,
)
}
mastodonApi.updateFilterV1(
id = contentFilterEdit.id,
phrase = contentFilterEdit.keywordsToModify?.firstOrNull()?.keyword ?: originalContentFilter.keywords.first().keyword,
wholeWord = contentFilterEdit.keywordsToModify?.firstOrNull()?.wholeWord,
contexts = contentFilterEdit.contexts ?: originalContentFilter.contexts,
contexts = networkContexts,
irreversible = false,
expiresInSeconds = expiresInSeconds,
).map { ContentFilter.from(it.body) }

View File

@ -18,12 +18,13 @@
package app.pachli.core.data.repository.filtersRepository
import app.cash.turbine.test
import app.pachli.core.data.model.NewContentFilterKeyword
import app.pachli.core.data.repository.NewContentFilter
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.NewContentFilter
import app.pachli.core.model.NewContentFilterKeyword
import app.pachli.core.network.model.Filter as NetworkFilter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.FilterV1 as NetworkFilterV1
import app.pachli.core.testing.success
import com.github.michaelbull.result.Ok
@ -57,27 +58,30 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() {
fun `creating v2 filter should send correct requests`() = runTest {
mastodonApi.stub {
onBlocking { getContentFilters() } doReturn success(emptyList())
onBlocking { createFilter(any(), any(), any(), any()) } doAnswer { call ->
onBlocking { createFilter(any<NewContentFilter>()) } doAnswer { call ->
success(
NetworkFilter(
id = "1",
title = call.getArgument(0),
contexts = call.getArgument(1),
filterAction = call.getArgument(2),
expiresAt = Date(System.currentTimeMillis() + (call.getArgument<String>(3).toInt() * 1000)),
title = call.getArgument<NewContentFilter>(0).title,
contexts = call.getArgument<NewContentFilter>(0).contexts.map {
NetworkFilterContext.from(it)
}.toSet(),
filterAction = NetworkFilterAction.from(
call.getArgument<NewContentFilter>(
0,
).filterAction,
),
expiresAt = Date(
System.currentTimeMillis() + (
call.getArgument<NewContentFilter>(
0,
).expiresIn * 1000
),
),
keywords = emptyList(),
),
)
}
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
success(
FilterKeyword(
id = "1",
keyword = call.getArgument(1),
wholeWord = call.getArgument(2),
),
)
}
}
contentFiltersRepository.contentFilters.test {
@ -87,16 +91,7 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() {
advanceUntilIdle()
// createFilter should have been called once, with the correct arguments.
verify(mastodonApi, times(1)).createFilter(
title = filterWithTwoKeywords.title,
contexts = filterWithTwoKeywords.contexts,
filterAction = filterWithTwoKeywords.filterAction,
expiresInSeconds = filterWithTwoKeywords.expiresIn.toString(),
)
// To create the keywords addFilterKeyword should have been called twice.
verify(mastodonApi, times(1)).addFilterKeyword("1", "first", false)
verify(mastodonApi, times(1)).addFilterKeyword("1", "second", true)
verify(mastodonApi, times(1)).createFilter(filterWithTwoKeywords)
// Filters should have been refreshed
verify(mastodonApi, times(2)).getContentFilters()
@ -110,27 +105,24 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() {
fun `expiresIn of 0 is converted to empty string`() = runTest {
mastodonApi.stub {
onBlocking { getContentFilters() } doReturn success(emptyList())
onBlocking { createFilter(any(), any(), any(), any()) } doAnswer { call ->
onBlocking { createFilter(any()) } doAnswer { call ->
success(
NetworkFilter(
id = "1",
title = call.getArgument(0),
contexts = call.getArgument(1),
filterAction = call.getArgument(2),
title = call.getArgument<NewContentFilter>(0).title,
contexts = call.getArgument<NewContentFilter>(0).contexts.map {
NetworkFilterContext.from(it)
}.toSet(),
filterAction = NetworkFilterAction.from(
call.getArgument<NewContentFilter>(
0,
).filterAction,
),
expiresAt = null,
keywords = emptyList(),
),
)
}
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
success(
FilterKeyword(
id = "1",
keyword = call.getArgument(1),
wholeWord = call.getArgument(2),
),
)
}
}
// The v2 filter creation test covers most things, this just verifies that
@ -143,12 +135,7 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() {
contentFiltersRepository.createContentFilter(filterWithZeroExpiry)
advanceUntilIdle()
verify(mastodonApi, times(1)).createFilter(
title = filterWithZeroExpiry.title,
contexts = filterWithZeroExpiry.contexts,
filterAction = filterWithZeroExpiry.filterAction,
expiresInSeconds = "",
)
verify(mastodonApi, times(1)).createFilter(filterWithZeroExpiry)
cancelAndConsumeRemainingEvents()
}
@ -185,7 +172,9 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() {
filterWithTwoKeywords.keywords.forEach { keyword ->
verify(mastodonApi, times(1)).createFilterV1(
phrase = keyword.keyword,
context = filterWithTwoKeywords.contexts,
context = filterWithTwoKeywords.contexts.map {
NetworkFilterContext.from(it)
}.toSet(),
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = filterWithTwoKeywords.expiresIn.toString(),

View File

@ -18,15 +18,17 @@
package app.pachli.core.data.repository.filtersRepository
import app.cash.turbine.test
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.repository.ContentFilterVersion.V1
import app.pachli.core.data.repository.ContentFilterVersion.V2
import app.pachli.core.data.repository.ContentFilters
import app.pachli.core.data.repository.ContentFiltersError
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.ContentFilterVersion
import app.pachli.core.model.FilterAction
import app.pachli.core.model.FilterContext
import app.pachli.core.model.FilterKeyword
import app.pachli.core.network.model.Filter as NetworkFilter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword
import app.pachli.core.network.model.FilterV1 as NetworkFilterV1
import app.pachli.core.network.retrofit.apiresult.ClientError
import app.pachli.core.testing.failure
@ -57,7 +59,12 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
advanceUntilIdle()
val item = expectMostRecentItem()
val filters = item.get()
assertThat(filters).isEqualTo(ContentFilters(version = V2, contentFilters = emptyList()))
assertThat(filters).isEqualTo(
ContentFilters(
version = ContentFilterVersion.V2,
contentFilters = emptyList(),
),
)
}
}
@ -72,10 +79,16 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
NetworkFilter(
id = "1",
title = "test filter",
contexts = setOf(FilterContext.HOME),
filterAction = FilterAction.WARN,
contexts = setOf(NetworkFilterContext.HOME),
filterAction = NetworkFilterAction.WARN,
expiresAt = expiresAt,
keywords = listOf(FilterKeyword(id = "1", keyword = "foo", wholeWord = true)),
keywords = listOf(
NetworkFilterKeyword(
id = "1",
keyword = "foo",
wholeWord = true,
),
),
),
),
)
@ -87,7 +100,7 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
val filters = item.get()
assertThat(filters).isEqualTo(
ContentFilters(
version = V2,
version = ContentFilterVersion.V2,
contentFilters = listOf(
ContentFilter(
id = "1",
@ -117,7 +130,12 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
advanceUntilIdle()
val item = expectMostRecentItem()
val filters = item.get()
assertThat(filters).isEqualTo(ContentFilters(version = V1, contentFilters = emptyList()))
assertThat(filters).isEqualTo(
ContentFilters(
version = ContentFilterVersion.V1,
contentFilters = emptyList(),
),
)
}
}
@ -132,7 +150,7 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
NetworkFilterV1(
id = "1",
phrase = "some_phrase",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = expiresAt,
irreversible = true,
wholeWord = true,
@ -149,7 +167,7 @@ class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() {
val filters = item.get()
assertThat(filters).isEqualTo(
ContentFilters(
version = V1,
version = ContentFilterVersion.V1,
contentFilters = listOf(
ContentFilter(
id = "1",

View File

@ -18,12 +18,14 @@
package app.pachli.core.data.repository.filtersRepository
import app.cash.turbine.test
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.data.model.from
import app.pachli.core.data.repository.ContentFilterEdit
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.FilterKeyword
import app.pachli.core.network.model.Filter as NetworkFilter
import app.pachli.core.network.model.FilterAction
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.model.FilterAction as NetworkFilterAction
import app.pachli.core.network.model.FilterContext as NetworkFilterContext
import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword
import app.pachli.core.testing.success
import dagger.hilt.android.testing.HiltAndroidTest
import java.util.Date
@ -48,14 +50,14 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() {
private val originalNetworkFilter = NetworkFilter(
id = "1",
title = "original filter",
contexts = setOf(FilterContext.HOME),
contexts = setOf(NetworkFilterContext.HOME),
expiresAt = null,
filterAction = FilterAction.WARN,
filterAction = NetworkFilterAction.WARN,
keywords = listOf(
FilterKeyword(id = "1", keyword = "first", wholeWord = false),
FilterKeyword(id = "2", keyword = "second", wholeWord = true),
FilterKeyword(id = "3", keyword = "three", wholeWord = true),
FilterKeyword(id = "4", keyword = "four", wholeWord = true),
NetworkFilterKeyword(id = "1", keyword = "first", wholeWord = false),
NetworkFilterKeyword(id = "2", keyword = "second", wholeWord = true),
NetworkFilterKeyword(id = "3", keyword = "three", wholeWord = true),
NetworkFilterKeyword(id = "4", keyword = "four", wholeWord = true),
),
)
@ -69,8 +71,8 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() {
success(
originalNetworkFilter.copy(
title = call.getArgument(1) ?: originalContentFilter.title,
contexts = call.getArgument(2) ?: originalContentFilter.contexts,
filterAction = call.getArgument(3) ?: originalContentFilter.filterAction,
contexts = call.getArgument(2) ?: originalContentFilter.contexts.map { NetworkFilterContext.from(it) }.toSet(),
filterAction = call.getArgument(3) ?: NetworkFilterAction.from(originalContentFilter.filterAction),
expiresAt = call.getArgument<String?>(4)?.let {
when (it) {
"" -> null
@ -95,8 +97,8 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() {
verify(mastodonApi, times(1)).updateFilter(
id = update.id,
title = update.title,
contexts = update.contexts,
filterAction = update.filterAction,
contexts = update.contexts?.map { NetworkFilterContext.from(it) }?.toSet(),
filterAction = update.filterAction?.let { NetworkFilterAction.from(it) },
expiresInSeconds = null,
)
@ -114,10 +116,10 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() {
onBlocking { getContentFilters() } doReturn success(emptyList())
onBlocking { deleteFilterKeyword(any()) } doReturn success(Unit)
onBlocking { updateFilterKeyword(any(), any(), any()) } doAnswer { call ->
success(FilterKeyword(call.getArgument(0), call.getArgument(1), call.getArgument(2)))
success(NetworkFilterKeyword(call.getArgument(0), call.getArgument(1), call.getArgument(2)))
}
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
success(FilterKeyword("x", call.getArgument(1), call.getArgument(2)))
success(NetworkFilterKeyword("x", call.getArgument(1), call.getArgument(2)))
}
onBlocking { getFilter(any()) } doReturn success(originalNetworkFilter)
}

View File

@ -0,0 +1,140 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* 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.
*
* Pachli 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 Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.model
import android.os.Parcelable
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
import kotlinx.parcelize.Parcelize
enum class ContentFilterVersion {
V1,
V2,
}
/**
* Internal representation of a Mastodon filter, whether v1 or v2.
*
* This is a *content* filter, to distinguish it from filters that operate on
* accounts, domains, or other data.
*
* @param id The server's ID for this filter
* @param title Filter's title (label to use in the UI)
* @param contexts One or more [FilterContext] the filter is applied to
* @param expiresAt Date the filter expires, null if the filter does not expire
* @param filterAction Action to take if the filter matches a status
* @param keywords One or more [FilterKeyword] the filter matches against a status
*/
// The @JsonClass annotations are used when this is serialized to the database.
@Parcelize
@JsonClass(generateAdapter = true)
data class ContentFilter(
val id: String,
val title: String,
val contexts: Set<FilterContext> = emptySet(),
val expiresAt: Date? = null,
val filterAction: FilterAction,
val keywords: List<FilterKeyword> = emptyList(),
) : Parcelable {
companion object {}
}
/** A filter choice, either content filter or account filter. */
// The @Json annotations are used when this is serialized by NewContentFilterConverterFactory.
enum class FilterAction {
/** No filtering, show item as normal. */
@Json(name = "none")
NONE,
/** Replace the item with a warning, allowing the user to click through. */
@Json(name = "warn")
WARN,
/** Remove the item, with no indication to the user it was present. */
@Json(name = "hide")
HIDE,
;
companion object
}
// The @JsonClass annotations are used when this is serialized to the database.
@Parcelize
@JsonClass(generateAdapter = true)
data class FilterKeyword(
val id: String,
val keyword: String,
@Json(name = "whole_word") val wholeWord: Boolean,
) : Parcelable {
companion object
}
/**
* The contexts in which a filter should be applied, for both a
* [v2](https://docs.joinmastodon.org/entities/Filter/#context) and
* [v1](https://docs.joinmastodon.org/entities/V1_Filter/#context) Mastodon
* filter. The API versions have identical contexts.
*/
// The @Json annotations are used when this is serialized by NewContentFilterConverterFactory
enum class FilterContext {
/** Filter applies to home timeline and lists */
@Json(name = "home")
HOME,
/** Filter applies to notifications */
@Json(name = "notifications")
NOTIFICATIONS,
/** Filter applies to public timelines */
@Json(name = "public")
PUBLIC,
/** Filter applies to expanded thread */
@Json(name = "thread")
THREAD,
/** Filter applies when viewing a profile */
@Json(name = "account")
ACCOUNT,
;
companion object {
/**
* @return The filter context for [timeline], or null if filters are not applied
* to this timeline.
*/
fun from(timeline: Timeline): FilterContext? = when (timeline) {
is Timeline.Home, is Timeline.UserList -> HOME
is Timeline.User -> ACCOUNT
Timeline.Notifications -> NOTIFICATIONS
Timeline.Bookmarks,
Timeline.Favourites,
Timeline.PublicFederated,
Timeline.PublicLocal,
is Timeline.Hashtags,
Timeline.TrendingStatuses,
Timeline.TrendingHashtags,
Timeline.TrendingLinks,
-> PUBLIC
Timeline.Conversations -> null
}
}
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* 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.
*
* Pachli 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 Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.model
/**
* A content filter to be created or updated.
*
* Same as [ContentFilter] except a [NewContentFilter] does not have an [id][ContentFilter.id], as it
* has not been created on the server.
*/
data class NewContentFilter(
val title: String,
val contexts: Set<FilterContext>,
val expiresIn: Int,
val filterAction: FilterAction,
val keywords: List<NewContentFilterKeyword>,
) {
fun toNewContentFilterV1() = this.keywords.map { keyword ->
NewContentFilterV1(
phrase = keyword.keyword,
contexts = this.contexts,
expiresIn = this.expiresIn,
irreversible = false,
wholeWord = keyword.wholeWord,
)
}
companion object {
fun from(contentFilter: ContentFilter) = NewContentFilter(
title = contentFilter.title,
contexts = contentFilter.contexts,
expiresIn = -1,
filterAction = contentFilter.filterAction,
keywords = contentFilter.keywords.map {
NewContentFilterKeyword(
keyword = it.keyword,
wholeWord = it.wholeWord,
)
},
)
}
}
/** A new filter keyword; has no ID as it has not been saved to the server. */
data class NewContentFilterKeyword(
val keyword: String,
val wholeWord: Boolean,
)
data class NewContentFilterV1(
val phrase: String,
val contexts: Set<FilterContext>,
val expiresIn: Int,
val irreversible: Boolean,
val wholeWord: Boolean,
)

View File

@ -21,8 +21,8 @@ import android.content.Context
import android.content.Intent
import android.os.Parcelable
import androidx.core.content.IntentCompat
import app.pachli.core.data.model.ContentFilter
import app.pachli.core.database.model.DraftAttachment
import app.pachli.core.model.ContentFilter
import app.pachli.core.model.Timeline
import app.pachli.core.navigation.LoginActivityIntent.LoginMode
import app.pachli.core.navigation.TimelineActivityIntent.Companion.bookmarks

View File

@ -29,6 +29,7 @@ import app.pachli.core.network.json.HasDefault
import app.pachli.core.network.model.MediaUploadApi
import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor
import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.network.retrofit.NewContentFilterConverterFactory
import app.pachli.core.network.retrofit.apiresult.ApiResultCallAdapterFactory
import app.pachli.core.network.util.localHandshakeCertificates
import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED
@ -138,6 +139,7 @@ object NetworkModule {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient)
.addConverterFactory(EnumConstantConverterFactory)
.addConverterFactory(NewContentFilterConverterFactory)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResultCallAdapterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())

View File

@ -17,6 +17,7 @@
package app.pachli.core.network.model
import app.pachli.core.model.FilterAction
import app.pachli.core.network.json.Default
import app.pachli.core.network.json.HasDefault
import com.squareup.moshi.Json
@ -36,4 +37,14 @@ enum class FilterAction {
/** Remove the item, with no indication to the user it was present. */
@Json(name = "hide")
HIDE,
;
companion object {
fun from(filterAction: app.pachli.core.model.FilterAction) = when (filterAction) {
FilterAction.NONE -> NONE
FilterAction.WARN -> WARN
FilterAction.HIDE -> HIDE
}
}
}

View File

@ -60,6 +60,7 @@ enum class FilterContext {
* @return The filter context for [timeline], or null if filters are not applied
* to this timeline.
*/
@Deprecated("Use app.pachli.core.model.FilterContext instead")
fun from(timeline: Timeline): FilterContext? = when (timeline) {
is Timeline.Home, is Timeline.UserList -> HOME
is Timeline.User -> ACCOUNT
@ -75,5 +76,13 @@ enum class FilterContext {
-> PUBLIC
Timeline.Conversations -> null
}
fun from(filterContext: app.pachli.core.model.FilterContext) = when (filterContext) {
app.pachli.core.model.FilterContext.HOME -> HOME
app.pachli.core.model.FilterContext.NOTIFICATIONS -> NOTIFICATIONS
app.pachli.core.model.FilterContext.PUBLIC -> PUBLIC
app.pachli.core.model.FilterContext.THREAD -> THREAD
app.pachli.core.model.FilterContext.ACCOUNT -> ACCOUNT
}
}
}

View File

@ -16,6 +16,7 @@
package app.pachli.core.network.retrofit
import app.pachli.core.model.NewContentFilter
import app.pachli.core.network.model.AccessToken
import app.pachli.core.network.model.Account
import app.pachli.core.network.model.Announcement
@ -649,16 +650,8 @@ interface MastodonApi {
@Path("id") id: String,
): ApiResult<Unit>
@FormUrlEncoded
@POST("api/v2/filters")
suspend fun createFilter(
@Field("title") title: String,
@Field("context[]") contexts: Set<FilterContext>,
@Field("filter_action") filterAction: FilterAction,
// String not Int because the empty string is used to represent "indefinite",
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
@Field("expires_in") expiresInSeconds: String?,
): ApiResult<Filter>
suspend fun createFilter(@Body newContentFilter: NewContentFilter): ApiResult<Filter>
@FormUrlEncoded
@PUT("api/v2/filters/{id}")

View File

@ -0,0 +1,75 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* 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.
*
* Pachli 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 Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.retrofit
import app.pachli.core.model.NewContentFilter
import app.pachli.core.network.json.EnumConstantConverterFactory
import java.lang.reflect.Type
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Converter
import retrofit2.Retrofit
/**
* Converts a [NewContentFilter] to a request body.
*
* Should be added to Retrofit using [addConverterFactory][retrofit2.Retrofit.Builder.addConverterFactory].
*/
// Retrofit can't do this natively because it can't handle fields like "keywords_attributes"
// that repeat multiple times with the same names.
object NewContentFilterConverterFactory : Converter.Factory() {
object NewContentFilterConverter : Converter<NewContentFilter, RequestBody> {
private val enumConverter = EnumConstantConverterFactory.EnumConstantConverter
private val utf8 = StandardCharsets.UTF_8.toString()
override fun convert(newContentFilter: NewContentFilter): RequestBody {
return buildList {
add("title=${encode(newContentFilter.title)}")
newContentFilter.contexts.forEach {
add("context[]=${encode(enumConverter.convert(it))}")
}
add("filter_action=${encode(enumConverter.convert(newContentFilter.filterAction))}")
if (newContentFilter.expiresIn != 0) {
add("expires_in=${newContentFilter.expiresIn}")
}
newContentFilter.keywords.forEach {
add("keywords_attributes[][keyword]=${encode(it.keyword)}")
add("keywords_attributes[][whole_word]=${it.wholeWord}")
}
}.joinToString("&").toRequestBody()
}
/** @return URL encoded [s] using UTF8 as the character encoding. */
private fun encode(s: String) = URLEncoder.encode(s, utf8)
}
override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<out Annotation>,
methodAnnotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<*, RequestBody>? {
return if (type == NewContentFilter::class.java) NewContentFilterConverter else null
}
}