From 28abc1e4ffcca9e21a88b5964723f82f06f19940 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 11 Aug 2021 13:18:38 -0300 Subject: [PATCH] Implement new Multiselect UX and groundwork for Multiforward. --- .../securesms/BindableConversationItem.java | 9 +- .../conversation/ConversationAdapter.java | 32 +-- .../conversation/ConversationFragment.java | 51 ++-- .../conversation/ConversationItem.java | 111 ++++++--- .../conversation/ConversationMessage.java | 15 +- .../conversation/ConversationUpdateItem.java | 36 ++- .../conversation/mutiselect/Multiselect.kt | 61 +++++ .../mutiselect/MultiselectCollection.kt | 50 ++++ .../mutiselect/MultiselectItemDecoration.kt | 220 ++++++++++++++++++ .../mutiselect/MultiselectPart.kt | 32 +++ .../mutiselect/MultiselectRecyclerView.kt | 39 ++++ .../mutiselect/Multiselectable.kt | 16 ++ .../securesms/util/FeatureFlags.java | 9 +- .../util/StickyHeaderDecoration.java | 2 +- .../securesms/wallpaper/ChatWallpaper.java | 4 + .../securesms/wallpaper/UriChatWallpaper.java | 5 + .../res/drawable/ic_check_circle_solid_24.xml | 9 + .../main/res/layout/conversation_fragment.xml | 2 +- .../res/layout/conversation_item_update.xml | 1 - 19 files changed, 625 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectRecyclerView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt create mode 100644 app/src/main/res/drawable/ic_check_circle_solid_24.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 177eae7c09..b638a8dba7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms; +import android.graphics.Point; import android.net.Uri; +import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; @@ -10,9 +12,12 @@ import androidx.lifecycle.Observer; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.conversation.colors.Colorizable; import org.thoughtcrime.securesms.conversation.colors.Colorizer; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; @@ -31,14 +36,14 @@ import java.util.List; import java.util.Locale; import java.util.Set; -public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable { +public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable { void bind(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage messageRecord, @NonNull Optional previousMessageRecord, @NonNull Optional nextMessageRecord, @NonNull GlideRequests glideRequests, @NonNull Locale locale, - @NonNull Set batchSelected, + @NonNull Set batchSelected, @NonNull Recipient recipients, @Nullable String searchQuery, boolean pulseMention, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 04e8c13f29..6862517a34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -16,6 +16,7 @@ */ package org.thoughtcrime.securesms.conversation; +import android.annotation.SuppressLint; import android.content.Context; import android.view.LayoutInflater; import android.view.View; @@ -45,6 +46,7 @@ import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.colors.Colorizable; import org.thoughtcrime.securesms.conversation.colors.Colorizer; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; @@ -111,7 +113,7 @@ public class ConversationAdapter private final Locale locale; private final Recipient recipient; - private final Set selected; + private final Set selected; private final List fastRecords; private final Set releasedFastRecords; private final Calendar calendar; @@ -210,6 +212,7 @@ public class ConversationAdapter return message.getUniqueId(digest); } + @SuppressLint("ClickableViewAccessibility") @Override public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { switch (viewType) { @@ -218,19 +221,20 @@ public class ConversationAdapter case MESSAGE_TYPE_OUTGOING_TEXT: case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: case MESSAGE_TYPE_UPDATE: - View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false); - BindableConversationItem bindable = (BindableConversationItem) itemView; + View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false); + BindableConversationItem bindable = (BindableConversationItem) itemView; - itemView.setOnClickListener(view -> { + itemView.setOnClickListener((v) -> { if (clickListener != null) { - clickListener.onItemClick(bindable.getConversationMessage()); + clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch()); } }); - itemView.setOnLongClickListener(view -> { + itemView.setOnLongClickListener((v) -> { if (clickListener != null) { - clickListener.onItemLongClick(itemView, bindable.getConversationMessage()); + clickListener.onItemLongClick(itemView, bindable.getMultiselectPartForLatestTouch()); } + return true; }); @@ -555,7 +559,7 @@ public class ConversationAdapter /** * Returns set of records that are selected in multi-select mode. */ - Set getSelectedItems() { + public Set getSelectedItems() { return new HashSet<>(selected); } @@ -569,11 +573,11 @@ public class ConversationAdapter /** * Toggles the selected state of a record in multi-select mode. */ - void toggleSelection(ConversationMessage conversationMessage) { - if (selected.contains(conversationMessage)) { - selected.remove(conversationMessage); + void toggleSelection(MultiselectPart multiselectPart) { + if (selected.contains(multiselectPart)) { + selected.remove(multiselectPart); } else { - selected.add(conversationMessage); + selected.add(multiselectPart); } } @@ -782,7 +786,7 @@ public class ConversationAdapter } interface ItemClickListener extends BindableConversationItem.EventListener { - void onItemClick(ConversationMessage item); - void onItemLongClick(View itemView, ConversationMessage item); + void onItemClick(MultiselectPart item); + void onItemLongClick(View itemView, MultiselectPart item); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index c0c42accc9..50ea790f20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -91,6 +91,9 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderV import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.colors.ColorizerView; +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -263,6 +266,7 @@ public class ConversationFragment extends LoggingFragment { final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); list.setHasFixedSize(false); list.setLayoutManager(layoutManager); + list.addItemDecoration(new MultiselectItemDecoration(requireContext(), () -> conversationViewModel.getWallpaper().getValue())); list.setItemAnimator(null); if (Build.VERSION.SDK_INT >= 31) { @@ -770,14 +774,14 @@ public class ConversationFragment extends LoggingFragment { } private void setCorrectMenuVisibility(@NonNull Menu menu) { - Set messages = getListAdapter().getSelectedItems(); + Set messages = getListAdapter().getSelectedItems(); if (actionMode != null && messages.size() == 0) { actionMode.finish(); return; } - MenuState menuState = MenuState.getMenuState(recipient.get(), Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest()); + MenuState menuState = MenuState.getMenuState(recipient.get(), Stream.of(messages).map(MultiselectPart::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest()); menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction()); menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction()); @@ -797,9 +801,9 @@ public class ConversationFragment extends LoggingFragment { } private ConversationMessage getSelectedConversationMessage() { - Set messageRecords = getListAdapter().getSelectedItems(); + Set messageRecords = getListAdapter().getSelectedItems(); - if (messageRecords.size() == 1) return messageRecords.iterator().next(); + if (messageRecords.size() == 1) return messageRecords.stream().findFirst().get().getConversationMessage(); else throw new AssertionError(); } @@ -848,15 +852,15 @@ public class ConversationFragment extends LoggingFragment { list.addItemDecoration(lastSeenDecoration); } - private void handleCopyMessage(final Set conversationMessages) { - List messageList = new ArrayList<>(conversationMessages); - Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived())); + private void handleCopyMessage(final Set multiselectParts) { + List multiselectPartList = new ArrayList<>(multiselectParts); + Collections.sort(multiselectPartList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived())); SpannableStringBuilder bodyBuilder = new SpannableStringBuilder(); ClipboardManager clipboard = (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE); - for (ConversationMessage message : messageList) { - CharSequence body = message.getDisplayBody(requireContext()); + for (MultiselectPart part : multiselectPartList) { + CharSequence body = part.getConversationMessage().getDisplayBody(requireContext()); if (!TextUtils.isEmpty(body)) { if (bodyBuilder.length() > 0) { bodyBuilder.append('\n'); @@ -870,8 +874,8 @@ public class ConversationFragment extends LoggingFragment { } } - private void handleDeleteMessages(final Set conversationMessages) { - Set messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()); + private void handleDeleteMessages(final Set multiselectParts) { + Set messageRecords = Stream.of(multiselectParts).map(MultiselectPart::getMessageRecord).collect(Collectors.toSet()); buildRemoteDeleteConfirmationDialog(messageRecords).show(); } @@ -1394,9 +1398,9 @@ public class ConversationFragment extends LoggingFragment { private class ConversationFragmentItemClickListener implements ItemClickListener { @Override - public void onItemClick(ConversationMessage conversationMessage) { + public void onItemClick(MultiselectPart item) { if (actionMode != null) { - ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + ((ConversationAdapter) list.getAdapter()).toggleSelection(item); list.getAdapter().notifyDataSetChanged(); if (getListAdapter().getSelectedItems().size() == 0) { @@ -1409,11 +1413,11 @@ public class ConversationFragment extends LoggingFragment { } @Override - public void onItemLongClick(View itemView, ConversationMessage conversationMessage) { + public void onItemLongClick(View itemView, MultiselectPart item) { if (actionMode != null) return; - MessageRecord messageRecord = conversationMessage.getMessageRecord(); + MessageRecord messageRecord = item.getConversationMessage().getMessageRecord();; if (messageRecord.isSecure() && !messageRecord.isRemoteDelete() && @@ -1425,13 +1429,13 @@ public class ConversationFragment extends LoggingFragment { { isReacting = true; list.setLayoutFrozen(true); - listener.handleReaction(getMaskTarget(itemView), messageRecord, new ReactionsToolbarListener(conversationMessage), () -> { + listener.handleReaction(getMaskTarget(itemView), messageRecord, new ReactionsToolbarListener(item.getConversationMessage()), () -> { isReacting = false; list.setLayoutFrozen(false); WindowUtil.setLightStatusBarFromTheme(requireActivity()); }); } else { - ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + ((ConversationAdapter) list.getAdapter()).toggleSelection(item); list.getAdapter().notifyDataSetChanged(); actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); @@ -1755,7 +1759,12 @@ public class ConversationFragment extends LoggingFragment { } private void handleEnterMultiSelect(@NonNull ConversationMessage conversationMessage) { - ((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage); + Set multiselectParts = conversationMessage.getMultiselectCollection().toSet(); + + multiselectParts.stream().forEach(part -> { + ((ConversationAdapter) list.getAdapter()).toggleSelection(part); + }); + list.getAdapter().notifyDataSetChanged(); actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); @@ -1828,8 +1837,8 @@ public class ConversationFragment extends LoggingFragment { public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.action_info: handleDisplayDetails(conversationMessage); return true; - case R.id.action_delete: handleDeleteMessages(SetUtil.newHashSet(conversationMessage)); return true; - case R.id.action_copy: handleCopyMessage(SetUtil.newHashSet(conversationMessage)); return true; + case R.id.action_delete: handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet()); return true; + case R.id.action_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true; case R.id.action_reply: handleReplyMessage(conversationMessage); return true; case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true; case R.id.action_forward: handleForwardMessage(conversationMessage); return true; @@ -1848,7 +1857,7 @@ public class ConversationFragment extends LoggingFragment { MenuInflater inflater = mode.getMenuInflater(); inflater.inflate(R.menu.conversation_context, menu); - mode.setTitle("1"); + mode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size())); if (Build.VERSION.SDK_INT >= 21) { Window window = getActivity().getWindow(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index b31a879a6b..08451ccca1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.conversation; +import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme; + import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; @@ -42,6 +44,7 @@ import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.util.TypedValue; +import android.view.MotionEvent; import android.view.TouchDelegate; import android.view.View; import android.view.ViewGroup; @@ -58,9 +61,9 @@ import androidx.core.content.ContextCompat; import androidx.core.text.util.LinkifyCompat; import androidx.lifecycle.LifecycleOwner; -import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.android.exoplayer2.source.MediaSource; +import com.google.common.collect.Sets; import org.jetbrains.annotations.NotNull; import org.signal.core.util.logging.Log; @@ -85,6 +88,9 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.conversation.colors.Colorizer; +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessageDatabase; @@ -116,7 +122,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.revealable.ViewOnceMessageView; -import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan; @@ -136,14 +141,13 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; -import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme; - /** * A view that displays an individual conversation item within a conversation * thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter. @@ -162,7 +166,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private static final Rect SWIPE_RECT = new Rect(); - private ClipProjectionDrawable backgroundDrawable; private ConversationMessage conversationMessage; private MessageRecord messageRecord; private Locale locale; @@ -185,7 +188,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private AlertView alertView; protected ReactionsConversationView reactionsView; - private @NonNull Set batchSelected = new HashSet<>(); + private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); private @NonNull Outliner pulseOutliner = new Outliner(); private @NonNull List outliners = new ArrayList<>(2); @@ -221,6 +224,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private Projection.Corners bodyBubbleCorners; private Colorizer colorizer; private boolean hasWallpaper; + private float lastYDownRelativeToThis; public ConversationItem(Context context) { this(context, null); @@ -242,8 +246,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo initializeAttributes(); - this.backgroundDrawable = new ClipProjectionDrawable(Objects.requireNonNull(ContextCompat.getDrawable(getContext(), - R.drawable.conversation_item_background))); this.bodyText = findViewById(R.id.conversation_item_body); this.footer = findViewById(R.id.conversation_item_footer); this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer); @@ -279,7 +281,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @NonNull Optional nextMessageRecord, @NonNull GlideRequests glideRequests, @NonNull Locale locale, - @NonNull Set batchSelected, + @NonNull Set batchSelected, @NonNull Recipient conversationRecipient, @Nullable String searchQuery, boolean pulse, @@ -292,6 +294,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this); + lastYDownRelativeToThis = 0; + conversationRecipient = conversationRecipient.resolve(); this.conversationMessage = conversationMessage; @@ -331,6 +335,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale); } + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + lastYDownRelativeToThis = ev.getY(); + } + + return super.onInterceptTouchEvent(ev); + } + @Override protected void onDetachedFromWindow() { ConversationSwipeAnimationHelper.update(this, 0f, 1f); @@ -456,6 +469,60 @@ public final class ConversationItem extends RelativeLayout implements BindableCo cancelPulseOutlinerAnimation(); } + @Override + public @NonNull MultiselectPart getMultiselectPartForLatestTouch() { + MultiselectCollection parts = conversationMessage.getMultiselectCollection(); + + if (parts.isSingle()) { + return parts.asSingle().getSinglePart(); + } + + MultiselectPart top = parts.asDouble().getTopPart(); + MultiselectPart bottom = parts.asDouble().getBottomPart(); + + if (hasThumbnail(messageRecord)) { + Projection thumbnailProjection = Projection.relativeToParent(this, mediaThumbnailStub.require(), null); + float mediaBoundary = thumbnailProjection.getY() + thumbnailProjection.getHeight(); + + if (lastYDownRelativeToThis > mediaBoundary) { + return bottom; + } else { + return top; + } + } else { + throw new IllegalStateException("Found a situation where we have something other than a thumbnail."); + } + } + + @Override + public int getTopBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { + if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) { + Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); + return (int) projection.getY(); + } else if (multiselectPart instanceof MultiselectPart.Text && hasThumbnail(messageRecord)) { + Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); + return (int) projection.getY() + projection.getHeight(); + } else { + return getTop(); + } + } + + @Override + public int getBottomBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { + if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) { + Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); + return (int) projection.getY() + projection.getHeight(); + } else { + return getBottom(); + } + } + + @Override + public boolean hasNonSelectableMedia() { + return hasQuote(messageRecord) || hasLinkPreview(messageRecord); + } + + @Override public ConversationMessage getConversationMessage() { return conversationMessage; } @@ -539,11 +606,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) { - if (batchSelected.contains(conversationMessage)) { - setBackground(backgroundDrawable); + Set multiselectParts = conversationMessage.getMultiselectCollection().toSet(); + boolean isMessageSelected = Util.hasItems(Sets.intersection(multiselectParts, batchSelected)); + + if (isMessageSelected) { setSelected(true); } else if (pulseMention) { - setBackground(null); setSelected(false); startPulseOutlinerAnimation(); } else { @@ -743,7 +811,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyBubble.setQuoteViewProjection(null); bodyBubble.setVideoPlayerProjection(null); - updateSelectedBackgroundDrawableProjections(); if (eventListener != null && audioViewStub.resolved()) { Log.d(TAG, "setMediaAttributes: unregistering voice note callbacks for audio slide " + audioViewStub.get().getAudioSlideUri()); @@ -1530,7 +1597,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) { mediaThumbnailStub.require().showThumbnailView(); bodyBubble.setVideoPlayerProjection(null); - updateSelectedBackgroundDrawableProjections(); } } @@ -1540,7 +1606,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo mediaThumbnailStub.require().hideThumbnailView(); mediaThumbnailStub.require().getDrawingRect(thumbnailMaskingRect); bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.require(), bodyBubble, null)); - updateSelectedBackgroundDrawableProjections(); } } @@ -1611,25 +1676,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX())); } - updateSelectedBackgroundDrawableProjections(); return projections; } - private void updateSelectedBackgroundDrawableProjections() { - Set projections = Stream.of(bodyBubble.getProjections()) - .map(p -> Projection.translateFromDescendantToParentCoords(p, bodyBubble, this)) - .collect(Collectors.toSet()); - - if (messageRecord.isOutgoing() && - !hasNoBubble(messageRecord) && - bodyBubbleCorners != null) - { - projections.add(Projection.relativeToParent(this, bodyBubble, bodyBubbleCorners)); - } - - backgroundDrawable.setProjections(projections); - } - private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index bb93acf544..52c9723c84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -10,6 +10,8 @@ import androidx.annotation.WorkerThread; import org.signal.core.util.Conversions; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MentionUtil; import org.thoughtcrime.securesms.database.model.Mention; @@ -24,9 +26,10 @@ import java.util.List; * for various presentations. */ public class ConversationMessage { - @NonNull private final MessageRecord messageRecord; - @NonNull private final List mentions; - @Nullable private final SpannableString body; + @NonNull private final MessageRecord messageRecord; + @NonNull private final List mentions; + @Nullable private final SpannableString body; + @NonNull private final MultiselectCollection multiselectCollection; private ConversationMessage(@NonNull MessageRecord messageRecord) { this(messageRecord, null, null); @@ -43,6 +46,8 @@ public class ConversationMessage { if (!this.mentions.isEmpty() && this.body != null) { MentionAnnotation.setMentionAnnotations(this.body, this.mentions); } + + multiselectCollection = Multiselect.getParts(this); } public @NonNull MessageRecord getMessageRecord() { @@ -53,6 +58,10 @@ public class ConversationMessage { return mentions; } + public @NonNull MultiselectCollection getMultiselectCollection() { + return multiselectCollection; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 8a8aecc2cc..4d6966bca0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; import android.content.res.ColorStateList; +import android.graphics.Point; import android.text.Spannable; import android.text.SpannableString; import android.util.AttributeSet; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; @@ -18,12 +20,15 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import com.google.android.material.button.MaterialButton; +import com.google.common.collect.Sets; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.VerifyIdentityActivity; import org.thoughtcrime.securesms.conversation.colors.Colorizer; +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; @@ -60,7 +65,7 @@ public final class ConversationUpdateItem extends FrameLayout { private static final String TAG = Log.tag(ConversationUpdateItem.class); - private Set batchSelected; + private Set batchSelected; private TextView body; private MaterialButton actionButton; @@ -72,6 +77,7 @@ public final class ConversationUpdateItem extends FrameLayout private boolean isMessageRequestAccepted; private LiveData displayBody; private EventListener eventListener; + private boolean hasWallpaper; private final UpdateObserver updateObserver = new UpdateObserver(); @@ -104,7 +110,7 @@ public final class ConversationUpdateItem extends FrameLayout @NonNull Optional nextMessageRecord, @NonNull GlideRequests glideRequests, @NonNull Locale locale, - @NonNull Set batchSelected, + @NonNull Set batchSelected, @NonNull Recipient conversationRecipient, @Nullable String searchQuery, boolean pulseMention, @@ -137,6 +143,7 @@ public final class ConversationUpdateItem extends FrameLayout boolean hasWallpaper, boolean isMessageRequestAccepted) { + this.hasWallpaper = hasWallpaper; this.conversationMessage = conversationMessage; this.messageRecord = conversationMessage.getMessageRecord(); this.nextMessageRecord = nextMessageRecord; @@ -251,6 +258,26 @@ public final class ConversationUpdateItem extends FrameLayout } } + @Override + public @NonNull MultiselectPart getMultiselectPartForLatestTouch() { + return conversationMessage.getMultiselectCollection().asSingle().getSinglePart(); + } + + @Override + public int getTopBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { + return getTop(); + } + + @Override + public int getBottomBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { + return getBottom(); + } + + @Override + public boolean hasNonSelectableMedia() { + return false; + } + private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData displayBody) { if (this.displayBody != displayBody) { if (this.displayBody != null) { @@ -279,8 +306,9 @@ public final class ConversationUpdateItem extends FrameLayout @NonNull Recipient conversationRecipient, boolean isMessageRequestAccepted) { - if (batchSelected.contains(conversationMessage)) setSelected(true); - else setSelected(false); + Set multiselectParts = conversationMessage.getMultiselectCollection().toSet(); + + setSelected(!Sets.intersection(multiselectParts, batchSelected).isEmpty()); if (conversationMessage.getMessageRecord().isGroupV1MigrationEvent() && (!nextMessageRecord.isPresent() || !nextMessageRecord.get().isGroupV1MigrationEvent())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt new file mode 100644 index 0000000000..d243ab83e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.TextSlide +import org.thoughtcrime.securesms.util.FeatureFlags + +/** + * General helper object for all things multiselect. This is only utilized by + * [ConversationMessage] + */ +object Multiselect { + + /** + * Returns a list of parts in the order in which they would appear to the user. + */ + @JvmStatic + fun getParts(conversationMessage: ConversationMessage): MultiselectCollection { + val messageRecord = conversationMessage.messageRecord + + if (!FeatureFlags.forwardMultipleMessages()) { + return MultiselectCollection.Single(MultiselectPart.Message(conversationMessage)) + } + + if (messageRecord.isUpdate) { + return MultiselectCollection.Single(MultiselectPart.Update(conversationMessage)) + } + + val parts: LinkedHashSet = linkedSetOf() + + if (messageRecord is MmsMessageRecord) { + parts.addAll(getMmsParts(conversationMessage, messageRecord)) + } + + if (messageRecord.body.isNotEmpty()) { + parts.add(MultiselectPart.Text(conversationMessage)) + } + + return if (parts.isEmpty()) { + MultiselectCollection.Single(MultiselectPart.Message(conversationMessage)) + } else { + MultiselectCollection.fromSet(parts) + } + } + + private fun getMmsParts(conversationMessage: ConversationMessage, mmsMessageRecord: MmsMessageRecord): Set { + val parts: LinkedHashSet = linkedSetOf() + + val slideDeck = mmsMessageRecord.slideDeck + + if (slideDeck.slides.filterNot { it is TextSlide }.isNotEmpty()) { + parts.add(MultiselectPart.Attachments(conversationMessage)) + } + + if (slideDeck.body.isNotEmpty()) { + parts.add(MultiselectPart.Text(conversationMessage)) + } + + return parts + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt new file mode 100644 index 0000000000..233a7fc156 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import java.lang.IllegalArgumentException +import java.lang.UnsupportedOperationException + +sealed class MultiselectCollection { + + data class Single(val singlePart: MultiselectPart) : MultiselectCollection() { + + override val size: Int = 1 + + override fun toSet(): Set = setOf(singlePart) + + override fun isSingle(): Boolean = true + + override fun asSingle(): Single = this + } + + data class Double(val topPart: MultiselectPart, val bottomPart: MultiselectPart) : MultiselectCollection() { + + override val size: Int = 2 + + override fun toSet(): Set = linkedSetOf(topPart, bottomPart) + + override fun asDouble(): Double = this + } + + companion object { + fun fromSet(partsSet: Set): MultiselectCollection { + return when (partsSet.size) { + 1 -> Single(partsSet.first()) + 2 -> { + val iter = partsSet.iterator() + Double(iter.next(), iter.next()) + } + else -> throw IllegalArgumentException("Unsupported set size: ${partsSet.size}") + } + } + } + + abstract val size: Int + + abstract fun toSet(): Set + + open fun isSingle(): Boolean = false + + open fun asSingle(): Single = throw UnsupportedOperationException() + + open fun asDouble(): Double = throw UnsupportedOperationException() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt new file mode 100644 index 0000000000..324d7d3671 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -0,0 +1,220 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.Region +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.SimpleColorFilter +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.SetUtil +import org.thoughtcrime.securesms.util.ThemeUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper + +/** + * Decoration which renders the background shade and selection bubble for a {@link Multiselectable} item. + */ +class MultiselectItemDecoration(context: Context, private val chatWallpaperProvider: () -> ChatWallpaper?) : RecyclerView.ItemDecoration() { + + private val path = Path() + private val rect = Rect() + private val gutter = ViewUtil.dpToPx(48) + private val paddingBottom = ViewUtil.dpToPx(9) + private val paddingStart = ViewUtil.dpToPx(17) + private val circleRadius = ViewUtil.dpToPx(11) + private val checkDrawable = requireNotNull(AppCompatResources.getDrawable(context, R.drawable.ic_check_circle_solid_24)).apply { + setBounds(0, 0, circleRadius * 2, circleRadius * 2) + } + private val photoCircleRadius = ViewUtil.dpToPx(12) + private val photoCirclePaddingStart = ViewUtil.dpToPx(16) + private val photoCirclePaddingBottom = ViewUtil.dpToPx(8) + + private val transparentBlack20 = ContextCompat.getColor(context, R.color.transparent_black_20) + private val transparentWhite20 = ContextCompat.getColor(context, R.color.transparent_white_20) + private val transparentWhite60 = ContextCompat.getColor(context, R.color.transparent_white_60) + private val ultramarine30 = ContextCompat.getColor(context, R.color.core_ultramarine_33) + private val ultramarine = ContextCompat.getColor(context, R.color.signal_accent_primary) + + private val unselectedPaint = Paint().apply { + isAntiAlias = true + strokeWidth = 1.5f + style = Paint.Style.STROKE + } + + private val shadePaint = Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + } + + private val photoCirclePaint = Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + color = transparentBlack20 + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val adapter = parent.adapter as ConversationAdapter + val isLtr = ViewUtil.isLtr(view) + + if (adapter.selectedItems.isNotEmpty() && view is Multiselectable) { + outRect.set( + if (isLtr) gutter else 0, + 0, + if (isLtr) 0 else gutter, + 0 + ) + } else { + outRect.setEmpty() + } + } + + /** + * Draws the background shade. + */ + @Suppress("DEPRECATION") + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val adapter = parent.adapter as ConversationAdapter + + if (adapter.selectedItems.isEmpty()) { + return + } + + shadePaint.color = when { + chatWallpaperProvider() != null -> transparentBlack20 + ThemeUtil.isDarkTheme(parent.context) -> transparentWhite20 + else -> ultramarine30 + } + + parent.children.filterIsInstance(Multiselectable::class.java).forEach { child -> + val parts: MultiselectCollection = child.conversationMessage.multiselectCollection + + val projections: List = child.colorizerProjections + path.reset() + projections.forEach { it.applyToPath(path) } + + canvas.save() + canvas.clipPath(path, Region.Op.DIFFERENCE) + + val view: View = child as View + val selectedParts: Set = SetUtil.intersection(parts.toSet(), adapter.selectedItems) + + if (selectedParts.isNotEmpty()) { + val selectedPart: MultiselectPart = selectedParts.first() + val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia()) + + if (shadeAll) { + rect.set(0, view.top, parent.right, view.bottom) + } else { + rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart)) + } + + canvas.drawRect(rect, shadePaint) + } + + canvas.restore() + } + } + + /** + * Draws the selected check or empty circle. + */ + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val adapter = parent.adapter as ConversationAdapter + if (adapter.selectedItems.isEmpty()) { + return + } + + val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true + val multiselectChildren: Sequence = parent.children.filterIsInstance(Multiselectable::class.java) + + val isDarkTheme = ThemeUtil.isDarkTheme(parent.context) + + unselectedPaint.color = when { + chatWallpaperProvider()?.isPhoto == true -> Color.WHITE + chatWallpaperProvider() != null || isDarkTheme -> transparentWhite60 + else -> transparentBlack20 + } + + if (chatWallpaperProvider() == null && !isDarkTheme) { + checkDrawable.colorFilter = SimpleColorFilter(ultramarine) + } else { + checkDrawable.clearColorFilter() + } + + multiselectChildren.forEach { child -> + val parts: MultiselectCollection = child.conversationMessage.multiselectCollection + + parts.toSet().forEach { + val boundary = child.getBottomBoundaryOfMultiselectPart(it) + if (drawCircleBehindSelector) { + drawPhotoCircle(canvas, parent, boundary) + } + + if (adapter.selectedItems.contains(it)) { + drawSelectedCircle(canvas, parent, boundary) + } else { + drawUnselectedCircle(canvas, parent, boundary) + } + } + } + } + + /** + * Draws an extra circle behind the selection circle. This is to make it easier to see and + * is specifically for when a photo wallpaper is being used. + */ + private fun drawPhotoCircle(canvas: Canvas, parent: RecyclerView, bottomBoundary: Int) { + val centerX: Float = if (ViewUtil.isLtr(parent)) { + photoCirclePaddingStart + photoCircleRadius + } else { + parent.right - photoCircleRadius - photoCirclePaddingStart + }.toFloat() + + val centerY: Float = bottomBoundary - photoCircleRadius - photoCirclePaddingBottom.toFloat() + + canvas.drawCircle(centerX, centerY, photoCircleRadius.toFloat(), photoCirclePaint) + } + + /** + * Draws the checkmark for selected content + */ + private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, bottomBoundary: Int) { + val topX: Float = if (ViewUtil.isLtr(parent)) { + paddingStart + } else { + parent.right - paddingStart - circleRadius * 2 + }.toFloat() + + val topY: Float = bottomBoundary - circleRadius * 2 - paddingBottom.toFloat() + + canvas.save() + canvas.translate(topX, topY) + checkDrawable.draw(canvas) + canvas.restore() + } + + /** + * Draws the empty circle for unselected content + */ + private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, bottomBoundary: Int) { + val centerX: Float = if (ViewUtil.isLtr(parent)) { + paddingStart + circleRadius + } else { + parent.right - circleRadius - paddingStart + }.toFloat() + + val centerY: Float = bottomBoundary - circleRadius - paddingBottom.toFloat() + + c.drawCircle(centerX, centerY, circleRadius.toFloat(), unselectedPaint) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt new file mode 100644 index 0000000000..4a4d1a5e33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MessageRecord + +/** + * Represents a part of a message that can be selected and sent as its own distinct entity. + */ +sealed class MultiselectPart(open val conversationMessage: ConversationMessage) { + + fun getMessageRecord(): MessageRecord = conversationMessage.messageRecord + + /** + * Represents the body of the message + */ + data class Text(override val conversationMessage: ConversationMessage) : MultiselectPart(conversationMessage) + + /** + * Represents an attachment on the message, such as a file or image + */ + data class Attachments(override val conversationMessage: ConversationMessage) : MultiselectPart(conversationMessage) + + /** + * Represents an update, which is not forwardable + */ + data class Update(override val conversationMessage: ConversationMessage) : MultiselectPart(conversationMessage) + + /** + * Represents the entire message, for use when we've not yet enabled multiforward. + */ + data class Message(override val conversationMessage: ConversationMessage) : MultiselectPart(conversationMessage) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectRecyclerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectRecyclerView.kt new file mode 100644 index 0000000000..92ba5d395e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectRecyclerView.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Adjusts touch events when child is in Multiselect mode so that we can + * touch within the offset region and still select / deselect content. + */ +class MultiselectRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : RecyclerView(context, attrs) { + + override fun onInterceptTouchEvent(e: MotionEvent): Boolean { + val child: View? = children.firstOrNull { it is Multiselectable } + if (child != null) { + child.getHitRect(rect) + + if (ViewUtil.isLtr(child) && rect.left != 0 && e.x < rect.left) { + e.offsetLocation(rect.left - e.x, 0f) + } else if (ViewUtil.isRtl(child) && rect.right < right && e.x > rect.right) { + e.offsetLocation(-(right - rect.right).toFloat(), 0f) + } + } + + return super.onInterceptTouchEvent(e) + } + + companion object { + private val rect = Rect() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt new file mode 100644 index 0000000000..18cf0d5f09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.colors.Colorizable + +interface Multiselectable : Colorizable { + val conversationMessage: ConversationMessage + + fun getTopBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int + + fun getBottomBoundaryOfMultiselectPart(multiselectPart: MultiselectPart): Int + + fun getMultiselectPartForLatestTouch(): MultiselectPart + + fun hasNonSelectableMedia(): Boolean +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index ccdd5bca9d..4533148365 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -82,6 +82,7 @@ public final class FeatureFlags { private static final String RETRY_RECEIPTS = "android.retryReceipts"; private static final String SUGGEST_SMS_BLACKLIST = "android.suggestSmsBlacklist"; private static final String ANNOUNCEMENT_GROUPS = "android.announcementGroups"; + private static final String FORWARD_MULTIPLE_MESSAGES = "android.forward.multiple.messages"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -122,7 +123,8 @@ public final class FeatureFlags { @VisibleForTesting static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( - PHONE_NUMBER_PRIVACY_VERSION + PHONE_NUMBER_PRIVACY_VERSION, + FORWARD_MULTIPLE_MESSAGES ); /** @@ -383,6 +385,11 @@ public final class FeatureFlags { return getString(SUGGEST_SMS_BLACKLIST, ""); } + /** Whether the user is able to forward multiple messages at once */ + public static boolean forwardMultipleMessages() { + return getBoolean(FORWARD_MULTIPLE_MESSAGES, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java index dfa555cec4..f3ab67e6de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java @@ -140,7 +140,7 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == start && sticky) || hasHeader(parent, adapter, adapterPos))) { View header = getHeader(parent, adapter, adapterPos).itemView; c.save(); - final int left = child.getLeft(); + final int left = parent.getLeft(); final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); c.translate(left, top); header.draw(c); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java index 160034a1c0..a55eaa08d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java @@ -27,6 +27,10 @@ public interface ChatWallpaper extends Parcelable { void loadInto(@NonNull ImageView imageView); + default boolean isPhoto() { + return false; + } + @NonNull Wallpaper serialize(); enum BuiltIns { diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java index 9955058a2b..8821404df7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java @@ -42,6 +42,11 @@ final class UriChatWallpaper implements ChatWallpaper, Parcelable { return dimLevelInDarkTheme; } + @Override + public boolean isPhoto() { + return true; + } + @Override public void loadInto(@NonNull ImageView imageView) { GlideApp.with(imageView) diff --git a/app/src/main/res/drawable/ic_check_circle_solid_24.xml b/app/src/main/res/drawable/ic_check_circle_solid_24.xml new file mode 100644 index 0000000000..1eca03064b --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index 93256720e6..ea688d4191 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -25,7 +25,7 @@ app:layout_constraintStart_toStartOf="@android:id/list" app:layout_constraintTop_toTopOf="@android:id/list" /> -