Share filters with web client (#956)

* First step toward synchronized content filters

* Add simple filter management UI

* Remove old regex filter UI

* More cleanup

* Escape filter phrases when applying them via regex

* Apply code review feedback

* Fix live timeline update when filters change
This commit is contained in:
Levi Bard 2019-03-20 19:25:26 +01:00 committed by Konrad Pozniak
parent 463b008090
commit 5135daad2c
13 changed files with 439 additions and 91 deletions

View file

@ -115,6 +115,7 @@
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" /> <activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver" />

View file

@ -0,0 +1,180 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.view.MenuItem
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.android.synthetic.main.activity_filters.*
import kotlinx.android.synthetic.main.dialog_filter.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class FiltersActivity: BaseActivity() {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
private lateinit var context : String
private lateinit var filters: MutableList<Filter>
private lateinit var dialog: AlertDialog
companion object {
const val FILTERS_CONTEXT = "filters_context"
const val FILTERS_TITLE = "filters_title"
}
private fun updateFilter(filter: Filter, itemIndex: Int) {
api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt)
.enqueue(object: Callback<Filter>{
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val updatedFilter = response.body()!!
if (updatedFilter.context.contains(context)) {
filters[itemIndex] = updatedFilter
} else {
filters.removeAt(itemIndex)
}
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
}
})
}
private fun deleteFilter(itemIndex: Int) {
val filter = filters[itemIndex]
if (filter.context.count() == 1) {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).enqueue(object: Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
}
})
} else {
// Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord)
updateFilter(newFilter, itemIndex)
}
}
private fun createFilter(phrase: String) {
api.createFilter(phrase, listOf(context), false, true, "").enqueue(object: Callback<Filter> {
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
filters.add(response.body()!!)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
}
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error creating filter '${phrase}'", Toast.LENGTH_SHORT).show()
}
})
}
private fun showAddFilterDialog() {
dialog = AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_addition_dialog_title)
.setView(R.layout.dialog_filter)
.setPositiveButton(android.R.string.ok){ _, _ ->
createFilter(dialog.phraseEditText.text.toString())
}
.setNeutralButton(android.R.string.cancel, null)
.create()
dialog.show()
}
private fun setupEditDialogForItem(itemIndex: Int) {
dialog = AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_edit_dialog_title)
.setView(R.layout.dialog_filter)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, dialog.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord)
updateFilter(newFilter, itemIndex)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.create()
dialog.show()
// Need to show the dialog before referencing any elements from its view
dialog.phraseEditText.setText(filters[itemIndex].phrase)
}
private fun refreshFilterDisplay() {
filtersView.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, filters.map { filter -> filter.phrase })
filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForItem(position) }
}
private fun loadFilters() {
api.filters.enqueue(object : Callback<List<Filter>> {
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) {
filters = response.body()!!.filter { filter -> filter.context.contains(context) }.toMutableList()
refreshFilterDisplay()
}
override fun onFailure(call: Call<List<Filter>>, t: Throwable) {
// Anything?
}
})
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_filters)
setupToolbarBackArrow()
filter_floating_add.setOnClickListener {
showAddFilterDialog()
}
title = intent?.getStringExtra(FILTERS_TITLE)
context = intent?.getStringExtra(FILTERS_CONTEXT)!!
loadFilters()
}
private fun setupToolbarBackArrow() {
setSupportActionBar(toolbar)
supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
}
// Activate back arrow in toolbar
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
}

View file

@ -89,4 +89,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity
@ContributesAndroidInjector
abstract fun contributesFiltersActivity(): FiltersActivity
} }

View file

