Implement new Multiselect UX and groundwork for Multiforward.

This commit is contained in:
Alex Hart
2021-08-11 13:18:38 -03:00
committed by Cody Henthorne
parent 655e43a079
commit 28abc1e4ff
19 changed files with 625 additions and 79 deletions

View File

@@ -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<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Set<MultiselectPart> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseMention,

View File

@@ -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<ConversationMessage> selected;
private final Set<MultiselectPart> selected;
private final List<ConversationMessage> fastRecords;
private final Set<Long> 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<ConversationMessage> getSelectedItems() {
public Set<MultiselectPart> 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);
}
}

View File

@@ -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<ConversationMessage> messages = getListAdapter().getSelectedItems();
Set<MultiselectPart> 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<ConversationMessage> messageRecords = getListAdapter().getSelectedItems();
Set<MultiselectPart> 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<ConversationMessage> conversationMessages) {
List<ConversationMessage> messageList = new ArrayList<>(conversationMessages);
Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived()));
private void handleCopyMessage(final Set<MultiselectPart> multiselectParts) {
List<MultiselectPart> 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<ConversationMessage> conversationMessages) {
Set<MessageRecord> messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet());
private void handleDeleteMessages(final Set<MultiselectPart> multiselectParts) {
Set<MessageRecord> 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<MultiselectPart> 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();

View File

@@ -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<ConversationMessage> batchSelected = new HashSet<>();
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
private @NonNull Outliner pulseOutliner = new Outliner();
private @NonNull List<Outliner> 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<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Set<MultiselectPart> 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<MultiselectPart> 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<Projection> 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) {

View File

@@ -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<Mention> mentions;
@Nullable private final SpannableString body;
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> 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;

View File

@@ -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<ConversationMessage> batchSelected;
private Set<MultiselectPart> batchSelected;
private TextView body;
private MaterialButton actionButton;
@@ -72,6 +77,7 @@ public final class ConversationUpdateItem extends FrameLayout
private boolean isMessageRequestAccepted;
private LiveData<SpannableString> 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<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Set<MultiselectPart> 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<SpannableString> 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<MultiselectPart> multiselectParts = conversationMessage.getMultiselectCollection().toSet();
setSelected(!Sets.intersection(multiselectParts, batchSelected).isEmpty());
if (conversationMessage.getMessageRecord().isGroupV1MigrationEvent() &&
(!nextMessageRecord.isPresent() || !nextMessageRecord.get().isGroupV1MigrationEvent()))

View File

@@ -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<MultiselectPart> = 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<MultiselectPart> {
val parts: LinkedHashSet<MultiselectPart> = 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
}
}

View File

@@ -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<MultiselectPart> = 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<MultiselectPart> = linkedSetOf(topPart, bottomPart)
override fun asDouble(): Double = this
}
companion object {
fun fromSet(partsSet: Set<MultiselectPart>): 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<MultiselectPart>
open fun isSingle(): Boolean = false
open fun asSingle(): Single = throw UnsupportedOperationException()
open fun asDouble(): Double = throw UnsupportedOperationException()
}

View File

@@ -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<Projection> = 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<MultiselectPart> = 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<Multiselectable> = 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)
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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<String> 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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View File

@@ -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);

View File

@@ -27,6 +27,10 @@ public interface ChatWallpaper extends Parcelable {
void loadInto(@NonNull ImageView imageView);
default boolean isPhoto() {
return false;
}
@NonNull Wallpaper serialize();
enum BuiltIns {

View File

@@ -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)