Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import org.fossify.messages.extensions.updateScheduledMessagesThreadId
import org.fossify.messages.helpers.CAPTURE_AUDIO_INTENT
import org.fossify.messages.helpers.CAPTURE_PHOTO_INTENT
import org.fossify.messages.helpers.CAPTURE_VIDEO_INTENT
import org.fossify.messages.helpers.EmojiReactionHelper
import org.fossify.messages.helpers.FILE_SIZE_NONE
import org.fossify.messages.helpers.IS_LAUNCHED_FROM_SHORTCUT
import org.fossify.messages.helpers.IS_RECYCLE_BIN
Expand Down Expand Up @@ -447,6 +448,7 @@ class ThreadActivity : SimpleActivity() {
messages.removeAll { it.isScheduled && it.millis() < System.currentTimeMillis() }

messages.sortBy { it.date }
messages = EmojiReactionHelper.applyEmojiReactions(messages)
if (messages.size > MESSAGES_LIMIT) {
messages = ArrayList(messages.takeLast(MESSAGES_LIMIT))
}
Expand Down Expand Up @@ -578,7 +580,8 @@ class ThreadActivity : SimpleActivity() {
toRecycleBin,
fromRecycleBin
)
}
},
bottomBarColor = getBottomBarColor(),
)

binding.threadMessagesList.adapter = currAdapter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.util.TypedValue
import android.view.Menu
import android.view.View
Expand Down Expand Up @@ -61,6 +62,7 @@ import org.fossify.messages.databinding.ItemThreadSendingBinding
import org.fossify.messages.databinding.ItemThreadSuccessBinding
import org.fossify.messages.dialogs.DeleteConfirmationDialog
import org.fossify.messages.dialogs.MessageDetailsDialog
import org.fossify.messages.dialogs.ReactionDetailsDialog
import org.fossify.messages.dialogs.SelectTextDialog
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.getContactFromAddress
Expand Down Expand Up @@ -94,13 +96,17 @@ class ThreadAdapter(
recyclerView: MyRecyclerView,
itemClick: (Any) -> Unit,
val isRecycleBin: Boolean,
val deleteMessages: (messages: List<Message>, toRecycleBin: Boolean, fromRecycleBin: Boolean) -> Unit
val deleteMessages: (messages: List<Message>, toRecycleBin: Boolean, fromRecycleBin: Boolean) -> Unit,
val bottomBarColor: Int,
) : MyRecyclerViewListAdapter<ThreadItem>(activity, recyclerView, ThreadItemDiffCallback(), itemClick) {
private var fontSize = activity.getTextSize()

@SuppressLint("MissingPermission")
private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1
private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt()
private val reactionHorizontalOverlap = 8.dpToPx()
private val reactionVerticalOverlap = 4.dpToPx()
private val reactionElevation = 1.dpToPx()

companion object {
private const val MAX_MEDIA_HEIGHT_RATIO = 3
Expand All @@ -111,6 +117,7 @@ class ThreadAdapter(
init {
setupDragListener(true)
setHasStableIds(true)
recyclerView.clipChildren = false
(recyclerView.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
}

Expand Down Expand Up @@ -385,6 +392,7 @@ class ThreadAdapter(
} else {
setupSentMessageView(messageBinding = this, message = message)
}
setupEmojiReactions(messageBinding = this, message = message)

if (message.attachment?.attachments?.isNotEmpty() == true) {
threadMessageAttachmentsHolder.beVisible()
Expand All @@ -406,6 +414,73 @@ class ThreadAdapter(
}
}

private fun setupEmojiReactions(messageBinding: ItemMessageBinding, message: Message) {
val reactions = message.emojiReactions
messageBinding.threadMessageReactions.apply {
beVisibleIf(reactions.isNotEmpty())
if (reactions.isEmpty()) {
text = ""
setOnLongClickListener(null)
return
}

val uniqueEmojis = reactions.map { it.emoji }.distinct()
text = if (reactions.size == 1) {
uniqueEmojis.first()
} else {
"${uniqueEmojis.joinToString("")}\u00A0${reactions.size}"
}
setOnLongClickListener {
showReactionDetails(message)
true
}

if (message.isReceivedMessage()) {
background = createReactionBackground()
elevation = reactionElevation
translationX = reactionHorizontalOverlap
translationY = -reactionVerticalOverlap
setTextColor(textColor)
updateLayoutParams<RelativeLayout.LayoutParams> {
removeRule(RelativeLayout.END_OF)
removeRule(RelativeLayout.ALIGN_PARENT_END)
removeRule(RelativeLayout.ALIGN_END)
removeRule(RelativeLayout.ALIGN_RIGHT)
removeRule(RelativeLayout.ALIGN_START)
removeRule(RelativeLayout.ALIGN_LEFT)
addRule(RelativeLayout.ALIGN_END, messageBinding.threadMessageBody.id)
}
} else {
background = createReactionBackground()
elevation = reactionElevation
translationX = -reactionHorizontalOverlap
translationY = -reactionVerticalOverlap
setTextColor(textColor)
updateLayoutParams<RelativeLayout.LayoutParams> {
removeRule(RelativeLayout.END_OF)
removeRule(RelativeLayout.ALIGN_PARENT_END)
removeRule(RelativeLayout.ALIGN_END)
removeRule(RelativeLayout.ALIGN_RIGHT)
removeRule(RelativeLayout.ALIGN_LEFT)
addRule(RelativeLayout.ALIGN_START, messageBinding.threadMessageBody.id)
}
}
}
}

private fun showReactionDetails(message: Message) {
val rows = message.emojiReactions.map { reaction ->
val contactName = message.participants
.firstOrNull { participant -> participant.doesHavePhoneNumber(reaction.senderPhoneNumber) }
?.name
?.takeIf { it.isNotBlank() }
?: reaction.senderPhoneNumber

"${reaction.emoji} $contactName"
}
ReactionDetailsDialog(activity, rows)
}

private fun setupReceivedMessageView(messageBinding: ItemMessageBinding, message: Message) {
messageBinding.apply {
with(ConstraintSet()) {
Expand Down Expand Up @@ -635,6 +710,18 @@ class ThreadAdapter(
}

inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root)

private fun Int.dpToPx(): Float {
return this * resources.displayMetrics.density
}

private fun createReactionBackground(): GradientDrawable {
return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 18.dpToPx()
setColor(bottomBarColor)
}
}
}

private class ThreadItemDiffCallback : DiffUtil.ItemCallback<ThreadItem>() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.fossify.messages.dialogs

import android.view.ViewGroup
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.getProperTextColor
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.commons.views.MyTextView
import org.fossify.messages.R
import org.fossify.messages.databinding.DialogReactionDetailsBinding

class ReactionDetailsDialog(
private val activity: BaseSimpleActivity,
reactions: List<String>,
) {
init {
val binding = DialogReactionDetailsBinding.inflate(activity.layoutInflater).apply {
val rowPadding = 8.dpToPx()
reactions.forEach { reaction ->
dialogReactionDetailsHolder.addView(
MyTextView(activity).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
text = reaction
setTextColor(activity.getProperTextColor())
textSize = REACTION_TEXT_SIZE
setPadding(0, rowPadding, 0, rowPadding)
}
)
}
}

activity.getAlertDialogBuilder().apply {
activity.setupDialogStuff(binding.root, this, R.string.reactions)
}
}

private fun Int.dpToPx(): Int {
return (this * activity.resources.displayMetrics.density).toInt()
}

private companion object {
const val REACTION_TEXT_SIZE = 22f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import org.fossify.messages.R
import org.fossify.messages.databases.MessagesDatabase
import org.fossify.messages.helpers.AttachmentUtils.parseAttachmentNames
import org.fossify.messages.helpers.Config
import org.fossify.messages.helpers.EmojiReactionHelper
import org.fossify.messages.helpers.FILE_SIZE_NONE
import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH
import org.fossify.messages.helpers.MESSAGES_LIMIT
Expand Down Expand Up @@ -205,7 +206,7 @@ fun Context.getMessages(
}
}

messages = messages
messages = EmojiReactionHelper.applyEmojiReactions(messages)
.filter { it.participants.isNotEmpty() }
.filterNot { it.isScheduled && it.millis() < System.currentTimeMillis() }
.sortedWith(compareBy<Message> { it.date }.thenBy { it.id })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
@file:Suppress("MaxLineLength")

package org.fossify.messages.helpers

import org.fossify.messages.models.EmojiReaction
import org.fossify.messages.models.Message

data class ParsedEmojiReaction(
val emoji: String,
val originalMessage: String,
val isRemoval: Boolean = false,
)

object EmojiReactionHelper {
private val reactionPatterns: LinkedHashMap<Regex, (MatchResult) -> ParsedEmojiReaction?> = linkedMapOf(
Regex(
"(?s)^\u200a[^\u200b\u200a]*\u200b([^\u200b]*)\u200b[^\u200b\u200a]*\u200a(.*)\u200a[^\u200b\u200a]*\u200a\\Z"
) to { match ->
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2])
}
)

private val removalPatterns: LinkedHashMap<Regex, (MatchResult) -> ParsedEmojiReaction?> = linkedMapOf(
Regex(
"(?s)^\u200a[^\u200c\u200a]*\u200c([^\u200c]*)\u200c[^\u200c\u200a]*\u200a(.*)\u200a[^\u200c\u200a]*\u200a\\Z"
) to { match ->
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2], isRemoval = true)
}
)

init {
addAppleTapbackPattern("❤️", "Loved", "Removed a heart from")
addAppleTapbackPattern("👍", "Liked", "Removed a like from")
addAppleTapbackPattern("👎", "Disliked", "Removed a dislike from")
addAppleTapbackPattern("😂", "Laughed at", "Removed a laugh from")
addAppleTapbackPattern("‼️", "Emphasized", "Removed an exclamation from")
addAppleTapbackPattern("❓", "Questioned", "Removed a question mark from")

reactionPatterns[Regex("""(?s)^Reacted (.+?) to ["“](.+?)["”]$""")] = { match ->
if (match.groupValues.getOrNull(1) == "with a sticker") {
null
} else {
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2])
}
}
removalPatterns[Regex("""(?s)^Removed (.+?) from ["“](.+?)["”]$""")] = { match ->
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2], isRemoval = true)
}
}