@ -0,0 +1,47 @@
/* Copyright 2018 Levi Bard
*
* 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.entity
import com.google.gson.annotations.SerializedName
data class Filter (
val id: String,
val phrase: String,
val context: List<String>,
@SerializedName("expires_at") val expiresAt: String?,
val irreversible: Boolean,
@SerializedName("whole_word") val wholeWord: Boolean
) {
public companion object {
const val HOME = "home"
const val NOTIFICATIONS = "notifications"
const val PUBLIC = "public"
const val THREAD = "thread"
}
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other !is Filter) {
return false
}
val filter = other as Filter?
return filter?.id.equals(id)
}
}

View file

@ -20,6 +20,7 @@ import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Bundle; import android.os.Bundle;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -45,6 +46,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
import com.keylesspalace.tusky.appstore.UnfollowEvent; import com.keylesspalace.tusky.appstore.UnfollowEvent;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -64,7 +66,9 @@ import com.keylesspalace.tusky.view.BackgroundMessageView;
import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@ -312,6 +316,20 @@ public class TimelineFragment extends SFragment implements
}); });
} }
private void reloadFilters(boolean refresh) {
mastodonApi.getFilters().enqueue(new Callback<List<Filter>>() {
@Override
public void onResponse(Call<List<Filter>> call, Response<List<Filter>> response) {
applyFilters(response.body(), refresh);
}
@Override
public void onFailure(Call<List<Filter>> call, Throwable t) {
Log.e(TAG, "Error getting filters from server");
}
});
}
private void setupTimelinePreferences() { private void setupTimelinePreferences() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
@ -325,16 +343,43 @@ public class TimelineFragment extends SFragment implements
filter = preferences.getBoolean("tabFilterHomeBoosts", true); filter = preferences.getBoolean("tabFilterHomeBoosts", true);
filterRemoveReblogs = kind == Kind.HOME && !filter; filterRemoveReblogs = kind == Kind.HOME && !filter;
reloadFilters(false);
}
String regexFilter = preferences.getString("tabFilterRegex", ""); private static boolean filterContextMatchesKind(Kind kind, List<String> filterContext) {
filterRemoveRegex = (kind == Kind.HOME // home, notifications, public, thread
|| kind == Kind.PUBLIC_LOCAL switch(kind) {
|| kind == Kind.PUBLIC_FEDERATED) case HOME:
&& !regexFilter.isEmpty(); return filterContext.contains(Filter.HOME);
case PUBLIC_FEDERATED:
case PUBLIC_LOCAL:
case TAG:
return filterContext.contains(Filter.PUBLIC);
case FAVOURITES:
return (filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS));
default:
return false;
}
}
private static String filterToRegexToken(Filter filter) {
String phrase = Pattern.quote(filter.getPhrase());
return filter.getWholeWord() ? String.format("\\b%s\\b", phrase) : phrase;
}
private void applyFilters(List<Filter> filters, boolean refresh) {
List<String> tokens = new ArrayList<>();
for (Filter filter : filters) {
if (filterContextMatchesKind(kind, filter.getContext())) {
tokens.add(filterToRegexToken(filter));
}
}
filterRemoveRegex = !tokens.isEmpty();
if (filterRemoveRegex) { if (filterRemoveRegex) {
filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE) filterRemoveRegexMatcher = Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE).matcher("");
.matcher(""); }
if (refresh) {
fullyRefresh();
} }
} }
@ -765,19 +810,12 @@ public class TimelineFragment extends SFragment implements
} }
break; break;
} }
case "tabFilterRegex": { case Filter.HOME:
boolean oldFilterRemoveRegex = filterRemoveRegex; case Filter.NOTIFICATIONS:
String newFilterRemoveRegexPattern = sharedPreferences.getString("tabFilterRegex", ""); case Filter.THREAD:
boolean patternChanged; case Filter.PUBLIC: {
if (filterRemoveRegexMatcher != null) { if (filterContextMatchesKind(kind, Collections.singletonList(key))) {
patternChanged = !newFilterRemoveRegexPattern.equalsIgnoreCase(filterRemoveRegexMatcher.pattern().pattern()); reloadFilters(true);
} else {
patternChanged = !newFilterRemoveRegexPattern.isEmpty();
}
filterRemoveRegex = (kind == Kind.HOME || kind == Kind.PUBLIC_LOCAL || kind == Kind.PUBLIC_FEDERATED) && !newFilterRemoveRegexPattern.isEmpty();
if (oldFilterRemoveRegex != filterRemoveRegex || patternChanged) {
filterRemoveRegexMatcher = Pattern.compile(newFilterRemoveRegexPattern, Pattern.CASE_INSENSITIVE).matcher("");
fullyRefresh();
} }
break; break;
} }

View file

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
@ -65,6 +66,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
private lateinit var defaultMediaSensitivityPreference: SwitchPreference private lateinit var defaultMediaSensitivityPreference: SwitchPreference
private lateinit var alwaysShowSensitiveMediaPreference: SwitchPreference private lateinit var alwaysShowSensitiveMediaPreference: SwitchPreference
private lateinit var mediaPreviewEnabledPreference: SwitchPreference private lateinit var mediaPreviewEnabledPreference: SwitchPreference
private lateinit var homeFiltersPreference: Preference
private lateinit var notificationFiltersPreference: Preference
private lateinit var publicFiltersPreference: Preference
private lateinit var threadFiltersPreference: Preference
private val iconSize by lazy {resources.getDimensionPixelSize(R.dimen.preference_icon_size)} private val iconSize by lazy {resources.getDimensionPixelSize(R.dimen.preference_icon_size)}
@ -79,6 +84,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
defaultMediaSensitivityPreference = findPreference("defaultMediaSensitivity") as SwitchPreference defaultMediaSensitivityPreference = findPreference("defaultMediaSensitivity") as SwitchPreference
mediaPreviewEnabledPreference = findPreference("mediaPreviewEnabled") as SwitchPreference mediaPreviewEnabledPreference = findPreference("mediaPreviewEnabled") as SwitchPreference
alwaysShowSensitiveMediaPreference = findPreference("alwaysShowSensitiveMedia") as SwitchPreference alwaysShowSensitiveMediaPreference = findPreference("alwaysShowSensitiveMedia") as SwitchPreference
homeFiltersPreference = findPreference("homeFilters")
notificationFiltersPreference = findPreference("notificationFilters")
publicFiltersPreference = findPreference("publicFilters")
threadFiltersPreference = findPreference("threadFilters")
notificationPreference.icon = IconicsDrawable(notificationPreference.context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(notificationPreference.context, R.attr.toolbar_icon_tint)) notificationPreference.icon = IconicsDrawable(notificationPreference.context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(notificationPreference.context, R.attr.toolbar_icon_tint))
mutedUsersPreference.icon = getTintedIcon(R.drawable.ic_mute_24dp) mutedUsersPreference.icon = getTintedIcon(R.drawable.ic_mute_24dp)
@ -88,12 +97,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
tabPreference.onPreferenceClickListener = this tabPreference.onPreferenceClickListener = this
mutedUsersPreference.onPreferenceClickListener = this mutedUsersPreference.onPreferenceClickListener = this
blockedUsersPreference.onPreferenceClickListener = this blockedUsersPreference.onPreferenceClickListener = this
homeFiltersPreference.onPreferenceClickListener = this
notificationFiltersPreference.onPreferenceClickListener = this
publicFiltersPreference.onPreferenceClickListener = this
threadFiltersPreference.onPreferenceClickListener = this
defaultPostPrivacyPreference.onPreferenceChangeListener = this defaultPostPrivacyPreference.onPreferenceChangeListener = this
defaultMediaSensitivityPreference.onPreferenceChangeListener = this defaultMediaSensitivityPreference.onPreferenceChangeListener = this
mediaPreviewEnabledPreference.onPreferenceChangeListener = this mediaPreviewEnabledPreference.onPreferenceChangeListener = this
alwaysShowSensitiveMediaPreference.onPreferenceChangeListener = this alwaysShowSensitiveMediaPreference.onPreferenceChangeListener = this
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -144,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
override fun onPreferenceClick(preference: Preference): Boolean { override fun onPreferenceClick(preference: Preference): Boolean {
when(preference) { return when(preference) {
notificationPreference -> { notificationPreference -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent() val intent = Intent()
@ -159,30 +171,42 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
} }
} }
return true true
} }
tabPreference -> { tabPreference -> {
val intent = Intent(context, TabPreferenceActivity::class.java) val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
return true true
} }
mutedUsersPreference -> { mutedUsersPreference -> {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES) intent.putExtra("type", AccountListActivity.Type.MUTES)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
return true true
} }
blockedUsersPreference -> { blockedUsersPreference -> {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.BLOCKS) intent.putExtra("type", AccountListActivity.Type.BLOCKS)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
return true true
}
homeFiltersPreference -> {
launchFilterActivity(Filter.HOME, R.string.title_home)
}
notificationFiltersPreference -> {
launchFilterActivity(Filter.NOTIFICATIONS, R.string.title_notifications)
}
publicFiltersPreference -> {
launchFilterActivity(Filter.PUBLIC, R.string.pref_title_public_filter_keywords)
}
threadFiltersPreference -> {
launchFilterActivity(Filter.THREAD, R.string.pref_title_thread_filter_keywords)
} }
else -> return false else -> false
} }
} }
@ -249,6 +273,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
return drawable return drawable
} }
fun launchFilterActivity(filterContext: String, titleResource: Int): Boolean {
val intent = Intent(context, FiltersActivity::class.java)
intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext)
intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource))
activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
return true
}
companion object { companion object {
fun newInstance(): AccountPreferencesFragment { fun newInstance(): AccountPreferencesFragment {
return AccountPreferencesFragment() return AccountPreferencesFragment()

View file

@ -17,14 +17,8 @@ package com.keylesspalace.tusky.fragment.preference
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
import androidx.preference.Preference
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import java.util.regex.Pattern
class TabFilterPreferencesFragment : PreferenceFragmentCompat() { class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
@ -32,50 +26,7 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.timeline_filter_preferences) addPreferencesFromResource(R.xml.timeline_filter_preferences)
sharedPreferences = preferenceManager.sharedPreferences sharedPreferences = preferenceManager.sharedPreferences
val regexPref: Preference = findPreference("tabFilterRegex")
regexPref.summary = sharedPreferences.getString("tabFilterRegex", "")
regexPref.setOnPreferenceClickListener {
val editText = EditText(requireContext())
editText.setText(sharedPreferences.getString("tabFilterRegex", ""))
val dialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.pref_title_filter_regex)
.setView(editText)
.setPositiveButton(android.R.string.ok) { _, _ ->
sharedPreferences
.edit()
.putString("tabFilterRegex", editText.text.toString())
.apply()
regexPref.summary = editText.text.toString()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
editText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newRegex: Editable) {
try {
Pattern.compile(newRegex.toString())
editText.error = null
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
} catch (e: IllegalArgumentException) {
editText.error = getString(R.string.error_invalid_regex)
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
}
}
override fun beforeTextChanged(s1: CharSequence, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s1: CharSequence, start: Int, before: Int, count: Int) {}
})
dialog.show()
true
}
} }
companion object { companion object {

View file

@ -22,6 +22,7 @@ import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Conversation; import com.keylesspalace.tusky.entity.Conversation;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Instance; import com.keylesspalace.tusky.entity.Instance;
import com.keylesspalace.tusky.entity.MastoList; import com.keylesspalace.tusky.entity.MastoList;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
@ -346,4 +347,37 @@ public interface MastodonApi {
@GET("/api/v1/conversations") @GET("/api/v1/conversations")
Call<List<Conversation>> getConversations(@Nullable @Query("max_id") String maxId, @Query("limit") int limit); Call<List<Conversation>> getConversations(@Nullable @Query("max_id") String maxId, @Query("limit") int limit);
@GET("api/v1/filters")
Call<List<Filter>> getFilters();
@FormUrlEncoded
@POST("api/v1/filters")
Call<Filter> createFilter(
@Field("phrase") String phrase,
@Field("context[]") List<String> context,
@Field("irreversible") Boolean irreversible,
@Field("whole_word") Boolean wholeWord,
@Field("expires_in") String expiresIn
);
@GET("api/v1/filters/{id}")
Call<Filter> getFilter(
@Path("id") String id
);
@FormUrlEncoded
@PUT("api/v1/filters/{id}")
Call<Filter> updateFilter(
@Path("id") String id,
@Field("phrase") String phrase,
@Field("context[]") List<String> context,
@Field("irreversible") Boolean irreversible,
@Field("whole_word") Boolean wholeWord,
@Field("expires_in") String expiresIn
);
@DELETE("api/v1/filters/{id}")
Call<ResponseBody> deleteFilter(
@Path("id") String id
);
} }

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activityFilters"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.FiltersActivity">
<include layout="@layout/toolbar_basic" />
<ListView
android:id="@+id/filtersView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/filter_floating_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/filter_addition_dialog_title"
app:layout_anchor="@id/filtersView"
app:layout_anchorGravity="bottom|end"
android:src="@drawable/ic_plus_24dp"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="16dp">
<EditText
android:id="@+id/phraseEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingEnd="24dp"
android:paddingStart="24dp"
android:hint="@string/filter_add_description"
app:layout_constraintTop_toTopOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -199,6 +199,7 @@
<string name="pref_title_appearance_settings">Appearance</string> <string name="pref_title_appearance_settings">Appearance</string>
<string name="pref_title_app_theme">App Theme</string> <string name="pref_title_app_theme">App Theme</string>
<string name="pref_title_timelines">Timelines</string> <string name="pref_title_timelines">Timelines</string>
<string name="pref_title_timeline_filters">Filters</string>
<string-array name="app_theme_names"> <string-array name="app_theme_names">
<item>Dark</item> <item>Dark</item>
@ -216,7 +217,6 @@
<string name="pref_title_status_tabs">Tabs</string> <string name="pref_title_status_tabs">Tabs</string>
<string name="pref_title_show_boosts">Show boosts</string> <string name="pref_title_show_boosts">Show boosts</string>
<string name="pref_title_show_replies">Show replies</string> <string name="pref_title_show_replies">Show replies</string>
<string name="pref_title_filter_regex">Filter out by regular expressions</string>
<string name="pref_title_show_media_preview">Download media previews</string> <string name="pref_title_show_media_preview">Download media previews</string>
<string name="pref_title_proxy_settings">Proxy</string> <string name="pref_title_proxy_settings">Proxy</string>
<string name="pref_title_http_proxy_settings">HTTP proxy</string> <string name="pref_title_http_proxy_settings">HTTP proxy</string>
@ -319,6 +319,14 @@
<string name="replying_to">Replying to @%s</string> <string name="replying_to">Replying to @%s</string>
<string name="load_more_placeholder_text">load more</string> <string name="load_more_placeholder_text">load more</string>
<string name="pref_title_public_filter_keywords">Public timelines</string>
<string name="pref_title_thread_filter_keywords">Conversations</string>
<string name="filter_addition_dialog_title">Add filter</string>
<string name="filter_edit_dialog_title">Edit filter</string>
<string name="filter_dialog_remove_button">Remove</string>
<string name="filter_dialog_update_button">Update</string>
<string name="filter_add_description">Phrase to filter</string>
<string name="add_account_name">Add Account</string> <string name="add_account_name">Add Account</string>
<string name="add_account_description">Add new Mastodon Account</string> <string name="add_account_description">Add new Mastodon Account</string>

View file

@ -45,7 +45,23 @@
<SwitchPreference <SwitchPreference
android:key="alwaysShowSensitiveMedia" android:key="alwaysShowSensitiveMedia"
android:title="@string/pref_title_alway_show_sensitive_media" /> android:title="@string/pref_title_alway_show_sensitive_media" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/pref_title_timeline_filters">
<Preference
android:key="publicFilters"
android:title="@string/pref_title_public_filter_keywords"
/>
<Preference
android:key="notificationFilters"
android:title="@string/title_notifications"
/>
<Preference
android:key="homeFilters"
android:title="@string/title_home"
/>
<Preference
android:key="threadFilters"
android:title="@string/pref_title_thread_filter_keywords"
/>
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View file

@ -17,15 +17,4 @@
android:title="@string/pref_title_show_replies" android:title="@string/pref_title_show_replies"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:title="@string/title_advanced"
app:iconSpaceReserved="false">
<Preference
android:inputType="textNoSuggestions"
android:key="tabFilterRegex"
android:title="@string/pref_title_filter_regex"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>