You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-android/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt

720 lines
35 KiB
Kotlin

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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
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.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
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.repository.ConversationRepository
import org.thoughtcrime.securesms.util.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@AndroidEntryPoint
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()
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var repository: ConversationRepository
private val scope = CoroutineScope(Dispatchers.Default)
private var job: Job? = null
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?) {
job?.cancel()
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) }
job = scope.launch(Dispatchers.IO) {
repository.changes(messageRecord.threadId)
.filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null }
.collect { withContext(Dispatchers.Main) { hide() } }
}
}
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?) {
job?.cancel()
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, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
// Reply
val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
}
// Copy message text
if (!containsControlMessage && hasText) {
items += ActionItem(R.attr.menu_copy_icon, 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, 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, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive)
}
// Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_block_icon, 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, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
}
// Message detail
items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) })
// Resend
if (message.isFailed) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) })
}
// Resync
if (message.isSyncFailed) {
items += ActionItem(R.attr.menu_reply_icon, 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, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, 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().apply {
addListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) {
visibility = GONE
}
})
interpolator = INTERPOLATOR
playTogether(newHideAnimators())
}
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: Float = 0f, private var max: Float = 0f) {
fun update(min: Float, max: Float) {
this.min = min
this.max = max
}
operator fun contains(value: Float) = 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()
}
}
private fun Duration.to2partString(): String? =
toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) }
.filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ")
private val MessageRecord.subtitle: ((Context) -> CharSequence?)?
get() = if (expiresIn <= 0) {
null
} else { context ->
(expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp)))
.coerceAtLeast(0L)
.milliseconds
.to2partString()
?.let { context.getString(R.string.auto_deletes_in, it) }
}