That fuck shit the fascists are using
1package org.tm.archive.conversation;
2
3import android.animation.Animator;
4import android.animation.AnimatorSet;
5import android.animation.ObjectAnimator;
6import android.animation.ValueAnimator;
7import android.app.Activity;
8import android.content.Context;
9import android.content.res.Configuration;
10import android.graphics.Bitmap;
11import android.graphics.PointF;
12import android.graphics.Rect;
13import android.graphics.drawable.BitmapDrawable;
14import android.os.Build;
15import android.util.AttributeSet;
16import android.view.HapticFeedbackConstants;
17import android.view.MotionEvent;
18import android.view.View;
19import android.view.Window;
20import android.view.animation.DecelerateInterpolator;
21import android.view.animation.Interpolator;
22import android.widget.FrameLayout;
23
24import androidx.annotation.NonNull;
25import androidx.annotation.Nullable;
26import androidx.constraintlayout.widget.ConstraintLayout;
27import androidx.constraintlayout.widget.ConstraintSet;
28import androidx.core.content.ContextCompat;
29import androidx.core.view.ViewKt;
30import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
31
32import com.annimon.stream.Stream;
33
34import org.signal.core.util.DimensionUnit;
35import org.tm.archive.R;
36import org.tm.archive.animation.AnimationCompleteListener;
37import org.tm.archive.components.emoji.EmojiImageView;
38import org.tm.archive.components.emoji.EmojiUtil;
39import org.tm.archive.components.menu.ActionItem;
40import org.tm.archive.database.model.MessageRecord;
41import org.tm.archive.database.model.ReactionRecord;
42import org.tm.archive.keyvalue.SignalStore;
43import org.tm.archive.recipients.Recipient;
44import org.tm.archive.util.ThemeUtil;
45import org.tm.archive.util.Util;
46import org.tm.archive.util.ViewUtil;
47import org.tm.archive.util.WindowUtil;
48
49import java.util.ArrayList;
50import java.util.List;
51
52import kotlin.Unit;
53
54public final class ConversationReactionOverlay extends FrameLayout {
55
56 private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
57
58 private final Rect emojiViewGlobalRect = new Rect();
59 private final Rect emojiStripViewBounds = new Rect();
60 private float segmentSize;
61
62 private final Boundary horizontalEmojiBoundary = new Boundary();
63 private final Boundary verticalScrubBoundary = new Boundary();
64 private final PointF deadzoneTouchPoint = new PointF();
65
66 private Activity activity;
67 private Recipient conversationRecipient;
68 private MessageRecord messageRecord;
69 private SelectedConversationModel selectedConversationModel;
70 private OverlayState overlayState = OverlayState.HIDDEN;
71 private boolean isNonAdminInAnnouncementGroup;
72
73 private boolean downIsOurs;
74 private int selected = -1;
75 private int customEmojiIndex;
76 private int originalStatusBarColor;
77 private int originalNavigationBarColor;
78
79 private View dropdownAnchor;
80 private View toolbarShade;
81 private View inputShade;
82 private View conversationItem;
83 private View backgroundView;
84 private ConstraintLayout foregroundView;
85 private View selectedView;
86 private EmojiImageView[] emojiViews;
87
88 private ConversationContextMenu contextMenu;
89
90 private float touchDownDeadZoneSize;
91 private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
92 private int scrubberWidth;
93 private int selectedVerticalTranslation;
94 private int scrubberHorizontalMargin;
95 private int animationEmojiStartDelayFactor;
96 private int statusBarHeight;
97 private int bottomNavigationBarHeight;
98
99 private OnReactionSelectedListener onReactionSelectedListener;
100 private OnActionSelectedListener onActionSelectedListener;
101 private OnHideListener onHideListener;
102
103 private AnimatorSet revealAnimatorSet = new AnimatorSet();
104 private AnimatorSet hideAnimatorSet = new AnimatorSet();
105
106 public ConversationReactionOverlay(@NonNull Context context) {
107 super(context);
108 }
109
110 public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
111 super(context, attrs);
112 }
113
114 @Override
115 protected void onFinishInflate() {
116 super.onFinishInflate();
117
118 dropdownAnchor = findViewById(R.id.dropdown_anchor);
119 toolbarShade = findViewById(R.id.toolbar_shade);
120 inputShade = findViewById(R.id.input_shade);
121 conversationItem = findViewById(R.id.conversation_item);
122 backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
123 foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
124 selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
125
126 emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
127 findViewById(R.id.reaction_2),
128 findViewById(R.id.reaction_3),
129 findViewById(R.id.reaction_4),
130 findViewById(R.id.reaction_5),
131 findViewById(R.id.reaction_6),
132 findViewById(R.id.reaction_7) };
133
134 customEmojiIndex = emojiViews.length - 1;
135
136 distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
137
138 touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
139 scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
140 selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
141 scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
142
143 animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
144
145 initAnimators();
146 }
147
148 public void show(@NonNull Activity activity,
149 @NonNull Recipient conversationRecipient,
150 @NonNull ConversationMessage conversationMessage,
151 @NonNull PointF lastSeenDownPoint,
152 boolean isNonAdminInAnnouncementGroup,
153 @NonNull SelectedConversationModel selectedConversationModel)
154 {
155 if (overlayState != OverlayState.HIDDEN) {
156 return;
157 }
158
159 this.messageRecord = conversationMessage.getMessageRecord();
160 this.conversationRecipient = conversationRecipient;
161 this.selectedConversationModel = selectedConversationModel;
162 this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup;
163 overlayState = OverlayState.UNINITAILIZED;
164 selected = -1;
165
166 setupSelectedEmoji();
167
168 View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
169 statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
170
171 View navigationBarBackground = activity.findViewById(android.R.id.navigationBarBackground);
172 bottomNavigationBarHeight = navigationBarBackground == null ? 0 : navigationBarBackground.getHeight();
173
174 if (zeroNavigationBarHeightForConfiguration()) {
175 bottomNavigationBarHeight = 0;
176 }
177
178 Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
179
180 conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
181 conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
182
183 boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
184
185 conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR);
186 conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR);
187
188 setVisibility(View.INVISIBLE);
189
190 this.activity = activity;
191 updateSystemUiOnShow(activity);
192
193 ViewKt.doOnLayout(this, v -> {
194 showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
195 return Unit.INSTANCE;
196 });
197 }
198
199 private void showAfterLayout(@NonNull Activity activity,
200 @NonNull ConversationMessage conversationMessage,
201 @NonNull PointF lastSeenDownPoint,
202 boolean isMessageOnLeft) {
203 updateToolbarShade();
204 updateInputShade();
205
206 contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
207
208 conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
209 conversationItem.setY(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
210
211 Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
212 boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
213
214 int overlayHeight = getHeight() - bottomNavigationBarHeight;
215 int bubbleWidth = selectedConversationModel.getBubbleWidth();
216
217 float endX = selectedConversationModel.getSnapshotMetrics().getSnapshotOffset();
218 float endY = conversationItem.getY();
219 float endApparentTop = endY;
220 float endScale = 1f;
221
222 float menuPadding = DimensionUnit.DP.toPixels(12f);
223 float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
224 int reactionBarHeight = backgroundView.getHeight();
225
226 float reactionBarBackgroundY;
227
228 if (isWideLayout) {
229 boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
230 if (everythingFitsVertically) {
231 boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
232
233 if (reactionBarFitsAboveItem) {
234 reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
235 } else {
236 endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
237 reactionBarBackgroundY = reactionBarTopPadding;
238 }
239 } else {
240 float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
241
242 endScale = spaceAvailableForItem / conversationItem.getHeight();
243 endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
244 endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
245 reactionBarBackgroundY = reactionBarTopPadding;
246 }
247 } else {
248 float reactionBarOffset = DimensionUnit.DP.toPixels(48);
249 float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset - conversationItemSnapshot.getHeight(), 0);
250 boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
251
252 if (everythingFitsVertically) {
253 float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
254 boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
255
256 if (menuFitsBelowItem) {
257 if (conversationItem.getY() < 0) {
258 endY = 0;
259 }
260 float contextMenuTop = endY + conversationItemSnapshot.getHeight();
261 reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
262
263 if (reactionBarBackgroundY <= reactionBarTopPadding) {
264 endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
265 }
266 } else {
267 endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
268
269 float contextMenuTop = endY + conversationItemSnapshot.getHeight();
270 reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
271 }
272
273 endApparentTop = endY;
274 } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
275 float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
276
277 endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
278 endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
279 endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
280
281 float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
282 reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
283 endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
284 } else {
285 contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
286
287 int menuHeight = contextMenu.getHeight();
288 boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
289
290 if (fitsVertically) {
291 float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
292 boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
293
294 if (menuFitsBelowItem) {
295 reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
296
297 if (reactionBarBackgroundY < reactionBarTopPadding) {
298 endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
299 reactionBarBackgroundY = reactionBarTopPadding;
300 }
301 } else {
302 endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
303 reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
304 }
305 endApparentTop = endY;
306 } else {
307 float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
308
309 endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
310 endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
311 endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
312 reactionBarBackgroundY = reactionBarTopPadding;
313 endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
314 }
315 }
316 }
317
318 reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
319
320 hideAnimatorSet.end();
321 setVisibility(View.VISIBLE);
322
323 float scrubberX;
324 if (isMessageOnLeft) {
325 scrubberX = scrubberHorizontalMargin;
326 } else {
327 scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
328 }
329
330 foregroundView.setX(scrubberX);
331 foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
332
333 backgroundView.setX(scrubberX);
334 backgroundView.setY(reactionBarBackgroundY);
335
336 verticalScrubBoundary.update(reactionBarBackgroundY,
337 lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
338
339 updateBoundsOnLayoutChanged();
340
341 revealAnimatorSet.start();
342
343 if (isWideLayout) {
344 float scrubberRight = scrubberX + scrubberWidth;
345 float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
346 contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
347 } else {
348 float contentX = selectedConversationModel.getSnapshotMetrics().getContextMenuPadding();
349 float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
350
351 float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
352 contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
353 }
354
355 int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
356
357 conversationItem.animate()
358 .x(endX)
359 .y(endY)
360 .scaleX(endScale)
361 .scaleY(endScale)
362 .setDuration(revealDuration);
363 }
364
365 private float getReactionBarOffsetForTouch(@NonNull PointF touchPoint,
366 float contextMenuTop,
367 float contextMenuPadding,
368 float reactionBarOffset,
369 int reactionBarHeight,
370 float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
371 float messageTop)
372 {
373 float adjustedTouchY = touchPoint.y - statusBarHeight;
374 float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
375
376 float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
377
378 if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
379 float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
380 reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
381 }
382
383 return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
384 }
385
386 private void updateToolbarShade() {
387 LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams();
388 layoutParams.height = 0;
389 toolbarShade.setLayoutParams(layoutParams);
390 }
391
392 private void updateInputShade() {
393 LayoutParams layoutParams = (LayoutParams) inputShade.getLayoutParams();
394 layoutParams.height = 0;
395 inputShade.setLayoutParams(layoutParams);
396 }
397
398 /**
399 * Returns true when the device is in a configuration where the navigation bar doesn't take up
400 * space at the bottom of the screen.
401 */
402 private boolean zeroNavigationBarHeightForConfiguration() {
403 boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
404
405 if (Build.VERSION.SDK_INT >= 29) {
406 return getRootWindowInsets().getSystemGestureInsets().bottom == 0 && isLandscape;
407 } else {
408 return isLandscape;
409 }
410 }
411
412 private void updateSystemUiOnShow(@NonNull Activity activity) {
413 Window window = activity.getWindow();
414 int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);
415
416 originalStatusBarColor = window.getStatusBarColor();
417 WindowUtil.setStatusBarColor(window, barColor);
418
419 originalNavigationBarColor = window.getNavigationBarColor();
420 WindowUtil.setNavigationBarColor(activity, barColor);
421
422 if (!ThemeUtil.isDarkTheme(getContext())) {
423 WindowUtil.clearLightStatusBar(window);
424 WindowUtil.clearLightNavigationBar(window);
425 }
426 }
427
428 public void hide() {
429 hideInternal(onHideListener);
430 }
431
432 public void hideForReactWithAny() {
433 hideInternal(onHideListener);
434 }
435
436 private void hideInternal(@Nullable OnHideListener onHideListener) {
437 overlayState = OverlayState.HIDDEN;
438
439 AnimatorSet animatorSet = newHideAnimatorSet();
440 hideAnimatorSet = animatorSet;
441
442 revealAnimatorSet.end();
443 animatorSet.start();
444
445 if (onHideListener != null) {
446 onHideListener.startHide(selectedConversationModel.getFocusedView());
447 }
448
449 animatorSet.addListener(new AnimationCompleteListener() {
450 @Override public void onAnimationEnd(Animator animation) {
451 animatorSet.removeListener(this);
452
453 toolbarShade.setVisibility(INVISIBLE);
454 inputShade.setVisibility(INVISIBLE);
455
456 if (onHideListener != null) {
457 onHideListener.onHide();
458 }
459 }
460 });
461
462 if (contextMenu != null) {
463 contextMenu.dismiss();
464 }
465 }
466
467 public boolean isShowing() {
468 return overlayState != OverlayState.HIDDEN;
469 }
470
471 public @NonNull MessageRecord getMessageRecord() {
472 return messageRecord;
473 }
474
475 @Override
476 protected void onLayout(boolean changed, int l, int t, int r, int b) {
477 super.onLayout(changed, l, t, r, b);
478
479 updateBoundsOnLayoutChanged();
480 }
481
482 private void updateBoundsOnLayoutChanged() {
483 backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
484 emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
485 emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
486 emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
487 emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
488
489 segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
490 }
491
492 private int getStart(@NonNull Rect rect) {
493 if (ViewUtil.isLtr(this)) {
494 return rect.left;
495 } else {
496 return rect.right;
497 }
498 }
499
500 private int getEnd(@NonNull Rect rect) {
501 if (ViewUtil.isLtr(this)) {
502 return rect.right;
503 } else {
504 return rect.left;
505 }
506 }
507
508 public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
509 if (!isShowing()) {
510 throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
511 }
512
513 if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
514 return true;
515 }
516
517 if (overlayState == OverlayState.UNINITAILIZED) {
518 downIsOurs = false;
519
520 deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
521
522 overlayState = OverlayState.DEADZONE;
523 }
524
525 if (overlayState == OverlayState.DEADZONE) {
526 float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
527 float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
528
529 if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
530 overlayState = OverlayState.SCRUB;
531 } else {
532 if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
533 overlayState = OverlayState.TAP;
534
535 if (downIsOurs) {
536 handleUpEvent();
537 return true;
538 }
539 }
540
541 return MotionEvent.ACTION_MOVE == motionEvent.getAction();
542 }
543 }
544
545 switch (motionEvent.getAction()) {
546 case MotionEvent.ACTION_DOWN:
547 selected = getSelectedIndexViaDownEvent(motionEvent);
548
549 deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
550 overlayState = OverlayState.DEADZONE;
551 downIsOurs = true;
552 return true;
553 case MotionEvent.ACTION_MOVE:
554 selected = getSelectedIndexViaMoveEvent(motionEvent);
555 return true;
556 case MotionEvent.ACTION_UP:
557 handleUpEvent();
558 return downIsOurs;
559 case MotionEvent.ACTION_CANCEL:
560 hide();
561 return downIsOurs;
562 default:
563 return false;
564 }
565 }
566
567 private void setupSelectedEmoji() {
568 final List<String> emojis = SignalStore.emojiValues().getReactions();
569 final String oldEmoji = getOldEmoji(messageRecord);
570
571 if (oldEmoji == null) {
572 selectedView.setVisibility(View.GONE);
573 }
574
575 boolean foundSelected = false;
576
577 for (int i = 0; i < emojiViews.length; i++) {
578 final EmojiImageView view = emojiViews[i];
579
580 view.setScaleX(1.0f);
581 view.setScaleY(1.0f);
582 view.setTranslationY(0);
583
584 boolean isAtCustomIndex = i == customEmojiIndex;
585 boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && oldEmoji != null && EmojiUtil.isCanonicallyEqual(emojis.get(i), oldEmoji);
586 boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null;
587
588 if (!foundSelected &&
589 (isNotAtCustomIndexAndOldEmojiMatches || isAtCustomIndexAndOldEmojiExists))
590 {
591 foundSelected = true;
592 selectedView.setVisibility(View.VISIBLE);
593
594 ConstraintSet constraintSet = new ConstraintSet();
595 constraintSet.clone(foregroundView);
596 constraintSet.clear(selectedView.getId(), ConstraintSet.LEFT);
597 constraintSet.clear(selectedView.getId(), ConstraintSet.RIGHT);
598 constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT);
599 constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT);
600 constraintSet.applyTo(foregroundView);
601
602 if (isAtCustomIndex) {
603 view.setImageEmoji(oldEmoji);
604 view.setTag(oldEmoji);
605 } else {
606 view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(emojis.get(i)));
607 }
608 } else if (isAtCustomIndex) {
609 view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_any_emoji_32));
610 view.setTag(null);
611 } else {
612 view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(emojis.get(i)));
613 }
614 }
615 }
616
617 private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
618 return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
619 }
620
621 private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
622 return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
623 }
624
625 private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
626 int selected = -1;
627
628 if (backgroundView.getVisibility() != View.VISIBLE) {
629 return selected;
630 }
631
632 for (int i = 0; i < emojiViews.length; i++) {
633 final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
634 horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
635
636 if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
637 selected = i;
638 }
639 }
640
641 if (this.selected != -1 && this.selected != selected) {
642 shrinkView(emojiViews[this.selected]);
643 }
644
645 if (this.selected != selected && selected != -1) {
646 growView(emojiViews[selected]);
647 }
648
649 return selected;
650 }
651
652 private void growView(@NonNull View view) {
653 view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
654 view.animate()
655 .scaleY(1.5f)
656 .scaleX(1.5f)
657 .translationY(-selectedVerticalTranslation)
658 .setDuration(200)
659 .setInterpolator(INTERPOLATOR)
660 .start();
661 }
662
663 private void shrinkView(@NonNull View view) {
664 view.animate()
665 .scaleX(1.0f)
666 .scaleY(1.0f)
667 .translationY(0)
668 .setDuration(200)
669 .setInterpolator(INTERPOLATOR)
670 .start();
671 }
672
673 private void handleUpEvent() {
674 if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
675 if (selected == customEmojiIndex) {
676 onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
677 } else {
678 onReactionSelectedListener.onReactionSelected(messageRecord, SignalStore.emojiValues().getPreferredVariation(SignalStore.emojiValues().getReactions().get(selected)));
679 }
680 } else {
681 hide();
682 }
683 }
684
685 public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
686 this.onReactionSelectedListener = onReactionSelectedListener;
687 }
688
689 public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
690 this.onActionSelectedListener = onActionSelectedListener;
691 }
692
693 public void setOnHideListener(@Nullable OnHideListener onHideListener) {
694 this.onHideListener = onHideListener;
695 }
696
697 private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
698 return Stream.of(messageRecord.getReactions())
699 .filter(record -> record.getAuthor()
700 .serialize()
701 .equals(Recipient.self()
702 .getId()
703 .serialize()))
704 .findFirst()
705 .map(ReactionRecord::getEmoji)
706 .orElse(null);
707 }
708
709 private @NonNull List<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
710 MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup);
711
712 List<ActionItem> items = new ArrayList<>();
713
714 if (menuState.shouldShowReplyAction()) {
715 items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
716 }
717
718 if (menuState.shouldShowEditAction()) {
719 items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
720 }
721
722 if (menuState.shouldShowForwardAction()) {
723 items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
724 }
725
726 if (menuState.shouldShowResendAction()) {
727 items.add(new ActionItem(R.drawable.symbol_refresh_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
728 }
729
730 if (menuState.shouldShowSaveAttachmentAction()) {
731 items.add(new ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
732 }
733
734 if (menuState.shouldShowCopyAction()) {
735 items.add(new ActionItem(R.drawable.symbol_copy_android_24, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY)));
736 }
737
738 if (menuState.shouldShowPaymentDetails()) {
739 items.add(new ActionItem(R.drawable.symbol_payment_24, getResources().getString(R.string.conversation_selection__menu_payment_details), () -> handleActionItemClicked(Action.PAYMENT_DETAILS)));
740 }
741
742 items.add(new ActionItem(R.drawable.symbol_check_circle_24, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT)));
743
744 if (menuState.shouldShowDetailsAction()) {
745 items.add(new ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
746 }
747
748 backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
749 foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
750
751 items.add(new ActionItem(R.drawable.symbol_trash_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE)));
752
753 return items;
754 }
755
756 private void handleActionItemClicked(@NonNull Action action) {
757 hideInternal(new OnHideListener() {
758 @Override
759 public void startHide(@Nullable View focusedView) {
760 if (onHideListener != null) {
761 onHideListener.startHide(focusedView);
762 }
763 }
764
765 @Override
766 public void onHide() {
767 if (onHideListener != null) {
768 onHideListener.onHide();
769 }
770
771 if (onActionSelectedListener != null) {
772 onActionSelectedListener.onActionSelected(action);
773 }
774 }
775 });
776 }
777
778 private void initAnimators() {
779
780 int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
781 int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
782
783 List<Animator> reveals = Stream.of(emojiViews)
784 .mapIndexed((idx, v) -> {
785 Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
786 anim.setTarget(v);
787 anim.setStartDelay(idx * animationEmojiStartDelayFactor);
788 return anim;
789 })
790 .toList();
791
792 Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
793 backgroundRevealAnim.setTarget(backgroundView);
794 backgroundRevealAnim.setDuration(revealDuration);
795 backgroundRevealAnim.setStartDelay(revealOffset);
796 reveals.add(backgroundRevealAnim);
797
798 Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
799 selectedRevealAnim.setTarget(selectedView);
800 backgroundRevealAnim.setDuration(revealDuration);
801 backgroundRevealAnim.setStartDelay(revealOffset);
802 reveals.add(selectedRevealAnim);
803
804 revealAnimatorSet.setInterpolator(INTERPOLATOR);
805 revealAnimatorSet.playTogether(reveals);
806 }
807
808 private @NonNull AnimatorSet newHideAnimatorSet() {
809 AnimatorSet set = new AnimatorSet();
810
811 set.addListener(new AnimationCompleteListener() {
812 @Override
813 public void onAnimationEnd(Animator animation) {
814 setVisibility(View.GONE);
815 }
816 });
817 set.setInterpolator(INTERPOLATOR);
818
819 set.playTogether(newHideAnimators());
820
821 return set;
822 }
823
824 private @NonNull List<Animator> newHideAnimators() {
825 int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
826
827 List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
828 .mapIndexed((idx, v) -> {
829 Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
830 anim.setTarget(v);
831 return anim;
832 })
833 .toList());
834
835 Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
836 backgroundHideAnim.setTarget(backgroundView);
837 backgroundHideAnim.setDuration(duration);
838 animators.add(backgroundHideAnim);
839
840 Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
841 selectedHideAnim.setTarget(selectedView);
842 selectedHideAnim.setDuration(duration);
843 animators.add(selectedHideAnim);
844
845 ObjectAnimator itemScaleXAnim = new ObjectAnimator();
846 itemScaleXAnim.setProperty(View.SCALE_X);
847 itemScaleXAnim.setFloatValues(1f);
848 itemScaleXAnim.setTarget(conversationItem);
849 itemScaleXAnim.setDuration(duration);
850 animators.add(itemScaleXAnim);
851
852 ObjectAnimator itemScaleYAnim = new ObjectAnimator();
853 itemScaleYAnim.setProperty(View.SCALE_Y);
854 itemScaleYAnim.setFloatValues(1f);
855 itemScaleYAnim.setTarget(conversationItem);
856 itemScaleYAnim.setDuration(duration);
857 animators.add(itemScaleYAnim);
858
859 ObjectAnimator itemXAnim = new ObjectAnimator();
860 itemXAnim.setProperty(View.X);
861 itemXAnim.setFloatValues(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
862 itemXAnim.setTarget(conversationItem);
863 itemXAnim.setDuration(duration);
864 animators.add(itemXAnim);
865
866 ObjectAnimator itemYAnim = new ObjectAnimator();
867 itemYAnim.setProperty(View.Y);
868 itemYAnim.setFloatValues(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
869 itemYAnim.setTarget(conversationItem);
870 itemYAnim.setDuration(duration);
871 animators.add(itemYAnim);
872
873 if (activity != null) {
874 ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
875 statusBarAnim.setDuration(duration);
876 statusBarAnim.addUpdateListener(animation -> {
877 WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
878 });
879 animators.add(statusBarAnim);
880
881 ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
882 navigationBarAnim.setDuration(duration);
883 navigationBarAnim.addUpdateListener(animation -> {
884 WindowUtil.setNavigationBarColor(activity, (int) animation.getAnimatedValue());
885 });
886 animators.add(navigationBarAnim);
887 }
888
889 return animators;
890 }
891
892 public interface OnHideListener {
893 void startHide(@Nullable View focusedView);
894 void onHide();
895 }
896
897 public interface OnReactionSelectedListener {
898 void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
899 void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
900 }
901
902 public interface OnActionSelectedListener {
903 void onActionSelected(@NonNull Action action);
904 }
905
906 private static class Boundary {
907 private float min;
908 private float max;
909
910 Boundary() {}
911
912 Boundary(float min, float max) {
913 update(min, max);
914 }
915
916 private void update(float min, float max) {
917 this.min = min;
918 this.max = max;
919 }
920
921 public boolean contains(float value) {
922 if (min < max) {
923 return this.min < value && this.max > value;
924 } else {
925 return this.min > value && this.max < value;
926 }
927 }
928 }
929
930 private enum OverlayState {
931 HIDDEN,
932 UNINITAILIZED,
933 DEADZONE,
934 SCRUB,
935 TAP
936 }
937
938 public enum Action {
939 REPLY,
940 EDIT,
941 FORWARD,
942 RESEND,
943 DOWNLOAD,
944 COPY,
945 MULTISELECT,
946 PAYMENT_DETAILS,
947 VIEW_INFO,
948 DELETE,
949 }
950}