mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 16:54:28 +01:00
Improve stylus behaivor in chat screen.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
committed by
Greyson Parrelli
co-authored by
Cody Henthorne
parent
ecfa053497
commit
239c54a77f
-120
@@ -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;
|
||||
}
|
||||
}
|
||||
-949
@@ -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
|
||||
}
|
||||
}
|
||||
+273
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-35
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+327
@@ -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)))
|
||||
|
||||
+149
-135
@@ -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>
|
||||
+179
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user