Implement bottom selection menu in chat.

This commit is contained in:
Rashad Sookram
2022-01-11 09:36:21 -05:00
committed by Cody Henthorne
parent 917744f091
commit 3943e670b2
15 changed files with 209 additions and 122 deletions

View File

@@ -96,6 +96,7 @@ public class InputPanel extends LinearLayout
private boolean hideForGroupState;
private boolean hideForBlockedState;
private boolean hideForSearch;
private boolean hideForSelection;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
@@ -336,6 +337,11 @@ public class InputPanel extends LinearLayout
updateVisibility();
}
public void setHideForSelection(boolean hideForSelection) {
this.hideForSelection = hideForSelection;
updateVisibility();
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -515,7 +521,7 @@ public class InputPanel extends LinearLayout
}
private void updateVisibility() {
if (hideForGroupState || hideForBlockedState || hideForSearch) {
if (hideForGroupState || hideForBlockedState || hideForSearch || hideForSelection) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);

View File

@@ -3775,6 +3775,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
searchViewItem.collapseActionView();
}
@Override
public void onBottomActionBarVisibilityChanged(int visibility) {
inputPanel.setHideForSelection(visibility == View.VISIBLE);
}
@Override
public void onForwardClicked() {
inputPanel.clearQuote();

View File

@@ -25,6 +25,7 @@ import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Rect;
import android.net.Uri;
import android.os.AsyncTask;
@@ -33,7 +34,6 @@ import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
@@ -55,6 +55,7 @@ import androidx.core.app.ActivityCompat;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.text.HtmlCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.ViewKt;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;
@@ -65,17 +66,19 @@ import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.components.ConversationScrollToView;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -160,20 +163,23 @@ import org.thoughtcrime.securesms.util.TopToastPopup;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import kotlin.Unit;
@@ -224,6 +230,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private LayoutTransition layoutTransition;
private TransitionListener transitionListener;
private View reactionsShade;
private SignalBottomActionBar bottomActionBar;
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
private Colorizer colorizer;
@@ -265,6 +272,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
reactionsShade = view.findViewById(R.id.reactions_shade);
bottomActionBar = view.findViewById(R.id.conversation_bottom_action_bar);
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
final ConversationItemAnimator conversationItemAnimator = new ConversationItemAnimator(
@@ -739,7 +747,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
});
}
private void setCorrectActionModeMenuVisibility(@NonNull Menu menu) {
private void setCorrectActionModeMenuVisibility() {
Set<MultiselectPart> selectedParts = getListAdapter().getSelectedItems();
if (actionMode != null && selectedParts.size() == 0) {
@@ -747,17 +755,86 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
return;
}
setBottomActionBarVisibility(true);
MenuState menuState = MenuState.getMenuState(recipient.get(), selectedParts, messageRequestViewModel.shouldShowMessageRequest(), groupViewModel.isNonAdminInAnnouncementGroup());
menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction());
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
menu.findItem(R.id.menu_context_details).setVisible(menuState.shouldShowDetailsAction());
menu.findItem(R.id.menu_context_save_attachment).setVisible(menuState.shouldShowSaveAttachmentAction());
menu.findItem(R.id.menu_context_resend).setVisible(menuState.shouldShowResendAction());
menu.findItem(R.id.menu_context_copy).setVisible(menuState.shouldShowCopyAction());
menu.findItem(R.id.menu_context_delete_message).setVisible(menuState.shouldShowDeleteAction());
List<ActionItem> items = new ArrayList<>();
AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
if (menuState.shouldShowReplyAction()) {
items.add(new ActionItem(R.drawable.ic_reply_24_tinted, getResources().getString(R.string.conversation_context__menu_reply_to_message), () -> {
maybeShowSwipeToReplyTooltip();
handleReplyMessage(getSelectedConversationMessage());
actionMode.finish();
}));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.ic_forward_24_tinted, getResources().getString(R.string.conversation_context__menu_forward_message), () -> handleForwardMessageParts(selectedParts)));
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.ic_save_24, getResources().getString(R.string.conversation_context_image__save_attachment), () -> {
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
}));
}
if (menuState.shouldShowCopyAction()) {
items.add(new ActionItem(R.drawable.ic_copy_24_tinted, getResources().getString(R.string.conversation_context__menu_copy_text), () -> {
handleCopyMessage(selectedParts);
actionMode.finish();
}));
}
if (menuState.shouldShowDetailsAction()) {
items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_context__menu_message_details), () -> {
handleDisplayDetails(getSelectedConversationMessage());
actionMode.finish();
}));
}
if (menuState.shouldShowDeleteAction()) {
items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_context__menu_delete_message), () -> {
handleDeleteMessages(selectedParts);
actionMode.finish();
}));
}
bottomActionBar.setItems(items);
}
private void setBottomActionBarVisibility(boolean isVisible) {
boolean isCurrentlyVisible = bottomActionBar.getVisibility() == View.VISIBLE;
if (isVisible == isCurrentlyVisible) {
return;
}
int scrollOffset = (int) DimensionUnit.DP.toPixels(34);
if (isVisible) {
ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation());
listener.onBottomActionBarVisibilityChanged(View.VISIBLE);
list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), (int) DimensionUnit.DP.toPixels(88));
list.scrollBy(0, -scrollOffset);
} else {
ViewUtil.animateOut(bottomActionBar, bottomActionBar.getExitAnimation())
.addListener(new ListenableFuture.Listener<Boolean>() {
@Override public void onSuccess(Boolean result) {
listener.onBottomActionBarVisibilityChanged(View.GONE);
list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), getResources().getDimensionPixelSize(R.dimen.conversation_bottom_padding));
ViewKt.doOnPreDraw(list, view -> {
list.scrollBy(0, scrollOffset);
return Unit.INSTANCE;
});
}
@Override public void onFailure(ExecutionException e) {
}
});
}
}
private @Nullable ConversationAdapter getListAdapter() {
@@ -769,9 +846,12 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
private ConversationMessage getSelectedConversationMessage() {
Set<MultiselectPart> messageRecords = getListAdapter().getSelectedItems();
Set<ConversationMessage> messageRecords = Stream.of(getListAdapter().getSelectedItems())
.map(MultiselectPart::getConversationMessage)
.distinct()
.collect(Collectors.toSet());
if (messageRecords.size() == 1) return messageRecords.stream().findFirst().get().getConversationMessage();
if (messageRecords.size() == 1) return messageRecords.stream().findFirst().get();
else throw new AssertionError();
}
@@ -1130,11 +1210,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
if (!TextSecurePreferences.hasSeenSwipeToReplyTooltip(requireContext())) {
int text = ViewUtil.isLtr(requireContext()) ? R.string.ConversationFragment_you_can_swipe_to_the_right_reply
: R.string.ConversationFragment_you_can_swipe_to_the_left_reply;
TooltipPopup.forTarget(requireActivity().findViewById(R.id.menu_context_reply))
.setText(text)
.setTextColor(getResources().getColor(R.color.core_white))
.setBackgroundTint(getResources().getColor(R.color.core_ultramarine))
.show(TooltipPopup.POSITION_BELOW);
Snackbar.make(list, text, Snackbar.LENGTH_LONG)
.setTextColor(Color.WHITE)
.show();
TextSecurePreferences.setHasSeenSwipeToReplyTooltip(requireContext(), true);
}
@@ -1204,15 +1282,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private @NonNull String calculateSelectedItemCount() {
ConversationAdapter adapter = getListAdapter();
if (adapter == null || adapter.getSelectedItems().isEmpty()) {
return String.valueOf(0);
int count = 0;
if (adapter != null && !adapter.getSelectedItems().isEmpty()) {
count = (int) adapter.getSelectedItems()
.stream()
.map(MultiselectPart::getConversationMessage)
.distinct()
.count();
}
return String.valueOf(adapter.getSelectedItems()
.stream()
.map(MultiselectPart::getConversationMessage)
.distinct()
.count());
return requireContext().getResources().getQuantityString(R.plurals.conversation_context__s_selected, count, count);
}
@Override
@@ -1227,6 +1307,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onBottomActionBarVisibilityChanged(int visibility);
void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull ConversationMessage conversationMessage,
@@ -1322,7 +1403,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
if (getListAdapter().getSelectedItems().size() == 0) {
actionMode.finish();
} else {
setCorrectActionModeMenuVisibility(actionMode.getMenu());
setCorrectActionModeMenuVisibility();
actionMode.setTitle(calculateSelectedItemCount());
}
}
@@ -1806,12 +1887,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.conversation_context, menu);
mode.setTitle(calculateSelectedItemCount());
setCorrectActionModeMenuVisibility(menu);
setCorrectActionModeMenuVisibility();
listener.onMessageActionToolbarOpened();
return true;
}
@@ -1825,44 +1903,12 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onDestroyActionMode(ActionMode mode) {
((ConversationAdapter)list.getAdapter()).clearSelection();
list.invalidateItemDecorations();
setBottomActionBarVisibility(false);
actionMode = null;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
if (actionMode == null) return false;
switch(item.getItemId()) {
case R.id.menu_context_copy:
handleCopyMessage(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
case R.id.menu_context_delete_message:
handleDeleteMessages(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
case R.id.menu_context_details:
handleDisplayDetails(getSelectedConversationMessage());
actionMode.finish();
return true;
case R.id.menu_context_forward:
handleForwardMessageParts(getListAdapter().getSelectedItems());
return true;
case R.id.menu_context_resend:
handleResendMessage(getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_save_attachment:
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_reply:
maybeShowSwipeToReplyTooltip();
handleReplyMessage(getSelectedConversationMessage());
actionMode.finish();
return true;
}
return false;
}
}

View File

@@ -108,8 +108,10 @@ class MultiselectItemDecoration(
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val currentSelection = getCurrentSelection(parent)
if (selectedParts.isEmpty() && currentSelection.isNotEmpty()) {
val wasRunning = enterExitAnimation?.isRunning ?: false
enterExitAnimation?.end()
enterExitAnimation = ValueAnimator.ofFloat(enterExitAnimation?.animatedFraction ?: 0f, 1f).apply {
val startValue = if (wasRunning) enterExitAnimation?.animatedFraction else 0f
enterExitAnimation = ValueAnimator.ofFloat(startValue ?: 0f, 1f).apply {
duration = 150L
start()
}
@@ -142,7 +144,10 @@ class MultiselectItemDecoration(
if (adapter.selectedItems.isEmpty()) {
drawFocusShadeUnderIfNecessary(canvas, parent)
return
if (enterExitAnimation == null || !isInitialAnimation()) {
return
}
}
shadePaint.color = when {
@@ -189,7 +194,9 @@ class MultiselectItemDecoration(
canvas.restore()
}
drawChecks(parent, canvas, adapter)
if (adapter.selectedItems.isNotEmpty()) {
drawChecks(parent, canvas, adapter)
}
}
/**
@@ -312,7 +319,8 @@ class MultiselectItemDecoration(
val adapter = parent.adapter as ConversationAdapter
val isLtr = ViewUtil.isLtr(child)
if (adapter.selectedItems.isNotEmpty() && child is Multiselectable) {
val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation()
if ((isAnimatingSelection || adapter.selectedItems.isNotEmpty()) && child is Multiselectable) {
val target = child.getHorizontalTranslationTarget()
if (target != null) {
@@ -323,7 +331,7 @@ class MultiselectItemDecoration(
}
val translation: Float = if (isInitialAnimation()) {
max(0, gutter - start) * (enterExitAnimation?.animatedFraction ?: 1f)
max(0, gutter - start) * (enterExitAnimation?.animatedValue as Float? ?: 1f)
} else {
max(0, gutter - start).toFloat()
}