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