Show a popup menu when long-pressing on the conversation list.

This commit is contained in:
Greyson Parrelli
2021-10-19 11:06:32 -04:00
parent c0a68202a7
commit 3e5be2cfe2
30 changed files with 554 additions and 96 deletions

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.res.use
import androidx.lifecycle.Lifecycle
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -13,7 +12,6 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.lang.IllegalArgumentException

View File

@@ -0,0 +1,204 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
*
* This will prefer showing the menu underneath the anchor, but if there's not enough space in the container, it will show it above the anchor and reverse the
* order of the menu items. If there's not enough room for either, it'll show it centered above the anchor. If there's not enough room then, it'll center it,
* chop off the part that doesn't fit, and make the menu scrollable.
*/
class SignalContextMenu private constructor(
val anchor: View,
val container: View,
val items: List<Item>,
val baseOffsetX: Int = 0,
val baseOffsetY: Int = 0,
val onDismiss: Runnable? = null
) : PopupWindow(
LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
) {
val context: Context = anchor.context
val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, ItemViewHolderFactory())
}
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
isFocusable = true
if (onDismiss != null) {
setOnDismissListener { onDismiss.run() }
}
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
contentView.findViewById<RecyclerView>(R.id.signal_context_menu_list).apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
mappingAdapter.submitList(items.toAdapterItems())
}
private fun show() {
if (anchor.width == 0 || anchor.height == 0) {
anchor.post(this::show)
return
}
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val menuBottomBound = anchor.y + anchor.height + contentView.measuredHeight + baseOffsetY
val menuTopBound = anchor.y - contentView.measuredHeight - baseOffsetY
val screenBottomBound = container.height
val screenTopBound = container.y
val offsetY: Int
if (menuBottomBound < screenBottomBound) {
offsetY = baseOffsetY
} else if (menuTopBound > screenTopBound) {
offsetY = -(anchor.height + contentView.measuredHeight + baseOffsetY)
mappingAdapter.submitList(items.reversed().toAdapterItems())
} else {
offsetY = -((anchor.height / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
}
showAsDropDown(anchor, baseOffsetX, offsetY)
}
private fun List<Item>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
data class Item(
@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int,
val action: Runnable
)
private data class DisplayItem(
val item: Item,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private inner class ItemViewHolder(itemView: View) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
override fun bind(model: DisplayItem) {
icon.setImageResource(model.item.iconRes)
title.setText(model.item.titleRes)
itemView.setOnClickListener {
model.item.action.run()
dismiss()
}
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}
}
private inner class ItemViewHolderFactory : MappingAdapter.Factory<DisplayItem> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
}
}
/**
* @param anchor The view to put the pop-up on
* @param container A parent of [anchor] that represents the acceptable boundaries of the popup
*/
class Builder(
val anchor: View,
val container: View
) {
var onDismiss: Runnable? = null
var offsetX: Int = 0
var offsetY: Int = 0
fun onDismiss(onDismiss: Runnable): Builder {
this.onDismiss = onDismiss
return this
}
fun offsetX(offsetPx: Int): Builder {
this.offsetX = offsetPx
return this
}
fun offsetY(offsetPx: Int): Builder {
this.offsetY = offsetPx
return this
}
fun show(items: List<Item>) {
SignalContextMenu(
anchor = anchor,
container = container,
items = items,
baseOffsetX = offsetX,
baseOffsetY = offsetY,
onDismiss = onDismiss
).show()
}
}
}

View File

@@ -90,7 +90,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
int position = holder.getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
return onConversationClickListener.onConversationLongClick(getItem(position));
return onConversationClickListener.onConversationLongClick(getItem(position), v);
}
return false;
@@ -302,8 +302,8 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
}
interface OnConversationClickListener {
void onConversationClick(Conversation conversation);
boolean onConversationLongClick(Conversation conversation);
void onConversationClick(@NonNull Conversation conversation);
boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view);
void onShowArchiveClick();
}
}

View File