fun parseEmojiReaction(body: String): ParsedEmojiReaction? {
parseRemoval(body)?.let { return it }

return reactionPatterns.firstNotNullOfOrNull { (pattern, parser) ->
pattern.find(body)?.let { match -> parser(match) }
}
}

fun applyEmojiReactions(messages: List<Message>): ArrayList<Message> {
messages.forEach { message ->
message.isEmojiReaction = false
message.emojiReactions = emptyList()
}

val orderedMessages = messages.sortedWith(compareBy<Message> { it.date }.thenBy { it.id })
orderedMessages.forEach { reactionMessage ->
val parsedReaction = parseEmojiReaction(reactionMessage.body) ?: return@forEach
val targetMessage = findTargetMessage(
messages = orderedMessages,
reactionMessage = reactionMessage,
originalMessageText = parsedReaction.originalMessage,
) ?: return@forEach

if (parsedReaction.isRemoval) {
removeEmojiReaction(reactionMessage, parsedReaction, targetMessage)
} else {
saveEmojiReaction(reactionMessage, parsedReaction, targetMessage)
}
}

return orderedMessages
.filterNot { it.isEmojiReaction }
.toCollection(ArrayList())
}

private fun addAppleTapbackPattern(emoji: String, addedPrefix: String, removedPrefix: String) {
reactionPatterns[Regex("""(?s)^$addedPrefix ["“](.+?)["”]$""")] = { match ->
ParsedEmojiReaction(emoji, match.groupValues[1])
}
removalPatterns[Regex("""(?s)^$removedPrefix ["“](.+?)["”]$""")] = { match ->
ParsedEmojiReaction(emoji, match.groupValues[1], isRemoval = true)
}
}

