mirror of
https://github.com/oxen-io/session-android.git
synced 2023-12-14 02:53:01 +01:00
291 lines
12 KiB
Kotlin
291 lines
12 KiB
Kotlin
|
package org.thoughtcrime.securesms.conversation.v2.messages
|
||
|
|
||
|
import android.content.Context
|
||
|
import android.os.Handler
|
||
|
import android.os.Looper
|
||
|
import android.util.AttributeSet
|
||
|
import android.view.*
|
||
|
import android.view.View.OnTouchListener
|
||
|
import android.widget.LinearLayout
|
||
|
import android.widget.TextView
|
||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||
|
import androidx.core.content.ContextCompat
|
||
|
import com.google.android.flexbox.JustifyContent
|
||
|
import network.loki.messenger.R
|
||
|
import network.loki.messenger.databinding.ViewEmojiReactionsBinding
|
||
|
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.EmojiUtil
|
||
|
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
|
||
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||
|
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||
|
import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber
|
||
|
import java.util.*
|
||
|
|
||
|
class EmojiReactionsView : ConstraintLayout, OnTouchListener {
|
||
|
companion object {
|
||
|
private const val DEFAULT_THRESHOLD = 5
|
||
|
private const val longPressDurationThreshold: Long = 250
|
||
|
private const val maxDoubleTapInterval: Long = 200
|
||
|
}
|
||
|
|
||
|
private val binding: ViewEmojiReactionsBinding by lazy { ViewEmojiReactionsBinding.bind(this) }
|
||
|
|
||
|
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
|
||
|
private val OUTER_MARGIN = ViewUtil.dpToPx(2)
|
||
|
private var records: MutableList<ReactionRecord>? = null
|
||
|
private var messageId: Long = 0
|
||
|
private var delegate: VisibleMessageViewDelegate? = null
|
||
|
private val gestureHandler = Handler(Looper.getMainLooper())
|
||
|
private var pressCallback: Runnable? = null
|
||
|
private var longPressCallback: Runnable? = null
|
||
|
private var onDownTimestamp: Long = 0
|
||
|
private var extended = false
|
||
|
|
||
|
constructor(context: Context) : super(context) { init(null) }
|
||
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) }
|
||
|
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) }
|
||
|
|
||
|
private fun init(attrs: AttributeSet?) {
|
||
|
records = ArrayList()
|
||
|
|
||
|
if (attrs != null) {
|
||
|
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0)
|
||
|
typedArray.recycle()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fun clear() {
|
||
|
records!!.clear()
|
||
|
binding.layoutEmojiContainer.removeAllViews()
|
||
|
}
|
||
|
|
||
|
fun setReactions(messageId: Long, records: List<ReactionRecord>, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) {
|
||
|
this.delegate = delegate
|
||
|
if (records == this.records) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
binding.layoutEmojiContainer.justifyContent = if (outgoing) JustifyContent.FLEX_END else JustifyContent.FLEX_START
|
||
|
this.records!!.clear()
|
||
|
this.records!!.addAll(records)
|
||
|
if (this.messageId != messageId) {
|
||
|
extended = false
|
||
|
}
|
||
|
this.messageId = messageId
|
||
|
displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD)
|
||
|
}
|
||
|
|
||
|
override fun onTouch(v: View, event: MotionEvent): Boolean {
|
||
|
if (v.tag == null) return false
|
||
|
val reaction = v.tag as Reaction
|
||
|
val action = event.action
|
||
|
if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction)
|
||
|
return true
|
||
|
}
|
||
|
|
||
|
private fun displayReactions(threshold: Int) {
|
||
|
val userPublicKey = getLocalNumber(context)
|
||
|
val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold)
|
||
|
binding.layoutEmojiContainer.removeAllViews()
|
||
|
val overflowContainer = LinearLayout(context)
|
||
|
overflowContainer.orientation = LinearLayout.HORIZONTAL
|
||
|
val innerPadding = ViewUtil.dpToPx(4)
|
||
|
overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding)
|
||
|
val pixelSize = ViewUtil.dpToPx(1)
|
||
|
for (reaction in reactions) {
|
||
|
if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) {
|
||
|
if (overflowContainer.parent == null) {
|
||
|
binding.layoutEmojiContainer.addView(overflowContainer)
|
||
|
val overflowParams = overflowContainer.layoutParams as MarginLayoutParams
|
||
|
overflowParams.height = ViewUtil.dpToPx(26)
|
||
|
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
|
||
|
overflowContainer.layoutParams = overflowParams
|
||
|
overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
|
||
|
}
|
||
|
val pill = buildPill(context, this, reaction, true)
|
||
|
pill.setOnClickListener { v: View? ->
|
||
|
extended = true
|
||
|
displayReactions(Int.MAX_VALUE)
|
||
|
}
|
||
|
pill.findViewById<View>(R.id.reactions_pill_count).visibility = GONE
|
||
|
pill.findViewById<View>(R.id.reactions_pill_spacer).visibility = GONE
|
||
|
overflowContainer.addView(pill)
|
||
|
} else {
|
||
|
val pill = buildPill(context, this, reaction, false)
|
||
|
pill.tag = reaction
|
||
|
pill.setOnTouchListener(this)
|
||
|
val params = pill.layoutParams as MarginLayoutParams
|
||
|
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize)
|
||
|
pill.layoutParams = params
|
||
|
binding.layoutEmojiContainer.addView(pill)
|
||
|
}
|
||
|
}
|
||
|
val overflowChildren = overflowContainer.childCount
|
||
|
val negativeMargin = ViewUtil.dpToPx(-8)
|
||
|
for (i in 0 until overflowChildren) {
|
||
|
val child = overflowContainer.getChildAt(i)
|
||
|
val childParams = child.layoutParams as MarginLayoutParams
|
||
|
if (i == 0 && overflowChildren > 1 || i + 1 < overflowChildren) {
|
||
|
// if first and there is more than one child, or we are not the last child then set negative right margin
|
||
|
childParams.setMargins(0, 0, negativeMargin, 0)
|
||
|
child.layoutParams = childParams
|
||
|
}
|
||
|
}
|
||
|
if (threshold == Int.MAX_VALUE) {
|
||
|
binding.groupShowLess.visibility = VISIBLE
|
||
|
for (id in binding.groupShowLess.referencedIds) {
|
||
|
findViewById<View>(id).setOnClickListener { view: View? ->
|
||
|
extended = false
|
||
|
displayReactions(DEFAULT_THRESHOLD)
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
binding.groupShowLess.visibility = GONE
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun buildSortedReactionsList(records: List<ReactionRecord>, userPublicKey: String?, threshold: Int): List<Reaction> {
|
||
|
val counters: MutableMap<String, Reaction> = LinkedHashMap()
|
||
|
|
||
|
records.forEach {
|
||
|
val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji)
|
||
|
val info = counters[baseEmoji]
|
||
|
|
||
|
if (info == null) {
|
||
|
counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author)
|
||
|
}
|
||
|
else {
|
||
|
info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
val reactions: List<Reaction> = ArrayList(counters.values)
|
||
|
Collections.sort(reactions, Collections.reverseOrder())
|
||
|
|
||
|
return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) {
|
||
|
val shortened: MutableList<Reaction> = ArrayList(threshold + 2)
|
||
|
shortened.addAll(reactions.subList(0, threshold + 2))
|
||
|
shortened
|
||
|
} else {
|
||
|
reactions
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun buildPill(context: Context, parent: ViewGroup, reaction: Reaction, isCompact: Boolean): View {
|
||
|
val root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false)
|
||
|
val emojiView = root.findViewById<EmojiImageView>(R.id.reactions_pill_emoji)
|
||
|
val countView = root.findViewById<TextView>(R.id.reactions_pill_count)
|
||
|
val spacer = root.findViewById<View>(R.id.reactions_pill_spacer)
|
||
|
if (isCompact) {
|
||
|
root.setPaddingRelative(1, 1, 1, 1)
|
||
|
val layoutParams = root.layoutParams
|
||
|
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||
|
root.layoutParams = layoutParams
|
||
|
}
|
||
|
if (reaction.emoji != null) {
|
||
|
emojiView.setImageEmoji(reaction.emoji)
|
||
|
if (reaction.count >= 1) {
|
||
|
countView.text = getFormattedNumber(reaction.count)
|
||
|
} else {
|
||
|
countView.visibility = GONE
|
||
|
spacer.visibility = GONE
|
||
|
}
|
||
|
} else {
|
||
|
emojiView.visibility = GONE
|
||
|
spacer.visibility = GONE
|
||
|
countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count)
|
||
|
}
|
||
|
if (reaction.userWasSender && !isCompact) {
|
||
|
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)
|
||
|
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor))
|
||
|
} else {
|
||
|
if (!isCompact) {
|
||
|
root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)
|
||
|
}
|
||
|
}
|
||
|
return root
|
||
|
}
|
||
|
|
||
|
private fun onReactionClicked(reaction: Reaction) {
|
||
|
if (reaction.messageId != 0L) {
|
||
|
val messageId = MessageId(reaction.messageId, reaction.isMms)
|
||
|
delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun onDown(messageId: MessageId) {
|
||
|
removeLongPressCallback()
|
||
|
val newLongPressCallback = Runnable {
|
||
|
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||
|
if (delegate != null) {
|
||
|
delegate!!.onReactionLongClicked(messageId)
|
||
|
}
|
||
|
}
|
||
|
longPressCallback = newLongPressCallback
|
||
|
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold)
|
||
|
onDownTimestamp = Date().time
|
||
|
}
|
||
|
|
||
|
private fun removeLongPressCallback() {
|
||
|
if (longPressCallback != null) {
|
||
|
gestureHandler.removeCallbacks(longPressCallback!!)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun onUp(reaction: Reaction) {
|
||
|
if (Date().time - onDownTimestamp < longPressDurationThreshold) {
|
||
|
removeLongPressCallback()
|
||
|
if (pressCallback != null) {
|
||
|
gestureHandler.removeCallbacks(pressCallback!!)
|
||
|
pressCallback = null
|
||
|
} else {
|
||
|
val newPressCallback = Runnable {
|
||
|
onReactionClicked(reaction)
|
||
|
pressCallback = null
|
||
|
}
|
||
|
pressCallback = newPressCallback
|
||
|
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal class Reaction(
|
||
|
internal val messageId: Long,
|
||
|
internal val isMms: Boolean,
|
||
|
internal var emoji: String?,
|
||
|
internal var count: Long,
|
||
|
internal val sortIndex: Long,
|
||
|
internal var lastSeen: Long,
|
||
|
internal var userWasSender: Boolean
|
||
|
) : Comparable<Reaction?> {
|
||
|
fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) {
|
||
|
if (!this.userWasSender) {
|
||
|
if (userWasSender || lastSeen > this.lastSeen) {
|
||
|
this.emoji = emoji
|
||
|
}
|
||
|
}
|
||
|
this.count = this.count + count
|
||
|
this.lastSeen = Math.max(this.lastSeen, lastSeen)
|
||
|
this.userWasSender = this.userWasSender || userWasSender
|
||
|
}
|
||
|
|
||
|
fun merge(other: Reaction): Reaction {
|
||
|
count = count + other.count
|
||
|
lastSeen = Math.max(lastSeen, other.lastSeen)
|
||
|
userWasSender = userWasSender || other.userWasSender
|
||
|
return this
|
||
|
}
|
||
|
|
||
|
override fun compareTo(other: Reaction?): Int {
|
||
|
if (other == null) { return -1 }
|
||
|
|
||
|
if (this.count == other.count) {
|
||
|
return this.sortIndex.compareTo(other.sortIndex)
|
||
|
}
|
||
|
|
||
|
return this.count.compareTo(other.count)
|
||
|
}
|
||
|
}
|
||
|
}
|