@@ -66,6 +66,7 @@ import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.greenrobot.eventbus.EventBus;
@@ -79,6 +80,7 @@ import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.components.SignalContextMenu;
import org.thoughtcrime.securesms.components.UnreadPaymentsView;
import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator;
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
@@ -145,6 +147,8 @@ import org.thoughtcrime.securesms.util.views.Stub;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
@@ -762,12 +766,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
private void handleMarkSelectedAsRead() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
private void handleMarkAsRead(@NonNull Collection<Long> ids) {
Context context = requireContext();
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(selectedConversations, false);
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(ids, false);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
@@ -781,12 +784,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
private void handleMarkSelectedAsUnread() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
private void handleMarkAsUnread(@NonNull Collection<Long> ids) {
Context context = requireContext();
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
DatabaseFactory.getThreadDatabase(context).setForcedUnread(selectedConversations);
DatabaseFactory.getThreadDatabase(context).setForcedUnread(ids);
StorageSyncHelper.scheduleSyncForDataChange();
return null;
}, none -> {
@@ -806,8 +808,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
@SuppressLint("StaticFieldLeak")
private void handleArchiveAllSelected() {
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) {
Set<Long> selectedConversations = new HashSet<>(ids);
int count = selectedConversations.size();
String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count);
@@ -816,7 +818,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
snackBarTitle,
getString(R.string.ConversationListFragment_undo),
getResources().getColor(R.color.amber_500),
Snackbar.LENGTH_LONG, true)
Snackbar.LENGTH_LONG,
showProgress)
{
@Override
@@ -838,22 +841,23 @@ public class ConversationListFragment extends MainFragment implements ActionMode
protected void reverseAction(@Nullable Void parameter) {
reverseArchiveThreads(selectedConversations);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}.executeOnExecutor(SignalExecutors.BOUNDED);
}
@SuppressLint("StaticFieldLeak")
private void handleDeleteAllSelected() {
int conversationsCount = defaultAdapter.getBatchSelectionIds().size();
AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
alert.setIcon(R.drawable.ic_warning);
alert.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
conversationsCount, conversationsCount));
alert.setMessage(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations,
conversationsCount, conversationsCount));
private void handleDelete(@NonNull Collection<Long> ids) {
int conversationsCount = ids.size();
MaterialAlertDialogBuilder alert = new MaterialAlertDialogBuilder(requireActivity());
Context context = requireContext();
alert.setTitle(context.getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
conversationsCount, conversationsCount));
alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations,
conversationsCount, conversationsCount));
alert.setCancelable(true);
alert.setPositiveButton(R.string.delete, (dialog, which) -> {
final Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
final Set<Long> selectedConversations = new HashSet<>(ids);
if (!selectedConversations.isEmpty()) {
new AsyncTask<Void, Void, Void>() {
@@ -861,16 +865,16 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
protected void onPreExecute() {
dialog = ProgressDialog.show(getActivity(),
getActivity().getString(R.string.ConversationListFragment_deleting),
getActivity().getString(R.string.ConversationListFragment_deleting_selected_conversations),
dialog = ProgressDialog.show(requireActivity(),
context.getString(R.string.ConversationListFragment_deleting),
context.getString(R.string.ConversationListFragment_deleting_selected_conversations),
true, false);
}
@Override
protected Void doInBackground(Void... params) {
DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations);
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
ApplicationDependencies.getMessageNotifier().updateNotification(requireActivity());
return null;
}
@@ -882,7 +886,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
actionMode = null;
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}.executeOnExecutor(SignalExecutors.BOUNDED);
}
});
@@ -890,8 +894,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
alert.show();
}
private void handlePinAllSelected() {
final Set<Long> toPin = new LinkedHashSet<>(Stream.of(defaultAdapter.getBatchSelection())
private void handlePin(@NonNull Collection<Conversation> conversations) {
final Set<Long> toPin = new LinkedHashSet<>(Stream.of(conversations)
.filterNot(conversation -> conversation.getThreadRecord().isPinned())
.map(conversation -> conversation.getThreadRecord().getThreadId())
.toList());
@@ -902,7 +906,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
Snackbar.LENGTH_LONG)
.setTextColor(Color.WHITE)
.show();
actionMode.finish();
if (actionMode != null) {
actionMode.finish();
}
return;
}
@@ -919,13 +925,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
private void handleUnpinAllSelected() {
final Set<Long> toPin = new HashSet<>(defaultAdapter.getBatchSelectionIds());
private void handleUnpin(@NonNull Collection<Long> ids) {
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication());
db.unpinConversations(toPin);
db.unpinConversations(ids);
return null;
}, unused -> {
@@ -1005,7 +1009,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
@Override
public void onConversationClick(Conversation conversation) {
public void onConversationClick(@NonNull Conversation conversation) {
if (actionMode == null) {
handleCreateConversation(conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient(), conversation.getThreadRecord().getDistributionType());
} else {
@@ -1021,16 +1025,51 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
@Override
public boolean onConversationLongClick(Conversation conversation) {
public boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view) {
if (actionMode != null) {
onConversationClick(conversation);
return true;
}
defaultAdapter.initializeBatchMode(true);
defaultAdapter.toggleConversationInBatchSet(conversation);
view.setSelected(true);
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
Collection<Long> id = Collections.singleton(conversation.getThreadRecord().getThreadId());
List<SignalContextMenu.Item> items = new ArrayList<>();
if (!conversation.getThreadRecord().isArchived()) {
if (conversation.getThreadRecord().isRead()) {
items.add(new SignalContextMenu.Item(R.drawable.ic_unread_24, R.string.ConversationListFragment_unread, () -> handleMarkAsUnread(id)));
} else {
items.add(new SignalContextMenu.Item(R.drawable.ic_read_24, R.string.ConversationListFragment_read, () -> handleMarkAsRead(id)));
}
if (conversation.getThreadRecord().isPinned()) {
items.add(new SignalContextMenu.Item(R.drawable.ic_unpin_24, R.string.ConversationListFragment_unpin, () -> handleUnpin(id)));
} else {
items.add(new SignalContextMenu.Item(R.drawable.ic_pin_24, R.string.ConversationListFragment_pin, () -> handlePin(Collections.singleton(conversation))));
}
}
items.add(new SignalContextMenu.Item(R.drawable.ic_select_24, R.string.ConversationListFragment_select, () -> {
defaultAdapter.initializeBatchMode(true);
defaultAdapter.toggleConversationInBatchSet(conversation);
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
}));
if (conversation.getThreadRecord().isArchived()) {
items.add(new SignalContextMenu.Item(R.drawable.ic_unarchive_24, R.string.ConversationListFragment_unarchive, () -> handleArchive(id, false)));
} else {
items.add(new SignalContextMenu.Item(R.drawable.ic_archive_24, R.string.ConversationListFragment_archive, () -> handleArchive(id, false)));
}
items.add(new SignalContextMenu.Item(R.drawable.ic_delete_24, R.string.ConversationListFragment_delete, () -> handleDelete(id)));
new SignalContextMenu.Builder(view, list)
.offsetX(ViewUtil.dpToPx(12))
.offsetY(ViewUtil.dpToPx(12))
.onDismiss(() -> view.setSelected(false))
.show(items);
return true;
}
@@ -1059,13 +1098,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_select_all: handleSelectAllThreads(); return true;
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
case R.id.menu_pin_selected: handlePinAllSelected(); return true;
case R.id.menu_unpin_selected: handleUnpinAllSelected(); return true;
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true;
case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true;
case R.id.menu_select_all: handleSelectAllThreads(); return true;
case R.id.menu_delete_selected: handleDelete(defaultAdapter.getBatchSelectionIds()); return true;
case R.id.menu_pin_selected: handlePin(defaultAdapter.getBatchSelection()); return true;
case R.id.menu_unpin_selected: handleUnpin(defaultAdapter.getBatchSelectionIds()); return true;
case R.id.menu_archive_selected: handleArchive(defaultAdapter.getBatchSelectionIds(), true); return true;
case R.id.menu_mark_as_read: handleMarkAsRead(defaultAdapter.getBatchSelectionIds()); return true;
case R.id.menu_mark_as_unread: handleMarkAsUnread(defaultAdapter.getBatchSelectionIds()); return true;
}
return false;

View File

@@ -867,7 +867,7 @@ public class ThreadDatabase extends Database {
StorageSyncHelper.scheduleSyncForDataChange();
}
public void unpinConversations(@NonNull Set<Long> threadIds) {
public void unpinConversations(@NonNull Collection<Long> threadIds) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
ContentValues contentValues = new ContentValues(1);
String placeholders = StringUtil.join(Stream.of(threadIds).map(unused -> "?").toList(), ",");