package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Color import android.graphics.Rect import android.text.Spannable import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.URLSpan import android.text.util.Linkify import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor import java.util.Locale import kotlin.math.roundToInt class VisibleMessageContentView : ConstraintLayout { private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 // region Lifecycle constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) // endregion // region Updating fun bind( message: MessageRecord, isStartOfMessageCluster: Boolean = true, isEndOfMessageCluster: Boolean = true, glide: GlideRequests = GlideApp.with(this), thread: Recipient, searchQuery: String? = null, onAttachmentNeedsDownload: (Long, Long) -> Unit, suppressThumbnails: Boolean = false ) { // Background val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) binding.contentParent.mainColor = color binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE } val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress } val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null // reset visibilities / containers onContentClick.clear() binding.albumThumbnailView.root.clearViews() onContentDoubleTap = null if (message.isDeleted) { binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.bodyTextView.isVisible = false binding.quoteView.root.isVisible = false binding.linkPreviewView.root.isVisible = false binding.voiceMessageView.root.isVisible = false binding.documentView.root.isVisible = false binding.albumThumbnailView.root.isVisible = false binding.openGroupInvitationView.root.isVisible = false return } else { binding.deletedMessageView.root.isVisible = false } // Note: Need to clear the body to prevent the message bubble getting incorrectly // sized based on text content from a recycled view binding.bodyTextView.text = null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation var hideBody = false if (message is MmsMessageRecord && message.quote != null) { binding.quoteView.root.isVisible = true val quote = message.quote!! val quoteText = if (quote.isOriginalMissing) { context.getString(R.string.QuoteView_original_missing) } else { quote.text } binding.quoteView.root.bind(quote.author.toString(), quoteText, quote.attachment, thread, message.isOutgoing, message.isOpenGroupInvitation, message.threadId, quote.isOriginalMissing, glide) onContentClick.add { event -> val r = Rect() binding.quoteView.root.getGlobalVisibleRect(r) if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { delegate?.scrollToMessageIfPossible(quote.id) } } } if (message is MmsMessageRecord) { message.slideDeck.asAttachments().forEach { attach -> val dbAttachment = attach as? DatabaseAttachment ?: return@forEach val attachmentId = dbAttachment.attachmentId.rowId if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId) } } message.linkPreviews.forEach { preview -> val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach val attachmentId = previewThumbnail.attachmentId.rowId if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId) } } } when { // LINK PREVIEW message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } // When in a link preview ensure the bodyTextView can expand to the full width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } // AUDIO message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { hideBody = true // Audio attachment if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) // We have to use onContentClick (rather than a click listener directly on the voice // message view) so as to not interfere with all the other gestures. onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } } else { hideBody = true (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> binding.pendingAttachmentView.root.bind( PendingAttachmentView.AttachmentType.AUDIO, getTextColor(context,message), attachment ) onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } } } } // DOCUMENT message is MmsMessageRecord && message.slideDeck.documentSlide != null -> { hideBody = true // TODO: check if this is still the logic we want // Document attachment if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.documentView.root.bind(message, getTextColor(context, message)) } else { hideBody = true (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> binding.pendingAttachmentView.root.bind( PendingAttachmentView.AttachmentType.DOCUMENT, getTextColor(context,message), attachment ) onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } } } } // IMAGE / VIDEO message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { if (mediaDownloaded || mediaInProgress || message.isOutgoing) { // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind binding.albumThumbnailView.root.bind( glideRequests = glide, message = message, isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f binding.albumThumbnailView.root.layoutParams = layoutParams onContentClick.add { event -> binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } } else { hideBody = true binding.albumThumbnailView.root.clearViews() val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment firstAttachment?.let { attachment -> binding.pendingAttachmentView.root.bind( PendingAttachmentView.AttachmentType.IMAGE, getTextColor(context,message), attachment ) onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } } } } message.isOpenGroupInvitation -> { hideBody = true binding.openGroupInvitationView.root.bind(message, getTextColor(context, message)) onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } } } binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody binding.contentParent.apply { isVisible = children.any { it.isVisible } } if (message.body.isNotEmpty() && !hideBody) { val color = getTextColor(context, message) binding.bodyTextView.setTextColor(color) binding.bodyTextView.setLinkTextColor(color) val body = getBodySpans(context, message, searchQuery) binding.bodyTextView.text = body onContentClick.add { e: MotionEvent -> binding.bodyTextView.getIntersectedModalSpans(e).iterator().forEach { span -> span.onClick(binding.bodyTextView) } } } val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f binding.contentParent.layoutParams = layoutParams } private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() fun onContentClick(event: MotionEvent) { onContentClick.forEach { clickHandler -> clickHandler.invoke(event) } } private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = listOf(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } fun recycle() { arrayOf( binding.deletedMessageView.root, binding.pendingAttachmentView.root, binding.voiceMessageView.root, binding.openGroupInvitationView.root, binding.documentView.root, binding.quoteView.root, binding.linkPreviewView.root, binding.albumThumbnailView.root, binding.bodyTextView ).forEach { view: View -> view.isVisible = false } } fun playVoiceMessage() { binding.voiceMessageView.root.togglePlayback() } fun playHighlight() { // Show the highlight colour immediately then slowly fade out val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme) val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0) binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1 binding.contentParent.sessionShadowColor = targetColor GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600) } // endregion // region Convenience companion object { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) Linkify.addLinks(body, Linkify.WEB_URLS) // replace URLSpans with ModalURLSpans body.getSpans(0, body.length).toList().forEach { urlSpan -> val updatedUrl = urlSpan.url.let { HttpUrl.parse(it).toString() } val replacementSpan = ModalURLSpan(updatedUrl) { url -> val activity = context as AppCompatActivity ModalUrlBottomSheet(url).show(activity.supportFragmentManager, "Open URL Dialog") } val start = body.getSpanStart(urlSpan) val end = body.getSpanEnd(urlSpan) val flags = body.getSpanFlags(urlSpan) body.removeSpan(urlSpan) body.setSpan(replacementSpan, start, end, flags) } return body } @ColorInt fun getTextColor(context: Context, message: MessageRecord): Int { val colorAttribute = if (message.isOutgoing) { // sent R.attr.message_sent_text_color } else { // received R.attr.message_received_text_color } return context.getColorFromAttr(colorAttribute) } } // endregion }