From 484ed01607539cc293332ec97c91019743db1d08 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Sat, 10 Oct 2026 01:51:28 +0530 Subject: [PATCH 1/2] feat: improve message bubbles and group sender labels Refs: https://github.com/FossifyOrg/Messages/issues/81, https://github.com/FossifyOrg/Messages/issues/215 --- CHANGELOG.md | 8 + .../messages/activities/ThreadActivity.kt | 12 +- .../messages/adapters/ThreadAdapter.kt | 298 +++++++++++++----- .../org/fossify/messages/models/Message.kt | 2 +- .../fossify/messages/models/ThreadItems.kt | 31 +- app/src/main/res/layout/item_message.xml | 94 ++++-- app/src/main/res/values/dimens.xml | 1 + 7 files changed, 327 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d55bb7f1..a30f14b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added sender names in group conversations ([#81]) + +### Changed +- Removed avatars from one-to-one conversations ([#215]) +- Reduced avatar sizes in group conversations ([#215]) ## [1.10.1] - 2026-10-10 ### Changed @@ -251,6 +257,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#52]: https://github.com/FossifyOrg/Messages/issues/52 [#70]: https://github.com/FossifyOrg/Messages/issues/70 [#75]: https://github.com/FossifyOrg/Messages/issues/75 +[#81]: https://github.com/FossifyOrg/Messages/issues/81 [#82]: https://github.com/FossifyOrg/Messages/issues/82 [#99]: https://github.com/FossifyOrg/Messages/issues/99 [#115]: https://github.com/FossifyOrg/Messages/issues/115 @@ -262,6 +269,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#180]: https://github.com/FossifyOrg/Messages/issues/180 [#185]: https://github.com/FossifyOrg/Messages/issues/185 [#209]: https://github.com/FossifyOrg/Messages/issues/209 +[#215]: https://github.com/FossifyOrg/Messages/issues/215 [#217]: https://github.com/FossifyOrg/Messages/issues/217 [#225]: https://github.com/FossifyOrg/Messages/issues/225 [#234]: https://github.com/FossifyOrg/Messages/issues/234 diff --git a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt index 371896e4d..54641228e 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt @@ -194,8 +194,10 @@ import org.fossify.messages.models.SIMCard import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem.ThreadDateTime import org.fossify.messages.models.ThreadItem.ThreadError +import org.fossify.messages.models.ThreadItem.ThreadMessage import org.fossify.messages.models.ThreadItem.ThreadSending import org.fossify.messages.models.ThreadItem.ThreadSent +import org.fossify.messages.models.withSenderGrouping import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -703,7 +705,7 @@ class ThreadActivity : SimpleActivity() { fromRecycleBin: Boolean, ) { val messagesToRemoveSet = messagesToRemove.toSet() - val deletePosition = threadItems.indexOf(messagesToRemove.first()) + val deletePosition = threadItems.indexOfFirst { (it as? ThreadMessage)?.message == messagesToRemove.first() } messages = messages.toSortedMessages() .filterNot { it in messagesToRemoveSet } val latestThreadItems = getThreadItems() @@ -753,7 +755,7 @@ class ThreadActivity : SimpleActivity() { private fun jumpToMessage(messageId: Long) { if (messages.any { it.id == messageId }) { - val index = threadItems.indexOfFirst { (it as? Message)?.id == messageId } + val index = threadItems.indexOfFirst { (it as? ThreadMessage)?.message?.id == messageId } if (index != -1) binding.threadMessagesList.smoothScrollToPosition(index) return } @@ -780,7 +782,7 @@ class ThreadActivity : SimpleActivity() { runOnUiThread { threadItems = latestThreadItems loadingOlderMessages = false - val index = latestThreadItems.indexOfFirst { (it as? Message)?.id == messageId } + val index = latestThreadItems.indexOfFirst { (it as? ThreadMessage)?.message?.id == messageId } getOrCreateThreadAdapter().updateMessages( newMessages = latestThreadItems, scrollPosition = index, smoothScroll = true ) @@ -1358,7 +1360,7 @@ class ThreadActivity : SimpleActivity() { items.add(ThreadDateTime(message.date, simCardID)) prevDateTime = message.date } - items.add(message) + items.add(ThreadMessage(message)) if (message.type == Telephony.Sms.MESSAGE_TYPE_FAILED) { items.add(ThreadError(message.id, message.body)) @@ -1389,7 +1391,7 @@ class ThreadActivity : SimpleActivity() { bus?.post(Events.RefreshConversations()) } - return items + return items.withSenderGrouping(participants.getAddresses().distinct().size > 1) } private fun launchActivityForResult( diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt index f7634bc06..3778ba9a4 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -3,10 +3,14 @@ package org.fossify.messages.adapters import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color +import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.Drawable import android.util.TypedValue +import android.view.Gravity import android.view.Menu +import android.view.MotionEvent +import android.view.TouchDelegate import android.view.View import android.view.ViewGroup import android.widget.LinearLayout @@ -14,7 +18,9 @@ import android.widget.RelativeLayout import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintSet import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.SimpleItemAnimator import androidx.viewbinding.ViewBinding @@ -27,11 +33,12 @@ import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target +import com.google.android.material.imageview.ShapeableImageView +import org.fossify.commons.R as CommonsR import org.fossify.commons.adapters.MyRecyclerViewListAdapter import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.beGone -import org.fossify.commons.extensions.beVisible import org.fossify.commons.extensions.beVisibleIf import org.fossify.commons.extensions.copyToClipboard import org.fossify.commons.extensions.formatDateOrTime @@ -51,9 +58,9 @@ import org.fossify.messages.activities.NewConversationActivity import org.fossify.messages.activities.SimpleActivity import org.fossify.messages.activities.ThreadActivity import org.fossify.messages.activities.VCardViewerActivity +import org.fossify.messages.databinding.ItemAttachmentAudioBinding import org.fossify.messages.databinding.ItemAttachmentDocumentBinding import org.fossify.messages.databinding.ItemAttachmentImageBinding -import org.fossify.messages.databinding.ItemAttachmentAudioBinding import org.fossify.messages.databinding.ItemAttachmentVcardBinding import org.fossify.messages.databinding.ItemMessageBinding import org.fossify.messages.databinding.ItemThreadDateTimeBinding @@ -88,6 +95,7 @@ import org.fossify.messages.models.Message import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem.ThreadDateTime import org.fossify.messages.models.ThreadItem.ThreadError +import org.fossify.messages.models.ThreadItem.ThreadMessage import org.fossify.messages.models.ThreadItem.ThreadSending import org.fossify.messages.models.ThreadItem.ThreadSent import org.joda.time.DateTime @@ -98,14 +106,17 @@ class ThreadAdapter( itemClick: (Any) -> Unit, val isRecycleBin: Boolean, val deleteMessages: (messages: List, toRecycleBin: Boolean, fromRecycleBin: Boolean) -> Unit -) : MyRecyclerViewListAdapter(activity, recyclerView, ThreadItemDiffCallback(), itemClick) { +) : MyRecyclerViewListAdapter(activity, recyclerView, ThreadItemDiffCallback(), { item -> + itemClick((item as? ThreadMessage)?.message ?: item) +}) { private var fontSize = activity.getTextSize() + private val maxMediaWidth get() = (activity.usableScreenSize.x * MAX_MEDIA_WIDTH_RATIO).toInt() @SuppressLint("MissingPermission") private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1 - private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt() companion object { + private const val MAX_MEDIA_WIDTH_RATIO = 0.9f private const val MAX_MEDIA_HEIGHT_RATIO = 3 private const val SIM_BITS = 21 private const val SIM_MASK = (1L shl SIM_BITS) - 1 @@ -121,10 +132,10 @@ class ThreadAdapter( override fun prepareActionMode(menu: Menu) { val isOneItemSelected = isOneItemSelected() - val selectedMessages = getSelectedItems().filterIsInstance() + val selectedMessages = getSelectedItems() val hasText = selectedMessages.any { it.body.isNotEmpty() } val showSaveAs = getSelectedItems().all { - it is Message && (it.attachment?.attachments?.size ?: 0) > 0 + (it.attachment?.attachments?.size ?: 0) > 0 } && getSelectedAttachments().isNotEmpty() menu.apply { @@ -156,16 +167,16 @@ class ThreadAdapter( } } - override fun getSelectableItemCount() = currentList.filterIsInstance().size + override fun getSelectableItemCount() = currentList.filterIsInstance().size override fun getIsItemSelectable(position: Int) = !isThreadDateTime(position) override fun getItemSelectionKey(position: Int): Int? { - return (currentList.getOrNull(position) as? Message)?.getSelectionKey() + return (currentList.getOrNull(position) as? ThreadMessage)?.message?.getSelectionKey() } override fun getItemKeyPosition(key: Int): Int { - return currentList.indexOfFirst { (it as? Message)?.getSelectionKey() == key } + return currentList.indexOfFirst { (it as? ThreadMessage)?.message?.getSelectionKey() == key } } override fun onActionModeCreated() {} @@ -186,15 +197,15 @@ class ThreadAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = getItem(position) - val isClickable = item is ThreadError || item is Message - val isLongClickable = item is Message + val isClickable = item is ThreadError || item is ThreadMessage + val isLongClickable = item is ThreadMessage holder.bindView(item, isClickable, isLongClickable) { itemView, _ -> when (item) { is ThreadDateTime -> setupDateTime(itemView, item) is ThreadError -> setupThreadError(itemView) is ThreadSent -> setupThreadSuccess(itemView, item.delivered) is ThreadSending -> setupThreadSending(itemView) - is Message -> setupView(holder, itemView, item) + is ThreadMessage -> setupView(holder, itemView, item) } } bindViewHolder(holder) @@ -202,7 +213,7 @@ class ThreadAdapter( override fun getItemId(position: Int): Long { return when (val item = getItem(position)) { - is Message -> item.getStableId() + is ThreadMessage -> item.message.getStableId() is ThreadDateTime -> { val sim = (item.simID.hashCode().toLong() and SIM_MASK) val key = (item.date.toLong() shl SIM_BITS) or sim @@ -220,12 +231,12 @@ class ThreadAdapter( is ThreadError -> THREAD_SENT_MESSAGE_ERROR is ThreadSent -> THREAD_SENT_MESSAGE_SENT is ThreadSending -> THREAD_SENT_MESSAGE_SENDING - is Message -> if (item.isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE + is ThreadMessage -> if (item.message.isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE } } private fun copyToClipboard() { - val selectedMessages = getSelectedItems().filterIsInstance() + val selectedMessages = getSelectedItems() if (selectedMessages.isEmpty()) return val textToCopy = if (selectedMessages.size == 1) { @@ -245,7 +256,7 @@ class ThreadAdapter( } private fun getSelectedAttachments(): List { - val selectedMessages = getSelectedItems().filterIsInstance() + val selectedMessages = getSelectedItems() return selectedMessages.flatMap { it.attachment?.attachments.orEmpty() } } @@ -257,19 +268,19 @@ class ThreadAdapter( } private fun shareText() { - val firstItem = getSelectedItems().firstOrNull() as? Message ?: return + val firstItem = getSelectedItems().firstOrNull() ?: return activity.shareTextIntent(firstItem.body) } private fun selectText() { - val firstItem = getSelectedItems().firstOrNull() as? Message ?: return + val firstItem = getSelectedItems().firstOrNull() ?: return if (firstItem.body.trim().isNotEmpty()) { SelectTextDialog(activity, firstItem.body) } } private fun showMessageDetails() { - val message = getSelectedItems().firstOrNull() as? Message ?: return + val message = getSelectedItems().firstOrNull() ?: return MessageDetailsDialog(activity, message) } @@ -296,7 +307,7 @@ class ThreadAdapter( val messagesToRemove = getSelectedItems() if (messagesToRemove.isNotEmpty()) { val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin && !isRecycleBin - deleteMessages(messagesToRemove.filterIsInstance(), toRecycleBin, false) + deleteMessages(messagesToRemove, toRecycleBin, false) } } } @@ -320,14 +331,14 @@ class ThreadAdapter( ensureBackgroundThread { val messagesToRestore = getSelectedItems() if (messagesToRestore.isNotEmpty()) { - deleteMessages(messagesToRestore.filterIsInstance(), false, true) + deleteMessages(messagesToRestore, false, true) } } } } private fun forwardMessage() { - val message = getSelectedItems().firstOrNull() as? Message ?: return + val message = getSelectedItems().firstOrNull() ?: return val attachment = message.attachment?.attachments?.firstOrNull() Intent(activity, NewConversationActivity::class.java).apply { action = Intent.ACTION_SEND @@ -341,10 +352,9 @@ class ThreadAdapter( } } - private fun getSelectedItems(): ArrayList { - return currentList.filter { - selectedKeys.contains((it as? Message)?.getSelectionKey() ?: 0) - } as ArrayList + private fun getSelectedItems(): List { + return currentList.filterIsInstance().map { it.message } + .filter { selectedKeys.contains(it.getSelectionKey()) } } private fun isThreadDateTime(position: Int) = currentList.getOrNull(position) is ThreadDateTime @@ -366,8 +376,17 @@ class ThreadAdapter( } } - private fun setupView(holder: ViewHolder, view: View, message: Message) { + private fun setupView(holder: ViewHolder, view: View, item: ThreadMessage) { + val message = item.message ItemMessageBinding.bind(view).apply { + val attachments = message.attachment?.attachments.orEmpty() + threadMessageContent.updateLayoutParams { + width = if (attachments.isEmpty()) { + ViewGroup.LayoutParams.WRAP_CONTENT + } else { + ViewGroup.LayoutParams.MATCH_PARENT + } + } threadMessageHolder.isSelected = selectedKeys.contains(message.getSelectionKey()) threadMessageBody.apply { text = message.body @@ -379,39 +398,47 @@ class ThreadAdapter( } setOnClickListener { - holder.viewClicked(message) + holder.viewClicked(item) } } if (message.isReceivedMessage()) { - setupReceivedMessageView(messageBinding = this, message = message) + setupReceivedMessageView(messageBinding = this, holder = holder, item = item) } else { setupSentMessageView(messageBinding = this, message = message) } + threadMessageHolder.touchDelegate = null + val bodyTopPadding = if (threadMessageSenderName.isVisible && attachments.isEmpty()) { + CommonsR.dimen.tiny_margin + } else { + CommonsR.dimen.normal_margin + } + threadMessageBody.updatePadding(top = resources.getDimensionPixelSize(bodyTopPadding)) + threadMessageSenderName.updatePadding( + bottom = if (attachments.isEmpty()) 0 else resources.getDimensionPixelSize(CommonsR.dimen.small_margin) + ) threadMessageAttachmentsHolder.removeAllViews() - if (message.attachment?.attachments?.isNotEmpty() == true) { - threadMessageAttachmentsHolder.beVisible() - for (attachment in message.attachment.attachments) { - val mimetype = attachment.mimetype - when { - mimetype.isImageMimeType() || mimetype.isVideoMimeType() -> setupImageView(holder, binding = this, message, attachment) - mimetype.isPlayableAudioMimeType() -> - setupAudioView(holder, threadMessageAttachmentsHolder, message, attachment) - mimetype.isVCardMimeType() -> setupVCardView(holder, threadMessageAttachmentsHolder, message, attachment) - else -> setupFileView(holder, threadMessageAttachmentsHolder, message, attachment) - } - - threadMessagePlayOutline.beVisibleIf(mimetype.startsWith("video/")) + threadMessageAttachmentsWrapper.beVisibleIf(attachments.isNotEmpty()) + threadMessagePlayOutline.beGone() + for (attachment in attachments) { + val mimetype = attachment.mimetype + when { + mimetype.isImageMimeType() || mimetype.isVideoMimeType() -> + setupImageView(holder, binding = this, item, attachment) + mimetype.isPlayableAudioMimeType() -> + setupAudioView(holder, threadMessageAttachmentsHolder, item, attachment) + mimetype.isVCardMimeType() -> + setupVCardView(holder, threadMessageAttachmentsHolder, item, attachment) + else -> setupFileView(holder, threadMessageAttachmentsHolder, item, attachment) } - } else { - threadMessageAttachmentsHolder.beGone() - threadMessagePlayOutline.beGone() + + threadMessagePlayOutline.beVisibleIf(mimetype.startsWith("video/")) } } } - private fun setupReceivedMessageView(messageBinding: ItemMessageBinding, message: Message) { + private fun setupReceivedMessageView(messageBinding: ItemMessageBinding, holder: ViewHolder, item: ThreadMessage) { messageBinding.apply { with(ConstraintSet()) { clone(threadMessageHolder) @@ -420,24 +447,63 @@ class ThreadAdapter( applyTo(threadMessageHolder) } - threadMessageSenderPhoto.beVisible() - threadMessageSenderPhoto.setOnClickListener { - val contact = message.getSender()!! - activity.getContactFromAddress(contact.phoneNumbers.first().normalizedNumber) { - if (it != null) { - activity.startContactDetailsIntent(it) - } - } + threadMessageContent.gravity = Gravity.START + threadMessageContent.background = + AppCompatResources.getDrawable(activity, R.drawable.item_received_background) + threadMessageContent.updateLayoutParams { + removeRule(RelativeLayout.ALIGN_PARENT_END) + addRule(RelativeLayout.END_OF, threadMessageSenderPhoto.id) } + setupSenderView(this, holder, item) threadMessageBody.apply { - background = AppCompatResources.getDrawable(activity, R.drawable.item_received_background) + background = null setTextColor(textColor) setLinkTextColor(activity.getProperPrimaryColor()) } + } + } - if (!activity.isFinishing && !activity.isDestroyed) { - val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName) + private fun setupSenderView(binding: ItemMessageBinding, holder: ViewHolder, item: ThreadMessage) { + val message = item.message + val senderName = message.senderName.ifBlank { message.senderPhoneNumber } + binding.apply { + threadMessageSenderName.apply { + text = senderName + setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) + setTextColor(activity.getProperPrimaryColor()) + beVisibleIf(item.isGroupConversation && item.isFirstInRun) + } + threadMessageSenderPhoto.visibility = when { + !item.isGroupConversation -> View.GONE + item.isLastInRun -> View.VISIBLE + else -> View.INVISIBLE + } + threadMessageHolder.minimumHeight = if (threadMessageSenderPhoto.isVisible) { + resources.getDimensionPixelSize(CommonsR.dimen.list_touch_target_min) + } else { + 0 + } + threadMessageSenderPhoto.contentDescription = + "$senderName, ${activity.getString(CommonsR.string.view_contact_details)}" + threadMessageSenderPhoto.setOnClickListener { + if (actModeCallback.isSelectable) { + holder.viewClicked(item) + } else { + activity.getContactFromAddress(message.senderPhoneNumber) { + if (it != null) { + activity.startContactDetailsIntent(it) + } + } + } + } + threadMessageSenderPhoto.setOnLongClickListener { + holder.viewLongClicked() + true + } + + if (threadMessageSenderPhoto.isVisible && !activity.isFinishing && !activity.isDestroyed) { + val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(senderName) val placeholder = contactLetterIcon.toDrawable(activity.resources) val options = RequestOptions() @@ -455,6 +521,36 @@ class ThreadAdapter( } } + private fun updateAvatarTouchTarget(binding: ItemMessageBinding) { + val parent = binding.threadMessageHolder + val avatar = binding.threadMessageSenderPhoto + parent.touchDelegate = null + if (!avatar.isVisible) return + + val size = resources.getDimensionPixelSize(CommonsR.dimen.list_touch_target_min) + val avatarBounds = Rect(0, 0, avatar.width, avatar.height) + parent.offsetDescendantRectToMyCoords(avatar, avatarBounds) + val content = binding.threadMessageContent + val contentBounds = Rect(0, 0, content.width, content.height) + parent.offsetDescendantRectToMyCoords(content, contentBounds) + val left = if (parent.layoutDirection == View.LAYOUT_DIRECTION_RTL) { + contentBounds.right + } else { + contentBounds.left - size + } + val top = (avatarBounds.centerY() - size / 2).coerceIn(0, parent.height - size) + parent.touchDelegate = object : TouchDelegate(Rect(left, top, left + size, top + size), avatar) { + override fun onTouchEvent(event: MotionEvent): Boolean { + val delegatedEvent = MotionEvent.obtain(event) + return try { + super.onTouchEvent(delegatedEvent) + } finally { + delegatedEvent.recycle() + } + } + } + } + private fun setupSentMessageView(messageBinding: ItemMessageBinding, message: Message) { messageBinding.apply { with(ConstraintSet()) { @@ -464,15 +560,20 @@ class ThreadAdapter( applyTo(threadMessageHolder) } + threadMessageContent.gravity = Gravity.END + threadMessageContent.background = null + threadMessageContent.updateLayoutParams { + removeRule(RelativeLayout.END_OF) + addRule(RelativeLayout.ALIGN_PARENT_END) + } + threadMessageSenderName.beGone() + threadMessageSenderPhoto.beGone() + threadMessageHolder.minimumHeight = 0 + val primaryColor = activity.getProperPrimaryColor() val contrastColor = primaryColor.getContrastColor() threadMessageBody.apply { - updateLayoutParams { - removeRule(RelativeLayout.END_OF) - addRule(RelativeLayout.ALIGN_PARENT_END) - } - background = AppCompatResources.getDrawable(activity, R.drawable.item_sent_background) background.applyColorFilter(primaryColor) setTextColor(contrastColor) @@ -495,12 +596,15 @@ class ThreadAdapter( } } - private fun setupImageView(holder: ViewHolder, binding: ItemMessageBinding, message: Message, attachment: Attachment) = binding.apply { + private fun setupImageView(holder: ViewHolder, binding: ItemMessageBinding, item: ThreadMessage, attachment: Attachment) = binding.apply { val mimetype = attachment.mimetype val uri = attachment.getUri() val imageView = ItemAttachmentImageBinding.inflate(layoutInflater) threadMessageAttachmentsHolder.addView(imageView.root) + if (item.message.isReceivedMessage()) { + setupReceivedImageCorners(this, imageView.attachmentImage, item.message, attachment) + } val placeholderDrawable = Color.TRANSPARENT.toDrawable() val options = RequestOptions() @@ -512,7 +616,7 @@ class ThreadAdapter( .load(uri) .apply(options) .dontAnimate() - .override(maxChatBubbleWidth, maxChatBubbleWidth * MAX_MEDIA_HEIGHT_RATIO) + .override(maxMediaWidth, maxMediaWidth * MAX_MEDIA_HEIGHT_RATIO) .downsample(DownsampleStrategy.AT_MOST) .listener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean): Boolean { @@ -526,13 +630,13 @@ class ThreadAdapter( .into(imageView.attachmentImage) imageView.attachmentImage.updateLayoutParams { - width = maxChatBubbleWidth + width = ViewGroup.LayoutParams.MATCH_PARENT height = ViewGroup.LayoutParams.WRAP_CONTENT } imageView.attachmentImage.setOnClickListener { if (actModeCallback.isSelectable) { - holder.viewClicked(message) + holder.viewClicked(item) } else { activity.launchViewIntent(uri, mimetype, attachment.filename) } @@ -543,22 +647,47 @@ class ThreadAdapter( } } - private fun setupAudioView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) { + private fun setupReceivedImageCorners( + binding: ItemMessageBinding, + image: ShapeableImageView, + message: Message, + attachment: Attachment + ) { + val hasHeader = binding.threadMessageSenderName.isVisible || + binding.threadMessageAttachmentsHolder.childCount > 1 + val hasFooter = message.body.isNotEmpty() || attachment != message.attachment?.attachments?.lastOrNull() + val isRtl = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + val largeCorner = resources.getDimension(CommonsR.dimen.big_margin) + val smallCorner = resources.getDimension(CommonsR.dimen.small_margin) + val leftCorner = if (isRtl) largeCorner else smallCorner + val rightCorner = if (isRtl) smallCorner else largeCorner + image.shapeAppearanceModel = image.shapeAppearanceModel.toBuilder() + .setTopLeftCornerSize(if (hasHeader) 0f else largeCorner) + .setTopRightCornerSize(if (hasHeader) 0f else largeCorner) + .setBottomLeftCornerSize(if (hasFooter) 0f else leftCorner) + .setBottomRightCornerSize(if (hasFooter) 0f else rightCorner) + .build() + } + + private fun setupAudioView(holder: ViewHolder, parent: LinearLayout, item: ThreadMessage, attachment: Attachment) { val audioView = ItemAttachmentAudioBinding.inflate(layoutInflater).apply { setupAudio( uri = attachment.getUri(), onOpenWith = { activity.launchViewIntent(attachment.getUri(), attachment.mimetype, attachment.filename) }, - onSelect = { holder.viewClicked(message) }, + onSelect = { holder.viewClicked(item) }, onLongClick = { holder.viewLongClicked() }, isSelecting = { actModeCallback.isSelectable } ) }.root + if (item.message.isReceivedMessage()) { + audioView.background = null + } parent.addView(audioView) } - private fun setupVCardView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) { + private fun setupVCardView(holder: ViewHolder, parent: LinearLayout, item: ThreadMessage, attachment: Attachment) { val uri = attachment.getUri() val vCardView = ItemAttachmentVcardBinding.inflate(layoutInflater).apply { setupVCardPreview( @@ -566,7 +695,7 @@ class ThreadAdapter( uri = uri, onClick = { if (actModeCallback.isSelectable) { - holder.viewClicked(message) + holder.viewClicked(item) } else { val intent = Intent(activity, VCardViewerActivity::class.java).also { it.putExtra(EXTRA_VCARD_URI, uri) @@ -578,10 +707,13 @@ class ThreadAdapter( ) }.root + if (item.message.isReceivedMessage()) { + vCardView.background = null + } parent.addView(vCardView) } - private fun setupFileView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) { + private fun setupFileView(holder: ViewHolder, parent: LinearLayout, item: ThreadMessage, attachment: Attachment) { val mimetype = attachment.mimetype val uri = attachment.getUri() val attachmentView = ItemAttachmentDocumentBinding.inflate(layoutInflater).apply { @@ -591,7 +723,7 @@ class ThreadAdapter( mimeType = attachment.mimetype, onClick = { if (actModeCallback.isSelectable) { - holder.viewClicked(message) + holder.viewClicked(item) } else { activity.launchViewIntent(uri, mimetype, attachment.filename) } @@ -600,6 +732,9 @@ class ThreadAdapter( ) }.root + if (item.message.isReceivedMessage()) { + attachmentView.background = null + } parent.addView(attachmentView) } @@ -654,7 +789,15 @@ class ThreadAdapter( } } - inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) + inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) { + init { + if (binding is ItemMessageBinding) { + binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateAvatarTouchTarget(binding) + } + } + } + } } private class ThreadItemDiffCallback : DiffUtil.ItemCallback() { @@ -665,7 +808,7 @@ private class ThreadItemDiffCallback : DiffUtil.ItemCallback() { is ThreadError -> oldItem.messageId == (newItem as ThreadError).messageId is ThreadSent -> oldItem.messageId == (newItem as ThreadSent).messageId is ThreadSending -> oldItem.messageId == (newItem as ThreadSending).messageId - is Message -> Message.areItemsTheSame(oldItem, newItem as Message) + is ThreadMessage -> Message.areItemsTheSame(oldItem.message, (newItem as ThreadMessage).message) is ThreadDateTime -> { val new = newItem as ThreadDateTime oldItem.date == new.date && oldItem.simID == new.simID @@ -680,7 +823,12 @@ private class ThreadItemDiffCallback : DiffUtil.ItemCallback() { is ThreadDateTime -> oldItem.simID == (newItem as ThreadDateTime).simID is ThreadError -> oldItem.messageText == (newItem as ThreadError).messageText is ThreadSent -> oldItem.delivered == (newItem as ThreadSent).delivered - is Message -> Message.areContentsTheSame(oldItem, newItem as Message) + is ThreadMessage -> { + val new = newItem as ThreadMessage + Message.areContentsTheSame(oldItem.message, new.message) && + oldItem.isGroupConversation == new.isGroupConversation && + oldItem.isFirstInRun == new.isFirstInRun && oldItem.isLastInRun == new.isLastInRun + } } } } diff --git a/app/src/main/kotlin/org/fossify/messages/models/Message.kt b/app/src/main/kotlin/org/fossify/messages/models/Message.kt index 807c407b9..94542451f 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/Message.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/Message.kt @@ -30,7 +30,7 @@ data class Message( @ColumnInfo(name = "sender_photo_uri") val senderPhotoUri: String, @ColumnInfo(name = "subscription_id") var subscriptionId: Int, @ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false -) : ThreadItem() { +) { fun isReceivedMessage() = type == Telephony.Sms.MESSAGE_TYPE_INBOX diff --git a/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt b/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt index 8453edaf4..96980d93f 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt @@ -1,11 +1,36 @@ package org.fossify.messages.models -/** - * Thread item representations for the main thread recyclerview. [Message] is also a [ThreadItem] - */ sealed class ThreadItem { + data class ThreadMessage( + val message: Message, + val isGroupConversation: Boolean = false, + val isFirstInRun: Boolean = true, + val isLastInRun: Boolean = true + ) : ThreadItem() + data class ThreadDateTime(val date: Int, val simID: String) : ThreadItem() data class ThreadError(val messageId: Long, val messageText: String) : ThreadItem() data class ThreadSent(val messageId: Long, val delivered: Boolean) : ThreadItem() data class ThreadSending(val messageId: Long) : ThreadItem() } + +fun List.withSenderGrouping(isGroupConversation: Boolean): List { + fun isSameSender(first: ThreadItem.ThreadMessage, second: ThreadItem?): Boolean { + val nextMessage = (second as? ThreadItem.ThreadMessage)?.message ?: return false + val message = first.message + return message.isReceivedMessage() && nextMessage.isReceivedMessage() && + message.senderPhoneNumber.isNotBlank() && message.senderPhoneNumber == nextMessage.senderPhoneNumber + } + + return mapIndexed { index, item -> + if (item is ThreadItem.ThreadMessage) { + item.copy( + isGroupConversation = isGroupConversation, + isFirstInRun = !isGroupConversation || !isSameSender(item, getOrNull(index - 1)), + isLastInRun = !isGroupConversation || !isSameSender(item, getOrNull(index + 1)) + ) + } else { + item + } + } +} diff --git a/app/src/main/res/layout/item_message.xml b/app/src/main/res/layout/item_message.xml index a57c387f5..dfbdcdc2d 100644 --- a/app/src/main/res/layout/item_message.xml +++ b/app/src/main/res/layout/item_message.xml @@ -16,54 +16,78 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintWidth_percent="0.8"> + app:layout_constraintWidth_percent="0.9"> - - - - + android:orientation="vertical"> + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 324712d5e..bca27168f 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,7 @@ 72dp 64dp + 28dp 36dp 64dp @dimen/attachment_preview_size From e110ceda823dc2eaaf7ca1fc58a90af0347d7ef3 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 12 Oct 2026 09:40:25 +0530 Subject: [PATCH 2/2] refactor: simplify things --- .../messages/adapters/ThreadAdapter.kt | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt index 3778ba9a4..8f17d7733 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -3,14 +3,11 @@ package org.fossify.messages.adapters import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color -import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.Drawable import android.util.TypedValue import android.view.Gravity import android.view.Menu -import android.view.MotionEvent -import android.view.TouchDelegate import android.view.View import android.view.ViewGroup import android.widget.LinearLayout @@ -408,7 +405,6 @@ class ThreadAdapter( setupSentMessageView(messageBinding = this, message = message) } - threadMessageHolder.touchDelegate = null val bodyTopPadding = if (threadMessageSenderName.isVisible && attachments.isEmpty()) { CommonsR.dimen.tiny_margin } else { @@ -479,11 +475,6 @@ class ThreadAdapter( item.isLastInRun -> View.VISIBLE else -> View.INVISIBLE } - threadMessageHolder.minimumHeight = if (threadMessageSenderPhoto.isVisible) { - resources.getDimensionPixelSize(CommonsR.dimen.list_touch_target_min) - } else { - 0 - } threadMessageSenderPhoto.contentDescription = "$senderName, ${activity.getString(CommonsR.string.view_contact_details)}" threadMessageSenderPhoto.setOnClickListener { @@ -521,36 +512,6 @@ class ThreadAdapter( } } - private fun updateAvatarTouchTarget(binding: ItemMessageBinding) { - val parent = binding.threadMessageHolder - val avatar = binding.threadMessageSenderPhoto - parent.touchDelegate = null - if (!avatar.isVisible) return - - val size = resources.getDimensionPixelSize(CommonsR.dimen.list_touch_target_min) - val avatarBounds = Rect(0, 0, avatar.width, avatar.height) - parent.offsetDescendantRectToMyCoords(avatar, avatarBounds) - val content = binding.threadMessageContent - val contentBounds = Rect(0, 0, content.width, content.height) - parent.offsetDescendantRectToMyCoords(content, contentBounds) - val left = if (parent.layoutDirection == View.LAYOUT_DIRECTION_RTL) { - contentBounds.right - } else { - contentBounds.left - size - } - val top = (avatarBounds.centerY() - size / 2).coerceIn(0, parent.height - size) - parent.touchDelegate = object : TouchDelegate(Rect(left, top, left + size, top + size), avatar) { - override fun onTouchEvent(event: MotionEvent): Boolean { - val delegatedEvent = MotionEvent.obtain(event) - return try { - super.onTouchEvent(delegatedEvent) - } finally { - delegatedEvent.recycle() - } - } - } - } - private fun setupSentMessageView(messageBinding: ItemMessageBinding, message: Message) { messageBinding.apply { with(ConstraintSet()) { @@ -568,7 +529,6 @@ class ThreadAdapter( } threadMessageSenderName.beGone() threadMessageSenderPhoto.beGone() - threadMessageHolder.minimumHeight = 0 val primaryColor = activity.getProperPrimaryColor() val contrastColor = primaryColor.getContrastColor() @@ -789,15 +749,7 @@ class ThreadAdapter( } } - inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) { - init { - if (binding is ItemMessageBinding) { - binding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - updateAvatarTouchTarget(binding) - } - } - } - } + inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) } private class ThreadItemDiffCallback : DiffUtil.ItemCallback() {