* 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>
86 lines
2.9 KiB
Kotlin
86 lines
2.9 KiB
Kotlin
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
|
|
import java.util.regex.Pattern
|
|
import javax.inject.Inject
|
|
|
|
/**
|
|
* One-stop for status filtering logic using Mastodon's filters.
|
|
*
|
|
* 1. You init with [initWithFilters], this compiles regex pattern.
|
|
* 2. You call [shouldFilterStatus] to figure out what to display when you load statuses.
|
|
*/
|
|
class FilterModel @Inject constructor() {
|
|
private var pattern: Pattern? = null
|
|
private var v1 = false
|
|
lateinit var kind: Filter.Kind
|
|
|
|
fun initWithFilters(filters: List<FilterV1>) {
|
|
v1 = true
|
|
this.pattern = makeFilter(filters)
|
|
}
|
|
|
|
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?.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 matchingKind = status.filtered?.filter { result ->
|
|
result.filter.kinds.contains(kind)
|
|
}
|
|
|
|
return if (matchingKind.isNullOrEmpty()) {
|
|
Filter.Action.NONE
|
|
} else {
|
|
matchingKind.maxOf { it.filter.action }
|
|
}
|
|
}
|
|
|
|
private fun filterToRegexToken(filter: FilterV1): String? {
|
|
val phrase = filter.phrase
|
|
val quotedPhrase = Pattern.quote(phrase)
|
|
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
|
|
String.format("(^|\\W)%s($|\\W)", quotedPhrase)
|
|
} else {
|
|
quotedPhrase
|
|
}
|
|
}
|
|
|
|
private fun makeFilter(filters: List<FilterV1>): Pattern? {
|
|
val now = Date()
|
|
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
|
|
if (nonExpiredFilters.isEmpty()) return null
|
|
val tokens = nonExpiredFilters
|
|
.asSequence()
|
|
.map { filterToRegexToken(it) }
|
|
.joinToString("|")
|
|
|
|
return Pattern.compile(tokens, Pattern.CASE_INSENSITIVE)
|
|
}
|
|
|
|
companion object {
|
|
private val ALPHANUMERIC = Pattern.compile("^\\w+$")
|
|
}
|
|
}
|