Use context menu when selecting a message in chat.

This commit is contained in:
Rashad Sookram
2022-01-28 15:12:35 -05:00
committed by Cody Henthorne
parent d254d24d77
commit e4d43ade93
38 changed files with 1013 additions and 435 deletions

View File

@@ -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<ActionItem>) {
mappingAdapter.submitList(items.toAdapterItems())
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
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<DisplayItem> {
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<DisplayItem>(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)
}
}
}
}
}

View File

@@ -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<RecyclerView>(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<ActionItem>.toAdapterItems(): List<DisplayItem> {
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<DisplayItem> {
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<DisplayItem>(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<DisplayItem> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
}
}
enum class HorizontalPosition {
START, END
}

View File

@@ -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.
*

View File

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

View File

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

View File

@@ -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<View, Unit>() {
@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<VoiceNotePlaybackState> 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;
}
}
}

View File

@@ -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<MessageRecord> 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<Projection> 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++) {

View File

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

View File

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

View File

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

View File

@@ -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<Animator> 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<Animator> 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<ActionItem> 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<ActionItem> 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<Animator> 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<Animator> 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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,6 +89,11 @@ final class GiphyMp4ViewHolder extends MappingViewHolder<GiphyImage> implements
return true;
}
@Override
public boolean shouldProjectContent() {
return true;
}
private void loadPlaceholderImage(@NonNull GiphyImage giphyImage) {
GlideApp.with(itemView)
.load(new ChunkedImageUrl(giphyImage.getStillUrl()))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,1C9.8244,1 7.6977,1.6451 5.8887,2.8538C4.0798,4.0625 2.6699,5.7805 1.8373,7.7905C1.0048,9.8005 0.7869,12.0122 1.2114,14.146C1.6358,16.2798 2.6834,18.2398 4.2218,19.7782C5.7602,21.3166 7.7202,22.3642 9.854,22.7886C11.9878,23.2131 14.1995,22.9952 16.2095,22.1627C18.2195,21.3301 19.9375,19.9202 21.1462,18.1113C22.3549,16.3023 23,14.1756 23,12C23,9.0826 21.8411,6.2847 19.7782,4.2218C17.7153,2.1589 14.9174,1 12,1V1ZM10.232,17L6.166,12.934L7.227,11.874L10.227,14.874L16.768,8.333L17.834,9.4L10.232,17Z"
android:fillColor="@color/signal_icon_tint_primary"/>
</vector>

View File

@@ -0,0 +1,4 @@
<vector android:height="24dp" android:viewportHeight="20"
android:viewportWidth="20" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/signal_icon_tint_primary" android:pathData="M14.189,10.329l-1.4,-1.385 -1.061,1.061 3.9,3.884 3.865,-3.866 -1.06,-1.06 -1.055,1.055L16.5,11.344V10a7.75,7.75 0,1 0,-2.27 5.48l-1.061,-1.061A6.249,6.249 0,1 1,15 10v1.671Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M22,10V19C22,19.7956 21.6839,20.5587 21.1213,21.1213C20.5587,21.6839 19.7956,22 19,22H5C4.2043,22 3.4413,21.6839 2.8787,21.1213C2.3161,20.5587 2,19.7956 2,19V10C2,9.2044 2.3161,8.4413 2.8787,7.8787C3.4413,7.3161 4.2043,7 5,7H9.75V8.5H5C4.6022,8.5 4.2206,8.658 3.9393,8.9393C3.658,9.2206 3.5,9.6022 3.5,10V19C3.5,19.3978 3.658,19.7794 3.9393,20.0607C4.2206,20.342 4.6022,20.5 5,20.5H19C19.3978,20.5 19.7794,20.342 20.0607,20.0607C20.342,19.7794 20.5,19.3978 20.5,19V10C20.5,9.6022 20.342,9.2206 20.0607,8.9393C19.7794,8.658 19.3978,8.5 19,8.5H14.25V7H19C19.7956,7 20.5587,7.3161 21.1213,7.8787C21.6839,8.4413 22,9.2044 22,10ZM15.419,11.47L13.586,13.3L12.766,14.448V2H11.266V14.45L10.524,13.411L8.581,11.485L7.525,12.55L12.012,17L16.479,12.532L15.419,11.47Z"
android:fillColor="@color/signal_icon_tint_primary"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2.5C13.8789,2.5 15.7156,3.0572 17.2779,4.101C18.8402,5.1449 20.0578,6.6286 20.7769,8.3645C21.4959,10.1004 21.684,12.0105 21.3175,13.8534C20.9509,15.6962 20.0461,17.3889 18.7175,18.7175C17.3889,20.0461 15.6962,20.9509 13.8534,21.3175C12.0105,21.684 10.1004,21.4959 8.3645,20.7769C6.6286,20.0578 5.1449,18.8402 4.101,17.2779C3.0572,15.7157 2.5,13.8789 2.5,12C2.5029,9.4813 3.5047,7.0667 5.2857,5.2857C7.0667,3.5047 9.4813,2.5029 12,2.5V2.5ZM12,1C9.8244,1 7.6977,1.6451 5.8887,2.8538C4.0798,4.0625 2.6699,5.7805 1.8373,7.7905C1.0048,9.8005 0.7869,12.0122 1.2114,14.146C1.6358,16.2798 2.6834,18.2398 4.2218,19.7782C5.7602,21.3166 7.7202,22.3642 9.854,22.7886C11.9878,23.2131 14.1995,22.9952 16.2095,22.1627C18.2195,21.3301 19.9375,19.9202 21.1462,18.1113C22.3549,16.3023 23,14.1756 23,12C23,9.0826 21.8411,6.2847 19.7782,4.2218C17.7153,2.1589 14.9174,1 12,1V1ZM17.834,9.4L16.773,8.338L10.232,14.879L7.232,11.879L6.171,12.939L10.232,17L17.834,9.4Z"
android:fillColor="@color/signal_icon_tint_primary"/>
</vector>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/signal_background_secondary"
app:contentInsetStart="0dp"
app:contentInsetStartWithNavigation="48sp"
app:menu="@menu/conversation_reactions_long_press_menu"
app:navigationIcon="@drawable/ic_x_tinted">
</org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar>

View File

@@ -13,9 +13,31 @@
android:visibility="gone"
tools:visibility="visible">
<include
android:id="@+id/conversation_reaction_toolbar"
layout="@layout/conversation_reaction_long_press_toolbar" />
<Space
android:id="@+id/dropdown_anchor"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignParentLeft="true"
tools:ignore="RtlHardcoded" />
<View
android:id="@+id/toolbar_shade"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@color/reactions_screen_shade_color"
android:layout_alignParentTop="true" />
<View
android:id="@+id/input_shade"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/reactions_screen_shade_color"
android:layout_alignParentBottom="true" />
<View
android:id="@+id/conversation_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<View
android:id="@+id/conversation_reaction_scrubber_background"

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_info"
android:icon="@drawable/ic_info_white_24"
android:title="@string/conversation_context__menu_message_details"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:id="@+id/action_delete"
android:icon="@drawable/ic_trash_24"
android:title="@string/conversation_context__menu_delete_message"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:id="@+id/action_copy"
android:icon="@drawable/ic_copy_24"
android:title="@string/conversation_context__menu_copy_text"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:id="@+id/action_reply"
android:icon="@drawable/ic_reply_24"
android:title="@string/conversation_context__menu_reply_to_message"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:visible="false"
android:id="@+id/action_download"
android:icon="@drawable/ic_save_24"
android:title="@string/conversation_context_image__save_attachment"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:id="@+id/action_multiselect"
android:icon="@drawable/ic_select_24"
android:title="@string/conversation_context__reaction_multi_select"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="always" />
<item
android:id="@+id/action_forward"
android:icon="@drawable/ic_forward_24"
android:title="@string/conversation_context__menu_forward_message"
app:iconTint="@color/signal_icon_tint_primary"
app:showAsAction="ifRoom" />
</menu>

View File

@@ -127,6 +127,7 @@
<color name="reactions_pill_text_color">@color/core_grey_35</color>
<color name="reactions_pill_selected_text_color">@color/core_grey_15</color>
<color name="reactions_screen_shade_color">@color/transparent_black_60</color>
<color name="reactions_status_bar_shade">#070707</color>
<color name="recipient_contact_button_color">@color/core_grey_80</color>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="action_mode_status_bar">@color/signal_background_secondary</color>
<color name="conversation_item_selected_system_ui">@color/reactions_status_bar_shade</color>
</resources>

View File

@@ -251,10 +251,6 @@
<attr name="rcv_outgoing" format="boolean" />
</declare-styleable>
<declare-styleable name="AdaptiveActionsToolbar">
<attr name="aat_max_shown" format="integer" />
</declare-styleable>
<declare-styleable name="VideoThumbnailsRangeSelectorView">
<attr name="thumbWidth" format="dimension" />
<attr name="thumbColorEdited" format="color" />

View File

@@ -30,7 +30,7 @@
<color name="conversation_compose_divider">#32000000</color>
<color name="action_mode_status_bar">@color/core_grey_60</color>
<color name="conversation_item_selected_system_ui">@color/core_grey_60</color>
<color name="touch_highlight">#400099cc</color>
<color name="device_link_item_background_light">#ffffffff</color>

View File

@@ -127,6 +127,7 @@
<color name="reactions_pill_text_color">@color/core_grey_60</color>
<color name="reactions_pill_selected_text_color">@color/core_grey_75</color>
<color name="reactions_screen_shade_color">@color/transparent_black_40</color>
<color name="reactions_status_bar_shade">#999999</color>
<color name="recipient_contact_button_color">@color/core_grey_02</color>

View File

@@ -2775,22 +2775,6 @@
<string name="conversation_callable_secure__menu_video">Signal video call</string>
<!-- conversation_context -->
<!-- Button to view detailed information for a message -->
<string name="conversation_context__menu_message_details">Info</string>
<!-- Button to copy a message\'s text to the clipboard -->
<string name="conversation_context__menu_copy_text">Copy</string>
<!-- Button to delete a message -->
<string name="conversation_context__menu_delete_message">Delete</string>
<!-- Button to forward a message to another person or group chat -->
<string name="conversation_context__menu_forward_message">Forward</string>
<!-- Button to retry sending a message -->
<string name="conversation_context__menu_resend_message">Resend message</string>
<!-- Button to reply to a message -->
<string name="conversation_context__menu_reply_to_message">Reply</string>
<!-- conversation_context_reaction -->
<!-- Button to select a message and enter selection mode -->
<string name="conversation_context__reaction_multi_select">Select multiple</string>
<!-- Heading which shows how many messages are currently selected -->
<plurals name="conversation_context__s_selected">
@@ -2818,6 +2802,10 @@
<string name="conversation_selection__menu_reply">Reply</string>
<!-- Button to save a message attachment (image, file etc.) -->
<string name="conversation_selection__menu_save">Save</string>
<!-- Button to retry sending a message -->
<string name="conversation_selection__menu_resend_message">Resend</string>
<!-- Button to select a message and enter selection mode -->
<string name="conversation_selection__menu_multi_select">Select</string>
<!-- conversation_expiring_on -->