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