mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-24 03:35:58 +00:00
Implement bottom selection menu in chat.
This commit is contained in:
committed by
Cody Henthorne
parent
917744f091
commit
3943e670b2
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user