mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-23 19:26:17 +00:00
Implement new Multiselect UX and groundwork for Multiforward.
This commit is contained in:
committed by
Cody Henthorne
parent
655e43a079
commit
28abc1e4ff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,10 @@ public interface ChatWallpaper extends Parcelable {
|
||||
|
||||
void loadInto(@NonNull ImageView imageView);
|
||||
|
||||
default boolean isPhoto() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull Wallpaper serialize();
|
||||
|
||||
enum BuiltIns {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user