diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java deleted file mode 100644 index 403b33a570..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.thoughtcrime.securesms.conversation; - -import android.app.Activity; -import android.graphics.PointF; -import android.view.MotionEvent; -import android.view.View; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.signal.core.ui.view.Stub; - -/** - * Delegate class that mimics the ConversationReactionOverlay public API - *

- * This allows us to properly stub out the ConversationReactionOverlay View class while still - * respecting listeners and other positional information that can be set BEFORE we want to actually - * resolve the view. - */ -public final class ConversationReactionDelegate { - - private final Stub overlayStub; - private final PointF lastSeenDownPoint = new PointF(); - - private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener; - private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener; - private ConversationReactionOverlay.OnHideListener onHideListener; - - public ConversationReactionDelegate(@NonNull Stub overlayStub) { - this.overlayStub = overlayStub; - } - - public boolean isShowing() { - return overlayStub.resolved() && overlayStub.get().isShowing(); - } - - public void show(@NonNull Activity activity, - @NonNull Recipient conversationRecipient, - @NonNull ConversationMessage conversationMessage, - boolean isNonAdminInAnnouncementGroup, - @NonNull SelectedConversationModel selectedConversationModel, - boolean canEditGroupInfo) - { - resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel, canEditGroupInfo); - } - - public void hide() { - overlayStub.get().hide(); - } - - public void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) { - this.onReactionSelectedListener = onReactionSelectedListener; - - if (overlayStub.resolved()) { - overlayStub.get().setOnReactionSelectedListener(onReactionSelectedListener); - } - } - - public void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) { - this.onActionSelectedListener = onActionSelectedListener; - - if (overlayStub.resolved()) { - overlayStub.get().setOnActionSelectedListener(onActionSelectedListener); - } - } - - public void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) { - this.onHideListener = onHideListener; - - if (overlayStub.resolved()) { - overlayStub.get().setOnHideListener(onHideListener); - } - } - - public @NonNull MessageRecord getMessageRecord() { - if (!overlayStub.resolved()) { - throw new IllegalStateException("Cannot call getMessageRecord right now."); - } - - return overlayStub.get().getMessageRecord(); - } - - public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { - if (!overlayStub.resolved() || !overlayStub.get().isShowing()) { - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { - lastSeenDownPoint.set(motionEvent.getX(), motionEvent.getY()); - } - return false; - } else { - return overlayStub.get().applyTouchEvent(motionEvent); - } - } - - private @NonNull ConversationReactionOverlay resolveOverlay() { - boolean wasAlreadyResolved = overlayStub.resolved(); - ConversationReactionOverlay overlay = overlayStub.get(); - - if (!wasAlreadyResolved && (overlay.getWidth() == 0 || overlay.getHeight() == 0)) { - // force immediate measurement and layout after ViewStub inflation to ensure proper dimensions before first use - // without doing this, the overlay child views will be positioned off screen in RTL layout direction because of negative values. - - View parent = (View) overlay.getParent(); - if (parent != null) { - int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); - int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.EXACTLY); - overlay.measure(widthSpec, heightSpec); - overlay.layout(0, 0, overlay.getMeasuredWidth(), overlay.getMeasuredHeight()); - } - } - - overlay.requestFitSystemWindows(); - - overlay.setOnHideListener(onHideListener); - overlay.setOnActionSelectedListener(onActionSelectedListener); - overlay.setOnReactionSelectedListener(onReactionSelectedListener); - - return overlay; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java deleted file mode 100644 index 46fc796f47..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ /dev/null @@ -1,949 +0,0 @@ -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.content.res.Configuration; -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.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.ConstraintSet; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.ViewKt; -import androidx.core.view.WindowInsetsCompat; -import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; - -import org.signal.core.util.DimensionUnit; -import org.signal.core.util.Util; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.animation.AnimationCompleteListener; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.signal.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; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.ViewUtil; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.LongStream; -import java.util.stream.Stream; - -import kotlin.Unit; - -public final class ConversationReactionOverlay extends FrameLayout { - - private static final String TAG = Log.tag(ConversationReactionOverlay.class); - 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 Recipient conversationRecipient; - private MessageRecord messageRecord; - private SelectedConversationModel selectedConversationModel; - private OverlayState overlayState = OverlayState.HIDDEN; - private boolean isNonAdminInAnnouncementGroup; - private boolean canEditGroupInfo; - - private boolean downIsOurs; - private int selected = -1; - private int customEmojiIndex; - - private View dropdownAnchor; - private View conversationItem; - private View backgroundView; - private ConstraintLayout foregroundView; - private View selectedView; - 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 int bottomNavigationBarHeight; - - private OnReactionSelectedListener onReactionSelectedListener; - private OnActionSelectedListener onActionSelectedListener; - private OnHideListener onHideListener; - - private final 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); - 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), - 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 Recipient conversationRecipient, - @NonNull ConversationMessage conversationMessage, - @NonNull PointF lastSeenDownPoint, - boolean isNonAdminInAnnouncementGroup, - @NonNull SelectedConversationModel selectedConversationModel, - boolean canEditGroupInfo) - { - if (overlayState != OverlayState.HIDDEN) { - return; - } - - this.messageRecord = conversationMessage.getMessageRecord(); - this.conversationRecipient = conversationRecipient; - this.selectedConversationModel = selectedConversationModel; - this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup; - this.canEditGroupInfo = canEditGroupInfo; - overlayState = OverlayState.UNINITAILIZED; - selected = -1; - - setupSelectedEmoji(); - - View root = activity.findViewById(android.R.id.content).getRootView(); - WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(root); - - if (rootWindowInsets != null) { - Log.i(TAG, "Capturing insets from root view."); - - Insets insets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - statusBarHeight = insets.top; - bottomNavigationBarHeight = insets.bottom; - } else { - Log.i(TAG, "Capturing insets from util methods."); - - statusBarHeight = ViewUtil.getStatusBarHeight(root); - bottomNavigationBarHeight = ViewUtil.getNavigationBarHeight(root); - } - - if (zeroNavigationBarHeightForConfiguration()) { - bottomNavigationBarHeight = 0; - } - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - - conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); - conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); - - boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this); - - conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR); - conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR); - - setVisibility(View.INVISIBLE); - - ViewKt.doOnLayout(this, v -> { - showAfterLayout(conversationMessage, lastSeenDownPoint, isMessageOnLeft); - return Unit.INSTANCE; - }); - } - - private void showAfterLayout(@NonNull ConversationMessage conversationMessage, - @NonNull PointF lastSeenDownPoint, - boolean isMessageOnLeft) - { - // A hide can land between show() and this layout pass. - if (overlayState == OverlayState.HIDDEN) { - return; - } - - contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage)); - - conversationItem.setX(selectedConversationModel.getBubbleX()); - conversationItem.setY(selectedConversationModel.getBubbleY()); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); - - int overlayHeight = getHeight() - bottomNavigationBarHeight; - int bubbleWidth = selectedConversationModel.getBubbleWidth(); - - float endX = selectedConversationModel.getBubbleX(); - float endY = conversationItem.getY(); - 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 - conversationItemSnapshot.getHeight(), 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; - - if (menuFitsBelowItem) { - if (conversationItem.getY() < 0) { - endY = 0; - } - float contextMenuTop = endY + conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - - if (reactionBarBackgroundY <= reactionBarTopPadding) { - endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding; - } - } else { - endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); - - float contextMenuTop = endY + conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - } - - 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 = getReactionBarOffsetForTouch(lastSeenDownPoint, 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; - - 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 = selectedConversationModel.getContextMenuX(); - 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); - - conversationItem.animate() - .x(endX) - .y(endY) - .scaleX(endScale) - .scaleY(endScale) - .setDuration(revealDuration); - } - - private float getReactionBarOffsetForTouch(@NonNull PointF touchPoint, - float contextMenuTop, - float contextMenuPadding, - float reactionBarOffset, - int reactionBarHeight, - float spaceNeededBetweenTopOfScreenAndTopOfReactionBar, - float messageTop) - { - float adjustedTouchY = touchPoint.y - 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); - } - - /** - * Returns true when the device is in a configuration where the navigation bar doesn't take up - * space at the bottom of the screen. - */ - private boolean zeroNavigationBarHeightForConfiguration() { - boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; - - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this); - return (insets == null || insets.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom == 0) && isLandscape; - } - - public void hide() { - hideInternal(onHideListener); - } - - private void hideInternal(@Nullable OnHideListener onHideListener) { - if (overlayState == OverlayState.HIDDEN || selectedConversationModel == null) { - return; - } - - overlayState = OverlayState.HIDDEN; - - AnimatorSet animatorSet = newHideAnimatorSet(); - hideAnimatorSet = animatorSet; - - revealAnimatorSet.end(); - animatorSet.start(); - - if (onHideListener != null) { - onHideListener.startHide(selectedConversationModel.getFocusedView()); - } - - animatorSet.addListener(new AnimationCompleteListener() { - @Override public void onAnimationEnd(Animator animation) { - animatorSet.removeListener(this); - - if (onHideListener != null) { - onHideListener.onHide(); - } - - if (overlayState == OverlayState.HIDDEN) { - releaseSelection(); - } - } - }); - - if (contextMenu != null) { - contextMenu.dismiss(); - } - } - - /** Drops the snapshot bitmap, model and menu the last long press left behind. */ - private void releaseSelection() { - selectedConversationModel = null; - contextMenu = null; - conversationItem.setBackground(null); - } - - 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 = SignalStore.emoji().getReactions(); - final String oldEmoji = getOldEmoji(messageRecord); - - if (oldEmoji == null) { - selectedView.setVisibility(View.GONE); - } - - boolean foundSelected = false; - - 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; - boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && oldEmoji != null && EmojiUtil.isCanonicallyEqual(emojis.get(i), oldEmoji); - boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null; - - if (!foundSelected && - (isNotAtCustomIndexAndOldEmojiMatches || isAtCustomIndexAndOldEmojiExists)) - { - foundSelected = true; - selectedView.setVisibility(View.VISIBLE); - - ConstraintSet constraintSet = new ConstraintSet(); - constraintSet.clone(foregroundView); - constraintSet.clear(selectedView.getId(), ConstraintSet.LEFT); - constraintSet.clear(selectedView.getId(), ConstraintSet.RIGHT); - constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT); - constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT); - constraintSet.applyTo(foregroundView); - - if (isAtCustomIndex) { - view.setImageEmoji(oldEmoji); - view.setTag(oldEmoji); - } else { - view.setImageEmoji(SignalStore.emoji().getPreferredVariation(emojis.get(i))); - } - } else if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_any_emoji_32)); - view.setTag(null); - } else { - view.setImageEmoji(SignalStore.emoji().getPreferredVariation(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, SignalStore.emoji().getPreferredVariation(SignalStore.emoji().getReactions().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 static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { - return messageRecord.getReactions().stream() - .filter(record -> record.getAuthor() - .serialize() - .equals(Recipient.self() - .getId() - .serialize())) - .findFirst() - .map(ReactionRecord::getEmoji) - .orElse(null); - } - - private @NonNull List getMenuActionItems(@NonNull ConversationMessage conversationMessage) { - MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup, canEditGroupInfo); - - List items = new ArrayList<>(); - - if (menuState.shouldShowReplyAction()) { - items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY))); - } - - if (menuState.shouldShowEditAction()) { - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT))); - } - - if (menuState.shouldShowForwardAction()) { - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD))); - } - - if (menuState.shouldShowResendAction()) { - items.add(new ActionItem(R.drawable.symbol_refresh_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); - } - - if (menuState.shouldShowSaveAttachmentAction()) { - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD))); - } - - if (menuState.shouldShowCopyAction()) { - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_copy_android_24, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY))); - } - - if (menuState.shouldShowPaymentDetails()) { - items.add(new ActionItem(R.drawable.symbol_payment_24, getResources().getString(R.string.conversation_selection__menu_payment_details), () -> handleActionItemClicked(Action.PAYMENT_DETAILS))); - } - - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_check_circle_24, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT))); - - if (menuState.shouldShowDetailsAction()) { - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); - } - - if (menuState.shouldShowPollTerminateAction()) { - items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL))); - } - - if (menuState.shouldShowPinMessage()) { - items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getString(R.string.conversation_selection__menu_pin_message), () -> handleActionItemClicked(Action.PIN_MESSAGE))); - } - - if (menuState.showShowUnpinMessage()) { - items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getString(R.string.conversation_selection__menu_unpin_message), () -> handleActionItemClicked(Action.UNPIN_MESSAGE))); - } - - if (menuState.shouldShowStarMessage()) { - items.add(new ActionItem(R.drawable.symbol_star_outline_24, getResources().getString(R.string.conversation_selection__menu_star), () -> handleActionItemClicked(Action.STAR_MESSAGE))); - } - - if (menuState.shouldShowUnstarMessage()) { - items.add(new ActionItem(R.drawable.symbol_star_outline_24, getResources().getString(R.string.conversation_selection__menu_unstar), () -> handleActionItemClicked(Action.UNSTAR_MESSAGE))); - } - - backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); - foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); - - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_trash_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE))); - - return items; - } - - private void handleActionItemClicked(@NonNull Action action) { - hideInternal(new OnHideListener() { - @Override - public void startHide(@Nullable View focusedView) { - if (onHideListener != null) { - onHideListener.startHide(action == Action.VIEW_INFO ? null : focusedView); - } - } - - @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 = LongStream.range(0, emojiViews.length) - .boxed() - .map(idx -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); - anim.setTarget(emojiViews[idx.intValue()]); - anim.setStartDelay(idx * animationEmojiStartDelayFactor); - return anim; - }).collect(Collectors.toList()); - - Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - backgroundRevealAnim.setTarget(backgroundView); - backgroundRevealAnim.setDuration(revealDuration); - backgroundRevealAnim.setStartDelay(revealOffset); - reveals.add(backgroundRevealAnim); - - Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - selectedRevealAnim.setTarget(selectedView); - backgroundRevealAnim.setDuration(revealDuration); - backgroundRevealAnim.setStartDelay(revealOffset); - reveals.add(selectedRevealAnim); - - 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) - .map(v -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); - anim.setTarget(v); - return anim; - }) - .collect(Collectors.toList())); - - Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - backgroundHideAnim.setTarget(backgroundView); - backgroundHideAnim.setDuration(duration); - animators.add(backgroundHideAnim); - - Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - selectedHideAnim.setTarget(selectedView); - selectedHideAnim.setDuration(duration); - animators.add(selectedHideAnim); - - 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); - - // Where the row is now, not where the press started. Null once it is gone. - PointF returnPosition = selectedConversationModel.getReturnPosition().get(); - float returnX = returnPosition != null ? returnPosition.x : selectedConversationModel.getBubbleX(); - float returnY = returnPosition != null ? returnPosition.y : selectedConversationModel.getBubbleY(); - - ObjectAnimator itemXAnim = new ObjectAnimator(); - itemXAnim.setProperty(View.X); - itemXAnim.setFloatValues(returnX); - itemXAnim.setTarget(conversationItem); - itemXAnim.setDuration(duration); - animators.add(itemXAnim); - - ObjectAnimator itemYAnim = new ObjectAnimator(); - itemYAnim.setProperty(View.Y); - itemYAnim.setFloatValues(returnY); - itemYAnim.setTarget(conversationItem); - itemYAnim.setDuration(duration); - animators.add(itemYAnim); - - return animators; - } - - public interface OnHideListener { - void startHide(@Nullable View focusedView); - - 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, - EDIT, - FORWARD, - RESEND, - DOWNLOAD, - COPY, - MULTISELECT, - PAYMENT_DETAILS, - VIEW_INFO, - DELETE, - END_POLL, - PIN_MESSAGE, - UNPIN_MESSAGE, - STAR_MESSAGE, - UNSTAR_MESSAGE - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionAction.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionAction.kt new file mode 100644 index 0000000000..fd971afd47 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionAction.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +/** + * What the long press menu can ask for. + */ +enum class ReactionAction { + REPLY, + EDIT, + FORWARD, + RESEND, + DOWNLOAD, + COPY, + MULTISELECT, + PAYMENT_DETAILS, + VIEW_INFO, + DELETE, + END_POLL, + PIN_MESSAGE, + UNPIN_MESSAGE, + STAR_MESSAGE, + UNSTAR_MESSAGE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt new file mode 100644 index 0000000000..07de07f08a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.signal.core.ui.R as CoreUiR + +/** + * What the long press menu offers for a given message, and whether that message can be reacted to + * at all. Lifted out of the reaction overlay view, which used to build this while also reaching in + * to hide its own strip; [canReact] is that side effect made into a result. + */ +object ReactionMenu { + + /** + * @param items The menu's rows, in order. + * @param canReact False for a message that takes no reactions, which hides the emoji strip. + */ + data class Menu( + val items: List, + val canReact: Boolean + ) + + fun of( + context: Context, + conversationRecipient: Recipient, + conversationMessage: ConversationMessage, + isNonAdminInAnnouncementGroup: Boolean, + canEditGroupInfo: Boolean, + onAction: (ReactionAction) -> Unit + ): Menu { + val menuState = MenuState.getMenuState( + conversationRecipient, + conversationMessage.multiselectCollection.toSet(), + false, + isNonAdminInAnnouncementGroup, + canEditGroupInfo + ) + + val items = mutableListOf() + + fun add(iconRes: Int, titleRes: Int, action: ReactionAction) { + items += ActionItem(iconRes = iconRes, title = context.getString(titleRes), action = { onAction(action) }) + } + + if (menuState.shouldShowReplyAction()) { + add(R.drawable.symbol_reply_24, R.string.conversation_selection__menu_reply, ReactionAction.REPLY) + } + + if (menuState.shouldShowEditAction()) { + add(CoreUiR.drawable.symbol_edit_24, R.string.conversation_selection__menu_edit, ReactionAction.EDIT) + } + + if (menuState.shouldShowForwardAction()) { + add(CoreUiR.drawable.symbol_forward_24, R.string.conversation_selection__menu_forward, ReactionAction.FORWARD) + } + + if (menuState.shouldShowResendAction()) { + add(R.drawable.symbol_refresh_24, R.string.conversation_selection__menu_resend_message, ReactionAction.RESEND) + } + + if (menuState.shouldShowSaveAttachmentAction()) { + add(CoreUiR.drawable.symbol_save_android_24, R.string.conversation_selection__menu_save, ReactionAction.DOWNLOAD) + } + + if (menuState.shouldShowCopyAction()) { + add(CoreUiR.drawable.symbol_copy_android_24, R.string.conversation_selection__menu_copy, ReactionAction.COPY) + } + + if (menuState.shouldShowPaymentDetails()) { + add(R.drawable.symbol_payment_24, R.string.conversation_selection__menu_payment_details, ReactionAction.PAYMENT_DETAILS) + } + + add(CoreUiR.drawable.symbol_check_circle_24, R.string.conversation_selection__menu_multi_select, ReactionAction.MULTISELECT) + + if (menuState.shouldShowDetailsAction()) { + add(CoreUiR.drawable.symbol_info_24, R.string.conversation_selection__menu_message_details, ReactionAction.VIEW_INFO) + } + + if (menuState.shouldShowPollTerminateAction()) { + add(R.drawable.symbol_stop_24, R.string.conversation_selection__menu_end_poll, ReactionAction.END_POLL) + } + + if (menuState.shouldShowPinMessage()) { + add(R.drawable.symbol_pin_24, R.string.conversation_selection__menu_pin_message, ReactionAction.PIN_MESSAGE) + } + + if (menuState.showShowUnpinMessage()) { + add(R.drawable.symbol_pin_slash_24, R.string.conversation_selection__menu_unpin_message, ReactionAction.UNPIN_MESSAGE) + } + + if (menuState.shouldShowStarMessage()) { + add(R.drawable.symbol_star_outline_24, R.string.conversation_selection__menu_star, ReactionAction.STAR_MESSAGE) + } + + if (menuState.shouldShowUnstarMessage()) { + add(R.drawable.symbol_star_outline_24, R.string.conversation_selection__menu_unstar, ReactionAction.UNSTAR_MESSAGE) + } + + add(CoreUiR.drawable.symbol_trash_24, R.string.conversation_selection__menu_delete, ReactionAction.DELETE) + + return Menu(items = items, canReact = menuState.shouldShowReactions()) + } + + /** The reaction this message already carries from us, if any. */ + fun appliedEmoji(messageRecord: MessageRecord): String? { + val self = Recipient.self().id.serialize() + + return messageRecord.reactions + .firstOrNull { it.author.serialize() == self } + ?.emoji + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacement.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacement.kt new file mode 100644 index 0000000000..c55c8edf2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacement.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import org.signal.core.util.Util +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Where the long press overlay puts the message snapshot, the emoji strip and the action menu. + * + * Overlay pixels, y growing downwards, origin under the status bar. A strip pushed above the message + * can legitimately land at a negative y, down to -[Metrics.statusBarHeight]. + */ +object ReactionOverlayPlacement { + + /** + * @param overlayHeight Status bar included; [navigationBarHeight] comes off before anything is placed. + * @param snapshotHeight Height of the snapshot bitmap, which is also the snapshot view's height. + * @param isMessageOnLeft Whether the snapshot hangs off the leading edge. + * @param lastSeenDownY Where the long press landed, in window coordinates. + */ + data class Metrics( + val overlayWidth: Int, + val overlayHeight: Int, + val statusBarHeight: Int, + val navigationBarHeight: Int, + val bubbleX: Float, + val bubbleY: Float, + val bubbleWidth: Int, + val contextMenuX: Float, + val snapshotWidth: Int, + val snapshotHeight: Int, + val reactionBarHeight: Int, + val scrubberForegroundHeight: Int, + val scrubberWidth: Int, + val scrubberHorizontalMargin: Int, + val menuMaxWidth: Int, + val menuMaxHeight: Int, + val isMessageOnLeft: Boolean, + val lastSeenDownY: Float + ) + + /** + * @param snapshotScale Shrinks the snapshot when there is no room for it at full size. + * @param isWideLayout True when the menu fits beside the strip rather than under the message. + * @param menuHeight Height to hold the menu to, or null to leave it at its natural maximum. + */ + data class Placement( + val snapshotX: Float, + val snapshotY: Float, + val snapshotScale: Float, + val reactionBarY: Float, + val scrubberX: Float, + val scrubberForegroundY: Float, + val isWideLayout: Boolean, + val menuHeight: Int?, + val menuOffsetX: Float, + val menuOffsetY: Float + ) + + private const val MENU_PADDING_DP = 12f + private const val REACTION_BAR_TOP_PADDING_DP = 32f + private const val REACTION_BAR_OFFSET_DP = 48f + + /** Below this, the strip pins to the message instead of the touch so it cannot crowd the menu. */ + private const val TIGHT_MENU_THRESHOLD_DP = 150f + + fun of(metrics: Metrics, dp: DpConverter): Placement { + val menuPadding = dp.toPx(MENU_PADDING_DP) + val reactionBarTopPadding = dp.toPx(REACTION_BAR_TOP_PADDING_DP) + val reactionBarOffset = dp.toPx(REACTION_BAR_OFFSET_DP) + val tightMenuThreshold = dp.toPx(TIGHT_MENU_THRESHOLD_DP) + + val isWideLayout = metrics.menuMaxWidth + metrics.scrubberWidth < metrics.overlayWidth + val overlayHeight = metrics.overlayHeight - metrics.navigationBarHeight + val reactionBarHeight = metrics.reactionBarHeight + val snapshotHeight = metrics.snapshotHeight + val horizontalScaleSign = if (metrics.isMessageOnLeft) -1 else 1 + + var endX = metrics.bubbleX + var endY = metrics.bubbleY + var endApparentTop = endY + var endScale = 1f + var menuHeightOverride: Int? = null + val reactionBarY: Float + + if (isWideLayout) { + val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + snapshotHeight < overlayHeight + + if (everythingFitsVertically) { + val reactionBarFitsAboveItem = metrics.bubbleY > reactionBarHeight + menuPadding + reactionBarTopPadding + + if (reactionBarFitsAboveItem) { + reactionBarY = metrics.bubbleY - menuPadding - reactionBarHeight + } else { + endY = reactionBarHeight + menuPadding + reactionBarTopPadding + reactionBarY = reactionBarTopPadding + } + } else { + val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding + + endScale = spaceAvailableForItem / snapshotHeight + endX += Util.halfOffsetFromScale(metrics.snapshotWidth, endScale) * horizontalScaleSign + endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(snapshotHeight, endScale) + reactionBarY = reactionBarTopPadding + } + } else { + val spaceForReactionBar = max(reactionBarHeight + reactionBarOffset - snapshotHeight, 0f) + val everythingFitsVertically = metrics.menuMaxHeight + snapshotHeight + menuPadding + spaceForReactionBar < overlayHeight + + if (everythingFitsVertically) { + val bubbleBottom = metrics.bubbleY + snapshotHeight + val menuFitsBelowItem = bubbleBottom + menuPadding + metrics.menuMaxHeight <= overlayHeight + + if (menuFitsBelowItem) { + if (metrics.bubbleY < 0) { + endY = 0f + } + + reactionBarY = barOffsetForTouch( + metrics = metrics, + contextMenuTop = endY + snapshotHeight, + menuPadding = menuPadding, + reactionBarOffset = reactionBarOffset, + reactionBarTopPadding = reactionBarTopPadding, + tightMenuThreshold = tightMenuThreshold, + messageTop = endY + ) + + if (reactionBarY <= reactionBarTopPadding) { + endY = reactionBarHeight + menuPadding + reactionBarTopPadding + } + } else { + endY = overlayHeight - metrics.menuMaxHeight - menuPadding - snapshotHeight + + reactionBarY = barOffsetForTouch( + metrics = metrics, + contextMenuTop = endY + snapshotHeight, + menuPadding = menuPadding, + reactionBarOffset = reactionBarOffset, + reactionBarTopPadding = reactionBarTopPadding, + tightMenuThreshold = tightMenuThreshold, + messageTop = endY + ) + } + + endApparentTop = endY + } else if (reactionBarOffset + reactionBarHeight + metrics.menuMaxHeight + menuPadding < overlayHeight) { + val spaceAvailableForItem = overlayHeight - metrics.menuMaxHeight - menuPadding - spaceForReactionBar + + endScale = spaceAvailableForItem / snapshotHeight + endX += Util.halfOffsetFromScale(metrics.snapshotWidth, endScale) * horizontalScaleSign + endY = spaceForReactionBar - Util.halfOffsetFromScale(snapshotHeight, endScale) + + val halfOffset = Util.halfOffsetFromScale(snapshotHeight, endScale) + + reactionBarY = barOffsetForTouch( + metrics = metrics, + contextMenuTop = endY + (snapshotHeight * endScale) + halfOffset, + menuPadding = menuPadding, + reactionBarOffset = reactionBarOffset, + reactionBarTopPadding = reactionBarTopPadding, + tightMenuThreshold = tightMenuThreshold, + messageTop = endY + ) + + endApparentTop = endY + halfOffset + } else { + val menuHeight = metrics.menuMaxHeight / 2 + menuHeightOverride = menuHeight + + val fitsVertically = menuHeight + snapshotHeight + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight + + if (fitsVertically) { + val bubbleBottom = metrics.bubbleY + snapshotHeight + val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + + if (menuFitsBelowItem) { + val aboveItem = metrics.bubbleY - menuPadding - reactionBarHeight + + if (aboveItem < reactionBarTopPadding) { + endY = reactionBarTopPadding + reactionBarHeight + menuPadding + reactionBarY = reactionBarTopPadding + } else { + reactionBarY = aboveItem + } + } else { + endY = overlayHeight - menuHeight - menuPadding - snapshotHeight + reactionBarY = endY - reactionBarHeight - menuPadding + } + + endApparentTop = endY + } else { + val spaceAvailableForItem = overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding + + endScale = spaceAvailableForItem / snapshotHeight + endX += Util.halfOffsetFromScale(metrics.snapshotWidth, endScale) * horizontalScaleSign + endY = reactionBarHeight - Util.halfOffsetFromScale(snapshotHeight, endScale) + menuPadding + reactionBarTopPadding + reactionBarY = reactionBarTopPadding + endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding + } + } + } + + val clampedReactionBarY = max(reactionBarY, -metrics.statusBarHeight.toFloat()) + + val scrubberX = if (metrics.isMessageOnLeft) { + metrics.scrubberHorizontalMargin.toFloat() + } else { + (metrics.overlayWidth - metrics.scrubberWidth - metrics.scrubberHorizontalMargin).toFloat() + } + + val menuOffsetX: Float + val menuOffsetY: Float + + if (isWideLayout) { + val scrubberRight = scrubberX + metrics.scrubberWidth + + menuOffsetX = if (metrics.isMessageOnLeft) scrubberRight + menuPadding else scrubberX - metrics.menuMaxWidth - menuPadding + menuOffsetY = min(clampedReactionBarY, (overlayHeight - metrics.menuMaxHeight).toFloat()) + } else { + menuOffsetX = if (metrics.isMessageOnLeft) { + metrics.contextMenuX + } else { + -metrics.menuMaxWidth + metrics.contextMenuX + metrics.bubbleWidth + } + menuOffsetY = endApparentTop + (snapshotHeight * endScale) + menuPadding + } + + return Placement( + snapshotX = endX, + snapshotY = endY, + snapshotScale = endScale, + reactionBarY = clampedReactionBarY, + scrubberX = scrubberX, + scrubberForegroundY = clampedReactionBarY + reactionBarHeight / 2f - metrics.scrubberForegroundHeight / 2f, + isWideLayout = isWideLayout, + menuHeight = menuHeightOverride, + menuOffsetX = menuOffsetX, + menuOffsetY = menuOffsetY + ) + } + + /** Puts the strip above whichever is higher, the long press or the menu, so it lands under the thumb. */ + private fun barOffsetForTouch( + metrics: Metrics, + contextMenuTop: Float, + menuPadding: Float, + reactionBarOffset: Float, + reactionBarTopPadding: Float, + tightMenuThreshold: Float, + messageTop: Float + ): Float { + val adjustedTouchY = metrics.lastSeenDownY - metrics.statusBarHeight + var reactionStartingPoint = min(adjustedTouchY, contextMenuTop) + + if (abs(messageTop - contextMenuTop) < tightMenuThreshold) { + reactionStartingPoint = messageTop + (reactionBarOffset - menuPadding) + } + + return max(reactionStartingPoint - reactionBarOffset - metrics.reactionBarHeight, reactionBarTopPadding) + } + + /** Keeps the dp constants convertible without Resources, so this stays testable off device. */ + fun interface DpConverter { + fun toPx(dp: Float): Float + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionScrubber.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionScrubber.kt new file mode 100644 index 0000000000..0a7ff3930f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionScrubber.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import android.view.MotionEvent +import kotlin.math.abs + +/** + * The scrub gesture behind the reaction overlay + * + * @param emojiCount Slots in the strip. The last opens the full picker. + */ +class ReactionScrubber(private val emojiCount: Int) { + + /** + * Where the strip ended up, in the coordinate space the gesture arrives in. + * + * [stripStart] and [stripEnd] are in layout direction order, so under RTL start is the greater of + * the two and the segment width comes out negative. Deliberately not normalised: the sign is what + * decides which emoji an x belongs to. + * + * @param scrubTop Top of the taller band a scrub may wander through, down to [scrubBottom]. + * @param deadZoneSize How far a pointer may drift before a tap becomes a scrub. + * @param isStripVisible False for a message that takes no reactions, making every point a miss. + */ + data class Geometry( + val stripStart: Float = 0f, + val stripEnd: Float = 0f, + val stripTop: Float = 0f, + val stripBottom: Float = 0f, + val scrubTop: Float = 0f, + val scrubBottom: Float = 0f, + val deadZoneSize: Float = 0f, + val isStripVisible: Boolean = false + ) + + enum class Phase { + HIDDEN, + UNINITIALIZED, + DEADZONE, + SCRUB, + TAP + } + + /** + * [consumed] is not the same as "something changed": a gesture that began outside the scrubber is + * watched without being claimed. + */ + sealed interface Outcome { + val consumed: Boolean + + data class Scrubbing(override val consumed: Boolean, val previousIndex: Int, val index: Int) : Outcome + + data class Commit(override val consumed: Boolean, val index: Int) : Outcome + + data class Dismiss(override val consumed: Boolean) : Outcome + } + + var geometry: Geometry = Geometry() + + var phase: Phase = Phase.HIDDEN + private set + + var selectedIndex: Int = NO_SELECTION + private set + + private var downIsOurs: Boolean = false + + private var deadZoneX: Float = 0f + private var deadZoneY: Float = 0f + + val isShowing: Boolean + get() = phase != Phase.HIDDEN + + fun open() { + phase = Phase.UNINITIALIZED + selectedIndex = NO_SELECTION + downIsOurs = false + } + + fun close() { + phase = Phase.HIDDEN + } + + /** [action] is a raw [MotionEvent] action, so a non-primary pointer is swallowed rather than obeyed. */ + fun apply(action: Int, x: Float, y: Float): Outcome { + check(isShowing) { "Touch events should only reach the scrubber while it is showing." } + + if (action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) { + return unchanged(consumed = true) + } + + if (phase == Phase.UNINITIALIZED) { + downIsOurs = false + deadZoneX = x + deadZoneY = y + phase = Phase.DEADZONE + } + + if (phase == Phase.DEADZONE) { + val escapedDeadZone = abs(deadZoneX - x) > geometry.deadZoneSize || abs(deadZoneY - y) > geometry.deadZoneSize + + if (escapedDeadZone) { + phase = Phase.SCRUB + } else { + if (action == MotionEvent.ACTION_UP) { + phase = Phase.TAP + + if (downIsOurs) { + return endGesture(consumed = true) + } + } + + return unchanged(consumed = action == MotionEvent.ACTION_MOVE) + } + } + + return when (action) { + MotionEvent.ACTION_DOWN -> { + val outcome = moveSelectionTo(selectionAt(x, y, geometry.stripTop, geometry.stripBottom), consumed = true) + deadZoneX = x + deadZoneY = y + phase = Phase.DEADZONE + downIsOurs = true + outcome + } + + MotionEvent.ACTION_MOVE -> moveSelectionTo(selectionAt(x, y, geometry.scrubTop, geometry.scrubBottom), consumed = true) + + MotionEvent.ACTION_UP -> endGesture(consumed = downIsOurs) + + MotionEvent.ACTION_CANCEL -> { + close() + Outcome.Dismiss(consumed = downIsOurs) + } + + else -> unchanged(consumed = false) + } + } + + /** A gesture that outlives the strip cannot apply a reaction. */ + private fun endGesture(consumed: Boolean): Outcome { + if (selectedIndex != NO_SELECTION && geometry.isStripVisible) { + return Outcome.Commit(consumed = consumed, index = selectedIndex) + } + + close() + return Outcome.Dismiss(consumed = consumed) + } + + private fun moveSelectionTo(index: Int, consumed: Boolean): Outcome { + val previousIndex = selectedIndex + selectedIndex = index + + return Outcome.Scrubbing(consumed = consumed, previousIndex = previousIndex, index = index) + } + + private fun unchanged(consumed: Boolean): Outcome { + return Outcome.Scrubbing(consumed = consumed, previousIndex = selectedIndex, index = selectedIndex) + } + + private fun selectionAt(x: Float, y: Float, bandStart: Float, bandEnd: Float): Int { + if (!geometry.isStripVisible) { + return NO_SELECTION + } + + val segmentSize = segmentSize() + var selection = NO_SELECTION + + for (index in 0 until emojiCount) { + val segmentStart = segmentSize * index + geometry.stripStart + + if (contains(segmentStart, segmentStart + segmentSize, x) && contains(bandStart, bandEnd, y)) { + selection = index + } + } + + return selection + } + + private fun segmentSize(): Float { + return (geometry.stripEnd - geometry.stripStart) / emojiCount + } + + companion object { + const val NO_SELECTION = -1 + + /** Exclusive on both ends, and indifferent to which of [min] and [max] is greater. */ + private fun contains(min: Float, max: Float, value: Float): Boolean { + return if (min < max) { + min < value && max > value + } else { + min > value && max < value + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt deleted file mode 100644 index d41159cb37..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/SelectedConversationModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.conversation - -import android.graphics.Bitmap -import android.graphics.PointF -import android.net.Uri -import android.view.View - -/** - * Contains information on a single selected conversation item. This is used when transitioning - * between selected and unselected states. - * - * Coordinates are in the reaction overlay's space, not the list's. - * - * @param bubbleX Left edge of the captured snapshot. - * @param bubbleY Top edge of the captured bubble. - * @param contextMenuX Left edge the context menu lines up with. - */ -data class SelectedConversationModel( - val bitmap: Bitmap, - val bubbleX: Float, - val bubbleY: Float, - val bubbleWidth: Int, - val contextMenuX: Float, - val audioUri: Uri? = null, - val isOutgoing: Boolean, - val focusedView: View?, - val returnPosition: ReturnPosition -) { - - /** Where the snapshot animates back to, read on dismiss so a list that moved is followed. */ - fun interface ReturnPosition { - /** @return The row's current position, or null if it is gone. */ - fun get(): PointF? - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlay.kt new file mode 100644 index 0000000000..cc13fda399 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlay.kt @@ -0,0 +1,427 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.graphics.drawable.Drawable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.integerResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationItem +import org.thoughtcrime.securesms.conversation.ReactionOverlayPlacement +import org.thoughtcrime.securesms.conversation.ReactionScrubber +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.roundToInt +import org.signal.core.ui.R as CoreUiR + +const val REACTION_EMOJI_COUNT = 7 + +private const val GROW_DURATION_MS = 200 +private const val GROW_SCALE = 1.5f +private val EMOJI_WIDTH = 32.dp +private val EMOJI_HEIGHT = 48.dp +private val SELECTION_INDICATOR_SIZE = 52.dp +private val STRIP_CORNER_RADIUS = 30.dp + +/** DecelerateInterpolator, which an interpolator on an AnimatorSet applied to the whole reveal and hide. */ +private val Decelerate = Easing { fraction -> 1f - (1f - fraction) * (1f - fraction) } + +/** ViewPropertyAnimator's default, which carried the snapshot to its place. */ +private val AccelerateDecelerate = Easing { fraction -> (cos((fraction + 1f) * PI).toFloat() / 2f) + 0.5f } + +private class StripBounds { + var barTop: Float = 0f + var barBottom: Float = 0f + var firstEmojiEdge: Float = 0f + var lastEmojiEdge: Float = 0f +} + +/** + * Everything one long press put on screen, in overlay coordinates. [lastSeenDownY] is the exception + * and is in window coordinates, as it arrives from the activity's touch relay. + * + * @param snapshot A picture of the row that was pressed, standing in for the real one. + * @param appliedEmojiIndex Slot holding the reaction already on this message, or -1 for none. + * @param canReact False for a message that takes no reactions, which hides the strip. + * @param returnPosition Where the row is *now*, for the hide to fly back to. Null once it is gone. + */ +data class ReactionOverlaySelection( + val snapshot: ImageBitmap, + val bubbleX: Float, + val bubbleY: Float, + val bubbleWidth: Int, + val contextMenuX: Float, + val isMessageOnLeft: Boolean, + val lastSeenDownY: Float, + val emoji: List, + val appliedEmojiIndex: Int, + val canReact: Boolean, + val returnPosition: () -> Offset? +) + +/** + * The long press reaction overlay, drawn above the keyboard scaffold. + */ +@Composable +fun ChatReactionOverlay( + controller: ChatReactionOverlayController, + modifier: Modifier = Modifier +) { + val selection = controller.selection + val isRevealed = controller.isRevealed + val scrubber = controller.scrubber + val menuWidth = controller.menu?.getMaxWidth() ?: 0 + val menuHeight = controller.menu?.getMaxHeight() ?: 0 + + val density = LocalDensity.current + val context = LocalContext.current + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + + val revealDuration = integerResource(R.integer.reaction_scrubber_reveal_duration) + val revealOffset = integerResource(R.integer.reaction_scrubber_reveal_offset) + val hideDuration = integerResource(R.integer.reaction_scrubber_hide_duration) + val emojiStagger = integerResource(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor) + val indicatorDuration = integerResource(android.R.integer.config_mediumAnimTime) + + val scrubberWidth = dimensionResource(R.dimen.reaction_scrubber_width) + val scrubberHeight = dimensionResource(R.dimen.conversation_reaction_scrubber_height) + val horizontalMargin = dimensionResource(R.dimen.conversation_reaction_scrub_horizontal_margin) + val deadZoneSize = dimensionResource(R.dimen.conversation_reaction_touch_deadzone_size) + val scrubDistanceBelowTouch = dimensionResource(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom) + + val barHeightPx = remember(context) { + val attributes = context.theme.obtainStyledAttributes(intArrayOf(androidx.appcompat.R.attr.actionBarSize)) + val height = attributes.getDimensionPixelSize(0, 0) + attributes.recycle() + + height + } + + val overlayTop = remember { mutableFloatStateOf(0f) } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val origin = coordinates.positionInWindow() + + overlayTop.floatValue = origin.y + controller.onOriginChanged(origin) + }, + contentAlignment = AbsoluteAlignment.TopLeft + ) { + if (selection == null) { + return@BoxWithConstraints + } + + val overlayWidthPx = with(density) { maxWidth.roundToPx() } + val overlayHeightPx = with(density) { maxHeight.roundToPx() } + val statusBarPx = overlayTop.floatValue.roundToInt() + val navigationBarPx = WindowInsets.navigationBars.getBottom(density) + val scrubBelowTouchPx = with(density) { scrubDistanceBelowTouch.toPx() } + + val placement = remember( + selection, + overlayWidthPx, + overlayHeightPx, + statusBarPx, + navigationBarPx, + barHeightPx, + menuWidth, + menuHeight + ) { + ReactionOverlayPlacement.of( + metrics = ReactionOverlayPlacement.Metrics( + overlayWidth = overlayWidthPx, + overlayHeight = overlayHeightPx, + statusBarHeight = statusBarPx, + navigationBarHeight = navigationBarPx, + bubbleX = selection.bubbleX, + bubbleY = selection.bubbleY, + bubbleWidth = selection.bubbleWidth, + contextMenuX = selection.contextMenuX, + snapshotWidth = selection.snapshot.width, + snapshotHeight = selection.snapshot.height, + reactionBarHeight = barHeightPx, + scrubberForegroundHeight = with(density) { scrubberHeight.roundToPx() }, + scrubberWidth = with(density) { scrubberWidth.roundToPx() }, + scrubberHorizontalMargin = with(density) { horizontalMargin.roundToPx() }, + menuMaxWidth = menuWidth, + menuMaxHeight = menuHeight, + isMessageOnLeft = selection.isMessageOnLeft, + lastSeenDownY = selection.lastSeenDownY + ), + dp = ReactionOverlayPlacement.DpConverter { with(density) { it.dp.toPx() } } + ) + } + + val bounds = remember(selection) { StripBounds() } + val deadZonePx = with(density) { deadZoneSize.toPx() } + + val pushGeometry = { + scrubber.geometry = ReactionScrubber.Geometry( + stripStart = bounds.firstEmojiEdge, + stripEnd = bounds.lastEmojiEdge, + stripTop = bounds.barTop, + stripBottom = bounds.barBottom, + scrubTop = bounds.barTop, + scrubBottom = selection.lastSeenDownY + scrubBelowTouchPx, + deadZoneSize = deadZonePx, + isStripVisible = selection.canReact + ) + } + + val shade = remember(selection) { Animatable(1f) } + val emojiReveal = remember(selection) { List(REACTION_EMOJI_COUNT) { Animatable(0f) } } + val indicatorReveal = remember(selection) { Animatable(0f) } + val stripReveal = remember(selection) { Animatable(0f) } + val snapshotX = remember(selection) { Animatable(selection.bubbleX) } + val snapshotY = remember(selection) { Animatable(selection.bubbleY) } + val snapshotScale = remember(selection) { Animatable(ConversationItem.LONG_PRESS_SCALE_FACTOR) } + + DisposableEffect(Unit) { + onDispose { controller.onHideFinished() } + } + + LaunchedEffect(selection, isRevealed) { + if (isRevealed) { + coroutineScope { + launch { snapshotX.animateTo(placement.snapshotX, tween(revealDuration, easing = AccelerateDecelerate)) } + launch { snapshotY.animateTo(placement.snapshotY, tween(revealDuration, easing = AccelerateDecelerate)) } + launch { snapshotScale.animateTo(placement.snapshotScale, tween(revealDuration, easing = AccelerateDecelerate)) } + launch { indicatorReveal.animateTo(1f, tween(indicatorDuration, easing = Decelerate)) } + launch { stripReveal.animateTo(1f, tween(revealDuration, delayMillis = revealOffset, easing = Decelerate)) } + + emojiReveal.forEachIndexed { index, animatable -> + launch { + animatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = revealDuration, + delayMillis = revealOffset + index * emojiStagger, + easing = Decelerate + ) + ) + } + } + } + } else { + val returned = selection.returnPosition() + + coroutineScope { + launch { shade.animateTo(0f, tween(hideDuration, easing = Decelerate)) } + launch { snapshotX.animateTo(returned?.x ?: selection.bubbleX, tween(hideDuration, easing = Decelerate)) } + launch { snapshotY.animateTo(returned?.y ?: selection.bubbleY, tween(hideDuration, easing = Decelerate)) } + launch { snapshotScale.animateTo(1f, tween(hideDuration, easing = Decelerate)) } + launch { indicatorReveal.animateTo(0f, tween(hideDuration, easing = Decelerate)) } + launch { stripReveal.animateTo(0f, tween(hideDuration, easing = Decelerate)) } + + emojiReveal.forEach { animatable -> + launch { animatable.animateTo(0f, tween(hideDuration, easing = Decelerate)) } + } + } + + controller.onHideFinished() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { controller.onPlaced(placement) } + .graphicsLayer { alpha = shade.value } + .background(colorResource(R.color.reactions_screen_light_shade_color)) + .background(colorResource(R.color.reactions_screen_dark_shade_color)) + ) + + Image( + bitmap = selection.snapshot, + contentDescription = null, + modifier = Modifier + .absoluteOffset { IntOffset(snapshotX.value.roundToInt(), snapshotY.value.roundToInt()) } + .size( + width = with(density) { selection.snapshot.width.toDp() }, + height = with(density) { selection.snapshot.height.toDp() } + ) + .graphicsLayer { + scaleX = snapshotScale.value + scaleY = snapshotScale.value + } + ) + + if (!selection.canReact) { + return@BoxWithConstraints + } + + Box( + modifier = Modifier + .absoluteOffset { IntOffset(placement.scrubberX.roundToInt(), placement.reactionBarY.roundToInt()) } + .width(scrubberWidth) + .height(with(density) { barHeightPx.toDp() }) + .graphicsLayer { alpha = stripReveal.value } + .background( + color = colorResource(CoreUiR.color.signal_colorSurface2), + shape = RoundedCornerShape(STRIP_CORNER_RADIUS) + ) + .onGloballyPositioned { coordinates -> + bounds.barTop = coordinates.positionInWindow().y + bounds.barBottom = bounds.barTop + coordinates.size.height + pushGeometry() + } + ) + + Row( + modifier = Modifier + .absoluteOffset { IntOffset(placement.scrubberX.roundToInt(), placement.scrubberForegroundY.roundToInt()) } + .width(scrubberWidth) + .height(scrubberHeight), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + for (index in 0 until REACTION_EMOJI_COUNT) { + ReactionEmoji( + index = index, + emoji = selection.emoji.getOrNull(index), + isApplied = index == selection.appliedEmojiIndex, + reveal = emojiReveal[index].value, + indicatorAlpha = indicatorReveal.value, + selectedIndex = { controller.selectedIndex }, + onEdgeMeasured = { left, right -> + // Layout direction order, so under RTL start is the greater edge. + if (index == 0) { + bounds.firstEmojiEdge = if (isLtr) left else right + } + + if (index == REACTION_EMOJI_COUNT - 1) { + bounds.lastEmojiEdge = if (isLtr) right else left + } + + pushGeometry() + } + ) + } + } + } +} + +/** + * One slot in the strip. + * + * Its own composable so that [selectedIndex], which changes as fast as a thumb moves, invalidates + * seven small scopes rather than the whole overlay. + * + * @param onEdgeMeasured The slot's untransformed leading and trailing edges, in window coordinates. + */ +@Composable +private fun ReactionEmoji( + index: Int, + emoji: Drawable?, + isApplied: Boolean, + reveal: Float, + indicatorAlpha: Float, + selectedIndex: () -> Int, + onEdgeMeasured: (Float, Float) -> Unit +) { + val density = LocalDensity.current + val startTranslation = dimensionResource(R.dimen.reaction_scrubber_anim_start_translation_y) + val growTranslation = dimensionResource(R.dimen.conversation_reaction_scrub_vertical_translation) + + val isUnderThumb = selectedIndex() == index + val revealLift = (1f - reveal) * with(density) { startTranslation.toPx() } + + val grow by animateFloatAsState( + targetValue = if (isUnderThumb) GROW_SCALE else 1f, + animationSpec = tween(GROW_DURATION_MS, easing = Decelerate), + label = "emojiGrow$index" + ) + val growLift by animateFloatAsState( + targetValue = if (isUnderThumb) -with(density) { growTranslation.toPx() } else 0f, + animationSpec = tween(GROW_DURATION_MS, easing = Decelerate), + label = "emojiGrowLift$index" + ) + + Box( + modifier = Modifier + .size(width = EMOJI_WIDTH, height = EMOJI_HEIGHT) + // Measured here rather than on the emoji, whose graphicsLayer scale would otherwise move the + // reported edges as it grows and shift the segment boundaries under a stationary thumb. + .onGloballyPositioned { coordinates -> + val left = coordinates.positionInWindow().x + + onEdgeMeasured(left, left + coordinates.size.width) + }, + contentAlignment = Alignment.Center + ) { + if (isApplied) { + Box( + modifier = Modifier + .size(SELECTION_INDICATOR_SIZE) + .graphicsLayer { alpha = indicatorAlpha } + .background( + color = colorResource(CoreUiR.color.signal_colorSurfaceVariant_16_no_alpha), + shape = CircleShape + ) + ) + } + + Image( + painter = rememberDrawablePainter(emoji), + contentDescription = null, + modifier = Modifier + .size(width = EMOJI_WIDTH, height = EMOJI_HEIGHT) + .graphicsLayer { + alpha = reveal + translationY = revealLift + growLift + scaleX = grow + scaleY = grow + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlayController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlayController.kt new file mode 100644 index 0000000000..2e1973b4f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatReactionOverlayController.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.graphics.PointF +import android.graphics.drawable.Drawable +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.core.content.ContextCompat +import org.signal.emoji.EmojiProvider +import org.signal.emoji.EmojiUtil +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationContextMenu +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.ReactionAction +import org.thoughtcrime.securesms.conversation.ReactionMenu +import org.thoughtcrime.securesms.conversation.ReactionOverlayPlacement +import org.thoughtcrime.securesms.conversation.ReactionScrubber +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import kotlin.math.roundToInt + +/** + * Holds what the long press overlay is showing and carries the activity's raw touch stream into it. + * + * [hapticView] and [menuAnchor] are providers because this outlives the fragment's view, which is + * rebuilt whenever a conversation is re-entered; a captured anchor would by then be detached and + * carry no window token for the popup to show against. + */ +class ChatReactionOverlayController( + private val context: Context, + private val hapticView: () -> View, + private val menuAnchor: () -> View, + private val onReactionSelected: (MessageRecord, String) -> Unit, + private val onCustomReactionSelected: (MessageRecord, Boolean) -> Unit, + private val onActionSelected: (ReactionAction) -> Unit, + private val onStartHide: (View?) -> Unit, + private val onHidden: () -> Unit +) { + + var selection: ReactionOverlaySelection? by mutableStateOf(null) + private set + + var isRevealed: Boolean by mutableStateOf(false) + private set + + var selectedIndex: Int by mutableIntStateOf(ReactionScrubber.NO_SELECTION) + private set + + /** Read during composition to size the placement, so it has to be observable. */ + var menu: ConversationContextMenu? by mutableStateOf(null) + private set + + /** + * Where the overlay's composition starts, in window coordinates. Everything the placement works + * in is relative to this, so a row has to be projected into it before it can be snapshotted. + */ + var originInWindow: Offset by mutableStateOf(Offset.Zero) + private set + + val scrubber = ReactionScrubber(REACTION_EMOJI_COUNT) + + val isShowing: Boolean + get() = selection != null + + private val lastSeenDownPoint = PointF() + + private var messageRecord: MessageRecord? = null + private var focusedView: View? = null + private var strip: Strip = Strip.EMPTY + private var menuPlaced = false + private var pendingAction: ReactionAction? = null + + private data class Strip( + val emojiStrings: List, + val drawables: List, + val appliedIndex: Int, + val customSlotHasEmoji: Boolean + ) { + companion object { + val EMPTY = Strip(emptyList(), emptyList(), ReactionScrubber.NO_SELECTION, false) + } + } + + fun messageRecord(): MessageRecord = requireNotNull(messageRecord) { "Nothing is showing." } + + /** The pointer is already down by now, so the scrubber is opened rather than waiting for one. */ + fun show( + conversationRecipient: Recipient, + conversationMessage: ConversationMessage, + snapshot: ReactionOverlaySnapshot, + isNonAdminInAnnouncementGroup: Boolean, + canEditGroupInfo: Boolean, + focusedView: View? + ) { + if (isShowing) { + return + } + + val record = conversationMessage.messageRecord + val builtMenu = ReactionMenu.of( + context = context, + conversationRecipient = conversationRecipient, + conversationMessage = conversationMessage, + isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup, + canEditGroupInfo = canEditGroupInfo, + onAction = ::onMenuAction + ) + + this.messageRecord = record + this.focusedView = focusedView + this.strip = buildStrip(record) + this.menuPlaced = false + this.pendingAction = null + this.menu = ConversationContextMenu(menuAnchor(), builtMenu.items) + + selectedIndex = ReactionScrubber.NO_SELECTION + + // Holds window coordinates from a view a re-entered conversation has already replaced. + scrubber.geometry = ReactionScrubber.Geometry() + scrubber.open() + + selection = ReactionOverlaySelection( + snapshot = snapshot.bitmap, + bubbleX = snapshot.bubbleX, + bubbleY = snapshot.bubbleY, + bubbleWidth = snapshot.bubbleWidth, + contextMenuX = snapshot.contextMenuX, + isMessageOnLeft = snapshot.isMessageOnLeft, + lastSeenDownY = lastSeenDownPoint.y, + emoji = strip.drawables, + appliedEmojiIndex = strip.appliedIndex, + canReact = builtMenu.canReact, + returnPosition = snapshot.returnPosition + ) + + isRevealed = true + } + + fun hide() { + if (!isShowing || !isRevealed) { + return + } + + isRevealed = false + scrubber.close() + menu?.dismiss() + + onStartHide(if (pendingAction == ReactionAction.VIEW_INFO) null else focusedView) + } + + /** + * Also called if the overlay's composition is disposed part way through a hide, so the teardown + * runs whether or not the animation got to finish. Idempotent. + */ + fun onHideFinished() { + if (selection == null) { + return + } + + isRevealed = false + menu?.dismiss() + + val action = pendingAction + + selection = null + messageRecord = null + focusedView = null + strip = Strip.EMPTY + menu = null + pendingAction = null + selectedIndex = ReactionScrubber.NO_SELECTION + + onHidden() + + if (action != null) { + onActionSelected(action) + } + } + + /** Corrects the offsets from overlay coordinates into the anchor's, which is all the move costs. */ + fun onOriginChanged(origin: Offset) { + originInWindow = origin + } + + fun onPlaced(placement: ReactionOverlayPlacement.Placement) { + val menu = this.menu ?: return + + if (menuPlaced || !isRevealed) { + return + } + + placement.menuHeight?.let { menu.height = it } + + val anchorOrigin = IntArray(2).also { menuAnchor().getLocationInWindow(it) } + val offsetX = originInWindow.x - anchorOrigin[0] + placement.menuOffsetX + val offsetY = originInWindow.y - anchorOrigin[1] + placement.menuOffsetY + + menu.show(offsetX.roundToInt(), offsetY.roundToInt()) + menuPlaced = true + } + + /** While nothing is showing this only remembers where a press landed, for the next [show]. */ + fun applyTouchEvent(motionEvent: MotionEvent): Boolean { + if (!isShowing || !scrubber.isShowing) { + if (motionEvent.action == MotionEvent.ACTION_DOWN) { + lastSeenDownPoint.set(motionEvent.x, motionEvent.y) + } + + return false + } + + val outcome = scrubber.apply(motionEvent.action, motionEvent.x, motionEvent.y) + + when (outcome) { + is ReactionScrubber.Outcome.Scrubbing -> { + if (outcome.index != outcome.previousIndex) { + selectedIndex = outcome.index + + if (outcome.index != ReactionScrubber.NO_SELECTION) { + hapticView().performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + } + } + + is ReactionScrubber.Outcome.Commit -> commit(outcome.index) + is ReactionScrubber.Outcome.Dismiss -> hide() + } + + return outcome.consumed + } + + private fun commit(index: Int) { + val record = messageRecord ?: return + + if (index == REACTION_EMOJI_COUNT - 1) { + onCustomReactionSelected(record, strip.customSlotHasEmoji) + } else { + strip.emojiStrings.getOrNull(index)?.let { emoji -> + onReactionSelected(record, SignalStore.emoji.getPreferredVariation(emoji)) + } + } + } + + private fun onMenuAction(action: ReactionAction) { + pendingAction = action + hide() + } + + /** A reaction not in the strip takes over the last slot in place of the picker. */ + private fun buildStrip(record: MessageRecord): Strip { + val emojiStrings = SignalStore.emoji.reactions + val applied = ReactionMenu.appliedEmoji(record) + val customIndex = REACTION_EMOJI_COUNT - 1 + + var appliedIndex = ReactionScrubber.NO_SELECTION + var customSlotHasEmoji = false + val drawables = mutableListOf() + + for (index in 0 until REACTION_EMOJI_COUNT) { + val isCustomSlot = index == customIndex + val stripEmoji = emojiStrings.getOrNull(index) + + val matchesApplied = !isCustomSlot && + applied != null && + stripEmoji != null && + EmojiUtil.isCanonicallyEqual(stripEmoji, applied) + + val isUnlistedApplied = isCustomSlot && applied != null + + if (appliedIndex == ReactionScrubber.NO_SELECTION && (matchesApplied || isUnlistedApplied)) { + appliedIndex = index + + if (isCustomSlot) { + customSlotHasEmoji = true + drawables += EmojiProvider.getEmojiDrawable(context, applied, true) + } else { + drawables += EmojiProvider.getEmojiDrawable(context, SignalStore.emoji.getPreferredVariation(stripEmoji!!), true) + } + } else if (isCustomSlot) { + drawables += ContextCompat.getDrawable(context, R.drawable.ic_any_emoji_32) + } else { + drawables += stripEmoji?.let { EmojiProvider.getEmojiDrawable(context, SignalStore.emoji.getPreferredVariation(it), true) } + } + } + + return Strip( + emojiStrings = emojiStrings, + drawables = drawables, + appliedIndex = appliedIndex, + customSlotHasEmoji = customSlotHasEmoji + ) + } +} + +/** What the overlay reports back as it goes away, matching the pair the view code had. */ +interface ReactionOverlayHideListener { + fun startHide(focusedView: View?) + + fun onHide() +} + +/** + * The picture of the pressed row and where it sits, handed over by the fragment that took it. + * + * @param returnPosition Where the row is now, for the hide to fly back to. Null once it is gone. + */ +data class ReactionOverlaySnapshot( + val bitmap: androidx.compose.ui.graphics.ImageBitmap, + val bubbleX: Float, + val bubbleY: Float, + val bubbleWidth: Int, + val contextMenuX: Float, + val isMessageOnLeft: Boolean, + val returnPosition: () -> Offset? +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt index d4bd829174..8cc2bc4247 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -52,9 +52,9 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f * @param onEvent The MediaKeyboard events stream for interacting with the media keyboard * @param scrim The color information for the top and bottom scrim * @param isBubble Whether we're displaying content in a bubble - * @param backgroundView The chat wallpaper - * @param contentView The area that actually moves up when the keyboards appear - * @param overlayView The long press overlay, which a keyboard neither covers nor resizes + * @param conversationView The conversation's own view hierarchy, and the only interop view here. + * A second one sharing pointer input would leave this one without its ACTION_HOVER_EXIT, which is + * why the long press overlay is Compose. See stylus-hover-interop.md. */ @Composable fun ChatScreen( @@ -62,9 +62,8 @@ fun ChatScreen( onEvent: (MediaKeyboardEvents) -> Unit, scrims: ChatScrimState, isBubble: Boolean, - backgroundView: View, - contentView: View, - overlayView: View, + conversationView: View, + overlayController: ChatReactionOverlayController, modifier: Modifier = Modifier ) { val minimumHeight = dimensionResource(R.dimen.default_custom_keyboard_size) @@ -91,11 +90,6 @@ fun ChatScreen( // that one inset has to survive or nothing lifts the input off the system keyboard. .then(if (isBubble) Modifier.consumeWindowInsets(WindowInsets.safeDrawing.exclude(WindowInsets.ime)) else Modifier) ) { - AndroidView( - factory = { backgroundView }, - modifier = Modifier.fillMaxSize() - ) - Box( modifier = Modifier .align(Alignment.TopCenter) @@ -139,15 +133,15 @@ fun ChatScreen( keyboardHeight = keyboardHeight ) { AndroidView( - factory = { contentView }, + factory = { conversationView }, modifier = Modifier.fillMaxSize() ) } // Above the scaffold so a closing keyboard neither covers nor resizes it. The bottom inset is - // left on; the overlay subtracts the navigation bar itself. - AndroidView( - factory = { overlayView }, + // left on; the placement subtracts the navigation bar itself. + ChatReactionOverlay( + controller = overlayController, modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.statusBars.add(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index cd70a76e2c..1fc1ac1a27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -44,8 +44,10 @@ import android.view.WindowManager import android.view.animation.AnimationUtils import android.view.inputmethod.EditorInfo import android.widget.EditText +import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView +import android.widget.Space import android.widget.TextView import android.widget.TextView.OnEditorActionListener import android.widget.Toast @@ -56,6 +58,8 @@ import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.SearchView import androidx.compose.runtime.getValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintSet @@ -209,10 +213,6 @@ import org.thoughtcrime.securesms.conversation.ConversationItemSelection import org.thoughtcrime.securesms.conversation.ConversationItemSwipeCallback import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu -import org.thoughtcrime.securesms.conversation.ConversationReactionDelegate -import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay -import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnActionSelectedListener -import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnHideListener import org.thoughtcrime.securesms.conversation.ConversationSearchViewModel import org.thoughtcrime.securesms.conversation.ConversationUpdateTick import org.thoughtcrime.securesms.conversation.MarkReadHelper @@ -220,6 +220,7 @@ import org.thoughtcrime.securesms.conversation.MenuState import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling import org.thoughtcrime.securesms.conversation.PinnedMessagesBottomSheet +import org.thoughtcrime.securesms.conversation.ReactionAction import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback @@ -227,7 +228,6 @@ import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSh import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet.Companion.showSchedule import org.thoughtcrime.securesms.conversation.ScheduledMessagesBottomSheet import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository -import org.thoughtcrime.securesms.conversation.SelectedConversationModel import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment import org.thoughtcrime.securesms.conversation.colors.ChatColors @@ -270,7 +270,6 @@ import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding -import org.thoughtcrime.securesms.databinding.V2ConversationOverlayBinding import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.GroupCallPeekEvent import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration @@ -478,7 +477,6 @@ class ConversationFragment : private val disposables = LifecycleDisposable() private val backgroundBinding by ViewBinderDelegate(bindingFactory = { V2ConversationBackgroundBinding.bind(conversationBackground) }) - private val overlayBinding by ViewBinderDelegate(bindingFactory = { V2ConversationOverlayBinding.bind(conversationOverlay) }) private val binding by ViewBinderDelegate(bindingFactory = { V2ConversationFragmentBinding.bind(conversationContent) }, onBindingWillBeDestroyed = { _binding -> _binding.conversationInputPanel.embeddedTextEditor.apply { setOnEditorActionListener(null) @@ -636,9 +634,6 @@ class ConversationFragment : /** The wallpaper, drawn behind [conversationContent]. */ private lateinit var conversationBackground: View - /** The long press overlay, drawn above [conversationContent]. */ - private lateinit var conversationOverlay: View - private val chatScreenViewModel: ChatScreenViewModel by viewModels() /** Stable across recomposition, so view code can ask for a keyboard from a click listener. */ @@ -681,14 +676,32 @@ class ConversationFragment : private val scheduledMessagesStub: Stub by lazy { Stub(binding.scheduledMessagesStub) } - private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) { - val conversationReactionStub = Stub(overlayBinding.conversationReactionScrubberStub) - val delegate = ConversationReactionDelegate(conversationReactionStub) - delegate.setOnReactionSelectedListener(OnReactionsSelectedListener()) - - delegate + private val reactionOverlay: ChatReactionOverlayController by lazy(LazyThreadSafetyMode.NONE) { + ChatReactionOverlayController( + context = requireContext(), + hapticView = { chatHost }, + menuAnchor = { reactionMenuAnchor }, + onReactionSelected = { messageRecord, emoji -> + reactionOverlay.hide() + disposables += viewModel.updateReaction(messageRecord, emoji).subscribe() + }, + onCustomReactionSelected = { messageRecord, hasAddedCustomEmoji -> + reactionOverlay.hide() + onCustomReactionSelected(messageRecord, hasAddedCustomEmoji) + }, + onActionSelected = { action -> reactionActionListener?.onActionSelected(action) }, + onStartHide = { focusedView -> reactionHideListener?.startHide(focusedView) }, + onHidden = { reactionHideListener?.onHide() } + ) } + /** Set for the life of one long press, so the overlay's callbacks reach that message. */ + private var reactionActionListener: ReactionsToolbarListener? = null + private var reactionHideListener: ReactionOverlayHideListener? = null + + private lateinit var reactionMenuAnchor: View + private lateinit var chatHost: ViewGroup + private lateinit var voiceMessageRecordingDelegate: VoiceMessageRecordingDelegate private val internalDidFirstFrameRender = MutableStateFlow(false) @@ -710,9 +723,8 @@ class ConversationFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false) conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false) - conversationOverlay = inflater.inflate(R.layout.v2_conversation_overlay, container, false) - return ComposeView(requireContext()).apply { + val composition = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { SignalTheme { @@ -721,24 +733,44 @@ class ConversationFragment : onEvent = ::onMediaKeyboardEvent, scrims = chatScrims, isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE, - backgroundView = conversationBackground, - contentView = conversationContent, - overlayView = conversationOverlay + conversationView = conversationContent, + overlayController = reactionOverlay ) } } } + + // Not the ComposeView itself: showAsDropDown measures from an anchor's bottom and flips a popup + // that will not fit below it. + reactionMenuAnchor = Space(requireContext()) + + // Behind the composition rather than inside it: an AndroidView would put the wallpaper in + // Compose's hit path, and a second interop view there costs the conversation its + // ACTION_HOVER_EXIT. See stylus-hover-interop.md. + chatHost = FrameLayout(requireContext()).apply { + addView(conversationBackground) + addView(composition) + addView(reactionMenuAnchor, FrameLayout.LayoutParams(0, 0)) + } + + return chatHost } /** * Where [target]'s row sits in the overlay's coordinate space. [Projection] walks the layout * positions; translations are not part of that walk, so they are added here. + * + * The overlay is a composition, so there is nothing to project into. It reports where it starts + * instead, which is the only reliable measure of its padding: a bubble consumes the insets it is + * padded by, leaving it flush with the root. */ private fun overlayOriginOf(target: InteractiveConversationElement, recycler: RecyclerView): PointF { - val projection = Projection.relativeToViewWithCommonRoot(target.root, conversationOverlay, null) + val projection = Projection.relativeToViewWithCommonRoot(target.root, chatHost, null) + val hostOrigin = IntArray(2).also { chatHost.getLocationInWindow(it) } + val overlayOrigin = reactionOverlay.originInWindow val origin = PointF( - projection.x + target.root.translationX, - projection.y + target.root.translationY + recycler.translationY + projection.x + target.root.translationX - (overlayOrigin.x - hostOrigin[0]), + projection.y + target.root.translationY + recycler.translationY - (overlayOrigin.y - hostOrigin[1]) ) projection.release() @@ -1028,11 +1060,11 @@ class ConversationFragment : } override fun onReactWithAnyEmojiDialogDismissed() { - reactionDelegate.hide() + reactionOverlay.hide() } override fun onReactWithAnyEmojiSelected(emoji: String) { - reactionDelegate.hide() + reactionOverlay.hide() } override fun onReactionsDialogDismissed() { @@ -1176,7 +1208,7 @@ class ConversationFragment : val state = viewModel.backPressedState.value when { - state.isReactionDelegateShowing -> reactionDelegate.hide() + state.isReactionDelegateShowing -> reactionOverlay.hide() state.isSearchRequested -> searchMenuItem?.collapseActionView() @@ -2963,13 +2995,17 @@ class ConversationFragment : private fun handleReaction( conversationMessage: ConversationMessage, - onActionSelectedListener: OnActionSelectedListener, - selectedConversationModel: SelectedConversationModel, - onHideListener: OnHideListener + snapshot: ReactionOverlaySnapshot, + focusedView: View? ) { - reactionDelegate.setOnActionSelectedListener(onActionSelectedListener) - reactionDelegate.setOnHideListener(onHideListener) - reactionDelegate.show(requireActivity(), viewModel.recipientSnapshot!!, conversationMessage, conversationGroupViewModel.isNonAdminInAnnouncementGroup(), selectedConversationModel, conversationGroupViewModel.canEditGroupInfo()) + reactionOverlay.show( + conversationRecipient = viewModel.recipientSnapshot!!, + conversationMessage = conversationMessage, + snapshot = snapshot, + isNonAdminInAnnouncementGroup = conversationGroupViewModel.isNonAdminInAnnouncementGroup(), + canEditGroupInfo = conversationGroupViewModel.canEditGroupInfo(), + focusedView = focusedView + ) viewModel.setIsReactionDelegateShowing(true) composeText.clearFocus() } @@ -4204,8 +4240,8 @@ class ConversationFragment : return } - if (reactionDelegate.isShowing()) { - // The overlay ignores a show while it is up, and nothing below would be undone by a hide that never comes. + if (reactionOverlay.isShowing) { + // Nothing below would be undone by a hide that never comes. Log.w(TAG, "Long press while the reaction overlay is still showing. Ignoring.") return } @@ -4214,13 +4250,9 @@ class ConversationFragment : // Held, not re-read: teardown has to work from a screen that is already going. val recycler = binding.conversationItemRecycler - val overlay = conversationOverlay - val shade = overlayBinding.reactionsShade multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage)) recycler.invalidateItemDecorations() - overlay.visibility = View.VISIBLE - shade.visibility = View.VISIBLE recycler.suppressLayout(true) val audioUri = messageRecord.getAudioUriForLongClick() @@ -4249,21 +4281,19 @@ class ConversationFragment : ) val origin = overlayOriginOf(target, recycler) - val selectedConversationModel = SelectedConversationModel( - bitmap = snapshot, + val overlaySnapshot = ReactionOverlaySnapshot( + bitmap = snapshot.asImageBitmap(), bubbleX = origin.x + snapshotMetrics.snapshotOffset, bubbleY = origin.y + bodyBubble.y, bubbleWidth = bodyBubble.width, contextMenuX = origin.x + snapshotMetrics.contextMenuPadding, - audioUri = audioUri, - isOutgoing = messageRecord.isOutgoing, - focusedView = focusedView, - returnPosition = SelectedConversationModel.ReturnPosition { + isMessageOnLeft = messageRecord.isOutgoing xor ViewUtil.isLtr(recycler), + returnPosition = { if (view == null || target.root.parent == null || target.conversationMessage.messageRecord.id != messageRecord.id) { null } else { val current = overlayOriginOf(target, recycler) - PointF(current.x + snapshotMetrics.snapshotOffset, current.y + bodyBubble.y) + Offset(current.x + snapshotMetrics.snapshotOffset, current.y + bodyBubble.y) } } ) @@ -4278,61 +4308,56 @@ class ConversationFragment : viewModel.setHideScrollButtonsForReactionOverlay(true) - handleReaction( - item.conversationMessage, - ReactionsToolbarListener(item.conversationMessage), - selectedConversationModel, - object : OnHideListener { - override fun startHide(focusedView: View?) { - // Ahead of the started check: a dismiss while stopped would leave the chat dimmed. - multiselectItemDecoration.hideShade(recycler) - ViewUtil.fadeOut(shade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) + reactionActionListener = ReactionsToolbarListener(item.conversationMessage) + reactionHideListener = object : ReactionOverlayHideListener { + override fun startHide(focusedView: View?) { + // Ahead of the started check: a dismiss while stopped would leave the chat dimmed. + multiselectItemDecoration.hideShade(recycler) - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { - return - } - - val searchField = expandedSearchField() - if (searchField != null && focusedView == searchField) { - // The input panel is gone while search is open, so composeText cannot take the keyboard back. - container.showSoftkey(searchField) - } else if (focusedView == composeText) { - container.showSoftkey(composeText) - } + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return } - override fun onHide() { - viewModel.setIsReactionDelegateShowing(false) - - // Likewise: otherwise the list stays frozen, the message invisible, and the idle - // overlay in front of the chat for touch purposes. - recycler.suppressLayout(false) - overlay.visibility = View.INVISIBLE - multiselectItemDecoration.setFocusedItem(null) - recycler.invalidateItemDecorations() - bodyBubble.visibility = View.VISIBLE - target.reactionsView.visibility = View.VISIBLE - viewModel.setHideScrollButtonsForReactionOverlay(false) - - if (quotedIndicatorVisible && target.quotedIndicatorView != null) { - ViewUtil.fadeIn(target.quotedIndicatorView!!, 150) - } - - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { - return - } - - if (selectedConversationModel.audioUri != null) { - getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id) - } - - if (mp4Holder != null) { - mp4Holder.show() - mp4Holder.resume() - } + val searchField = expandedSearchField() + if (searchField != null && focusedView == searchField) { + // The input panel is gone while search is open, so composeText cannot take the keyboard back. + container.showSoftkey(searchField) + } else if (focusedView == composeText) { + container.showSoftkey(composeText) } } - ) + + override fun onHide() { + viewModel.setIsReactionDelegateShowing(false) + + // Likewise: otherwise the list stays frozen and the message invisible. + recycler.suppressLayout(false) + multiselectItemDecoration.setFocusedItem(null) + recycler.invalidateItemDecorations() + bodyBubble.visibility = View.VISIBLE + target.reactionsView.visibility = View.VISIBLE + viewModel.setHideScrollButtonsForReactionOverlay(false) + + if (quotedIndicatorVisible && target.quotedIndicatorView != null) { + ViewUtil.fadeIn(target.quotedIndicatorView!!, 150) + } + + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) { + return + } + + if (audioUri != null) { + getVoiceNoteMediaController().resumePlayback(audioUri, messageRecord.id) + } + + if (mp4Holder != null) { + mp4Holder.show() + mp4Holder.resume() + } + } + } + + handleReaction(item.conversationMessage, overlaySnapshot, focusedView) } override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) { @@ -4639,27 +4664,16 @@ class ConversationFragment : } } - private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener { - override fun onReactionSelected(messageRecord: MessageRecord, emoji: String?) { - reactionDelegate.hide() - - if (emoji != null) { - disposables += viewModel.updateReaction(messageRecord, emoji).subscribe() - } - } - - override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { - reactionDelegate.hide() - disposables += viewModel.updateCustomReaction(messageRecord, hasAddedCustomEmoji) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { - ReactWithAnyEmojiBottomSheetDialogFragment - .createForMessageRecord(messageRecord, -1) - .show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) - } - ) - } + private fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { + disposables += viewModel.updateCustomReaction(messageRecord, hasAddedCustomEmoji) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy( + onSuccess = { + ReactWithAnyEmojiBottomSheetDialogFragment + .createForMessageRecord(messageRecord, -1) + .show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + ) } private inner class MotionEventRelayDrain(lifecycleOwner: LifecycleOwner) : MotionEventRelay.Drain { @@ -4667,7 +4681,7 @@ class ConversationFragment : override fun accept(motionEvent: MotionEvent): Boolean { return if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - reactionDelegate.applyTouchEvent(motionEvent) + reactionOverlay.applyTouchEvent(motionEvent) } else { false } @@ -4676,24 +4690,24 @@ class ConversationFragment : private inner class ReactionsToolbarListener( private val conversationMessage: ConversationMessage - ) : OnActionSelectedListener { - override fun onActionSelected(action: ConversationReactionOverlay.Action) { + ) { + fun onActionSelected(action: ReactionAction) { when (action) { - ConversationReactionOverlay.Action.REPLY -> handleReplyToMessage(conversationMessage) - ConversationReactionOverlay.Action.EDIT -> handleEditMessage(conversationMessage) - ConversationReactionOverlay.Action.FORWARD -> handleForwardMessageParts(conversationMessage.multiselectCollection.toSet()) - ConversationReactionOverlay.Action.RESEND -> handleResend(conversationMessage) - ConversationReactionOverlay.Action.DOWNLOAD -> handleSaveAttachment(conversationMessage.messageRecord as MmsMessageRecord) - ConversationReactionOverlay.Action.COPY -> handleCopyMessage(conversationMessage.multiselectCollection.toSet()) - ConversationReactionOverlay.Action.MULTISELECT -> handleEnterMultiselect(conversationMessage) - ConversationReactionOverlay.Action.PAYMENT_DETAILS -> handleViewPaymentDetails(conversationMessage) - ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage) - ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet()) - ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id) - ConversationReactionOverlay.Action.PIN_MESSAGE -> handlePinMessage(conversationMessage) - ConversationReactionOverlay.Action.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id) - ConversationReactionOverlay.Action.STAR_MESSAGE -> handleStarMessages(setOf(conversationMessage.messageRecord.id)) - ConversationReactionOverlay.Action.UNSTAR_MESSAGE -> handleUnstarMessages(setOf(conversationMessage.messageRecord.id)) + ReactionAction.REPLY -> handleReplyToMessage(conversationMessage) + ReactionAction.EDIT -> handleEditMessage(conversationMessage) + ReactionAction.FORWARD -> handleForwardMessageParts(conversationMessage.multiselectCollection.toSet()) + ReactionAction.RESEND -> handleResend(conversationMessage) + ReactionAction.DOWNLOAD -> handleSaveAttachment(conversationMessage.messageRecord as MmsMessageRecord) + ReactionAction.COPY -> handleCopyMessage(conversationMessage.multiselectCollection.toSet()) + ReactionAction.MULTISELECT -> handleEnterMultiselect(conversationMessage) + ReactionAction.PAYMENT_DETAILS -> handleViewPaymentDetails(conversationMessage) + ReactionAction.VIEW_INFO -> handleDisplayDetails(conversationMessage) + ReactionAction.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet()) + ReactionAction.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id) + ReactionAction.PIN_MESSAGE -> handlePinMessage(conversationMessage) + ReactionAction.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id) + ReactionAction.STAR_MESSAGE -> handleStarMessages(setOf(conversationMessage.messageRecord.id)) + ReactionAction.UNSTAR_MESSAGE -> handleUnstarMessages(setOf(conversationMessage.messageRecord.id)) } } } diff --git a/app/src/main/res/layout/conversation_reaction_scrubber.xml b/app/src/main/res/layout/conversation_reaction_scrubber.xml deleted file mode 100644 index 4e73cc0c7f..0000000000 --- a/app/src/main/res/layout/conversation_reaction_scrubber.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/v2_conversation_overlay.xml b/app/src/main/res/layout/v2_conversation_overlay.xml deleted file mode 100644 index 86caa1fac2..0000000000 --- a/app/src/main/res/layout/v2_conversation_overlay.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacementTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacementTest.kt new file mode 100644 index 0000000000..0dea8c2f32 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionOverlayPlacementTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isGreaterThanOrEqualTo +import assertk.assertions.isLessThan +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.junit.Test + +/** + * Covers each fallback the placement walks through, so the Compose renderer can be swapped in + * underneath it without the ladder quietly changing which branch a given message lands in. + * + * Density is 1, so dp and px are the same number: menu padding 12, bar top padding 32, bar offset + * 48, tight menu threshold 150. + */ +class ReactionOverlayPlacementTest { + + companion object { + private val DENSITY = ReactionOverlayPlacement.DpConverter { it } + + private const val MENU_PADDING = 12f + private const val BAR_TOP_PADDING = 32f + + /** A tall window, a short message near the middle, and a menu narrow enough to sit beside. */ + private val BASE = ReactionOverlayPlacement.Metrics( + overlayWidth = 1080, + overlayHeight = 2000, + statusBarHeight = 60, + navigationBarHeight = 40, + bubbleX = 80f, + bubbleY = 800f, + bubbleWidth = 600, + contextMenuX = 80f, + snapshotWidth = 600, + snapshotHeight = 200, + reactionBarHeight = 120, + scrubberForegroundHeight = 80, + scrubberWidth = 500, + scrubberHorizontalMargin = 24, + menuMaxWidth = 400, + menuMaxHeight = 500, + isMessageOnLeft = true, + lastSeenDownY = 900f + ) + + /** Wide enough that the menu can no longer sit beside the strip. */ + private val NARROW = BASE.copy(menuMaxWidth = 700) + } + + private fun place(metrics: ReactionOverlayPlacement.Metrics): ReactionOverlayPlacement.Placement { + return ReactionOverlayPlacement.of(metrics, DENSITY) + } + + @Test + fun `the menu goes beside the strip only when both fit across`() { + assertThat(place(BASE).isWideLayout).isTrue() + assertThat(place(NARROW).isWideLayout).isFalse() + } + + @Test + fun `a wide layout with room puts the strip just above the message`() { + val placement = place(BASE) + + assertThat(placement.snapshotY).isEqualTo(BASE.bubbleY) + assertThat(placement.snapshotScale).isEqualTo(1f) + assertThat(placement.reactionBarY).isEqualTo(BASE.bubbleY - MENU_PADDING - BASE.reactionBarHeight) + } + + @Test + fun `a wide layout pushes the message down when the strip will not fit above it`() { + val placement = place(BASE.copy(bubbleY = 10f)) + + assertThat(placement.reactionBarY).isEqualTo(BAR_TOP_PADDING) + assertThat(placement.snapshotY).isEqualTo(BASE.reactionBarHeight + MENU_PADDING + BAR_TOP_PADDING) + assertThat(placement.snapshotScale).isEqualTo(1f) + } + + @Test + fun `a wide layout shrinks a message too tall to fit`() { + val placement = place(BASE.copy(snapshotHeight = 1900)) + + assertThat(placement.snapshotScale).isLessThan(1f) + assertThat(placement.reactionBarY).isEqualTo(BAR_TOP_PADDING) + } + + @Test + fun `a narrow layout with room leaves the message where it is`() { + val placement = place(NARROW) + + assertThat(placement.snapshotY).isEqualTo(NARROW.bubbleY) + assertThat(placement.snapshotScale).isEqualTo(1f) + assertThat(placement.menuHeight).isNull() + } + + @Test + fun `a narrow layout lifts the message when the menu will not fit below it`() { + val placement = place(NARROW.copy(bubbleY = 1300f)) + + // Room for the message and the full menu above the navigation bar, and no further. + assertThat(placement.snapshotY).isEqualTo(1960f - NARROW.menuMaxHeight - MENU_PADDING - NARROW.snapshotHeight) + assertThat(placement.snapshotScale).isEqualTo(1f) + } + + @Test + fun `a narrow layout shrinks the message when the menu takes most of the window`() { + val placement = place(NARROW.copy(snapshotHeight = 1500)) + + assertThat(placement.snapshotScale).isLessThan(1f) + assertThat(placement.menuHeight).isNull() + } + + @Test + fun `the menu is halved only once nothing else fits`() { + val placement = place(NARROW.copy(snapshotHeight = 1400, menuMaxHeight = 1800)) + + assertThat(placement.menuHeight).isEqualTo(900) + } + + @Test + fun `the strip never rises past the status bar`() { + val awkward = listOf( + BASE.copy(bubbleY = -400f, snapshotHeight = 1900), + BASE.copy(bubbleY = 0f), + NARROW.copy(bubbleY = -900f, snapshotHeight = 1500), + NARROW.copy(bubbleY = 1900f, snapshotHeight = 800, menuMaxHeight = 1780), + NARROW.copy(bubbleY = -100f, snapshotHeight = 1400, menuMaxHeight = 1800), + BASE.copy(overlayHeight = 700, snapshotHeight = 600) + ) + + for (metrics in awkward) { + assertThat(place(metrics).reactionBarY).isGreaterThanOrEqualTo(-metrics.statusBarHeight.toFloat()) + } + } + + @Test + fun `the strip hugs the leading edge for an incoming message and the trailing edge for an outgoing one`() { + assertThat(place(BASE).scrubberX).isEqualTo(BASE.scrubberHorizontalMargin.toFloat()) + + val outgoing = place(BASE.copy(isMessageOnLeft = false)) + + assertThat(outgoing.scrubberX).isEqualTo((BASE.overlayWidth - BASE.scrubberWidth - BASE.scrubberHorizontalMargin).toFloat()) + } + + @Test + fun `the emoji row is centred on the strip background`() { + val placement = place(BASE) + + val backgroundCentre = placement.reactionBarY + BASE.reactionBarHeight / 2f + val foregroundCentre = placement.scrubberForegroundY + BASE.scrubberForegroundHeight / 2f + + assertThat(foregroundCentre).isEqualTo(backgroundCentre) + } + + @Test + fun `a wide menu is held on screen even when the strip is far down`() { + val placement = place(BASE.copy(bubbleY = 1900f)) + + // The strip lands at 1768, but the menu stops where its full height still fits. + assertThat(placement.reactionBarY).isEqualTo(1768f) + assertThat(placement.menuOffsetY).isEqualTo((1960 - BASE.menuMaxHeight).toFloat()) + } + + @Test + fun `a wide menu sits on the far side of the strip from the message`() { + val incoming = place(BASE) + assertThat(incoming.menuOffsetX).isEqualTo(incoming.scrubberX + BASE.scrubberWidth + MENU_PADDING) + + val outgoing = place(BASE.copy(isMessageOnLeft = false)) + assertThat(outgoing.menuOffsetX).isEqualTo(outgoing.scrubberX - BASE.menuMaxWidth - MENU_PADDING) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionScrubberTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionScrubberTest.kt new file mode 100644 index 0000000000..83a4856f87 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/ReactionScrubberTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import android.view.MotionEvent +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import org.junit.Test + +/** + * Pins the scrub gesture's behaviour as the view code had it, ahead of the Compose renderer taking + * it over. MotionEvent action constants are compile time ints, so none of this needs a device. + */ +class ReactionScrubberTest { + + companion object { + private const val EMOJI_COUNT = 7 + + /** Seven 100 wide segments from 100 to 800, a short strip band and a tall scrub band. */ + private val LTR = ReactionScrubber.Geometry( + stripStart = 100f, + stripEnd = 800f, + stripTop = 200f, + stripBottom = 300f, + scrubTop = 200f, + scrubBottom = 900f, + deadZoneSize = 20f, + isStripVisible = true + ) + + /** The same strip laid out right to left, so start is the greater edge. */ + private val RTL = LTR.copy(stripStart = 800f, stripEnd = 100f) + } + + private fun scrubber(geometry: ReactionScrubber.Geometry = LTR): ReactionScrubber { + val scrubber = ReactionScrubber(EMOJI_COUNT) + scrubber.geometry = geometry + scrubber.open() + + return scrubber + } + + /** Anchors the dead zone and then escapes it, which is the only way into the scrub phase. */ + private fun ReactionScrubber.beginScrub(): ReactionScrubber { + apply(MotionEvent.ACTION_MOVE, 400f, 1000f) + apply(MotionEvent.ACTION_MOVE, 400f, 950f) + return this + } + + @Test + fun `open shows with no selection`() { + val scrubber = scrubber() + + assertThat(scrubber.isShowing).isTrue() + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.UNINITIALIZED) + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + } + + @Test + fun `first event only anchors the dead zone`() { + val scrubber = scrubber() + + val outcome = scrubber.apply(MotionEvent.ACTION_MOVE, 400f, 1000f) + + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.DEADZONE) + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + assertThat(outcome.consumed).isTrue() + } + + @Test + fun `a down right after open cannot claim the gesture`() { + val scrubber = scrubber() + + val outcome = scrubber.apply(MotionEvent.ACTION_DOWN, 250f, 250f) + + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.DEADZONE) + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + assertThat(outcome.consumed).isFalse() + } + + @Test + fun `escaping the dead zone starts scrubbing and selects`() { + val scrubber = scrubber().beginScrub() + + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.SCRUB) + + val outcome = scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + + assertThat(scrubber.selectedIndex).isEqualTo(1) + assertThat(outcome).isInstanceOf(ReactionScrubber.Outcome.Scrubbing::class) + .prop(ReactionScrubber.Outcome.Scrubbing::previousIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + } + + @Test + fun `each segment maps to its own emoji`() { + val scrubber = scrubber().beginScrub() + + for (index in 0 until EMOJI_COUNT) { + val centre = 150f + (100f * index) + scrubber.apply(MotionEvent.ACTION_MOVE, centre, 250f) + + assertThat(scrubber.selectedIndex).isEqualTo(index) + } + } + + @Test + fun `segment edges belong to neither neighbour`() { + val scrubber = scrubber().beginScrub() + + scrubber.apply(MotionEvent.ACTION_MOVE, 200f, 250f) + + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + } + + @Test + fun `laid out right to left the first emoji is the rightmost`() { + val scrubber = scrubber(RTL).beginScrub() + + scrubber.apply(MotionEvent.ACTION_MOVE, 750f, 250f) + assertThat(scrubber.selectedIndex).isEqualTo(0) + + scrubber.apply(MotionEvent.ACTION_MOVE, 150f, 250f) + assertThat(scrubber.selectedIndex).isEqualTo(EMOJI_COUNT - 1) + } + + @Test + fun `a scrub outside the band selects nothing`() { + val scrubber = scrubber().beginScrub() + + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + assertThat(scrubber.selectedIndex).isEqualTo(1) + + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 1500f) + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + } + + @Test + fun `lifting on a selection commits it and leaves the scrubber showing`() { + val scrubber = scrubber().beginScrub() + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + + val outcome = scrubber.apply(MotionEvent.ACTION_UP, 250f, 250f) + + assertThat(outcome).isInstanceOf(ReactionScrubber.Outcome.Commit::class) + .prop(ReactionScrubber.Outcome.Commit::index).isEqualTo(1) + assertThat(scrubber.isShowing).isTrue() + } + + @Test + fun `lifting on nothing dismisses and hides`() { + val scrubber = scrubber().beginScrub() + + val outcome = scrubber.apply(MotionEvent.ACTION_UP, 250f, 1500f) + + assertThat(outcome).isInstanceOf(ReactionScrubber.Outcome.Dismiss::class) + assertThat(scrubber.isShowing).isFalse() + } + + @Test + fun `a second press on the strip claims the gesture and taps through`() { + val scrubber = scrubber().beginScrub() + + val down = scrubber.apply(MotionEvent.ACTION_DOWN, 250f, 250f) + + assertThat(down.consumed).isTrue() + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.DEADZONE) + assertThat(scrubber.selectedIndex).isEqualTo(1) + + val up = scrubber.apply(MotionEvent.ACTION_UP, 255f, 255f) + + assertThat(scrubber.phase).isEqualTo(ReactionScrubber.Phase.TAP) + assertThat(up).isInstanceOf(ReactionScrubber.Outcome.Commit::class) + .prop(ReactionScrubber.Outcome.Commit::index).isEqualTo(1) + } + + @Test + fun `cancel dismisses and hides`() { + val scrubber = scrubber().beginScrub() + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + + val outcome = scrubber.apply(MotionEvent.ACTION_CANCEL, 250f, 250f) + + assertThat(outcome).isInstanceOf(ReactionScrubber.Outcome.Dismiss::class) + assertThat(scrubber.isShowing).isFalse() + } + + @Test + fun `a non primary pointer is swallowed without moving the selection`() { + val scrubber = scrubber().beginScrub() + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + + val secondPointerDown = MotionEvent.ACTION_POINTER_DOWN or (1 shl 8) + val outcome = scrubber.apply(secondPointerDown, 650f, 250f) + + assertThat(outcome.consumed).isTrue() + assertThat(scrubber.selectedIndex).isEqualTo(1) + } + + @Test + fun `nothing is selectable while the strip is down`() { + val scrubber = scrubber(LTR.copy(isStripVisible = false)).beginScrub() + + scrubber.apply(MotionEvent.ACTION_MOVE, 250f, 250f) + assertThat(scrubber.selectedIndex).isEqualTo(ReactionScrubber.NO_SELECTION) + + val outcome = scrubber.apply(MotionEvent.ACTION_UP, 250f, 250f) + assertThat(outcome).isInstanceOf(ReactionScrubber.Outcome.Dismiss::class) + } + + @Test(expected = IllegalStateException::class) + fun `events before open are a programming error`() { + ReactionScrubber(EMOJI_COUNT).apply(MotionEvent.ACTION_MOVE, 250f, 250f) + } +}