Implement instance mutes (#1311)

* Implement instance mutes. #1143

* Move new classes to instancemute component

* Add progress bar while instance list loads

* Add undo snackbar for instance unmuting

* Update display text for instance mutes
This commit is contained in:
Levi Bard 2019-06-11 15:56:27 +02:00 committed by Konrad Pozniak
parent c10f3bce24
commit a6819ce28e
20 changed files with 494 additions and 5 deletions

View file

@ -119,6 +119,7 @@
<activity android:name=".FiltersActivity" /> <activity android:name=".FiltersActivity" />
<activity android:name=".components.report.ReportActivity" <activity android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver" />

View file

@ -93,6 +93,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private var avatarSize: Float = 0f private var avatarSize: Float = 0f
@Px @Px
private var titleVisibleHeight: Int = 0 private var titleVisibleHeight: Int = 0
private lateinit var domain: String
private enum class FollowState { private enum class FollowState {
NOT_FOLLOWING, NOT_FOLLOWING,
@ -601,6 +602,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
getString(R.string.action_mute) getString(R.string.action_mute)
} }
if (loadedAccount != null) {
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = LinkHelper.getDomain(loadedAccount?.url)
if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
} else {
muteDomain.title = getString(R.string.action_mute_domain, domain)
}
}
if (followState == FollowState.FOLLOWING) { if (followState == FollowState.FOLLOWING) {
val showReblogs = menu.findItem(R.id.action_show_reblogs) val showReblogs = menu.findItem(R.id.action_show_reblogs)
showReblogs.title = if (showingReblogs) { showReblogs.title = if (showingReblogs) {
@ -618,6 +630,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
menu.removeItem(R.id.action_follow) menu.removeItem(R.id.action_follow)
menu.removeItem(R.id.action_block) menu.removeItem(R.id.action_block)
menu.removeItem(R.id.action_mute) menu.removeItem(R.id.action_mute)
menu.removeItem(R.id.action_mute_domain)
menu.removeItem(R.id.action_show_reblogs) menu.removeItem(R.id.action_show_reblogs)
} }
@ -640,6 +653,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.show() .show()
} }
private fun showMuteDomainWarningDialog(instance: String) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.muteDomain(instance) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun mention() { private fun mention() {
loadedAccount?.let { loadedAccount?.let {
val intent = ComposeActivity.IntentBuilder() val intent = ComposeActivity.IntentBuilder()
@ -694,7 +715,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.changeMuteState() viewModel.changeMuteState()
return true return true
} }
R.id.action_mute_domain -> {
showMuteDomainWarningDialog(domain)
return true
}
R.id.action_show_reblogs -> { R.id.action_show_reblogs -> {
viewModel.changeShowReblogsState() viewModel.changeShowReblogsState()
return true return true

View file

@ -16,3 +16,4 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable

View file

@ -0,0 +1,50 @@
package com.keylesspalace.tusky.components.instancemute
import android.os.Bundle
import android.view.MenuItem
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import javax.inject.Inject
import kotlinx.android.synthetic.main.toolbar_basic.*
class InstanceListActivity: BaseActivity(), HasSupportFragmentInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
override fun supportFragmentInjector(): AndroidInjector<Fragment>? {
return dispatchingAndroidInjector
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_account_list)
setSupportActionBar(toolbar)
supportActionBar?.apply {
setTitle(R.string.title_domain_mutes)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment())
.commit()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
}

View file

@ -0,0 +1,57 @@
package com.keylesspalace.tusky.components.instancemute.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
import kotlinx.android.synthetic.main.item_muted_domain.view.*
class DomainMutesAdapter(private val actionListener: InstanceActionListener): RecyclerView.Adapter<DomainMutesAdapter.ViewHolder>() {
var instances: MutableList<String> = mutableListOf()
var bottomLoading: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_muted_domain, parent, false), actionListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.setupWithInstance(instances[position])
}
override fun getItemCount(): Int {
var count = instances.size
if (bottomLoading)
++count
return count
}
fun addItems(newInstances: List<String>) {
val end = instances.size
instances.addAll(newInstances)
notifyItemRangeInserted(end, instances.size)
}
fun addItem(instance: String) {
instances.add(instance)
notifyItemInserted(instances.size)
}
fun removeItem(position: Int)
{
if (position >= 0 && position < instances.size) {
instances.removeAt(position)
notifyItemRemoved(position)
}
}
class ViewHolder(rootView: View, private val actionListener: InstanceActionListener): RecyclerView.ViewHolder(rootView) {
fun setupWithInstance(instance: String) {
itemView.muted_domain.text = instance
itemView.muted_domain_unmute.setOnClickListener {
actionListener.mute(false, instance, adapterPosition)
}
}
}
}

View file

@ -0,0 +1,179 @@
package com.keylesspalace.tusky.components.instancemute.fragment
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.fragment.BaseFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_instance_list.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener {
@Inject
lateinit var api: MastodonApi
private var fetching = false
private var bottomId: String? = null
private var adapter = DomainMutesAdapter(this)
private lateinit var scrollListener: EndlessOnScrollListener
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_instance_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.setHasFixedSize(true)
recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(view.context)
recyclerView.layoutManager = layoutManager
scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId != null) {
fetchInstances(bottomId)
}
}
}
recyclerView.addOnScrollListener(scrollListener)
fetchInstances()
}
override fun mute(mute: Boolean, instance: String, position: Int) {
if (mute) {
api.blockDomain(instance).enqueue(object: Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error muting domain $instance")
}
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
adapter.addItem(instance)
} else {
Log.e(TAG, "Error muting domain $instance")
}
}
})
} else {
api.unblockDomain(instance).enqueue(object: Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error unmuting domain $instance")
}
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
adapter.removeItem(position)
Snackbar.make(recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mute(true, instance, position)
}
.show()
} else {
Log.e(TAG, "Error unmuting domain $instance")
}
}
})
}
}
private fun fetchInstances(id: String? = null) {
if (fetching) {
return
}
fetching = true
instanceProgressBar.show()
if (id != null) {
recyclerView.post { adapter.bottomLoading = true }
}
api.domainBlocks(id, bottomId, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ response ->
val instances = response.body()
if (response.isSuccessful && instances != null) {
onFetchInstancesSuccess(instances, response.headers().get("Link"))
} else {
onFetchInstancesFailure(Exception(response.message()))
}
}, {throwable ->
onFetchInstancesFailure(throwable)
})
}
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
adapter.bottomLoading = false
instanceProgressBar.hide()
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
val fromId = next?.uri?.getQueryParameter("max_id")
adapter.addItems(instances)
bottomId = fromId
fetching = false
if (adapter.itemCount == 0) {
messageView.show()
messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
messageView.hide()
}
}
private fun onFetchInstancesFailure(throwable: Throwable) {
fetching = false
instanceProgressBar.hide()
Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) {
messageView.show()
if (throwable is IOException) {
messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
messageView.hide()
this.fetchInstances(null)
}
} else {
messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
messageView.hide()
this.fetchInstances(null)
}
}
}
}
companion object {
private const val TAG = "InstanceList" // logging tag
}
}

