Support the mastodon 4 filter api (#3188)

* 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>
This commit is contained in:
Levi Bard 2023-03-11 13:12:50 +01:00 committed by GitHub
commit ff8dd37855
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 2770 additions and 631 deletions

View file

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date
@ -15,36 +16,48 @@ import javax.inject.Inject
*/
class FilterModel @Inject constructor() {
private var pattern: Pattern? = null
private var v1 = false
lateinit var kind: Filter.Kind
fun initWithFilters(filters: List<Filter>) {
fun initWithFilters(filters: List<FilterV1>) {
v1 = true
this.pattern = makeFilter(filters)
}
fun shouldFilterStatus(status: Status): Boolean {
// Patterns are expensive and thread-safe, matchers are neither.
val matcher = pattern?.matcher("") ?: return false
fun shouldFilterStatus(status: Status): Filter.Action {
if (v1) {
// Patterns are expensive and thread-safe, matchers are neither.
val matcher = pattern?.matcher("") ?: return Filter.Action.NONE
if (status.poll != null) {
val pollMatches = status.poll.options.any { matcher.reset(it.title).find() }
if (pollMatches) return true
if (status.poll?.options?.any { matcher.reset(it.title).find() } == true)
return Filter.Action.HIDE
val spoilerText = status.actionableStatus.spoilerText
val attachmentsDescriptions = status.attachments.mapNotNull { it.description }
return if (
matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() ||
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
(attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find())
) {
Filter.Action.HIDE
} else {
Filter.Action.NONE
}
}
val spoilerText = status.actionableStatus.spoilerText
val attachmentsDescriptions = status.attachments
.mapNotNull { it.description }
val matchingKind = status.filtered?.filter { result ->
result.filter.kinds.contains(kind)
}
return (
matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() ||
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
(
attachmentsDescriptions.isNotEmpty() &&
matcher.reset(attachmentsDescriptions.joinToString("\n"))
.find()
)
)
return if (matchingKind.isNullOrEmpty()) {
Filter.Action.NONE
} else {
matchingKind.maxOf { it.filter.action }
}
}
private fun filterToRegexToken(filter: Filter): String? {
private fun filterToRegexToken(filter: FilterV1): String? {
val phrase = filter.phrase
val quotedPhrase = Pattern.quote(phrase)
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
@ -54,7 +67,7 @@ class FilterModel @Inject constructor() {
}
}
private fun makeFilter(filters: List<Filter>): Pattern? {
private fun makeFilter(filters: List<FilterV1>): Pattern? {
val now = Date()
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
if (nonExpiredFilters.isEmpty()) return null

View file

@ -25,6 +25,8 @@ import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker
@ -85,6 +87,9 @@ interface MastodonApi {
suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<Instance>
@GET("api/v1/filters")
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>
@GET("api/v2/filters")
suspend fun getFilters(): NetworkResult<List<Filter>>
@GET("api/v1/timelines/home")
@ -572,30 +577,75 @@ interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/filters")
suspend fun createFilter(
suspend fun createFilterV1(
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter>
): NetworkResult<FilterV1>
@FormUrlEncoded
@PUT("api/v1/filters/{id}")
suspend fun updateFilter(
suspend fun updateFilterV1(
@Path("id") id: String,
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter>
): NetworkResult<FilterV1>
@DELETE("api/v1/filters/{id}")
suspend fun deleteFilterV1(
@Path("id") id: String
): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v2/filters")
suspend fun createFilter(
@Field("title") title: String,
@Field("context[]") context: List<String>,
@Field("filter_action") filterAction: String,
@Field("expires_in") expiresInSeconds: Int?,
): NetworkResult<Filter>
@FormUrlEncoded
@PUT("api/v2/filters/{id}")
suspend fun updateFilter(
@Path("id") id: String,
@Field("title") title: String? = null,
@Field("context[]") context: List<String>? = null,
@Field("filter_action") filterAction: String? = null,
@Field("expires_in") expiresInSeconds: Int? = null,
): NetworkResult<Filter>
@DELETE("api/v2/filters/{id}")
suspend fun deleteFilter(
@Path("id") id: String
): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v2/filters/{filterId}/keywords")
suspend fun addFilterKeyword(
@Path("filterId") filterId: String,
@Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword>
@FormUrlEncoded
@PUT("api/v2/filters/keywords/{keywordId}")
suspend fun updateFilterKeyword(
@Path("keywordId") keywordId: String,
@Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword>
@DELETE("api/v2/filters/keywords/{keywordId}")
suspend fun deleteFilterKeyword(
@Path("keywordId") keywordId: String,
): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v1/polls/{id}/votes")
fun voteInPoll(