From e4d43ade937b9e5b6e2c378d172eebbac636405a Mon Sep 17 00:00:00 2001 From: Rashad Sookram Date: Fri, 28 Jan 2022 15:12:35 -0500 Subject: [PATCH] Use context menu when selecting a message in chat. --- .../components/menu/ContextMenuList.kt | 91 ++++ .../components/menu/SignalContextMenu.kt | 84 +--- .../voice/VoiceNoteMediaController.java | 21 + .../conversation/ConversationAdapter.java | 5 + .../conversation/ConversationContextMenu.kt | 55 ++ .../conversation/ConversationFragment.java | 141 ++++-- .../conversation/ConversationItem.java | 95 +++- .../conversation/ConversationItemSelection.kt | 106 ++++ .../ConversationParentFragment.java | 12 +- .../ConversationReactionDelegate.java | 16 +- .../ConversationReactionOverlay.java | 472 +++++++++++++----- .../conversation/ConversationUpdateItem.java | 5 + .../conversation/SelectedConversationModel.kt | 18 + .../colors/RecyclerViewColorizer.kt | 9 + .../mutiselect/MultiselectItemDecoration.kt | 2 +- .../securesms/giph/mp4/GiphyMp4Playable.java | 6 + .../mp4/GiphyMp4ProjectionPlayerHolder.java | 13 + .../giph/mp4/GiphyMp4ProjectionRecycler.java | 2 +- .../giph/mp4/GiphyMp4VideoPlayer.java | 18 + .../giph/mp4/GiphyMp4ViewHolder.java | 5 + .../MessageHeaderViewHolder.java | 10 +- .../securesms/util/Projection.java | 8 + .../org/thoughtcrime/securesms/util/Util.java | 9 + .../securesms/util/WindowUtil.java | 6 + .../util/views/AdaptiveActionsToolbar.java | 91 ---- .../drawable-night/ic_select_24_tinted.xml | 9 + app/src/main/res/drawable/ic_retry_24.xml | 4 + .../main/res/drawable/ic_save_24_tinted.xml | 9 + .../main/res/drawable/ic_select_24_tinted.xml | 9 + ...nversation_reaction_long_press_toolbar.xml | 12 - .../layout/conversation_reaction_scrubber.xml | 28 +- ...conversation_reactions_long_press_menu.xml | 47 -- app/src/main/res/values-night/dark_colors.xml | 1 + app/src/main/res/values-v23/colors.xml | 2 +- app/src/main/res/values/attrs.xml | 4 - app/src/main/res/values/colors.xml | 2 +- app/src/main/res/values/light_colors.xml | 1 + app/src/main/res/values/strings.xml | 20 +- 38 files changed, 1013 insertions(+), 435 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationContextMenu.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java create mode 100644 app/src/main/res/drawable-night/ic_select_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_retry_24.xml create mode 100644 app/src/main/res/drawable/ic_save_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_select_24_tinted.xml delete mode 100644 app/src/main/res/layout/conversation_reaction_long_press_toolbar.xml delete mode 100644 app/src/main/res/menu/conversation_reactions_long_press_menu.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt new file mode 100644 index 0000000000..44d94571cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.components.menu + +import android.os.Build +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Handles the setup and display of actions shown in a context menu. + */ +class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { + + private val mappingAdapter = MappingAdapter().apply { + registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.signal_context_menu_item)) + } + + init { + recyclerView.apply { + adapter = mappingAdapter + layoutManager = LinearLayoutManager(context) + itemAnimator = null + } + } + + fun setItems(items: List) { + mappingAdapter.submitList(items.toAdapterItems()) + } + + private fun List.toAdapterItems(): List { + return this.mapIndexed { index, item -> + val displayType: DisplayType = when { + this.size == 1 -> DisplayType.ONLY + index == 0 -> DisplayType.TOP + index == this.size - 1 -> DisplayType.BOTTOM + else -> DisplayType.MIDDLE + } + + DisplayItem(item, displayType) + } + } + + private data class DisplayItem( + val item: ActionItem, + val displayType: DisplayType + ) : MappingModel { + override fun areItemsTheSame(newItem: DisplayItem): Boolean { + return this == newItem + } + + override fun areContentsTheSame(newItem: DisplayItem): Boolean { + return this == newItem + } + } + + private enum class DisplayType { + TOP, BOTTOM, MIDDLE, ONLY + } + + private class ItemViewHolder( + itemView: View, + private val onItemClick: () -> Unit, + ) : MappingViewHolder(itemView) { + val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon) + val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title) + + override fun bind(model: DisplayItem) { + icon.setImageResource(model.item.iconRes) + title.text = model.item.title + itemView.setOnClickListener { + model.item.action.run() + onItemClick() + } + + if (Build.VERSION.SDK_INT >= 21) { + when (model.displayType) { + DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top) + DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom) + DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle) + DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt index 1af6c37232..42bbca6d90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt @@ -6,18 +6,10 @@ import android.os.Build import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView import android.widget.PopupWindow -import android.widget.TextView import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.adapter.mapping.Factory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder /** * A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules. @@ -42,9 +34,10 @@ class SignalContextMenu private constructor( val context: Context = anchor.context - val mappingAdapter = MappingAdapter().apply { - registerFactory(DisplayItem::class.java, ItemViewHolderFactory()) - } + private val contextMenuList = ContextMenuList( + recyclerView = contentView.findViewById(R.id.signal_context_menu_list), + onItemClick = { dismiss() }, + ) init { setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background)) @@ -59,13 +52,7 @@ class SignalContextMenu private constructor( elevation = 20f } - contentView.findViewById(R.id.signal_context_menu_list).apply { - adapter = mappingAdapter - layoutManager = LinearLayoutManager(context) - itemAnimator = null - } - - mappingAdapter.submitList(items.toAdapterItems()) + contextMenuList.setItems(items) } private fun show() { @@ -97,7 +84,7 @@ class SignalContextMenu private constructor( offsetY = baseOffsetY } else if (menuTopBound > screenTopBound) { offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY) - mappingAdapter.submitList(items.reversed().toAdapterItems()) + contextMenuList.setItems(items.reversed()) } else { offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY) } @@ -122,65 +109,6 @@ class SignalContextMenu private constructor( showAsDropDown(anchor, offsetX, offsetY) } - private fun List.toAdapterItems(): List { - return this.mapIndexed { index, item -> - val displayType: DisplayType = when { - this.size == 1 -> DisplayType.ONLY - index == 0 -> DisplayType.TOP - index == this.size - 1 -> DisplayType.BOTTOM - else -> DisplayType.MIDDLE - } - - DisplayItem(item, displayType) - } - } - - private data class DisplayItem( - val item: ActionItem, - val displayType: DisplayType - ) : MappingModel { - override fun areItemsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } - - override fun areContentsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } - } - - private enum class DisplayType { - TOP, BOTTOM, MIDDLE, ONLY - } - - private inner class ItemViewHolder(itemView: View) : MappingViewHolder(itemView) { - val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon) - val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title) - - override fun bind(model: DisplayItem) { - icon.setImageResource(model.item.iconRes) - title.text = model.item.title - itemView.setOnClickListener { - model.item.action.run() - dismiss() - } - - if (Build.VERSION.SDK_INT >= 21) { - when (model.displayType) { - DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top) - DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom) - DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle) - DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only) - } - } - } - } - - private inner class ItemViewHolderFactory : Factory { - override fun createViewHolder(parent: ViewGroup): MappingViewHolder { - return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false)) - } - } - enum class HorizontalPosition { START, END } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java index 8b97f322ce..0ad5c9ea44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -191,6 +191,27 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } } + /** + * Tells the Media service to resume playback of a given audio slide. If the audio slide is not + * currently paused, playback will be started from the beginning. + * + * @param audioSlideUri The Uri of the desired audio slide + * @param messageId The Message id of the given audio slide + */ + public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) { + if (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().play(); + } else { + Bundle extras = new Bundle(); + extras.putLong(EXTRA_MESSAGE_ID, messageId); + extras.putLong(EXTRA_THREAD_ID, -1L); + extras.putDouble(EXTRA_PROGRESS, 0.0); + extras.putBoolean(EXTRA_PLAY_SINGLE, true); + + getMediaController().getTransportControls().playFromUri(audioSlideUri, extras); + } + } + /** * Pauses playback if the given audio slide is playing. * diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index bbd2c5addb..45eaa9f085 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -706,6 +706,11 @@ public class ConversationAdapter return getBindable().canPlayContent(); } + @Override + public boolean shouldProjectContent() { + return getBindable().shouldProjectContent(); + } + @Override public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) { return getBindable().getColorizerProjections(coordinateRoot); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationContextMenu.kt new file mode 100644 index 0000000000..62ff7003db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationContextMenu.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import android.os.Build +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupWindow +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.ContextMenuList + +/** + * The context menu shown after long pressing a message in ConversationActivity. + */ +class ConversationContextMenu(private val anchor: View, items: List) : PopupWindow( + LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null), + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, +) { + + val context: Context = anchor.context + + private val contextMenuList = ContextMenuList( + recyclerView = contentView.findViewById(R.id.signal_context_menu_list), + onItemClick = { dismiss() }, + ) + + init { + setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background)) + + isFocusable = false + isOutsideTouchable = true + + if (Build.VERSION.SDK_INT >= 21) { + elevation = 20f + } + + contextMenuList.setItems(items) + + contentView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + } + + fun getMaxWidth(): Int = contentView.measuredWidth + fun getMaxHeight(): Int = contentView.measuredHeight + + fun show(offsetX: Int, offsetY: Int) { + showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index e8ba448e28..6e842f3665 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -24,6 +24,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; +import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Rect; import android.net.Uri; @@ -148,6 +149,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.HtmlUtil; +import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SignalLocalMetrics; @@ -180,7 +182,6 @@ import java.util.Set; import java.util.concurrent.ExecutionException; import kotlin.Unit; -import kotlin.jvm.functions.Function1; @SuppressLint("StaticFieldLeak") public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback { @@ -196,7 +197,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect private LiveRecipient recipient; private long threadId; - private boolean isReacting; private ActionMode actionMode; private Locale locale; private FrameLayout videoContainer; @@ -768,7 +768,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } if (menuState.shouldShowSaveAttachmentAction()) { - items.add(new ActionItem(R.drawable.ic_save_24, getResources().getString(R.string.conversation_selection__menu_save), () -> { + items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> { handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord()); actionMode.finish(); })); @@ -810,19 +810,21 @@ public class ConversationFragment extends LoggingFragment implements Multiselect ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation()); listener.onBottomActionBarVisibilityChanged(View.VISIBLE); - ViewKt.doOnPreDraw(bottomActionBar, new Function1() { - @Override public Unit invoke(View view) { - if (view.getHeight() == 0 && view.getVisibility() == View.VISIBLE) { - ViewKt.doOnPreDraw(bottomActionBar, this); - return Unit.INSTANCE; + bottomActionBar.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (bottomActionBar.getHeight() == 0 && bottomActionBar.getVisibility() == View.VISIBLE) { + return false; } - int bottomPadding = view.getHeight() + (int) DimensionUnit.DP.toPixels(18); + bottomActionBar.getViewTreeObserver().removeOnPreDrawListener(this); + + int bottomPadding = bottomActionBar.getHeight() + (int) DimensionUnit.DP.toPixels(18); list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), bottomPadding); list.scrollBy(0, -(bottomPadding - additionalScrollOffset)); - return Unit.INSTANCE; + return false; } }); } else { @@ -1314,12 +1316,14 @@ public class ConversationFragment extends LoggingFragment implements Multiselect void onForwardClicked(); void onMessageRequest(@NonNull MessageRequestViewModel viewModel); void handleReaction(@NonNull ConversationMessage conversationMessage, - @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener, + @NonNull SelectedConversationModel selectedConversationModel, @NonNull ConversationReactionOverlay.OnHideListener onHideListener); void onCursorChanged(); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); void onVoiceNotePause(@NonNull Uri uri); void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress); + void onVoiceNoteResume(@NonNull Uri uri, long messageId); void onVoiceNoteSeekTo(@NonNull Uri uri, double progress); void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed); void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); @@ -1430,16 +1434,63 @@ public class ConversationFragment extends LoggingFragment implements Multiselect multiselectItemDecoration.setFocusedItem(new MultiselectPart.Message(item.getConversationMessage())); list.invalidateItemDecorations(); - isReacting = true; reactionsShade.setVisibility(View.VISIBLE); list.setLayoutFrozen(true); - listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), () -> { - isReacting = false; - reactionsShade.setVisibility(View.GONE); - list.setLayoutFrozen(false); - WindowUtil.setLightStatusBarFromTheme(requireActivity()); - clearFocusedItem(); - }); + + if (itemView instanceof ConversationItem) { + Uri audioUri = getAudioUriForLongClick(messageRecord); + if (audioUri != null) { + listener.onVoiceNotePause(audioUri); + } + + Bitmap videoBitmap = null; + int childAdapterPosition = list.getChildAdapterPosition(itemView); + + final GiphyMp4ProjectionPlayerHolder mp4Holder; + if (childAdapterPosition != RecyclerView.NO_POSITION) { + mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition); + if (mp4Holder != null) { + mp4Holder.pause(); + videoBitmap = mp4Holder.getBitmap(); + mp4Holder.hide(); + } + } else { + mp4Holder = null; + } + + ConversationItem conversationItem = (ConversationItem) itemView; + Bitmap bitmap = ConversationItemSelection.snapshotView(conversationItem, list, messageRecord, videoBitmap); + + final ConversationItemBodyBubble bodyBubble = conversationItem.bodyBubble; + SelectedConversationModel selectedConversationModel = new SelectedConversationModel(bitmap, + itemView.getX(), + itemView.getY() + list.getTranslationY(), + bodyBubble.getX(), + bodyBubble.getWidth(), + audioUri, + messageRecord.isOutgoing()); + + bodyBubble.setVisibility(View.INVISIBLE); + + listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), selectedConversationModel, () -> { + reactionsShade.setVisibility(View.GONE); + list.setLayoutFrozen(false); + + if (selectedConversationModel.getAudioUri() != null) { + listener.onVoiceNoteResume(selectedConversationModel.getAudioUri(), messageRecord.getId()); + } + + WindowUtil.setLightStatusBarFromTheme(requireActivity()); + clearFocusedItem(); + + if (mp4Holder != null) { + mp4Holder.show(); + mp4Holder.resume(); + } + + bodyBubble.setVisibility(View.VISIBLE); + }); + } } else { clearFocusedItem(); ((ConversationAdapter) list.getAdapter()).toggleSelection(item); @@ -1449,6 +1500,20 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } } + @Nullable private Uri getAudioUriForLongClick(@NonNull MessageRecord messageRecord) { + VoiceNotePlaybackState playbackState = listener.getVoiceNoteMediaController().getVoiceNotePlaybackState().getValue(); + if (playbackState == null || !playbackState.isPlaying()) { + return null; + } + + if (!MessageRecordUtil.hasAudio(messageRecord) || !messageRecord.isMms()) { + return null; + } + + Uri messageUri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri(); + return playbackState.getUri().equals(messageUri) ? messageUri : null; + } + @Override public void onQuoteClicked(MmsMessageRecord messageRecord) { if (messageRecord.getQuote() == null) { @@ -1867,7 +1932,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } } - private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener { + private class ReactionsToolbarListener implements ConversationReactionOverlay.OnActionSelectedListener { private final ConversationMessage conversationMessage; @@ -1876,16 +1941,32 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } @Override - public boolean onMenuItemClick(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_info: handleDisplayDetails(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: handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); return true; - case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true; - default: return false; + public void onActionSelected(@NonNull ConversationReactionOverlay.Action action) { + switch (action) { + case REPLY: + handleReplyMessage(conversationMessage); + break; + case FORWARD: + handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); + break; + case RESEND: + handleResendMessage(conversationMessage.getMessageRecord()); + break; + case DOWNLOAD: + handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); + break; + case COPY: + handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); + break; + case MULTISELECT: + handleEnterMultiSelect(conversationMessage); + break; + case VIEW_INFO: + handleDisplayDetails(conversationMessage); + break; + case DELETE: + handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet()); + break; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 921a85793e..e9cb491f7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -160,6 +160,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private static final Rect SWIPE_RECT = new Rect(); + public static final float LONG_PRESS_SCALE_FACTOR = 0.95f; + private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100; + private ConversationMessage conversationMessage; private MessageRecord messageRecord; private Optional nextMessageRecord; @@ -224,6 +227,21 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private float lastYDownRelativeToThis; private ProjectionList colorizerProjections = new ProjectionList(3); + private final Runnable shrinkBubble = new Runnable() { + @Override + public void run() { + bodyBubble.animate() + .scaleX(LONG_PRESS_SCALE_FACTOR) + .scaleY(LONG_PRESS_SCALE_FACTOR) + .setUpdateListener(animation -> { + View parent = (View) getParent(); + if (parent != null) { + parent.invalidate(); + } + }); + } + }; + public ConversationItem(Context context) { this(context, null); } @@ -343,6 +361,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setGroupAuthorColor(messageRecord, hasWallpaper, colorizer); } + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS); + } else { + getHandler().removeCallbacks(shrinkBubble); + bodyBubble.animate() + .scaleX(1.0f) + .scaleY(1.0f); + } + + return super.dispatchTouchEvent(ev); + } + @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { @@ -1737,7 +1769,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @Override public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) { if (mediaThumbnailStub != null && mediaThumbnailStub.isResolvable()) { - return Projection.relativeToParent(recyclerView, mediaThumbnailStub.require(), mediaThumbnailStub.require().getCorners()) + ConversationItemThumbnail thumbnail = mediaThumbnailStub.require(); + return Projection.relativeToParent(recyclerView, thumbnail, thumbnail.getCorners()) + .scale(bodyBubble.getScaleX()) + .translateX(Util.halfOffsetFromScale(thumbnail.getWidth(), bodyBubble.getScaleX())) + .translateY(Util.halfOffsetFromScale(thumbnail.getHeight(), bodyBubble.getScaleY())) .translateY(getTranslationY()) .translateX(bodyBubble.getTranslationX()) .translateX(getTranslationX()); @@ -1754,6 +1790,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return mediaThumbnailStub != null && mediaThumbnailStub.isResolvable() && canPlayContent; } + @Override + public boolean shouldProjectContent() { + return canPlayContent() && bodyBubble.getVisibility() == VISIBLE; + } + @Override public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) { colorizerProjections.clear(); @@ -1761,34 +1802,70 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (messageRecord.isOutgoing() && !hasNoBubble(messageRecord) && !messageRecord.isRemoteDelete() && - bodyBubbleCorners != null) + bodyBubbleCorners != null && + bodyBubble.getVisibility() == VISIBLE) { Projection bodyBubbleToRoot = Projection.relativeToParent(coordinateRoot, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()); Projection videoToBubble = bodyBubble.getVideoPlayerProjection(); + + float translationX = Util.halfOffsetFromScale(bodyBubble.getWidth(), bodyBubble.getScaleX()); + float translationY = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY()); + if (videoToBubble != null) { Projection videoToRoot = Projection.translateFromDescendantToParentCoords(videoToBubble, bodyBubble, coordinateRoot); - colorizerProjections.addAll(Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot)); + + List projections = Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot); + if (!projections.isEmpty()) { + projections.get(0) + .scale(bodyBubble.getScaleX()) + .translateX(translationX) + .translateY(translationY); + projections.get(1) + .scale(bodyBubble.getScaleX()) + .translateX(translationX) + .translateY(-translationY); + } + + colorizerProjections.addAll(projections); } else { - colorizerProjections.add(bodyBubbleToRoot); + colorizerProjections.add( + bodyBubbleToRoot.scale(bodyBubble.getScaleX()) + .translateX(translationX) + .translateY(translationY) + ); } } if (messageRecord.isOutgoing() && hasNoBubble(messageRecord) && - hasWallpaper) + hasWallpaper && + bodyBubble.getVisibility() == VISIBLE) { - Projection footerProjection = getActiveFooter(messageRecord).getProjection(coordinateRoot); + ConversationItemFooter footer = getActiveFooter(messageRecord); + Projection footerProjection = footer.getProjection(coordinateRoot); if (footerProjection != null) { - colorizerProjections.add(footerProjection.translateX(bodyBubble.getTranslationX())); + colorizerProjections.add( + footerProjection.translateX(bodyBubble.getTranslationX()) + .scale(bodyBubble.getScaleX()) + .translateX(Util.halfOffsetFromScale(footer.getWidth(), bodyBubble.getScaleX())) + .translateY(-Util.halfOffsetFromScale(footer.getHeight(), bodyBubble.getScaleY())) + ); } } if (!messageRecord.isOutgoing() && hasQuote(messageRecord) && - quoteView != null) + quoteView != null && + bodyBubble.getVisibility() == VISIBLE) { bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble)); - colorizerProjections.add(quoteView.getProjection(coordinateRoot).translateX(bodyBubble.getTranslationX() + this.getTranslationX())); + + float bubbleOffsetFromScale = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY()); + Projection cProj = quoteView.getProjection(coordinateRoot) + .translateX(bodyBubble.getTranslationX() + this.getTranslationX() + Util.halfOffsetFromScale(quoteView.getWidth(), bodyBubble.getScaleX())) + .translateY(bubbleOffsetFromScale - quoteView.getY() + (quoteView.getY() * bodyBubble.getScaleY())) + .scale(bodyBubble.getScaleX()); + colorizerProjections.add(cProj); } for (int i = 0; i < colorizerProjections.size(); i++) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt new file mode 100644 index 0000000000..c31a6d40a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSelection.kt @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.conversation + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path +import android.view.View +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withClip +import androidx.core.graphics.withTranslation +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.util.hasNoBubble + +object ConversationItemSelection { + + @JvmStatic + fun snapshotView( + conversationItem: ConversationItem, + list: RecyclerView, + messageRecord: MessageRecord, + videoBitmap: Bitmap?, + ): Bitmap { + val isOutgoing = messageRecord.isOutgoing + val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context) + + return snapshotMessage( + conversationItem = conversationItem, + list = list, + videoBitmap = videoBitmap, + drawConversationItem = !isOutgoing || hasNoBubble, + ) + } + + private fun snapshotMessage( + conversationItem: ConversationItem, + list: RecyclerView, + videoBitmap: Bitmap?, + drawConversationItem: Boolean, + ): Bitmap { + val initialReactionVisibility = conversationItem.reactionsView.visibility + if (initialReactionVisibility == View.VISIBLE) { + conversationItem.reactionsView.visibility = View.INVISIBLE + } + + val originalScale = conversationItem.bodyBubble.scaleX + conversationItem.bodyBubble.scaleX = 1.0f + conversationItem.bodyBubble.scaleY = 1.0f + + val projections = conversationItem.getColorizerProjections(list) + + val path = Path() + + val yTranslation = -conversationItem.y + + val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list) + var scaledVideoBitmap = videoBitmap + if (videoBitmap != null) { + scaledVideoBitmap = Bitmap.createScaledBitmap( + videoBitmap, + (videoBitmap.width / originalScale).toInt(), + (videoBitmap.height / originalScale).toInt(), + true + ) + + mp4Projection.translateY(yTranslation) + mp4Projection.applyToPath(path) + } + + projections.use { + it.forEach { p -> + p.translateY(yTranslation) + p.applyToPath(path) + } + } + + val distanceToBubbleBottom = conversationItem.bodyBubble.height + conversationItem.bodyBubble.y.toInt() + return createBitmap(conversationItem.width, distanceToBubbleBottom).applyCanvas { + if (drawConversationItem) { + draw(conversationItem) + } + + withClip(path) { + withTranslation(y = yTranslation) { + list.draw(this) + + if (scaledVideoBitmap != null) { + drawBitmap(scaledVideoBitmap, mp4Projection.x, mp4Projection.y - yTranslation, null) + } + } + } + }.also { + mp4Projection.release() + conversationItem.reactionsView.visibility = initialReactionVisibility + conversationItem.bodyBubble.scaleX = originalScale + conversationItem.bodyBubble.scaleY = originalScale + } + } + + private fun Canvas.draw(conversationItem: ConversationItem) { + val bodyBubble = conversationItem.bodyBubble + withTranslation(bodyBubble.x, bodyBubble.y) { + bodyBubble.draw(this@draw) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index dfdefa9389..7cf348d009 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -3664,12 +3664,13 @@ public class ConversationParentFragment extends Fragment @Override public void handleReaction(@NonNull ConversationMessage conversationMessage, - @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener, + @NonNull SelectedConversationModel selectedConversationModel, @NonNull ConversationReactionOverlay.OnHideListener onHideListener) { - reactionDelegate.setOnToolbarItemClickedListener(toolbarListener); + reactionDelegate.setOnActionSelectedListener(onActionSelectedListener); reactionDelegate.setOnHideListener(onHideListener); - reactionDelegate.show(requireActivity(), recipient.get(), conversationMessage, groupViewModel.isNonAdminInAnnouncementGroup()); + reactionDelegate.show(requireActivity(), recipient.get(), conversationMessage, groupViewModel.isNonAdminInAnnouncementGroup(), selectedConversationModel); } @Override @@ -3697,6 +3698,11 @@ public class ConversationParentFragment extends Fragment voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress); } + @Override + public void onVoiceNoteResume(@NonNull Uri uri, long messageId) { + voiceNoteMediaController.resumePlayback(uri, messageId); + } + @Override public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) { voiceNoteMediaController.seekToPosition(uri, progress); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java index 140ff8ac27..73bd43c574 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java @@ -5,7 +5,6 @@ import android.graphics.PointF; import android.view.MotionEvent; import androidx.annotation.NonNull; -import androidx.appcompat.widget.Toolbar; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.recipients.Recipient; @@ -24,7 +23,7 @@ final class ConversationReactionDelegate { private final PointF lastSeenDownPoint = new PointF(); private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener; - private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener; + private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener; private ConversationReactionOverlay.OnHideListener onHideListener; ConversationReactionDelegate(@NonNull Stub overlayStub) { @@ -38,9 +37,10 @@ final class ConversationReactionDelegate { void show(@NonNull Activity activity, @NonNull Recipient conversationRecipient, @NonNull ConversationMessage conversationMessage, - boolean isNonAdminInAnnouncementGroup) + boolean isNonAdminInAnnouncementGroup, + @NonNull SelectedConversationModel selectedConversationModel) { - resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup); + resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel); } void hide() { @@ -59,11 +59,11 @@ final class ConversationReactionDelegate { } } - void setOnToolbarItemClickedListener(@NonNull Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) { - this.onToolbarItemClickedListener = onToolbarItemClickedListener; + void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) { + this.onActionSelectedListener = onActionSelectedListener; if (overlayStub.resolved()) { - overlayStub.get().setOnToolbarItemClickedListener(onToolbarItemClickedListener); + overlayStub.get().setOnActionSelectedListener(onActionSelectedListener); } } @@ -99,7 +99,7 @@ final class ConversationReactionDelegate { overlay.requestFitSystemWindows(); overlay.setOnHideListener(onHideListener); - overlay.setOnToolbarItemClickedListener(onToolbarItemClickedListener); + overlay.setOnActionSelectedListener(onActionSelectedListener); overlay.setOnReactionSelectedListener(onReactionSelectedListener); return overlay; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index d9d01357de..740e0f20a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -2,34 +2,41 @@ package org.thoughtcrime.securesms.conversation; import android.animation.Animator; import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.app.Activity; import android.content.Context; +import android.graphics.Bitmap; import android.graphics.PointF; import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; import android.os.Build; import android.util.AttributeSet; import android.view.HapticFeedbackConstants; -import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; +import android.view.ViewGroup; +import android.view.Window; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.RelativeLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; +import androidx.annotation.RequiresApi; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.core.content.ContextCompat; +import androidx.core.view.ViewKt; import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; import com.annimon.stream.Stream; +import org.signal.core.util.DimensionUnit; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.emoji.EmojiUtil; +import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -39,12 +46,15 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.WindowUtil; -import java.util.LinkedList; +import java.util.ArrayList; import java.util.List; +import kotlin.Unit; + public final class ConversationReactionOverlay extends RelativeLayout { - private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); + private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); + private static final long TRANSITION_Y_DURATION = 150; private final Rect emojiViewGlobalRect = new Rect(); private final Rect emojiStripViewBounds = new Rect(); @@ -54,23 +64,29 @@ public final class ConversationReactionOverlay extends RelativeLayout { private final Boundary verticalScrubBoundary = new Boundary(); private final PointF deadzoneTouchPoint = new PointF(); - private Activity activity; - private Recipient conversationRecipient; - private MessageRecord messageRecord; - private OverlayState overlayState = OverlayState.HIDDEN; - private boolean isNonAdminInAnnouncementGroup; + private Activity activity; + private Recipient conversationRecipient; + private MessageRecord messageRecord; + private SelectedConversationModel selectedConversationModel; + private OverlayState overlayState = OverlayState.HIDDEN; + private boolean isNonAdminInAnnouncementGroup; private boolean downIsOurs; - private boolean isToolbarTouch; private int selected = -1; private int customEmojiIndex; private int originalStatusBarColor; + private int originalNavigationBarColor; + private View dropdownAnchor; + private View toolbarShade; + private View inputShade; + private View conversationItem; private View backgroundView; private ConstraintLayout foregroundView; private View selectedView; private EmojiImageView[] emojiViews; - private Toolbar toolbar; + + private ConversationContextMenu contextMenu; private float touchDownDeadZoneSize; private float distanceFromTouchDownPointToTopOfScrubberDeadZone; @@ -78,21 +94,18 @@ public final class ConversationReactionOverlay extends RelativeLayout { private int scrubberDistanceFromTouchDown; private int scrubberHeight; private int scrubberWidth; - private int actionBarHeight; private int selectedVerticalTranslation; private int scrubberHorizontalMargin; private int animationEmojiStartDelayFactor; private int statusBarHeight; private OnReactionSelectedListener onReactionSelectedListener; - private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener; + private OnActionSelectedListener onActionSelectedListener; private OnHideListener onHideListener; - private AnimatorSet revealAnimatorSet = new AnimatorSet(); - private AnimatorSet revealMaskAnimatorSet = new AnimatorSet(); - private AnimatorSet hideAnimatorSet = new AnimatorSet(); - private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet(); - private AnimatorSet hideMaskAnimatorSet = new AnimatorSet(); + private AnimatorSet revealAnimatorSet = new AnimatorSet(); + private AnimatorSet hideAnimatorSet = new AnimatorSet(); + private List hideAnimators; public ConversationReactionOverlay(@NonNull Context context) { super(context); @@ -106,13 +119,13 @@ public final class ConversationReactionOverlay extends RelativeLayout { protected void onFinishInflate() { super.onFinishInflate(); - backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); - foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); - selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator); - toolbar = findViewById(R.id.conversation_reaction_toolbar); - - toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked); - toolbar.setNavigationOnClickListener(view -> hide()); + dropdownAnchor = findViewById(R.id.dropdown_anchor); + toolbarShade = findViewById(R.id.toolbar_shade); + inputShade = findViewById(R.id.input_shade); + conversationItem = findViewById(R.id.conversation_item); + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); + selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator); emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1), findViewById(R.id.reaction_2), @@ -131,7 +144,6 @@ public final class ConversationReactionOverlay extends RelativeLayout { scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance); scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height); scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width); - actionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize); selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); @@ -144,7 +156,8 @@ public final class ConversationReactionOverlay extends RelativeLayout { @NonNull Recipient conversationRecipient, @NonNull ConversationMessage conversationMessage, @NonNull PointF lastSeenDownPoint, - boolean isNonAdminInAnnouncementGroup) + boolean isNonAdminInAnnouncementGroup, + @NonNull SelectedConversationModel selectedConversationModel) { if (overlayState != OverlayState.HIDDEN) { return; @@ -152,11 +165,11 @@ public final class ConversationReactionOverlay extends RelativeLayout { this.messageRecord = conversationMessage.getMessageRecord(); this.conversationRecipient = conversationRecipient; + this.selectedConversationModel = selectedConversationModel; this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup; overlayState = OverlayState.UNINITAILIZED; selected = -1; - setupToolbarMenuItems(conversationMessage); setupSelectedEmoji(); if (Build.VERSION.SDK_INT >= 21) { @@ -166,63 +179,260 @@ public final class ConversationReactionOverlay extends RelativeLayout { statusBarHeight = ViewUtil.getStatusBarHeight(this); } - final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + actionBarHeight, - lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown - statusBarHeight); + ViewGroup.LayoutParams layoutParams = inputShade.getLayoutParams(); + layoutParams.height = activity.findViewById(R.id.bottom_panel).getHeight(); + inputShade.setLayoutParams(layoutParams); - final float halfWidth = scrubberWidth / 2f + scrubberHorizontalMargin; - final float screenWidth = getResources().getDisplayMetrics().widthPixels; - final float downX = ViewUtil.isLtr(this) ? lastSeenDownPoint.x : screenWidth - lastSeenDownPoint.x; - final float scrubberTranslationX = Util.clamp(downX - halfWidth, - scrubberHorizontalMargin, - screenWidth + scrubberHorizontalMargin - halfWidth * 2) * (ViewUtil.isLtr(this) ? 1 : -1); + toolbarShade.setVisibility(VISIBLE); + inputShade.setVisibility(VISIBLE); - backgroundView.setTranslationX(scrubberTranslationX); - backgroundView.setTranslationY(scrubberTranslationY); + Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - foregroundView.setTranslationX(scrubberTranslationX); - foregroundView.setTranslationY(scrubberTranslationY); + conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); + conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); - verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone, - lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); + float initialX = selectedConversationModel.getBitmapX(); + boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this); + + conversationItem.setX(initialX); + conversationItem.setY(selectedConversationModel.getBitmapY() - statusBarHeight); + + conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR); + conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR); + + setVisibility(View.INVISIBLE); + + ViewKt.doOnLayout(this, v -> { + showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft); + return Unit.INSTANCE; + }); + } + + private void showAfterLayout(@NonNull Activity activity, + @NonNull ConversationMessage conversationMessage, + @NonNull PointF lastSeenDownPoint, + boolean isMessageOnLeft) { + contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage)); + + Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); + boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); + + int bubbleWidth = selectedConversationModel.getBubbleWidth(); + + float endX = selectedConversationModel.getBitmapX(); + float endY = conversationItem.getY(); + float endApparentTop = endY; + float endScale = 1f; + + float menuPadding = DimensionUnit.DP.toPixels(12f); + int reactionBarHeight = backgroundView.getHeight(); + + float reactionBarBackgroundY; + + if (isWideLayout) { + boolean everythingFitsVertically = scrubberHeight + conversationItemSnapshot.getHeight() < getHeight(); + if (everythingFitsVertically) { + boolean reactionBarFitsAboveItem = conversationItem.getY() > scrubberHeight; + + if (reactionBarFitsAboveItem) { + reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; + } else { + endY = reactionBarHeight + menuPadding; + reactionBarBackgroundY = 0f; + } + } else { + float spaceAvailableForItem = getHeight() - reactionBarHeight - menuPadding; + + endScale = spaceAvailableForItem / conversationItem.getHeight(); + endY = reactionBarHeight + menuPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); + reactionBarBackgroundY = 0f; + } + } else { + boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + reactionBarHeight < getHeight(); + + if (everythingFitsVertically) { + float itemBottom = selectedConversationModel.getBitmapY() + conversationItemSnapshot.getHeight(); + boolean menuFitsBelowItem = itemBottom + menuPadding + contextMenu.getMaxHeight() <= getHeight(); + + if (menuFitsBelowItem) { + reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; + + if (reactionBarBackgroundY < 0) { + endY = backgroundView.getHeight(); + reactionBarBackgroundY = 0f; + } + } else { + endY = getHeight() - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); + reactionBarBackgroundY = endY - menuPadding - reactionBarHeight; + } + + endApparentTop = endY; + } else if (reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < getHeight()) { + float spaceAvailableForItem = (float) getHeight() - contextMenu.getMaxHeight() - menuPadding - reactionBarHeight; + + endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); + endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); + reactionBarBackgroundY = 0f; + endApparentTop = reactionBarHeight; + } else { + contextMenu.setHeight(contextMenu.getMaxHeight() / 2); + + int menuHeight = contextMenu.getHeight(); + boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding + reactionBarHeight < getHeight(); + + if (fitsVertically) { + endY = getHeight() - menuHeight - menuPadding - conversationItemSnapshot.getHeight(); + reactionBarBackgroundY = endY - reactionBarHeight; + endApparentTop = endY; + } else { + float spaceAvailableForItem = (float) getHeight() - menuHeight - menuPadding - reactionBarHeight; + + endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); + endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); + reactionBarBackgroundY = 0f; + endApparentTop = reactionBarHeight; + } + } + } + + reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight); hideAnimatorSet.end(); - toolbar.setVisibility(VISIBLE); + hideAnimatorSet = newHideAnimatorSet(); setVisibility(View.VISIBLE); - revealAnimatorSet.start(); if (Build.VERSION.SDK_INT >= 21) { this.activity = activity; - originalStatusBarColor = activity.getWindow().getStatusBarColor(); - WindowUtil.setStatusBarColor(activity.getWindow(), ContextCompat.getColor(getContext(), R.color.action_mode_status_bar)); + updateSystemUiOnShow(activity); + } - if (!ThemeUtil.isDarkTheme(getContext())) { - WindowUtil.setLightStatusBar(activity.getWindow()); - } + float scrubberX; + if (isMessageOnLeft) { + scrubberX = scrubberHorizontalMargin; + } else { + scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin; + } + + foregroundView.setX(scrubberX); + foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f); + + backgroundView.setX(scrubberX); + backgroundView.setY(reactionBarBackgroundY); + + verticalScrubBoundary.update(reactionBarBackgroundY, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); + + updateBoundsOnLayoutChanged(); + + revealAnimatorSet.start(); + + if (isWideLayout) { + float scrubberRight = scrubberX + scrubberWidth; + float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding; + contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), getHeight() - contextMenu.getMaxHeight())); + } else { + float contentX = selectedConversationModel.getContentX(); + float offsetX = isMessageOnLeft ? contentX : - contextMenu.getMaxWidth() + contentX + bubbleWidth; + + float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale); + contextMenu.show((int) offsetX, (int) (menuTop + menuPadding)); + } + + conversationItem.animate() + .x(endX) + .scaleX(endScale) + .scaleY(endScale); + + conversationItem.animate() + .y(endY) + .setDuration(TRANSITION_Y_DURATION); + } + + @RequiresApi(api = 21) + private void updateSystemUiOnShow(@NonNull Activity activity) { + Window window = activity.getWindow(); + int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui); + + originalStatusBarColor = window.getStatusBarColor(); + WindowUtil.setStatusBarColor(window, barColor); + + originalNavigationBarColor = window.getNavigationBarColor(); + WindowUtil.setNavigationBarColor(window, barColor); + + if (!ThemeUtil.isDarkTheme(getContext())) { + WindowUtil.clearLightStatusBar(window); } } public void hide() { - hideInternal(hideAnimatorSet, onHideListener); + hideInternal(onHideListener); } public void hideForReactWithAny() { - hideInternal(hideAnimatorSet, null); + hideInternal(onHideListener); } - private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) { + private void hideInternal(@Nullable OnHideListener onHideListener) { overlayState = OverlayState.HIDDEN; + int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); + + List hides = new ArrayList<>(hideAnimators); + + ObjectAnimator itemScaleXAnim = new ObjectAnimator(); + itemScaleXAnim.setProperty(View.SCALE_X); + itemScaleXAnim.setFloatValues(1f); + itemScaleXAnim.setTarget(conversationItem); + itemScaleXAnim.setDuration(duration); + hides.add(itemScaleXAnim); + + ObjectAnimator itemScaleYAnim = new ObjectAnimator(); + itemScaleYAnim.setProperty(View.SCALE_Y); + itemScaleYAnim.setFloatValues(1f); + itemScaleYAnim.setTarget(conversationItem); + itemScaleYAnim.setDuration(duration); + hides.add(itemScaleYAnim); + + ObjectAnimator itemXAnim = new ObjectAnimator(); + itemXAnim.setProperty(View.X); + itemXAnim.setFloatValues(selectedConversationModel.getBitmapX()); + itemXAnim.setTarget(conversationItem); + itemXAnim.setDuration(duration); + hides.add(itemXAnim); + + ObjectAnimator itemYAnim = new ObjectAnimator(); + itemYAnim.setProperty(View.Y); + itemYAnim.setFloatValues(selectedConversationModel.getBitmapY() - statusBarHeight); + itemYAnim.setTarget(conversationItem); + itemYAnim.setDuration(TRANSITION_Y_DURATION); + hides.add(itemYAnim); + + hideAnimatorSet.playTogether(hides); + revealAnimatorSet.end(); hideAnimatorSet.start(); - if (Build.VERSION.SDK_INT >= 21 && activity != null) { - WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor); - WindowUtil.clearLightStatusBar(activity.getWindow()); - activity = null; - } + hideAnimatorSet.addListener(new AnimationCompleteListener() { + @Override public void onAnimationEnd(Animator animation) { + hideAnimatorSet.removeListener(this); - if (onHideListener != null) { - onHideListener.onHide(); + toolbarShade.setVisibility(INVISIBLE); + inputShade.setVisibility(INVISIBLE); + + if (Build.VERSION.SDK_INT >= 21 && activity != null) { + WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor); + WindowUtil.setNavigationBarColor(activity.getWindow(), originalNavigationBarColor); + activity = null; + } + + if (onHideListener != null) { + onHideListener.onHide(); + } + } + }); + + if (contextMenu != null) { + contextMenu.dismiss(); } } @@ -238,6 +448,10 @@ public final class ConversationReactionOverlay extends RelativeLayout { protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); + updateBoundsOnLayoutChanged(); + } + + private void updateBoundsOnLayoutChanged() { backgroundView.getGlobalVisibleRect(emojiStripViewBounds); emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect); emojiStripViewBounds.left = getStart(emojiViewGlobalRect); @@ -300,24 +514,10 @@ public final class ConversationReactionOverlay extends RelativeLayout { } } - if (isToolbarTouch) { - if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) { - isToolbarTouch = false; - } - return false; - } - switch (motionEvent.getAction()) { case MotionEvent.ACTION_DOWN: selected = getSelectedIndexViaDownEvent(motionEvent); - if (selected == -1) { - if (motionEvent.getY() < toolbar.getHeight() + statusBarHeight) { - isToolbarTouch = true; - return false; - } - } - deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); overlayState = OverlayState.DEADZONE; downIsOurs = true; @@ -454,8 +654,8 @@ public final class ConversationReactionOverlay extends RelativeLayout { this.onReactionSelectedListener = onReactionSelectedListener; } - public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) { - this.onToolbarItemClickedListener = onToolbarItemClickedListener; + public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) { + this.onActionSelectedListener = onActionSelectedListener; } public void setOnHideListener(@Nullable OnHideListener onHideListener) { @@ -474,24 +674,48 @@ public final class ConversationReactionOverlay extends RelativeLayout { .orElse(null); } - private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) { + private @NonNull List getMenuActionItems(@NonNull ConversationMessage conversationMessage) { MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup); - toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction()); - toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction()); - toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction()); - toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction()); - } + List items = new ArrayList<>(); - private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) { - - hide(); - - if (onToolbarItemClickedListener == null) { - return false; + if (menuState.shouldShowReplyAction()) { + items.add(new ActionItem(R.drawable.ic_reply_24_tinted, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY))); } - return onToolbarItemClickedListener.onMenuItemClick(menuItem); + if (menuState.shouldShowForwardAction()) { + items.add(new ActionItem(R.drawable.ic_forward_24_tinted, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD))); + } + + if (menuState.shouldShowResendAction()) { + items.add(new ActionItem(R.drawable.ic_retry_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); + } + + if (menuState.shouldShowSaveAttachmentAction()) { + items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD))); + } + + if (menuState.shouldShowCopyAction()) { + items.add(new ActionItem(R.drawable.ic_copy_24_tinted, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY))); + } + + items.add(new ActionItem(R.drawable.ic_select_24_tinted, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT))); + items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); + items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE))); + + return items; + } + + private void handleActionItemClicked(@NonNull Action action) { + hideInternal(() -> { + if (onHideListener != null) { + onHideListener.onHide(); + } + + if (onActionSelectedListener != null) { + onActionSelectedListener.onActionSelected(action); + } + }); } private void initAnimators() { @@ -521,64 +745,47 @@ public final class ConversationReactionOverlay extends RelativeLayout { selectedRevealAnim.setDuration(duration); reveals.add(selectedRevealAnim); - Animator toolbarRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - toolbarRevealAnim.setTarget(toolbar); - toolbarRevealAnim.setDuration(duration); - reveals.add(toolbarRevealAnim); - revealAnimatorSet.setInterpolator(INTERPOLATOR); revealAnimatorSet.playTogether(reveals); - revealMaskAnimatorSet.setInterpolator(INTERPOLATOR); - revealMaskAnimatorSet.playTogether(overlayRevealAnim); - - List hides = Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); - anim.setTarget(v); - anim.setStartDelay(idx * animationEmojiStartDelayFactor); - return anim; - }) - .toList(); + hideAnimators = Stream.of(emojiViews) + .mapIndexed((idx, v) -> { + Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); + anim.setTarget(v); + anim.setStartDelay(idx * animationEmojiStartDelayFactor); + return anim; + }) + .toList(); Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); overlayHideAnim.setDuration(duration); + hideAnimators.add(overlayHideAnim); Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); backgroundHideAnim.setTarget(backgroundView); backgroundHideAnim.setDuration(duration); - hides.add(backgroundHideAnim); + hideAnimators.add(backgroundHideAnim); Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); selectedHideAnim.setTarget(selectedView); selectedHideAnim.setDuration(duration); - hides.add(selectedHideAnim); + hideAnimators.add(selectedHideAnim); - Animator toolbarHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - toolbarHideAnim.setTarget(toolbar); - toolbarHideAnim.setDuration(duration); - hides.add(toolbarHideAnim); + hideAnimatorSet = newHideAnimatorSet(); + } - AnimationCompleteListener hideListener = new AnimationCompleteListener() { + private @NonNull AnimatorSet newHideAnimatorSet() { + AnimatorSet set = new AnimatorSet(); + + set.addListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { setVisibility(View.GONE); } - }; + }); + set.setInterpolator(INTERPOLATOR); - List hideAllAnimators = new LinkedList<>(hides); - hideAllAnimators.add(overlayHideAnim); - - hideAnimatorSet.addListener(hideListener); - hideAnimatorSet.setInterpolator(INTERPOLATOR); - hideAnimatorSet.playTogether(hideAllAnimators); - - hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR); - hideAllButMaskAnimatorSet.playTogether(hides); - - hideMaskAnimatorSet.addListener(hideListener); - hideMaskAnimatorSet.setInterpolator(INTERPOLATOR); - hideMaskAnimatorSet.playTogether(overlayHideAnim); + return set; } public interface OnHideListener { @@ -590,6 +797,10 @@ public final class ConversationReactionOverlay extends RelativeLayout { void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji); } + public interface OnActionSelectedListener { + void onActionSelected(@NonNull Action action); + } + private static class Boundary { private float min; private float max; @@ -621,4 +832,15 @@ public final class ConversationReactionOverlay extends RelativeLayout { SCRUB, TAP } + + public enum Action { + REPLY, + FORWARD, + RESEND, + DOWNLOAD, + COPY, + MULTISELECT, + VIEW_INFO, + DELETE, + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index dbeb2e310f..00bc27cd4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -220,6 +220,11 @@ public final class ConversationUpdateItem extends FrameLayout return false; } + @Override + public boolean shouldProjectContent() { + return false; + } + @Override public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) { return EMPTY_PROJECTION_LIST; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt new file mode 100644 index 0000000000..6d51777eb0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.conversation + +import android.graphics.Bitmap +import android.net.Uri + +/** + * Contains information on a single selected conversation item. This is used when transitioning + * between selected and unselected states. + */ +data class SelectedConversationModel( + val bitmap: Bitmap, + val bitmapX: Float, + val bitmapY: Float, + val contentX: Float, + val bubbleWidth: Int, + val audioUri: Uri? = null, + val isOutgoing: Boolean, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt index 184c734084..910a20a36d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/RecyclerViewColorizer.kt @@ -79,6 +79,7 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) { } private val colorPaint = Paint() + private val outOfBoundsPaint = Paint() override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { outRect.setEmpty() @@ -122,6 +123,8 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) { mask.setXfermode(colorPaint.xfermode) mask.setBounds(0, 0, parent.width, parent.height) mask.draw(canvas) + + outOfBoundsPaint.color = chatColors.getColors().last() } else { colorPaint.color = chatColors.asSingleColor() canvas.drawRect( @@ -131,7 +134,13 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) { parent.height.toFloat(), colorPaint ) + + outOfBoundsPaint.color = chatColors.asSingleColor() } + + canvas.drawRect( + 0f, parent.height.toFloat(), parent.width.toFloat(), parent.height * 2f, outOfBoundsPaint + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index 915a356cc2..76f220d32d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -362,7 +362,7 @@ class MultiselectItemDecoration( } } - if (child.canPlayContent()) { + if (child.canPlayContent() && child.shouldProjectContent()) { val mp4GifProjection = child.getGiphyMp4PlayableProjection(child.rootView as ViewGroup) path.op(mp4GifProjection.path, Path.Op.DIFFERENCE) mp4GifProjection.release() diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java index 98cb2b5eb7..fa3b5367ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java @@ -51,4 +51,10 @@ public interface GiphyMp4Playable { * Specifies whether the content can start playing. */ boolean canPlayContent(); + + /** + * Specifies whether the projection from {@link #getGiphyMp4PlayableProjection(ViewGroup)} should + * be used to project into a view. + */ + boolean shouldProjectContent(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java index 2be51bc28f..dd222fc6de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.giph.mp4; import android.content.Context; +import android.graphics.Bitmap; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -98,10 +99,18 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, De container.setVisibility(View.GONE); } + public void pause() { + player.pause(); + } + public void show() { container.setVisibility(View.VISIBLE); } + public void resume() { + player.play(); + } + @Override public void onPlaybackStateChanged(int playbackState) { if (playbackState == Player.STATE_READY) { @@ -177,4 +186,8 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, De public void setCorners(@Nullable Projection.Corners corners) { player.setCorners(corners); } + + public @Nullable Bitmap getBitmap() { + return player.getBitmap(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java index 6547ca2a58..d1cf6736bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java @@ -121,7 +121,7 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl } } - private @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) { + public @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) { if (playing.get(adapterPosition) != null) { return playing.get(adapterPosition); } else if (notPlaying.get(adapterPosition) != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java index 58e3a5002e..f18bd86299 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.giph.mp4; import android.content.Context; +import android.graphics.Bitmap; import android.graphics.Canvas; import android.util.AttributeSet; +import android.view.TextureView; +import android.view.View; import android.widget.FrameLayout; import androidx.annotation.NonNull; @@ -103,6 +106,12 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif } } + void pause() { + if (exoPlayer != null) { + exoPlayer.pause(); + } + } + void stop() { if (exoPlayer != null) { exoPlayer.stop(); @@ -122,4 +131,13 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) { exoView.setResizeMode(resizeMode); } + + @Nullable Bitmap getBitmap() { + final View view = exoView.getVideoSurfaceView(); + if (view instanceof TextureView) { + return ((TextureView) view).getBitmap(); + } + + return null; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java index 7b944f02d1..37e25c19b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java @@ -89,6 +89,11 @@ final class GiphyMp4ViewHolder extends MappingViewHolder implements return true; } + @Override + public boolean shouldProjectContent() { + return true; + } + private void loadPlaceholderImage(@NonNull GiphyImage giphyImage) { GlideApp.with(itemView) .load(new ChunkedImageUrl(giphyImage.getStillUrl())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 78fc08ac8d..6c1dc022de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -16,13 +16,11 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.recyclerview.widget.RecyclerView; -import com.annimon.stream.Stream; import com.google.android.exoplayer2.MediaItem; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.conversation.ClipProjectionDrawable; import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.conversation.colors.Colorizable; @@ -41,10 +39,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.sql.Date; import java.text.SimpleDateFormat; import java.util.HashSet; -import java.util.List; import java.util.Locale; -import java.util.Set; -import java.util.stream.Collectors; final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable { private final TextView sentDate; @@ -250,6 +245,11 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G return conversationItem.canPlayContent(); } + @Override + public boolean shouldProjectContent() { + return conversationItem.shouldProjectContent(); + } + @Override public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) { return conversationItem.getColorizerProjections(coordinateRoot); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java index db8220facd..bcf212c0f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java @@ -114,6 +114,14 @@ public final class Projection { return set(x, y + yTranslation, width, height, corners); } + public @NonNull Projection scale(float scale) { + Corners newCorners = new Corners(this.corners.topLeft * scale, + this.corners.topRight * scale, + this.corners.bottomRight * scale, + this.corners.bottomLeft * scale); + return set(x, y, (int) (width * scale), (int) (height * scale), newCorners); + } + public static @NonNull Projection relativeToParent(@NonNull ViewGroup parent, @NonNull View view, @Nullable Corners corners) { Rect viewBounds = new Rect(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index eb60b200d5..22580c79e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -444,6 +444,15 @@ public class Util { return Math.min(Math.max(value, min), max); } + /** + * Returns half of the difference between the given length, and the length when scaled by the + * given scale. + */ + public static float halfOffsetFromScale(int length, float scale) { + float scaledLength = length * scale; + return (length - scaledLength) / 2; + } + public static @Nullable String readTextFromClipboard(@NonNull Context context) { { ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java index 91680ab1c7..5c36921b6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/WindowUtil.java @@ -35,6 +35,12 @@ public final class WindowUtil { setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); } + public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) { + if (Build.VERSION.SDK_INT < 21) return; + + window.setNavigationBarColor(color); + } + public static void setLightStatusBarFromTheme(@NonNull Activity activity) { if (Build.VERSION.SDK_INT < 23) return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java deleted file mode 100644 index ad0cd617a4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/views/AdaptiveActionsToolbar.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.thoughtcrime.securesms.util.views; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.view.Menu; -import android.view.MenuItem; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ViewUtil; - -/** - * AdaptiveActionsToolbar behaves like a normal {@link Toolbar} except in that it ignores the - * showAsAlways attributes of menu items added via menu inflation, opting for an adaptive algorithm - * instead. This algorithm will display as many icons as it can up to a specific percentage of the - * screen. - * - * Each ActionView icon is expected to occupy 48dp of space, including padding. Items are stacked one - * after the next with no margins. - * - * This view can be customized via attributes: - * - * aat_max_shown -- controls the max number of items to display. - * aat_percent_for_actions -- controls the max percent of screen width the buttons can occupy. - */ -public class AdaptiveActionsToolbar extends Toolbar { - - private static final int NAVIGATION_DP = 56; - private static final int ACTION_VIEW_WIDTH_DP = 48; - private static final int OVERFLOW_VIEW_WIDTH_DP = 36; - - private int maxShown; - - public AdaptiveActionsToolbar(@NonNull Context context) { - this(context, null); - } - - public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, R.attr.toolbarStyle); - } - - public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.AdaptiveActionsToolbar); - - maxShown = array.getInteger(R.styleable.AdaptiveActionsToolbar_aat_max_shown, 100); - - array.recycle(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - adjustMenuActions(getMenu(), maxShown, getMeasuredWidth()); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - public static void adjustMenuActions(@NonNull Menu menu, int maxToShow, int toolbarWidthPx) { - int menuSize = 0; - - for (int i = 0; i < menu.size(); i++) { - if (menu.getItem(i).isVisible()) { - menuSize++; - } - } - - int widthAllowed = toolbarWidthPx - ViewUtil.dpToPx(NAVIGATION_DP); - int nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); - - if (nItemsToShow < menuSize) { - widthAllowed -= ViewUtil.dpToPx(OVERFLOW_VIEW_WIDTH_DP); - } - - nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP)); - - for (int i = 0; i < menu.size(); i++) { - MenuItem item = menu.getItem(i); - if (item.isVisible() && nItemsToShow > 0) { - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - nItemsToShow--; - } else { - item.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); - } - } - } -} diff --git a/app/src/main/res/drawable-night/ic_select_24_tinted.xml b/app/src/main/res/drawable-night/ic_select_24_tinted.xml new file mode 100644 index 0000000000..d36a2d5153 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_select_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_retry_24.xml b/app/src/main/res/drawable/ic_retry_24.xml new file mode 100644 index 0000000000..f8b3e0dadc --- /dev/null +++ b/app/src/main/res/drawable/ic_retry_24.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_save_24_tinted.xml b/app/src/main/res/drawable/ic_save_24_tinted.xml new file mode 100644 index 0000000000..3923861d5f --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_select_24_tinted.xml b/app/src/main/res/drawable/ic_select_24_tinted.xml new file mode 100644 index 0000000000..13fa4864a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/conversation_reaction_long_press_toolbar.xml b/app/src/main/res/layout/conversation_reaction_long_press_toolbar.xml deleted file mode 100644 index 8c2f1a3dd3..0000000000 --- a/app/src/main/res/layout/conversation_reaction_long_press_toolbar.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_reaction_scrubber.xml b/app/src/main/res/layout/conversation_reaction_scrubber.xml index df01b641a7..e147e8e62f 100644 --- a/app/src/main/res/layout/conversation_reaction_scrubber.xml +++ b/app/src/main/res/layout/conversation_reaction_scrubber.xml @@ -13,9 +13,31 @@ android:visibility="gone" tools:visibility="visible"> - + + + + + + + - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 5c08eaa334..a698c35a49 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -127,6 +127,7 @@ @color/core_grey_35 @color/core_grey_15 @color/transparent_black_60 + #070707 @color/core_grey_80 diff --git a/app/src/main/res/values-v23/colors.xml b/app/src/main/res/values-v23/colors.xml index 7dcfda9757..936fb1abbc 100644 --- a/app/src/main/res/values-v23/colors.xml +++ b/app/src/main/res/values-v23/colors.xml @@ -1,4 +1,4 @@ - @color/signal_background_secondary + @color/reactions_status_bar_shade \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index b0a2c2b5c0..b309c46fca 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -251,10 +251,6 @@ - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 17c46b6049..04b1c57b67 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -30,7 +30,7 @@ #32000000 - @color/core_grey_60 + @color/core_grey_60 #400099cc #ffffffff diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index 978a544063..abd1b7fba8 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -127,6 +127,7 @@ @color/core_grey_60 @color/core_grey_75 @color/transparent_black_40 + #999999 @color/core_grey_02 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 197ce2ed39..a7569cf20a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2775,22 +2775,6 @@ Signal video call - - Info - - Copy - - Delete - - Forward - - Resend message - - Reply - - - - Select multiple @@ -2818,6 +2802,10 @@ Reply Save + + Resend + + Select