private fun parseRemoval(body: String): ParsedEmojiReaction? {
return removalPatterns.firstNotNullOfOrNull { (pattern, parser) ->
pattern.find(body)?.let { match -> parser(match) }
}
}

private fun findTargetMessage(
messages: List<Message>,
reactionMessage: Message,
originalMessageText: String,
): Message? {
val originalMessageRegex = parseTruncatedMessage(originalMessageText)
return messages
.asReversed()
.firstOrNull { candidate ->
candidate.threadId == reactionMessage.threadId &&
candidate.id != reactionMessage.id &&
candidate.date <= reactionMessage.date &&
!candidate.isEmojiReaction &&
originalMessageRegex.matches(candidate.body.trim())
}
}

private fun parseTruncatedMessage(originalMessageText: String): Regex {
val reactionText = originalMessageText.trim()
val delimiter = "\u2026"
val index = reactionText.lastIndexOf(delimiter)
val regexPattern = if (index == -1) {
Regex.escape(reactionText)
} else {
val before = reactionText.take(index)
Regex.escape(before) + ".*"
}
return Regex("^$regexPattern$", RegexOption.DOT_MATCHES_ALL)
}

private fun saveEmojiReaction(
reactionMessage: Message,
parsedReaction: ParsedEmojiReaction,
targetMessage: Message,
) {
val reaction = EmojiReaction(
reactionMessageId = reactionMessage.id,
senderPhoneNumber = reactionMessage.senderPhoneNumber,
emoji = parsedReaction.emoji,
originalMessageText = parsedReaction.originalMessage,
)
targetMessage.emojiReactions = targetMessage.emojiReactions
.filterNot { it.senderPhoneNumber == reaction.senderPhoneNumber } + reaction
reactionMessage.isEmojiReaction = true
}

private fun removeEmojiReaction(
reactionMessage: Message,
parsedReaction: ParsedEmojiReaction,
targetMessage: Message,
) {
targetMessage.emojiReactions = targetMessage.emojiReactions.filterNot { reaction ->
reaction.senderPhoneNumber == reactionMessage.senderPhoneNumber &&
reaction.emoji == parsedReaction.emoji
}
reactionMessage.isEmojiReaction = true
}
}
Loading
Loading