Improve stylus behaivor in chat screen.

Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
Alex Hart
2026-09-15 18:26:06 -04:00
committed by Greyson Parrelli
co-authored by Cody Henthorne
parent ecfa053497
commit 239c54a77f
15 changed files with 1932 additions and 1435 deletions
@@ -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
* <p>
* 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<ConversationReactionOverlay> overlayStub;
private final PointF lastSeenDownPoint = new PointF();
private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener;
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
private ConversationReactionOverlay.OnHideListener onHideListener;
public ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> 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;
}
}
@@ -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<String> 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<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup, canEditGroupInfo);
List<ActionItem> 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<Animator> 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<Animator> newHideAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
List<Animator> 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
}
}
@@ -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
}
@@ -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<ActionItem>,
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<ActionItem>()
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
}
}
@@ -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
}
}
@@ -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
}
}
}
}
@@ -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?
}
}
@@ -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<Drawable?>,
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
}
)
}
}
@@ -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<String>,
val drawables: List<Drawable?>,
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<Drawable?>()
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?
)
@@ -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)))
@@ -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<View> by lazy { Stub(binding.scheduledMessagesStub) }
private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) {
val conversationReactionStub = Stub<ConversationReactionOverlay>(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))
}
}
}
@@ -1,153 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.ConversationReactionOverlay xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_reaction_scrubber"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="1000dp"
android:visibility="gone"
tools:visibility="visible">
<Space
android:id="@+id/dropdown_anchor"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_gravity="left"
tools:ignore="RtlHardcoded" />
<View
android:id="@+id/conversation_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<View
android:id="@+id/conversation_reaction_scrubber_background"
android:layout_width="@dimen/reaction_scrubber_width"
android:layout_height="?attr/actionBarSize"
android:layout_marginTop="40dp"
android:layout_marginBottom="40dp"
android:alpha="0"
android:background="@drawable/conversation_reaction_overlay_background"
android:elevation="4dp"
tools:alpha="1" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/conversation_reaction_scrubber_foreground"
android:layout_width="@dimen/reaction_scrubber_width"
android:layout_height="@dimen/conversation_reaction_scrubber_height"
android:clipToPadding="false"
android:elevation="4dp">
<View
android:id="@+id/conversation_reaction_current_selection_indicator"
android:layout_width="52dp"
android:layout_height="52dp"
android:alpha="0"
android:background="@drawable/reactions_old_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="@id/reaction_3"
app:layout_constraintRight_toRightOf="@id/reaction_3"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_1"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_2"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_3"
app:layout_constraintStart_toEndOf="@id/reaction_1"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_3"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_4"
app:layout_constraintStart_toEndOf="@id/reaction_2"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_4"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_5"
app:layout_constraintStart_toEndOf="@id/reaction_3"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_5"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_6"
app:layout_constraintStart_toEndOf="@id/reaction_4"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_6"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reaction_7"
app:layout_constraintStart_toEndOf="@id/reaction_5"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/reaction_7"
android:layout_width="32dp"
android:layout_height="48dp"
android:alpha="0"
android:translationY="@dimen/reaction_scrubber_anim_start_translation_y"
app:forceJumbo="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reaction_6"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:translationY="0dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</org.thoughtcrime.securesms.conversation.ConversationReactionOverlay>
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
The long press overlay and its shade, drawn above the scaffold so a closing keyboard never resizes them.
Invisible rather than gone while idle: invisible keeps this laid out, which the overlay needs in order
to measure itself and to project a row's position into its coordinate space, but takes it out of touch
dispatch so an idle overlay cannot swallow a gesture meant for the chat beneath it.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible">
<FrameLayout
android:id="@+id/reactions_shade"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color"
android:visibility="gone" />
<ViewStub
android:id="@+id/conversation_reaction_scrubber_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber" />
</FrameLayout>
@@ -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)
}
}
@@ -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)
}
}