diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 8846819b..27ad700a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -13,7 +13,7 @@ data class MuteStatusEvent(val statusId: String, val mute: Boolean) : Dispatchab data class EmojiReactEvent(val newStatus: Status) : Dispatchable data class UnfollowEvent(val accountId: String) : Dispatchable data class BlockEvent(val accountId: String) : Dispatchable -data class MuteEvent(val accountId: String) : Dispatchable +data class MuteEvent(val accountId: String, val mute: Boolean) : Dispatchable data class StatusDeletedEvent(val statusId: String) : Dispatchable data class StatusPreviewEvent(val status: Status) : Dispatchable data class StatusComposedEvent(val status: Status) : Dispatchable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index db9f8781..0507d5c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -42,7 +42,6 @@ class NotificationFetcher @Inject constructor( authHeader, account.domain, account.lastNotificationId, - true, Notification.Type.asStringList ).blockingGet() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index a791f920..add6704d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -132,7 +132,7 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", - "useBlurhash", "showCardsInTimelines", "confirmReblogs", "hideMutedUsers", + "useBlurhash", "showCardsInTimelines", "confirmReblogs", "enableSwipeForTabs", "bigEmojis", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> { restartActivitiesOnExit = true } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 3ca3e8b8..370e4778 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -165,6 +165,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { isSingleLineTitle = false } + switchPreference { + setDefaultValue(false) + key = PrefKeys.HIDE_MUTED_USERS + setTitle(R.string.pref_title_hide_muted_users) + isSingleLineTitle = true + } + switchPreference { setDefaultValue(true) key = PrefKeys.ENABLE_SWIPE_FOR_TABS diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index 00efb08d..60561914 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -140,7 +140,7 @@ class ReportViewModel @Inject constructor( val muting = relationship?.muting == true muteStateMutable.value = Success(muting) if (muting) { - eventHub.dispatch(MuteEvent(accountId)) + eventHub.dispatch(MuteEvent(accountId, true)) } }, { error -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt index b888da55..12fd8ccd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt @@ -73,12 +73,11 @@ class StatusesDataSource(private val accountId: String, retryInitial = null initialLoad.postValue(NetworkState.LOADING) val initialKey = params.requestedInitialKey - val withMuted = true // TODO: configurable if (initialKey == null) { - mastodonApi.accountStatusesObservable(accountId, null, null, params.requestedLoadSize, true, withMuted) + mastodonApi.accountStatusesObservable(accountId, null, null, params.requestedLoadSize, true) } else { mastodonApi.statusObservable(initialKey).zipWith( - mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true, withMuted), + mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true), BiFunction { status: Status, list: List -> val ret = ArrayList() ret.add(status) @@ -107,8 +106,7 @@ class StatusesDataSource(private val accountId: String, override fun loadAfter(params: LoadParams, callback: LoadCallback) { networkStateAfter.postValue(NetworkState.LOADING) retryAfter = null - val withMuted = true // TODO: configurable - mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true, withMuted) + mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true) .doOnSubscribe { disposables.add(it) } @@ -130,8 +128,7 @@ class StatusesDataSource(private val accountId: String, override fun loadBefore(params: LoadParams, callback: LoadCallback) { networkStateBefore.postValue(NetworkState.LOADING) retryBefore = null - val withMuted = true // TODO: configurable - mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true, withMuted) + mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true) .doOnSubscribe { disposables.add(it) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt index 97a1d44e..b526cdb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -24,6 +24,7 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -34,6 +35,7 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show @@ -72,6 +74,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { private var isSwipeToRefreshEnabled: Boolean = true private var needToRefresh = false + private var filterMuted = false @Inject lateinit var api: MastodonApi @@ -115,10 +118,12 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { val body = response.body() body?.let { fetched -> - statuses.addAll(0, fetched) + // filter muted statuses if needed + val filtered = fetched.filter { !(filterMuted && it.muted) } + statuses.addAll(0, filtered) // flatMap requires iterable but I don't want to box each array into list val result = mutableListOf() - for (status in fetched) { + for (status in filtered) { result.addAll(AttachmentViewData.list(status)) } adapter.addTop(result) @@ -148,11 +153,15 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { body?.let { fetched -> Log.d(TAG, "fetched ${fetched.size} statuses") if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}") - statuses.addAll(fetched) + + // filter muted statuses if needed + val filtered = fetched.filter { !(filterMuted && it.muted) } + + statuses.addAll(filtered) Log.d(TAG, "now there are ${statuses.size} statuses") // flatMap requires iterable but I don't want to box each array into list val result = mutableListOf() - for (status in fetched) { + for (status in filtered) { result.addAll(AttachmentViewData.list(status)) } adapter.addBottom(result) @@ -201,8 +210,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { statuses.lastOrNull()?.let { last -> Log.d(TAG, "Requesting statuses with max_id: ${last.id}, (bottom)") fetchingStatus = FetchingStatus.FETCHING_BOTTOM - val withMuted = true // TODO: configurable - currentCall = api.accountStatuses(accountId, last.id, null, null, null, true, null, withMuted) + currentCall = api.accountStatuses(accountId, last.id, null, null, null, true, null) currentCall?.enqueue(bottomCallback) } } @@ -210,19 +218,22 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { } }) + filterMuted = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean( + PrefKeys.HIDE_MUTED_USERS, false + ) + doInitialLoadingIfNeeded() } private fun refresh() { statusView.hide() - val withMuted = true // TODO: configurable if (fetchingStatus != FetchingStatus.NOT_FETCHING) return currentCall = if (statuses.isEmpty()) { fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, null, true, null, withMuted) + api.accountStatuses(accountId, null, null, null, null, true, null) } else { fetchingStatus = FetchingStatus.REFRESHING - api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null, withMuted) + api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) } currentCall?.enqueue(callback) @@ -236,8 +247,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { } if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { fetchingStatus = FetchingStatus.INITIAL_FETCHING - val withMuted = true // TODO: configurable - currentCall = api.accountStatuses(accountId, null, null, null, null, true, null, withMuted) + currentCall = api.accountStatuses(accountId, null, null, null, null, true, null) currentCall?.enqueue(callback) } else if (needToRefresh) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 3d420fb1..5be0f3a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -67,6 +67,7 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; @@ -168,6 +169,7 @@ public class NotificationsFragment extends SFragment implements private boolean alwaysOpenSpoiler; private boolean showNotificationsFilter; private boolean showingError; + private boolean withMuted; // Each element is either a Notification for loading data or a Placeholder private final PairedList, NotificationViewData> notifications @@ -201,7 +203,7 @@ public class NotificationsFragment extends SFragment implements View rootView = inflater.inflate(R.layout.fragment_timeline_notifications, container, false); @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); //Clear notifications on filter visibility change to force refresh @@ -247,6 +249,7 @@ public class NotificationsFragment extends SFragment implements CardViewMode.NONE, preferences.getBoolean("confirmReblogs", true) ); + withMuted = !preferences.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), dataSource, statusDisplayOptions, this, this, this); @@ -340,20 +343,48 @@ public class NotificationsFragment extends SFragment implements int conversationId = posAndNotification.second.getStatus().getConversationId(); if(conversationId == -1) { // invalid conversation ID - setMutedStatusForStatus(posAndNotification.first, posAndNotification.second.getStatus(), event.getMute()); + if(withMuted) { + setMutedStatusForStatus(posAndNotification.first, posAndNotification.second.getStatus(), event.getMute(), event.getMute()); + } else { + notifications.remove(posAndNotification.first); + } } else { //noinspection ConstantConditions - // using iterator to safely remove items while iterating + if(withMuted) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION && + notification.getStatus().getConversationId() == conversationId) { + setMutedStatusForStatus(i, notification.getStatus(), event.getMute(), event.getMute()); + } + } + } else { + removeAllByConversationId(conversationId); + } + } + updateAdapter(); + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean mute = event.getMute(); + + if(withMuted) { for (int i = 0; i < notifications.size(); i++) { Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION && - notification.getStatus().getConversationId() == conversationId) { - setMutedStatusForStatus(i, notification.getStatus(), event.getMute()); + if (notification != null + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && notification.getAccount().getId().equals(id) + && !notification.getStatus().isThreadMuted()) { + setMutedStatusForStatus(i, notification.getStatus(), mute, false); } } + updateAdapter(); + } else { + removeAllByAccountId(id); } - updateAdapter(); } @Override @@ -411,6 +442,8 @@ public class NotificationsFragment extends SFragment implements handleMuteStatusEvent((MuteStatusEvent) event); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof MuteEvent) { + handleMuteEvent((MuteEvent)event); } else if (event instanceof PreferenceChangedEvent) { onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); } else if (event instanceof EmojiReactEvent) { @@ -645,13 +678,13 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); } - private void setMutedStatusForStatus(int position, Status status, boolean muted) { - status.setThreadMuted(muted); + private void setMutedStatusForStatus(int position, Status status, boolean muted, boolean threadMuted) { + status.setThreadMuted(threadMuted); NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setThreadMuted(muted); + viewDataBuilder.setThreadMuted(threadMuted); viewDataBuilder.setMuted(muted); NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( @@ -924,9 +957,11 @@ public class NotificationsFragment extends SFragment implements } private void onPreferenceChanged(String key) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + switch (key) { case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(getContext()).getBoolean("fabHide", false); + hideFab = sharedPreferences.getBoolean("fabHide", false); break; } case "mediaPreviewEnabled": { @@ -938,11 +973,15 @@ public class NotificationsFragment extends SFragment implements } case "showNotificationsFilter": { if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(getContext()).getBoolean("showNotificationsFilter", true); + showNotificationsFilter = sharedPreferences.getBoolean("showNotificationsFilter", true); updateFilterVisibility(); fullyRefreshWithProgressBar(true); } } + case PrefKeys.HIDE_MUTED_USERS: { + withMuted = !sharedPreferences.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); + fullyRefresh(); + } } } @@ -952,6 +991,21 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); } + private void removeAllByConversationId(int conversationId) { + // using iterator to safely remove items while iterating + Iterator> iterator = notifications.iterator(); + while (iterator.hasNext()) { + Either placeholderOrNotification = iterator.next(); + Notification notification = placeholderOrNotification.asRightOrNull(); + if (notification != null && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION && + notification.getStatus().getConversationId() == conversationId) { + iterator.remove(); + } + } + updateAdapter(); + } + private void removeAllByAccountId(String accountId) { // using iterator to safely remove items while iterating Iterator> iterator = notifications.iterator(); @@ -1020,8 +1074,6 @@ public class NotificationsFragment extends SFragment implements bottomLoading = true; } - boolean withMuted = true; // TODO: configurable - Call> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null, withMuted); call.enqueue(new Callback>() { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index fc358a9a..44da71cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -21,6 +21,7 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Environment; @@ -62,6 +63,7 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.EmojiReaction; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -101,6 +103,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { private boolean filterRemoveRegex; private Matcher filterRemoveRegexMatcher; private static Matcher alphanumeric = Pattern.compile("^\\w+$").matcher(""); + private boolean filterMuted; @Inject public MastodonApi mastodonApi; @@ -543,8 +546,23 @@ public abstract class SFragment extends BaseFragment implements Injectable { }); } - @VisibleForTesting - public void reloadFilters(boolean forceRefresh) { + public boolean isFilteringMuted() { + return filterMuted; + } + + public void updateMuteFilter(@NonNull SharedPreferences pref, boolean reload) { + filterMuted = pref.getBoolean(PrefKeys.HIDE_MUTED_USERS, false); + + if(reload) { + refreshAfterApplyingFilters(); + } + } + + public void reloadFilters(SharedPreferences pref, boolean forceRefresh) { + if(pref != null) { + updateMuteFilter(pref, false); // will be reloaded later + } + if (filters != null && !forceRefresh) { applyFilters(forceRefresh); return; @@ -581,6 +599,9 @@ public abstract class SFragment extends BaseFragment implements Injectable { @VisibleForTesting public boolean shouldFilterStatus(Status status) { + if (filterMuted && status.getMuted()) { + return true; + } if (filterRemoveRegex && status.getPoll() != null) { for (PollOption option : status.getPoll().getOptions()) { @@ -594,7 +615,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); } - private void applyFilters(boolean refresh) { + public void applyFilters(boolean refresh) { List tokens = new ArrayList<>(); for (Filter filter : filters) { if (filterIsRelevant(filter)) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index a1e6213b..c150c614 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -61,6 +61,7 @@ import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.repository.Placeholder; import com.keylesspalace.tusky.repository.TimelineRepository; import com.keylesspalace.tusky.repository.TimelineRequestMode; +import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; @@ -245,7 +246,6 @@ public class TimelineFragment extends SFragment implements adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this); isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true); - } @Override @@ -288,7 +288,7 @@ public class TimelineFragment extends SFragment implements private void tryCache() { // Request timeline from disk to make it quick, then replace it with timeline from // the server to update it - this.timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, + timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) .observeOn(AndroidSchedulers.mainThread()) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) @@ -373,7 +373,8 @@ public class TimelineFragment extends SFragment implements filter = preferences.getBoolean("tabFilterHomeBoosts", true); filterRemoveReblogs = kind == Kind.HOME && !filter; - reloadFilters(false); + + reloadFilters(preferences,false); } private static boolean filterContextMatchesKind(Kind kind, List filterContext) { @@ -455,12 +456,13 @@ public class TimelineFragment extends SFragment implements public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't * guaranteed to be set until then. */ if (actionButtonPresent()) { /* Use a modified scroll listener that both loads more statuses as it goes, and hides * the follow button on down-scroll. */ - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); hideFab = preferences.getBoolean("fabHide", false); scrollListener = new EndlessOnScrollListener(layoutManager) { @Override @@ -530,8 +532,7 @@ public class TimelineFragment extends SFragment implements } } else if (event instanceof MuteEvent) { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - String id = ((MuteEvent) event).getAccountId(); - removeAllByAccountId(id); + handleMuteEvent((MuteEvent)event); } } else if (event instanceof DomainMuteEvent) { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { @@ -700,12 +701,12 @@ public class TimelineFragment extends SFragment implements updateAdapter(); } - private void setMutedStatusForStatus(int position, Status status, boolean muted) { - status.setThreadMuted(muted); + private void setMutedStatusForStatus(int position, Status status, boolean muted, boolean threadMuted) { + status.setThreadMuted(threadMuted); StatusViewData.Builder statusViewData = new StatusViewData.Builder((StatusViewData.Concrete)statuses.getPairedItem(position)); statusViewData.setMuted(muted); - statusViewData.setThreadMuted(muted); + statusViewData.setThreadMuted(threadMuted); statuses.setPairedItem(position, statusViewData.createStatusViewData()); } @@ -912,13 +913,17 @@ public class TimelineFragment extends SFragment implements } break; } + case PrefKeys.HIDE_MUTED_USERS: { + updateMuteFilter(sharedPreferences, true); + break; + } case Filter.HOME: case Filter.NOTIFICATIONS: case Filter.THREAD: case Filter.PUBLIC: case Filter.ACCOUNT: { if (filterContextMatchesKind(kind, Collections.singletonList(key))) { - reloadFilters(true); + reloadFilters(sharedPreferences, true); } break; } @@ -936,6 +941,19 @@ public class TimelineFragment extends SFragment implements updateAdapter(); } + private void removeAllByConversationId(int conversationId) { + // using iterator to safely remove items while iterating + Iterator> iterator = statuses.iterator(); + while (iterator.hasNext()) { + Status status = iterator.next().asRightOrNull(); + if (status != null && + (status.getConversationId() == conversationId) || status.getActionableStatus().getConversationId() == conversationId) { + iterator.remove(); + } + } + updateAdapter(); + } + private void removeAllByAccountId(String accountId) { // using iterator to safely remove items while iterating Iterator> iterator = statuses.iterator(); @@ -1026,32 +1044,30 @@ public class TimelineFragment extends SFragment implements private Call> getFetchCallByTimelineType(String fromId, String uptoId) { MastodonApi api = mastodonApi; - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - boolean withMuted = !preferences.getBoolean("hideMutedUsers", false); switch (kind) { default: case HOME: - return api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE); case PUBLIC_FEDERATED: - return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE); case PUBLIC_LOCAL: - return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE); case TAG: String firstHashtag = tags.get(0); List additionalHashtags = tags.subList(1, tags.size()); - return api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE); case USER: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, true, null, null, withMuted); + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, true, null, null); case USER_PINNED: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, true, withMuted); + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, true); case USER_WITH_REPLIES: - return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, null, withMuted); + return api.accountStatuses(id, fromId, uptoId, LOAD_AT_ONCE, null, null, null); case FAVOURITES: - return api.favourites(fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.favourites(fromId, uptoId, LOAD_AT_ONCE); case BOOKMARKS: - return api.bookmarks(fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.bookmarks(fromId, uptoId, LOAD_AT_ONCE); case LIST: - return api.listTimeline(id, fromId, uptoId, LOAD_AT_ONCE, withMuted); + return api.listTimeline(id, fromId, uptoId, LOAD_AT_ONCE); } } @@ -1421,17 +1437,45 @@ public class TimelineFragment extends SFragment implements int conversationId = eventStatus.getConversationId(); if(conversationId == -1) { // invalid conversation ID - setMutedStatusForStatus(pos, eventStatus, event.getMute()); + if(isFilteringMuted()) { + statuses.remove(pos); + } else { + setMutedStatusForStatus(pos, eventStatus, event.getMute(), event.getMute()); + } + updateAdapter(); } else { //noinspection ConstantConditions + if(isFilteringMuted()) { + removeAllByConversationId(conversationId); + } else { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i).asRightOrNull(); + if (status != null && status.getConversationId() == conversationId) { + setMutedStatusForStatus(i, status, event.getMute(), event.getMute()); + } + } + updateAdapter(); + } + } + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean muting = event.getMute(); + + if(isFilteringMuted() && muting) { + removeAllByAccountId(id); + } else { for (int i = 0; i < statuses.size(); i++) { Status status = statuses.get(i).asRightOrNull(); - if (status != null && status.getConversationId() == conversationId) { - setMutedStatusForStatus(i, status, event.getMute()); + if (status != null + && status.getAccount().getId().equals(id) + && !status.isThreadMuted()) { + setMutedStatusForStatus(i, status, muting, false); } } + updateAdapter(); } - updateAdapter(); } private List> liftStatusList(List list) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index e7288c91..c63b5933 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -53,7 +53,6 @@ import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; import com.keylesspalace.tusky.viewdata.StatusViewData; @@ -155,7 +154,7 @@ public final class ViewThreadFragment extends SFragment implements recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - reloadFilters(false); + reloadFilters(PreferenceManager.getDefaultSharedPreferences(context), false); recyclerView.setAdapter(adapter); @@ -184,6 +183,8 @@ public final class ViewThreadFragment extends SFragment implements handleBookmarkEvent((BookmarkEvent) event); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof MuteEvent) { + handleMuteEvent((MuteEvent) event); } else if (event instanceof StatusComposedEvent) { handleStatusComposedEvent((StatusComposedEvent) event); } else if (event instanceof StatusDeletedEvent) { @@ -207,7 +208,7 @@ public final class ViewThreadFragment extends SFragment implements .createStatusViewData(); statuses.setPairedItem(i, newViewData); } - adapter.setStatuses(statuses.getPairedCopy()); + updateAdapter(); updateRevealIcon(); } @@ -411,7 +412,7 @@ public final class ViewThreadFragment extends SFragment implements getActivity().finish(); } statuses.remove(position); - adapter.setStatuses(statuses.getPairedCopy()); + updateAdapter(); } public void onVoteInPoll(int position, @NonNull List choices) { @@ -441,6 +442,10 @@ public final class ViewThreadFragment extends SFragment implements adapter.setItem(position, newViewData, true); } + private void updateAdapter() { + adapter.setStatuses(statuses.getPairedCopy()); + } + private void removeAllByAccountId(String accountId) { Status status = null; if (!statuses.isEmpty()) { @@ -461,7 +466,7 @@ public final class ViewThreadFragment extends SFragment implements return; } adapter.setDetailedStatusPosition(statusIndex); - adapter.setStatuses(statuses.getPairedCopy()); + updateAdapter(); } private void sendStatusRequest(final String id) { @@ -602,6 +607,33 @@ public final class ViewThreadFragment extends SFragment implements updateRevealIcon(); } + private void setMutedStatusForStatus(int position, Status status, boolean muted) { + StatusViewData.Builder statusViewData = new StatusViewData.Builder(statuses.getPairedItem(position)); + statusViewData.setMuted(muted); + + statuses.setPairedItem(position, statusViewData.createStatusViewData()); + } + + private void handleMuteEvent(MuteEvent event) { + String id = event.getAccountId(); + boolean muting = event.getMute(); + + if(isFilteringMuted()) { + removeAllByAccountId(id); + } else { + for (int i = 0; i < statuses.size(); i++) { + Status status = statuses.get(i); + if (status != null + && status.getAccount().getId().equals(id) + && !status.isThreadMuted()) { + setMutedStatusForStatus(i, status, muting); + } + } + updateAdapter(); + } + } + + private void handleFavEvent(FavoriteEvent event) { Pair posAndStatus = findStatusAndPos(event.getStatusId()); if (posAndStatus == null) return; diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index a3f00390..bcf7ad83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -51,49 +51,44 @@ interface MastodonApi { @GET("api/v1/filters") fun getFilters(): Call> - @GET("api/v1/timelines/home") + @GET("api/v1/timelines/home?with_muted=true") fun homeTimeline( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> - @GET("api/v1/timelines/home") + @GET("api/v1/timelines/home?with_muted=true") fun homeTimelineSingle( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Single> - @GET("api/v1/timelines/public") + @GET("api/v1/timelines/public?with_muted=true") fun publicTimeline( @Query("local") local: Boolean?, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> - @GET("api/v1/timelines/tag/{hashtag}") + @GET("api/v1/timelines/tag/{hashtag}?with_muted=true") fun hashtagTimeline( @Path("hashtag") hashtag: String, @Query("any[]") any: List?, @Query("local") local: Boolean?, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> - @GET("api/v1/timelines/list/{listId}") + @GET("api/v1/timelines/list/{listId}?with_muted=true") fun listTimeline( @Path("listId") listId: String, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> @GET("api/v1/notifications") @@ -112,12 +107,11 @@ interface MastodonApi { @Query("timeline[]") timelines: List ): Single> - @GET("api/v1/notifications") + @GET("api/v1/notifications?with_muted=true") fun notificationsWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Query("since_id") sinceId: String?, - @Query("with_muted") withMuted: Boolean?, @Query("include_types[]") includeTypes: List? ): Single> @@ -288,7 +282,7 @@ interface MastodonApi { * @param excludeReplies only return statuses that are no replies * @param onlyMedia only return statuses that have media attached */ - @GET("api/v1/accounts/{id}/statuses") + @GET("api/v1/accounts/{id}/statuses?with_muted=true") fun accountStatuses( @Path("id") accountId: String, @Query("max_id") maxId: String?, @@ -296,8 +290,7 @@ interface MastodonApi { @Query("limit") limit: Int?, @Query("exclude_replies") excludeReplies: Boolean?, @Query("only_media") onlyMedia: Boolean?, - @Query("pinned") pinned: Boolean?, - @Query("with_muted") withMuted: Boolean? + @Query("pinned") pinned: Boolean? ): Call> @GET("api/v1/accounts/{id}/followers") @@ -392,20 +385,18 @@ interface MastodonApi { @HTTP(method = "DELETE", path = "api/v1/domain_blocks", hasBody = true) fun unblockDomain(@Field("domain") domain: String): Call - @GET("api/v1/favourites") + @GET("api/v1/favourites?with_muted=true") fun favourites( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> - @GET("api/v1/bookmarks") + @GET("api/v1/bookmarks?with_muted=true") fun bookmarks( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("with_muted") withMuted: Boolean? + @Query("limit") limit: Int? ): Call> @GET("api/v1/follow_requests") @@ -572,14 +563,13 @@ interface MastodonApi { @Field("forward") isNotifyRemote: Boolean? ): Single - @GET("api/v1/accounts/{id}/statuses") + @GET("api/v1/accounts/{id}/statuses?with_muted=true") fun accountStatusesObservable( @Path("id") accountId: String, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int?, - @Query("exclude_reblogs") excludeReblogs: Boolean?, - @Query("with_muted") withMuted: Boolean? + @Query("exclude_reblogs") excludeReblogs: Boolean? ): Single> @GET("api/v1/statuses/{id}") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt index 7eeb0700..c830f187 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -103,7 +103,7 @@ class TimelineCasesImpl( override fun onFailure(call: Call, t: Throwable) {} }) - eventHub.dispatch(MuteEvent(id)) + eventHub.dispatch(MuteEvent(id, true)) } override fun muteStatus(status: Status, mute: Boolean) { @@ -115,7 +115,7 @@ class TimelineCasesImpl( mastodonApi.unmuteStatus(id) }).subscribe( { status -> eventHub.dispatch(MuteStatusEvent(status.id, mute)) - }, {}) + }, {}).addTo(this.cancelDisposable) } override fun block(id: String) { @@ -126,7 +126,6 @@ class TimelineCasesImpl( override fun onFailure(call: Call, t: Throwable) {} }) eventHub.dispatch(BlockEvent(id)) - } override fun delete(id: String): Single { diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt index c4e8c5b2..58233f32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -66,8 +66,7 @@ class TimelineRepositoryImpl( sinceIdMinusOne: String?, limit: Int, accountId: Long, requestMode: TimelineRequestMode ): Single> { - val withMuted = true // TODO: configurable - return mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1, withMuted) + return mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1) .map { statuses -> this.saveStatusesToDb(accountId, statuses, maxId, sinceId) } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 42d67e76..6759d214 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -34,6 +34,7 @@ object PrefKeys { const val BIG_EMOJIS = "bigEmojis" const val STICKERS = "stickers" const val ANONYMIZE_FILENAMES = "anonymizeFilenames" + const val HIDE_MUTED_USERS = "hideMutedUsers" const val CUSTOM_TABS = "customTabs" diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt index 87dc4ed7..5f1d74b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -247,7 +247,8 @@ class AccountViewModel @Inject constructor( when (relationshipAction) { RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) - RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId, true)) + RelationShipAction.UNMUTE -> eventHub.dispatch(MuteEvent(accountId, false)) else -> { } } @@ -276,7 +277,6 @@ class AccountViewModel @Inject constructor( call.enqueue(callback) callList.add(call) - } override fun onCleared() { @@ -299,7 +299,6 @@ class AccountViewModel @Inject constructor( if (!isSelf) obtainRelationship(isReload) } - } fun setAccountInfo(accountId: String) {