View file

@ -0,0 +1,5 @@
package com.keylesspalace.tusky.components.instancemute.interfaces
interface InstanceActionListener {
fun mute(mute: Boolean, instance: String, position: Int)
}

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.di package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.* import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@ -92,4 +93,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesReportActivity(): ReportActivity abstract fun contributesReportActivity(): ReportActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesInstanceListActivity(): InstanceListActivity
} }

View file

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.AccountsInListFragment import com.keylesspalace.tusky.AccountsInListFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import com.keylesspalace.tusky.fragment.* import com.keylesspalace.tusky.fragment.*
import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment
import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment
@ -71,4 +72,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun reportDoneFragment(): ReportDoneFragment abstract fun reportDoneFragment(): ReportDoneFragment
@ContributesAndroidInjector
abstract fun instanceListFragment(): InstanceListFragment
} }

View file

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.adapter.TimelineAdapter; import com.keylesspalace.tusky.adapter.TimelineAdapter;
import com.keylesspalace.tusky.appstore.BlockEvent; import com.keylesspalace.tusky.appstore.BlockEvent;
import com.keylesspalace.tusky.appstore.DomainMuteEvent;
import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.FavoriteEvent; import com.keylesspalace.tusky.appstore.FavoriteEvent;
import com.keylesspalace.tusky.appstore.MuteEvent; import com.keylesspalace.tusky.appstore.MuteEvent;
@ -56,6 +57,7 @@ import com.keylesspalace.tusky.repository.Placeholder;
import com.keylesspalace.tusky.repository.TimelineRepository; import com.keylesspalace.tusky.repository.TimelineRepository;
import com.keylesspalace.tusky.repository.TimelineRequestMode; import com.keylesspalace.tusky.repository.TimelineRequestMode;
import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.PairedList;
@ -526,6 +528,11 @@ public class TimelineFragment extends SFragment implements
String id = ((MuteEvent) event).getAccountId(); String id = ((MuteEvent) event).getAccountId();
removeAllByAccountId(id); removeAllByAccountId(id);
} }
} else if (event instanceof DomainMuteEvent) {
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
String instance = ((DomainMuteEvent) event).getInstance();
removeAllByInstance(instance);
}
} else if (event instanceof StatusDeletedEvent) { } else if (event instanceof StatusDeletedEvent) {
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
String id = ((StatusDeletedEvent) event).getStatusId(); String id = ((StatusDeletedEvent) event).getStatusId();
@ -870,6 +877,18 @@ public class TimelineFragment extends SFragment implements
updateAdapter(); updateAdapter();
} }
private void removeAllByInstance(String instance) {
// using iterator to safely remove items while iterating
Iterator<Either<Placeholder, Status>> iterator = statuses.iterator();
while (iterator.hasNext()) {
Status status = iterator.next().asRightOrNull();
if (status != null && LinkHelper.getDomain(status.getAccount().getUrl()).equals(instance)) {
iterator.remove();
}
}
updateAdapter();
}
private void onLoadMore() { private void onLoadMore() {
if (didLoadEverythingBottom || bottomLoading) { if (didLoadEverythingBottom || bottomLoading) {
return; return;

View file

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
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
@ -59,6 +60,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
private lateinit var tabPreference: Preference private lateinit var tabPreference: Preference
private lateinit var mutedUsersPreference: Preference private lateinit var mutedUsersPreference: Preference
private lateinit var blockedUsersPreference: Preference private lateinit var blockedUsersPreference: Preference
private lateinit var mutedDomainsPreference: Preference
private lateinit var defaultPostPrivacyPreference: ListPreference private lateinit var defaultPostPrivacyPreference: ListPreference
private lateinit var defaultMediaSensitivityPreference: SwitchPreferenceCompat private lateinit var defaultMediaSensitivityPreference: SwitchPreferenceCompat
@ -78,6 +80,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
tabPreference = requirePreference("tabPreference") tabPreference = requirePreference("tabPreference")
mutedUsersPreference = requirePreference("mutedUsersPreference") mutedUsersPreference = requirePreference("mutedUsersPreference")
blockedUsersPreference = requirePreference("blockedUsersPreference") blockedUsersPreference = requirePreference("blockedUsersPreference")
mutedDomainsPreference = requirePreference("mutedDomainsPreference")
defaultPostPrivacyPreference = requirePreference("defaultPostPrivacy") as ListPreference defaultPostPrivacyPreference = requirePreference("defaultPostPrivacy") as ListPreference
defaultMediaSensitivityPreference = requirePreference("defaultMediaSensitivity") as SwitchPreferenceCompat defaultMediaSensitivityPreference = requirePreference("defaultMediaSensitivity") as SwitchPreferenceCompat
mediaPreviewEnabledPreference = requirePreference("mediaPreviewEnabled") as SwitchPreferenceCompat mediaPreviewEnabledPreference = requirePreference("mediaPreviewEnabled") as SwitchPreferenceCompat
@ -90,11 +93,13 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
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)
blockedUsersPreference.icon = IconicsDrawable(blockedUsersPreference.context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(blockedUsersPreference.context, R.attr.toolbar_icon_tint)) blockedUsersPreference.icon = IconicsDrawable(blockedUsersPreference.context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(blockedUsersPreference.context, R.attr.toolbar_icon_tint))
mutedDomainsPreference.icon = getTintedIcon(R.drawable.ic_mute_24dp)
notificationPreference.onPreferenceClickListener = this notificationPreference.onPreferenceClickListener = this
tabPreference.onPreferenceClickListener = this tabPreference.onPreferenceClickListener = this
mutedUsersPreference.onPreferenceClickListener = this mutedUsersPreference.onPreferenceClickListener = this
blockedUsersPreference.onPreferenceClickListener = this blockedUsersPreference.onPreferenceClickListener = this
mutedDomainsPreference.onPreferenceClickListener = this
homeFiltersPreference.onPreferenceClickListener = this homeFiltersPreference.onPreferenceClickListener = this
notificationFiltersPreference.onPreferenceClickListener = this notificationFiltersPreference.onPreferenceClickListener = this
publicFiltersPreference.onPreferenceClickListener = this publicFiltersPreference.onPreferenceClickListener = this
@ -191,6 +196,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
true true
} }
mutedDomainsPreference -> {
val intent = Intent(context, InstanceListActivity::class.java)
activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
true
}
homeFiltersPreference -> { homeFiltersPreference -> {
launchFilterActivity(Filter.HOME, R.string.title_home) launchFilterActivity(Filter.HOME, R.string.title_home)
} }

View file

@ -47,6 +47,7 @@ import retrofit2.http.DELETE;
import retrofit2.http.Field; import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded; import retrofit2.http.FormUrlEncoded;
import retrofit2.http.GET; import retrofit2.http.GET;
import retrofit2.http.HTTP;
import retrofit2.http.Header; import retrofit2.http.Header;
import retrofit2.http.Multipart; import retrofit2.http.Multipart;
import retrofit2.http.PATCH; import retrofit2.http.PATCH;
@ -267,6 +268,21 @@ public interface MastodonApi {
@GET("api/v1/mutes") @GET("api/v1/mutes")
Single<Response<List<Account>>> mutes(@Query("max_id") String maxId); Single<Response<List<Account>>> mutes(@Query("max_id") String maxId);
@GET("api/v1/domain_blocks")
Single<Response<List<String>>> domainBlocks(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@FormUrlEncoded
@POST("api/v1/domain_blocks")
Call<Object> blockDomain(@Field("domain") String domain);
@FormUrlEncoded
// Normal @DELETE doesn't support fields?
@HTTP(method = "DELETE", path = "api/v1/domain_blocks", hasBody = true)
Call<Object> unblockDomain(@Field("domain") String domain);
@GET("api/v1/favourites") @GET("api/v1/favourites")
Call<List<Status>> favourites( Call<List<Status>> favourites(
@Query("max_id") String maxId, @Query("max_id") String maxId,

View file

@ -40,7 +40,7 @@ import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
public class LinkHelper { public class LinkHelper {
private static String getDomain(String urlString) { public static String getDomain(String urlString) {
URI uri; URI uri;
try { try {
uri = new URI(urlString); uri = new URI(urlString);

View file

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.viewmodel package com.keylesspalace.tusky.viewmodel
import android.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.appstore.*
@ -122,6 +123,22 @@ class AccountViewModel @Inject constructor(
} }
} }
fun muteDomain(instance: String) {
mastodonApi.blockDomain(instance).enqueue(object: Callback<Any> {
override fun onResponse(call: Call<Any>, response: Response<Any>) {
if (response.isSuccessful) {
eventHub.dispatch(DomainMuteEvent(instance))
} else {
Log.e(TAG, String.format("Error muting %s", instance))
}
}
override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, String.format("Error muting %s", instance), t)
}
})
}
fun changeShowReblogsState() { fun changeShowReblogsState() {
if (relationshipData.value?.data?.showingReblogs == true) { if (relationshipData.value?.data?.showingReblogs == true) {
changeRelationship(RelationShipAction.FOLLOW, false) changeRelationship(RelationShipAction.FOLLOW, false)
@ -226,4 +243,7 @@ class AccountViewModel @Inject constructor(
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE
} }
companion object {
const val TAG = "AccountViewModel"
}
} }

View file

@ -0,0 +1,18 @@
<?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/activity_instance_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.InstanceListActivity">
<include layout="@layout/toolbar_basic" />
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/instanceProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -0,0 +1,41 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
>
<ImageButton
android:id="@+id/muted_domain_unmute"
style="?attr/image_button_style"
android:layout_width="32dp"
android:layout_height="32dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_unmute"
android:padding="4dp"
app:srcCompat="@drawable/ic_unmute_24dp"
/>
<TextView
android:id="@+id/muted_domain"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:gravity="center_vertical"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
tools:text="instance.domain.tld" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,6 +22,10 @@
android:title="@string/action_block" android:title="@string/action_block"
app:showAsAction="never" /> app:showAsAction="never" />
<item android:id="@+id/action_mute_domain"
android:title="@string/action_mute_domain"
app:showAsAction="never" />
<item android:id="@+id/action_show_reblogs" <item android:id="@+id/action_show_reblogs"
android:title="@string/action_hide_reblogs" android:title="@string/action_hide_reblogs"
app:showAsAction="never" /> app:showAsAction="never" />

View file

@ -37,6 +37,7 @@
<string name="title_favourites">Favourites</string> <string name="title_favourites">Favourites</string>
<string name="title_mutes">Muted users</string> <string name="title_mutes">Muted users</string>
<string name="title_blocks">Blocked users</string> <string name="title_blocks">Blocked users</string>
<string name="title_domain_mutes">Hidden domains</string>
<string name="title_follow_requests">Follow Requests</string> <string name="title_follow_requests">Follow Requests</string>
<string name="title_edit_profile">Edit your profile</string> <string name="title_edit_profile">Edit your profile</string>
<string name="title_saved_toot">Drafts</string> <string name="title_saved_toot">Drafts</string>
@ -92,6 +93,7 @@
<string name="action_view_favourites">Favourites</string> <string name="action_view_favourites">Favourites</string>
<string name="action_view_mutes">Muted users</string> <string name="action_view_mutes">Muted users</string>
<string name="action_view_blocks">Blocked users</string> <string name="action_view_blocks">Blocked users</string>
<string name="action_view_domain_mutes">Hidden domains</string>
<string name="action_view_follow_requests">Follow Requests</string> <string name="action_view_follow_requests">Follow Requests</string>
<string name="action_view_media">Media</string> <string name="action_view_media">Media</string>
<string name="action_open_in_web">Open in browser</string> <string name="action_open_in_web">Open in browser</string>
@ -100,6 +102,7 @@
<string name="action_share">Share</string> <string name="action_share">Share</string>
<string name="action_mute">Mute</string> <string name="action_mute">Mute</string>
<string name="action_unmute">Unmute</string> <string name="action_unmute">Unmute</string>
<string name="action_mute_domain">Mute %s</string>
<string name="action_mention">Mention</string> <string name="action_mention">Mention</string>
<string name="action_hide_media">Hide media</string> <string name="action_hide_media">Hide media</string>
<string name="action_open_drawer">Open drawer</string> <string name="action_open_drawer">Open drawer</string>
@ -142,6 +145,7 @@
<string name="confirmation_reported">Sent!</string> <string name="confirmation_reported">Sent!</string>
<string name="confirmation_unblocked">User unblocked</string> <string name="confirmation_unblocked">User unblocked</string>
<string name="confirmation_unmuted">User unmuted</string> <string name="confirmation_unmuted">User unmuted</string>
<string name="confirmation_domain_unmuted">%s unhidden</string>
<string name="status_sent">Sent!</string> <string name="status_sent">Sent!</string>
<string name="status_sent_long">Reply sent successfully.</string> <string name="status_sent_long">Reply sent successfully.</string>
@ -179,6 +183,8 @@
<string name="dialog_unfollow_warning">Unfollow this account?</string> <string name="dialog_unfollow_warning">Unfollow this account?</string>
<string name="dialog_delete_toot_warning">Delete this toot?</string> <string name="dialog_delete_toot_warning">Delete this toot?</string>
<string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string> <string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string>
<string name="mute_domain_warning">Are you sure you want to block all of %s? You will not see content from that domain in any public timelines or in your notifications. Your followers from that domain will be removed.</string>
<string name="mute_domain_warning_dialog_ok">Hide entire domain</string>
<string name="visibility_public">Public: Post to public timelines</string> <string name="visibility_public">Public: Post to public timelines</string>
<string name="visibility_unlisted">Unlisted: Do not show in public timelines</string> <string name="visibility_unlisted">Unlisted: Do not show in public timelines</string>

View file

@ -18,6 +18,10 @@
android:key="blockedUsersPreference" android:key="blockedUsersPreference"
android:title="@string/action_view_blocks" /> android:title="@string/action_view_blocks" />
<Preference
android:key="mutedDomainsPreference"
android:title="@string/action_view_domain_mutes" />
<PreferenceCategory android:title="@string/pref_publishing"> <PreferenceCategory android:title="@string/pref_publishing">
<ListPreference <ListPreference
android:defaultValue="public" android:defaultValue="public"