package org.thoughtcrime.securesms.conversation.v2; import android.animation.Animator; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; 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.util.AttributeSet; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; import android.view.Window; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.view.ViewKt; import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; import com.annimon.stream.Stream; import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.util.AnimationCompleteListener; import org.thoughtcrime.securesms.util.DateUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import kotlin.Unit; import network.loki.messenger.R; public final class ConversationReactionOverlay extends FrameLayout { public static final float LONG_PRESS_SCALE_FACTOR = 0.95f; private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); private final Rect emojiViewGlobalRect = new Rect(); private final Rect emojiStripViewBounds = new Rect(); private float segmentSize; private final Boundary horizontalEmojiBoundary = new Boundary(); private final Boundary verticalScrubBoundary = new Boundary(); private final PointF deadzoneTouchPoint = new PointF(); private Activity activity; private MessageRecord messageRecord; private SelectedConversationModel selectedConversationModel; private String blindedPublicKey; private OverlayState overlayState = OverlayState.HIDDEN; private RecentEmojiPageModel recentEmojiPageModel; private boolean downIsOurs; private int selected = -1; private int customEmojiIndex; private int originalStatusBarColor; private int originalNavigationBarColor; private View dropdownAnchor; private LinearLayout conversationItem; private View conversationBubble; private TextView conversationTimestamp; private View backgroundView; private ConstraintLayout foregroundView; private EmojiImageView[] emojiViews; private ConversationContextMenu contextMenu; private float touchDownDeadZoneSize; private float distanceFromTouchDownPointToBottomOfScrubberDeadZone; private int scrubberWidth; private int selectedVerticalTranslation; private int scrubberHorizontalMargin; private int animationEmojiStartDelayFactor; private int statusBarHeight; private OnReactionSelectedListener onReactionSelectedListener; private OnActionSelectedListener onActionSelectedListener; private OnHideListener onHideListener; private AnimatorSet revealAnimatorSet = new AnimatorSet(); private AnimatorSet hideAnimatorSet = new AnimatorSet(); public ConversationReactionOverlay(@NonNull Context context) { super(context); } public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); dropdownAnchor = findViewById(R.id.dropdown_anchor); conversationItem = findViewById(R.id.conversation_item); conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble); conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1), findViewById(R.id.reaction_2), findViewById(R.id.reaction_3), findViewById(R.id.reaction_4), findViewById(R.id.reaction_5), findViewById(R.id.reaction_6), findViewById(R.id.reaction_7) }; customEmojiIndex = emojiViews.length - 1; distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom); touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size); scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width); selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor); initAnimators(); } public void show(@NonNull Activity activity, @NonNull MessageRecord messageRecord, @NonNull PointF lastSeenDownPoint, @NonNull SelectedConversationModel selectedConversationModel, @Nullable String blindedPublicKey) { if (overlayState != OverlayState.HIDDEN) { return; } this.messageRecord = messageRecord; this.selectedConversationModel = selectedConversationModel; this.blindedPublicKey = blindedPublicKey; overlayState = OverlayState.UNINITAILIZED; selected = -1; recentEmojiPageModel = new RecentEmojiPageModel(activity); setupSelectedEmoji(); View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground); statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight(); Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp())); updateConversationTimestamp(messageRecord); boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this); conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR); conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR); setVisibility(View.INVISIBLE); this.activity = activity; updateSystemUiOnShow(activity); ViewKt.doOnLayout(this, v -> { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft); return Unit.INSTANCE; }); } private void updateConversationTimestamp(MessageRecord message) { if (message.isOutgoing()) conversationBubble.bringToFront(); else conversationTimestamp.bringToFront(); } private void showAfterLayout(@NonNull MessageRecord messageRecord, @NonNull PointF lastSeenDownPoint, boolean isMessageOnLeft) { contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)); float endX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth(); float endY = selectedConversationModel.getBubbleY() - statusBarHeight; conversationItem.setX(endX); conversationItem.setY(endY); Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); int overlayHeight = getHeight(); int bubbleWidth = selectedConversationModel.getBubbleWidth(); float endApparentTop = endY; float endScale = 1f; float menuPadding = DimensionUnit.DP.toPixels(12f); float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f); int reactionBarHeight = backgroundView.getHeight(); float reactionBarBackgroundY; if (isWideLayout) { boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight; if (everythingFitsVertically) { boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding; if (reactionBarFitsAboveItem) { reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; } else { endY = reactionBarHeight + menuPadding + reactionBarTopPadding; reactionBarBackgroundY = reactionBarTopPadding; } } else { float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding; endScale = spaceAvailableForItem / conversationItem.getHeight(); endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); reactionBarBackgroundY = reactionBarTopPadding; } } else { float reactionBarOffset = DimensionUnit.DP.toPixels(48); float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0); boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight; if (everythingFitsVertically) { float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight; if (menuFitsBelowItem) { if (conversationItem.getY() < 0) { endY = 0; } float contextMenuTop = endY + conversationItemSnapshot.getHeight(); reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); if (reactionBarBackgroundY <= reactionBarTopPadding) { endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding; } } else { endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; } endApparentTop = endY; } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar; endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale); reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); } else { contextMenu.setHeight(contextMenu.getMaxHeight() / 2); int menuHeight = contextMenu.getHeight(); boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight; if (fitsVertically) { float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight; if (menuFitsBelowItem) { reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; if (reactionBarBackgroundY < reactionBarTopPadding) { endY = reactionBarTopPadding + reactionBarHeight + menuPadding; reactionBarBackgroundY = reactionBarTopPadding; } } else { endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight(); reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; } endApparentTop = endY; } else { float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding; endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding; reactionBarBackgroundY = reactionBarTopPadding; endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding; } } } reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight); hideAnimatorSet.end(); setVisibility(View.VISIBLE); 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(), overlayHeight - contextMenu.getMaxHeight())); } else { float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX(); float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth; float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale); contextMenu.show((int) offsetX, (int) (menuTop + menuPadding)); } int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); conversationBubble.animate() .scaleX(endScale) .scaleY(endScale) .setDuration(revealDuration); conversationItem.animate() .x(endX) .y(endY) .setDuration(revealDuration); } private float getReactionBarOffsetForTouch(float itemY, float contextMenuTop, float contextMenuPadding, float reactionBarOffset, int reactionBarHeight, float spaceNeededBetweenTopOfScreenAndTopOfReactionBar, float messageTop) { float adjustedTouchY = itemY - statusBarHeight; float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop); float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop); if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) { float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding; reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding; } return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar); } private void updateSystemUiOnShow(@NonNull Activity activity) { Window window = activity.getWindow(); int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color); originalStatusBarColor = window.getStatusBarColor(); WindowUtil.setStatusBarColor(window, barColor); originalNavigationBarColor = window.getNavigationBarColor(); WindowUtil.setNavigationBarColor(window, barColor); if (!ThemeUtil.isDarkTheme(getContext())) { WindowUtil.clearLightStatusBar(window); WindowUtil.clearLightNavigationBar(window); } } public void hide() { hideInternal(onHideListener); } public void hideForReactWithAny() { hideInternal(onHideListener); } private void hideInternal(@Nullable OnHideListener onHideListener) { overlayState = OverlayState.HIDDEN; AnimatorSet animatorSet = newHideAnimatorSet(); hideAnimatorSet = animatorSet; revealAnimatorSet.end(); animatorSet.start(); if (onHideListener != null) { onHideListener.startHide(); } if (selectedConversationModel.getFocusedView() != null) { ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView()); } animatorSet.addListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { animatorSet.removeListener(this); if (onHideListener != null) { onHideListener.onHide(); } } }); if (contextMenu != null) { contextMenu.dismiss(); } } public boolean isShowing() { return overlayState != OverlayState.HIDDEN; } public @NonNull MessageRecord getMessageRecord() { return messageRecord; } @Override 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); emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect); emojiStripViewBounds.right = getEnd(emojiViewGlobalRect); segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length; } private int getStart(@NonNull Rect rect) { if (ViewUtil.isLtr(this)) { return rect.left; } else { return rect.right; } } private int getEnd(@NonNull Rect rect) { if (ViewUtil.isLtr(this)) { return rect.right; } else { return rect.left; } } public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { if (!isShowing()) { throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber."); } if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) { return true; } if (overlayState == OverlayState.UNINITAILIZED) { downIsOurs = false; deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); overlayState = OverlayState.DEADZONE; } if (overlayState == OverlayState.DEADZONE) { float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX()); float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY()); if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { overlayState = OverlayState.SCRUB; } else { if (motionEvent.getAction() == MotionEvent.ACTION_UP) { overlayState = OverlayState.TAP; if (downIsOurs) { handleUpEvent(); return true; } } return MotionEvent.ACTION_MOVE == motionEvent.getAction(); } } switch (motionEvent.getAction()) { case MotionEvent.ACTION_DOWN: selected = getSelectedIndexViaDownEvent(motionEvent); deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); overlayState = OverlayState.DEADZONE; downIsOurs = true; return true; case MotionEvent.ACTION_MOVE: selected = getSelectedIndexViaMoveEvent(motionEvent); return true; case MotionEvent.ACTION_UP: handleUpEvent(); return downIsOurs; case MotionEvent.ACTION_CANCEL: hide(); return downIsOurs; default: return false; } } private void setupSelectedEmoji() { final List emojis = recentEmojiPageModel.getEmoji(); for (int i = 0; i < emojiViews.length; i++) { final EmojiImageView view = emojiViews[i]; view.setScaleX(1.0f); view.setScaleY(1.0f); view.setTranslationY(0); boolean isAtCustomIndex = i == customEmojiIndex; if (isAtCustomIndex) { view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24)); view.setTag(null); } else { view.setImageEmoji(emojis.get(i)); } } } private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); } private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); } private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { int selected = -1; if (backgroundView.getVisibility() != View.VISIBLE) { return selected; } for (int i = 0; i < emojiViews.length; i++) { final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) { selected = i; } } if (this.selected != -1 && this.selected != selected) { shrinkView(emojiViews[this.selected]); } if (this.selected != selected && selected != -1) { growView(emojiViews[selected]); } return selected; } private void growView(@NonNull View view) { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); view.animate() .scaleY(1.5f) .scaleX(1.5f) .translationY(-selectedVerticalTranslation) .setDuration(200) .setInterpolator(INTERPOLATOR) .start(); } private void shrinkView(@NonNull View view) { view.animate() .scaleX(1.0f) .scaleY(1.0f) .translationY(0) .setDuration(200) .setInterpolator(INTERPOLATOR) .start(); } private void handleUpEvent() { if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) { if (selected == customEmojiIndex) { onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null); } else { onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected)); } } else { hide(); } } public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { this.onReactionSelectedListener = onReactionSelectedListener; } public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) { this.onActionSelectedListener = onActionSelectedListener; } public void setOnHideListener(@Nullable OnHideListener onHideListener) { this.onHideListener = onHideListener; } private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { return Stream.of(messageRecord.getReactions()) .filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext()))) .findFirst() .map(ReactionRecord::getEmoji) .orElse(null); } private @NonNull List getMenuActionItems(@NonNull MessageRecord message) { List items = new ArrayList<>(); // Prepare boolean containsControlMessage = message.isUpdate(); boolean hasText = !message.getBody().isEmpty(); OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId()); Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId()); if (recipient == null) return Collections.emptyList(); String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); // Select message items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT), getContext().getResources().getString(R.string.AccessibilityId_select))); // Reply boolean canWrite = openGroup == null || openGroup.getCanWrite(); if (canWrite && !message.isPending() && !message.isFailed()) { items.add( new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY), getContext().getResources().getString(R.string.AccessibilityId_reply_message)) ); } // Copy message text if (!containsControlMessage && hasText) { items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE))); } // Copy Session ID if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) { items.add(new ActionItem( R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)) ); } // Delete message if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), () -> handleActionItemClicked(Action.DELETE), getContext().getResources().getString(R.string.AccessibilityId_delete_message) ) ); } // Ban user if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER))); } // Ban and delete all if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL))); } // Message detail items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); // Resend if (message.isFailed()) { items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); } // Resync if (message.isSyncFailed()) { items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC))); } // Save media if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD), getContext().getResources().getString(R.string.AccessibilityId_save_attachment)) ); } backgroundView.setVisibility(View.VISIBLE); foregroundView.setVisibility(View.VISIBLE); return items; } private void handleActionItemClicked(@NonNull Action action) { hideInternal(new OnHideListener() { @Override public void startHide() { if (onHideListener != null) { onHideListener.startHide(); } } @Override public void onHide() { if (onHideListener != null) { onHideListener.onHide(); } if (onActionSelectedListener != null) { onActionSelectedListener.onActionSelected(action); } } }); } private void initAnimators() { int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset); List reveals = Stream.of(emojiViews) .mapIndexed((idx, v) -> { Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); anim.setTarget(v); anim.setStartDelay(idx * animationEmojiStartDelayFactor); return anim; }) .toList(); Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); backgroundRevealAnim.setTarget(backgroundView); backgroundRevealAnim.setDuration(revealDuration); backgroundRevealAnim.setStartDelay(revealOffset); reveals.add(backgroundRevealAnim); revealAnimatorSet.setInterpolator(INTERPOLATOR); revealAnimatorSet.playTogether(reveals); } private @NonNull AnimatorSet newHideAnimatorSet() { AnimatorSet set = new AnimatorSet(); set.addListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { setVisibility(View.GONE); } }); set.setInterpolator(INTERPOLATOR); set.playTogether(newHideAnimators()); return set; } private @NonNull List newHideAnimators() { int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration); List animators = new ArrayList<>(Stream.of(emojiViews) .mapIndexed((idx, v) -> { Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); anim.setTarget(v); return anim; }) .toList()); Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); backgroundHideAnim.setTarget(backgroundView); backgroundHideAnim.setDuration(duration); animators.add(backgroundHideAnim); ObjectAnimator itemScaleXAnim = new ObjectAnimator(); itemScaleXAnim.setProperty(View.SCALE_X); itemScaleXAnim.setFloatValues(1f); itemScaleXAnim.setTarget(conversationItem); itemScaleXAnim.setDuration(duration); animators.add(itemScaleXAnim); ObjectAnimator itemScaleYAnim = new ObjectAnimator(); itemScaleYAnim.setProperty(View.SCALE_Y); itemScaleYAnim.setFloatValues(1f); itemScaleYAnim.setTarget(conversationItem); itemScaleYAnim.setDuration(duration); animators.add(itemScaleYAnim); ObjectAnimator itemXAnim = new ObjectAnimator(); itemXAnim.setProperty(View.X); itemXAnim.setFloatValues(selectedConversationModel.getBubbleX()); itemXAnim.setTarget(conversationItem); itemXAnim.setDuration(duration); animators.add(itemXAnim); ObjectAnimator itemYAnim = new ObjectAnimator(); itemYAnim.setProperty(View.Y); itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight); itemYAnim.setTarget(conversationItem); itemYAnim.setDuration(duration); animators.add(itemYAnim); if (activity != null) { ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor); statusBarAnim.setDuration(duration); statusBarAnim.addUpdateListener(animation -> { WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); }); animators.add(statusBarAnim); ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor); navigationBarAnim.setDuration(duration); navigationBarAnim.addUpdateListener(animation -> { WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); }); animators.add(navigationBarAnim); } return animators; } public interface OnHideListener { void startHide(); void onHide(); } public interface OnReactionSelectedListener { void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); 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; Boundary() {} Boundary(float min, float max) { update(min, max); } private void update(float min, float max) { this.min = min; this.max = max; } public boolean contains(float value) { if (min < max) { return this.min < value && this.max > value; } else { return this.min > value && this.max < value; } } } private enum OverlayState { HIDDEN, UNINITAILIZED, DEADZONE, SCRUB, TAP } public enum Action { REPLY, RESEND, RESYNC, DOWNLOAD, COPY_MESSAGE, COPY_SESSION_ID, VIEW_INFO, SELECT, DELETE, BAN_USER, BAN_AND_DELETE_ALL, } }