diff --git a/app/build.gradle b/app/build.gradle index cee0f9841..8fe6ab08f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -124,7 +124,7 @@ dependencies { implementation "androidx.appcompat:appcompat:1.2.0-rc01" implementation "androidx.fragment:fragment-ktx:1.2.5" implementation "androidx.browser:browser:1.2.0" - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.recyclerview:recyclerview:1.1.0" implementation "androidx.exifinterface:exifinterface:1.2.0" implementation "androidx.cardview:cardview:1.0.0" @@ -172,7 +172,7 @@ dependencies { implementation "com.github.connyduck:sparkbutton:4.0.0" - implementation 'com.github.MikeOrtiz:TouchImageView:3.0.1' + implementation "com.github.chrisbanes:PhotoView:2.3.0" implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index 0ba3428b6..df8070214 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -58,6 +58,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.pager.AccountPagerAdapter import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewmodel.AccountViewModel import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -352,8 +353,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI swipeToRefreshLayout.isRefreshing = isRefreshing == true }) swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeToRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(this, - android.R.attr.colorBackground)) } private fun onAccountChanged(account: Account?) { @@ -382,7 +381,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI invalidateOptionsMenu() accountMuteButton.setOnClickListener { - viewModel.changeMuteState() + viewModel.unmuteAccount() updateMuteButton() } } @@ -703,13 +702,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private fun toggleMute() { if (viewModel.relationshipData.value?.data?.muting != true) { - AlertDialog.Builder(this) - .setMessage(getString(R.string.dialog_mute_warning, loadedAccount?.username)) - .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeMuteState() } - .setNegativeButton(android.R.string.cancel, null) - .show() + loadedAccount?.let { + showMuteAccountDialog( + this, + it.username, + { notifications -> viewModel.muteAccount(notifications) } + ) + } } else { - viewModel.changeMuteState() + viewModel.unmuteAccount() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java index 3cc686ffc..073d76dab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java @@ -103,7 +103,7 @@ public class BlocksAdapter extends AccountAdapter { listener.onBlock(false, id, position); } }); - avatar.setOnClickListener(v -> listener.onViewAccount(id)); + itemView.setOnClickListener(v -> listener.onViewAccount(id)); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 41a96a6f9..ff87f9781 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -44,6 +44,6 @@ internal class FollowRequestViewHolder(itemView: View, private val showHeader: B listener.onRespondToFollowRequest(false, id, position) } } - itemView.avatar.setOnClickListener { listener.onViewAccount(id) } + itemView.setOnClickListener { listener.onViewAccount(id) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java index db3a0ae55..c4224c9c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java @@ -8,6 +8,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; @@ -17,10 +18,14 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; +import java.util.HashMap; + public class MutesAdapter extends AccountAdapter { + private HashMap mutingNotificationsMap; public MutesAdapter(AccountActionListener accountActionListener) { super(accountActionListener); + mutingNotificationsMap = new HashMap(); } @NonNull @@ -45,19 +50,31 @@ public class MutesAdapter extends AccountAdapter { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder; - holder.setupWithAccount(accountList.get(position)); + Account account = accountList.get(position); + holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId())); holder.setupActionListener(accountActionListener); } } + public void updateMutingNotifications(String id, boolean mutingNotifications, int position) { + mutingNotificationsMap.put(id, mutingNotifications); + notifyItemChanged(position); + } + + public void updateMutingNotificationsMap(HashMap newMutingNotificationsMap) { + mutingNotificationsMap.putAll(newMutingNotificationsMap); + notifyDataSetChanged(); + } static class MutedUserViewHolder extends RecyclerView.ViewHolder { private ImageView avatar; private TextView username; private TextView displayName; private ImageButton unmute; + private ImageButton muteNotifications; private String id; private boolean animateAvatar; + private boolean notifications; MutedUserViewHolder(View itemView) { super(itemView); @@ -65,11 +82,12 @@ public class MutesAdapter extends AccountAdapter { username = itemView.findViewById(R.id.muted_user_username); displayName = itemView.findViewById(R.id.muted_user_display_name); unmute = itemView.findViewById(R.id.muted_user_unmute); + muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications); animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()) .getBoolean("animateGifAvatars", false); } - void setupWithAccount(Account account) { + void setupWithAccount(Account account, Boolean mutingNotifications) { id = account.getId(); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); displayName.setText(emojifiedName); @@ -79,11 +97,39 @@ public class MutesAdapter extends AccountAdapter { int avatarRadius = avatar.getContext().getResources() .getDimensionPixelSize(R.dimen.avatar_radius_48dp); ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); + + String unmuteString = unmute.getContext().getString(R.string.action_unmute_desc, formattedUsername); + unmute.setContentDescription(unmuteString); + ViewCompat.setTooltipText(unmute, unmuteString); + + if (mutingNotifications == null) { + muteNotifications.setEnabled(false); + notifications = true; + } else { + muteNotifications.setEnabled(true); + notifications = mutingNotifications; + } + + if (notifications) { + muteNotifications.setImageResource(R.drawable.ic_notifications_24dp); + String unmuteNotificationsString = muteNotifications.getContext() + .getString(R.string.action_unmute_notifications_desc, formattedUsername); + muteNotifications.setContentDescription(unmuteNotificationsString); + ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString); + } else { + muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp); + String muteNotificationsString = muteNotifications.getContext() + .getString(R.string.action_mute_notifications_desc, formattedUsername); + muteNotifications.setContentDescription(muteNotificationsString); + ViewCompat.setTooltipText(muteNotifications, muteNotificationsString); + } } void setupActionListener(final AccountActionListener listener) { - unmute.setOnClickListener(v -> listener.onMute(false, id, getAdapterPosition())); - avatar.setOnClickListener(v -> listener.onViewAccount(id)); + unmute.setOnClickListener(v -> listener.onMute(false, id, getAdapterPosition(), false)); + muteNotifications.setOnClickListener( + v -> listener.onMute(true, id, getAdapterPosition(), !notifications)); + itemView.setOnClickListener(v -> listener.onViewAccount(id)); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 7359bc8c7..f31293e85 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -355,7 +355,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } void setupButtons(final NotificationActionListener listener, final String accountId) { - avatar.setOnClickListener(v -> listener.onViewAccount(accountId)); + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index a768df098..f1e4b3ea7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -33,9 +33,9 @@ import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition +import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.util.withLifecycleContext -import com.ortiz.touchview.TouchImageView // https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420 @@ -50,8 +50,8 @@ fun T.makeCaptionDialog(existingDescription: String?, dialogLayout.setPadding(padding, padding, padding, padding) dialogLayout.orientation = LinearLayout.VERTICAL - val imageView = TouchImageView(this).apply { - maxZoom = 6f + val imageView = PhotoView(this).apply { + maximumScale = 6f } val displayMetrics = DisplayMetrics() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index f87fb347d..5692d015c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -104,7 +104,6 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res viewModel.refresh() } swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)) } private fun onTopLoaded() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 5df01503b..76305d055 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -102,7 +102,6 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler { private fun setupSwipeRefreshLayout() { swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)) swipeRefreshLayout.setOnRefreshListener { snackbarErrorRetry?.dismiss() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt index 17682ad3b..2ade20b8c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt @@ -59,8 +59,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses) swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeRefreshLayout.setProgressBackgroundColorSchemeColor( - ThemeUtils.getColor(this, android.R.attr.colorBackground)) scheduledTootList.setHasFixedSize(true) scheduledTootList.layoutManager = LinearLayoutManager(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 279028ae9..0a551fb70 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -245,8 +245,8 @@ class SearchViewModel @Inject constructor( return accountManager.getAllAccountsOrderedByActive() } - fun muteAccount(accountId: String) { - timelineCases.mute(accountId) + fun muteAccount(accountId: String, notifications: Boolean) { + timelineCases.mute(accountId, notifications) } fun pinAccount(status: Status, isPin: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 5ac44c55d..1aa3ab5d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -58,9 +58,6 @@ abstract class SearchFragment : Fragment(), private fun setupSwipeRefreshLayout() { swipeRefreshLayout.setOnRefreshListener(this) swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeRefreshLayout.setProgressBackgroundColorSchemeColor( - ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground) - ) } private fun subscribeObservables() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt index a4c50e7a4..2ed1422e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from @@ -377,11 +378,11 @@ class SearchNotestockFragment : SearchFragment viewModel.muteAccount(accountId) } - .setNegativeButton(android.R.string.cancel, null) - .show() + showMuteAccountDialog( + this.requireActivity(), + accountUsername, + { notifications -> viewModel.muteAccount(accountId, notifications) } + ) } private fun accountIsInMentions(account: AccountEntity?, mentions: Array): Boolean { @@ -486,4 +487,4 @@ class SearchNotestockFragment : SearchFragment viewModel.muteAccount(accountId) } - .setNegativeButton(android.R.string.cancel, null) - .show() + showMuteAccountDialog( + this.requireActivity(), + accountUsername, + { notifications -> viewModel.muteAccount(accountId, notifications) } + ) } private fun accountIsInMentions(account: AccountEntity?, mentions: Array): Boolean { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index eb1d20b6a..0c974d194 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -23,6 +23,7 @@ data class Relationship ( @SerializedName("followed_by") val followedBy: Boolean, val blocking: Boolean, val muting: Boolean, + @SerializedName("muting_notifications") val mutingNotifications: Boolean, val requested: Boolean, @SerializedName("showing_reblogs") val showingReblogs: Boolean, @SerializedName("domain_blocking") val blockingDomain: Boolean diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index 62ba853ef..9ec53b2de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.AccountListActivity.Type @@ -48,6 +49,7 @@ import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.io.IOException +import java.util.HashMap import javax.inject.Inject @@ -80,6 +82,7 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) recyclerView.layoutManager = layoutManager + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) @@ -112,50 +115,55 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { } } - override fun onMute(mute: Boolean, id: String, position: Int) { + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { val callback = object : Callback { override fun onResponse(call: Call, response: Response) { if (response.isSuccessful) { - onMuteSuccess(mute, id, position) + onMuteSuccess(mute, id, position, notifications) } else { - onMuteFailure(mute, id) + onMuteFailure(mute, id, notifications) } } override fun onFailure(call: Call, t: Throwable) { - onMuteFailure(mute, id) + onMuteFailure(mute, id, notifications) } } val call = if (!mute) { api.unmuteAccount(id) } else { - api.muteAccount(id) + api.muteAccount(id, notifications) } callList.add(call) call.enqueue(callback) } - private fun onMuteSuccess(muted: Boolean, id: String, position: Int) { + private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) { + val mutesAdapter = adapter as MutesAdapter if (muted) { + mutesAdapter.updateMutingNotifications(id, notifications, position) return } - val mutesAdapter = adapter as MutesAdapter val unmutedUser = mutesAdapter.removeItem(position) if (unmutedUser != null) { Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) .setAction(R.string.action_undo) { mutesAdapter.addItem(unmutedUser, position) - onMute(true, id, position) + onMute(true, id, position, notifications) } .show() } } - private fun onMuteFailure(mute: Boolean, accountId: String) { + private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) { val verb = if (mute) { - "mute" + if (notifications) { + "mute (notifications = true)" + } else { + "mute (notifications = false)" + } } else { "unmute" } @@ -321,6 +329,10 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { adapter.update(accounts) } + if (adapter is MutesAdapter) { + fetchRelationships(accounts.map { it.id }) + } + bottomId = fromId fetching = false @@ -337,6 +349,38 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { } } + private fun fetchRelationships(ids: List) { + val callback = object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + val body = response.body() + if (response.isSuccessful && body != null) { + onFetchRelationshipsSuccess(body) + } else { + onFetchRelationshipsFailure(ids) + } + } + + override fun onFailure(call: Call>, t: Throwable) { + onFetchRelationshipsFailure(ids) + } + } + + val call = api.relationships(ids) + callList.add(call) + call.enqueue(callback) + } + + private fun onFetchRelationshipsSuccess(relationships: List) { + val mutesAdapter = adapter as MutesAdapter + var mutingNotificationsMap = HashMap() + relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) } + mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) + } + + private fun onFetchRelationshipsFailure(ids: List) { + Log.e(TAG, "Fetch failure for relationships of accounts: $ids") + } + private fun onFetchAccountsFailure(throwable: Throwable) { fetching = false Log.e(TAG, "Fetch failure", throwable) 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 74c97b6ca..ed1fbad8f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -188,7 +188,6 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { refresh() } swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(view.context, android.R.attr.colorBackground)) } statusView.visibility = View.GONE 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 6d404f707..922d40e46 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -226,7 +226,6 @@ public class NotificationsFragment extends SFragment implements swipeRefreshLayout.setOnRefreshListener(this); swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)); loadNotificationsFilter(); @@ -832,7 +831,7 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onMute(boolean mute, String id, int position) { + public void onMute(boolean mute, String id, int position, boolean notifications) { // No muting from notifications yet } 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 71e4e9d17..fb8860655 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -20,6 +20,7 @@ import android.app.DownloadManager; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; @@ -29,6 +30,8 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; @@ -60,6 +63,7 @@ import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.viewdata.AttachmentViewData; import java.util.ArrayList; @@ -71,6 +75,8 @@ import java.util.regex.Pattern; import javax.inject.Inject; +import kotlin.Unit; + import io.reactivex.android.schedulers.AndroidSchedulers; import retrofit2.Call; import retrofit2.Callback; @@ -358,11 +364,14 @@ public abstract class SFragment extends BaseFragment implements Injectable { } private void onMute(String accountId, String accountUsername) { - new AlertDialog.Builder(requireContext()) - .setMessage(getString(R.string.dialog_mute_warning, accountUsername)) - .setPositiveButton(android.R.string.ok, (__, ___) -> timelineCases.mute(accountId)) - .setNegativeButton(android.R.string.cancel, null) - .show(); + MuteAccountDialog.showMuteAccountDialog( + this.getActivity(), + accountUsername, + (notifications) -> { + timelineCases.mute(accountId, notifications); + return Unit.INSTANCE; + } + ); } private void onBlock(String accountId, String accountUsername) { 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 ba8f04652..d82d6da8f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -531,11 +531,8 @@ public class TimelineFragment extends SFragment implements private void setupSwipeRefreshLayout() { swipeRefreshLayout.setEnabled(isSwipeToRefreshEnabled); if (isSwipeToRefreshEnabled) { - Context context = swipeRefreshLayout.getContext(); swipeRefreshLayout.setOnRefreshListener(this); swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, - android.R.attr.colorBackground)); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 49e313fae..42513b237 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -29,6 +29,7 @@ import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import com.github.chrisbanes.photoview.PhotoViewAttacher import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide @@ -45,6 +46,7 @@ class ViewImageFragment : ViewMediaFragment() { fun onPhotoTap() } + private lateinit var attacher: PhotoViewAttacher private lateinit var photoActionsListener: PhotoActionsListener private lateinit var toolbar: View private var transition = BehaviorSubject.create() @@ -61,9 +63,68 @@ class ViewImageFragment : ViewMediaFragment() { photoActionsListener = context as PhotoActionsListener } + @SuppressLint("ClickableViewAccessibility") override fun setupMediaView(url: String, previewUrl: String?) { descriptionView = mediaDescription photoView.transitionName = url + attacher = PhotoViewAttacher(photoView).apply { + // This prevents conflicts with ViewPager + setAllowParentInterceptOnEdge(true) + + // Clicking outside the photo closes the viewer. + setOnOutsidePhotoTapListener { photoActionsListener.onDismiss() } + setOnClickListener { onMediaTap() } + + /* A vertical swipe motion also closes the viewer. This is especially useful when the photo + * mostly fills the screen so clicking outside is difficult. */ + setOnSingleFlingListener { _, _, velocityX, velocityY -> + var result = false + if (abs(velocityY) > abs(velocityX)) { + photoActionsListener.onDismiss() + result = true + } + result + } + } + + val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + onMediaTap() + return true + } + }) + + var lastY = 0f + + photoView.setOnTouchListener { v, event -> + // This part is for scaling/translating on vertical move. + // We use raw coordinates to get the correct ones during scaling + + gestureDetector.onTouchEvent(event) + + if (event.action == MotionEvent.ACTION_DOWN) { + lastY = event.rawY + } else if (event.pointerCount == 1 + && attacher.scale == 1f + && event.action == MotionEvent.ACTION_MOVE + ) { + val diff = event.rawY - lastY + // This code is to prevent transformations during page scrolling + // If we are already translating or we reached the threshold, then transform. + if (photoView.translationY != 0f || abs(diff) > 40) { + photoView.translationY += (diff) + val scale = (-abs(photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) + photoView.scaleY = scale + photoView.scaleX = scale + lastY = event.rawY + return@setOnTouchListener true + } + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + onGestureEnd() + } + attacher.onTouch(v, event) + } + startedTransition = false loadImageFromNetwork(url, previewUrl, photoView) } @@ -74,64 +135,9 @@ class ViewImageFragment : ViewMediaFragment() { return inflater.inflate(R.layout.fragment_view_image, container, false) } - @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { - override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - onMediaTap() - return true - } - }) - - var lastY = 0f - photoView.setOnTouchListener { _, event -> - // This part is for scaling/translating on vertical move. - // We use raw coordinates to get the correct ones during scaling - var result = true - - gestureDetector.onTouchEvent(event) - - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (!photoView.isZoomed && event.action == MotionEvent.ACTION_MOVE) { - val diff = event.rawY - lastY - // This code is to prevent transformations during page scrolling - // If we are already translating or we reached the threshold, then transform. - if (photoView.translationY != 0f || abs(diff) > 40) { - photoView.translationY += (diff) - val scale = (-abs(photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - photoView.scaleY = scale - photoView.scaleX = scale - lastY = event.rawY - } - return@setOnTouchListener true - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - onGestureEnd() - } else if (event.pointerCount >= 2 || photoView.canScrollHorizontally(1) && photoView.canScrollHorizontally(-1)) { - // Starting from here is adapted code from TouchImageView to play nice with pager. - - // Can scroll horizontally checks if there's still a part of the image. - // That can be scrolled until you reach the edge multi-touch event. - val parent = view.parent - result = when (event.action) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - // Disallow RecyclerView to intercept touch events. - parent.requestDisallowInterceptTouchEvent(true) - // Disable touch on view - false - } - MotionEvent.ACTION_UP -> { - // Allow RecyclerView to intercept touch events. - parent.requestDisallowInterceptTouchEvent(false) - true - } - else -> true - } - } - result - } val arguments = this.requireArguments() val attachment = arguments.getParcelable(ARG_ATTACHMENT) @@ -199,6 +205,7 @@ class ViewImageFragment : ViewMediaFragment() { .load(previewUrl) .dontAnimate() .onlyRetrieveFromCache(true) + .centerInside() .addListener(ImageRequestListener(true, isThumnailRequest = true))) else it } @@ -207,6 +214,7 @@ class ViewImageFragment : ViewMediaFragment() { .centerInside() .addListener(ImageRequestListener(false, isThumnailRequest = false)) ) + .centerInside() .addListener(ImageRequestListener(true, isThumnailRequest = false)) .into(photoView) } @@ -267,7 +275,13 @@ class ViewImageFragment : ViewMediaFragment() { // another branch. take() will unsubscribe after we have it to not leak menmory transition .take(1) - .subscribe { target.onResourceReady(resource, null) } + .subscribe { + target.onResourceReady(resource, null) + // It's needed. Don't ask why, I don't know, setImageDrawable() should + // do it by itself but somehow it doesn't work automatically. + // Just do it. If you don't, image will jump around when touched. + attacher.update() + } } return true } 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 71bc7d101..3144e792f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -153,8 +153,6 @@ public final class ViewThreadFragment extends SFragment implements swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); swipeRefreshLayout.setOnRefreshListener(this); swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - swipeRefreshLayout.setProgressBackgroundColorSchemeColor( - ThemeUtils.getColor(context, android.R.attr.colorBackground)); recyclerView = rootView.findViewById(R.id.recyclerView); recyclerView.setHasFixedSize(true); diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java index 116bcae8f..c353d0f3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.java @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.interfaces; public interface AccountActionListener { void onViewAccount(String id); - void onMute(final boolean mute, final String id, final int position); + void onMute(final boolean mute, final String id, final int position, final boolean notifications); void onBlock(final boolean block, final String id, final int position); void onRespondToFollowRequest(final boolean accept, final String id, final int position); } 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 d73a24634..0d89045db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -317,9 +317,11 @@ interface MastodonApi { @Path("id") accountId: String ): Call + @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") fun muteAccount( - @Path("id") accountId: String + @Path("id") accountId: String, + @Field("notifications") notifications: Boolean ): Call @POST("api/v1/accounts/{id}/unmute") 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 50b086982..5b295f987 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -36,7 +36,7 @@ interface TimelineCases { fun reblog(status: Status, reblog: Boolean): Single fun favourite(status: Status, favourite: Boolean): Single fun bookmark(status: Status, bookmark: Boolean): Single - fun mute(id: String) + fun mute(id: String, notifications: Boolean) fun block(id: String) fun delete(id: String): Single fun pin(status: Status, pin: Boolean) @@ -107,8 +107,8 @@ class TimelineCasesImpl( } } - override fun mute(id: String) { - val call = mastodonApi.muteAccount(id) + override fun mute(id: String, notifications: Boolean) { + val call = mastodonApi.muteAccount(id, notifications) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) {} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt b/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt deleted file mode 100644 index a6bbac801..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* Copyright 2019 Joel Pyska - * - * 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 . */ - -package com.keylesspalace.tusky.util - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import com.google.android.material.appbar.AppBarLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView - - -/** - * Disable AppBar scroll if content view empty or don't need to scroll - */ -class AppBarLayoutNoEmptyScrollBehavior : AppBarLayout.Behavior { - - constructor() : super() - - constructor (context: Context, attrs: AttributeSet) : super(context, attrs) - - private fun isRecyclerViewScrollable(appBar: AppBarLayout, recyclerView: RecyclerView?): Boolean { - if (recyclerView == null) - return false - var recyclerViewHeight = recyclerView.height // Height includes RecyclerView plus AppBarLayout at same level - val appCompatHeight = appBar.height - recyclerViewHeight -= appCompatHeight - - return recyclerView.computeVerticalScrollRange() > recyclerViewHeight - } - - override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean { - return if (isRecyclerViewScrollable(child, getRecyclerView(parent))) { - super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type) - } else false - } - - override fun onTouchEvent(parent: CoordinatorLayout, child: AppBarLayout, ev: MotionEvent): Boolean { - //Prevent scroll on app bar drag - return if (child.isShown && !isRecyclerViewScrollable(child, getRecyclerView(parent))) - true - else - super.onTouchEvent(parent, child, ev) - } - - private fun getRecyclerView(parent: ViewGroup): RecyclerView? { - for (i in 0 until parent.childCount) { - val child = parent.getChildAt(i) - if (child is RecyclerView) - return child - else if (child is ViewGroup) { - val childRecyclerView = getRecyclerView(child) - if (childRecyclerView is RecyclerView) - return childRecyclerView - } - } - return null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt new file mode 100644 index 000000000..2702a333b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/MuteAccountDialog.kt @@ -0,0 +1,27 @@ +@file:JvmName("MuteAccountDialog") + +package com.keylesspalace.tusky.view + +import android.app.Activity +import android.widget.CheckBox +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.R + +fun showMuteAccountDialog( + activity: Activity, + accountUsername: String, + onOk: (notifications: Boolean) -> Unit +) { + val view = activity.layoutInflater.inflate(R.layout.dialog_mute_account, null) + (view.findViewById(R.id.warning) as TextView).text = + activity.getString(R.string.dialog_mute_warning, accountUsername) + val checkbox: CheckBox = view.findViewById(R.id.checkbox) + checkbox.setChecked(true) + + AlertDialog.Builder(activity) + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> onOk(checkbox.isChecked) } + .setNegativeButton(android.R.string.cancel, null) + .show() +} \ No newline at end of file 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 2495fce2a..bf8879744 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -148,12 +148,12 @@ class AccountViewModel @Inject constructor( } } - fun changeMuteState() { - if (relationshipData.value?.data?.muting == true) { - changeRelationship(RelationShipAction.UNMUTE) - } else { - changeRelationship(RelationShipAction.MUTE) - } + fun muteAccount(notifications: Boolean) { + changeRelationship(RelationShipAction.MUTE, notifications) + } + + fun unmuteAccount() { + changeRelationship(RelationShipAction.UNMUTE) } fun blockDomain(instance: String) { @@ -203,7 +203,10 @@ class AccountViewModel @Inject constructor( } } - private fun changeRelationship(relationshipAction: RelationShipAction, showReblogs: Boolean = true) { + /** + * @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE + */ + private fun changeRelationship(relationshipAction: RelationShipAction, parameter: Boolean? = null) { val relation = relationshipData.value?.data val account = accountData.value?.data @@ -254,11 +257,11 @@ class AccountViewModel @Inject constructor( } val call = when (relationshipAction) { - RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs) + RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true) RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) - RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId) + RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) } diff --git a/app/src/main/res/drawable/ic_notifications_off_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_24dp.xml new file mode 100644 index 000000000..627eafd22 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_off_24dp.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml index e39998bfa..da5a204bd 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -17,15 +17,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/colorSurface" - app:elevation="0dp" - app:layout_behavior="com.keylesspalace.tusky.util.AppBarLayoutNoEmptyScrollBehavior"> + app:elevation="0dp"> + android:orientation="horizontal">