@ -10,11 +10,10 @@ import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.text.style.URLSpan
import android.text.util.Linkify
import android.text.util.Linkify
import android.util.AttributeSet
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatActivity
@ -23,22 +22,20 @@ import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.text.getSpans
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import androidx.core.text.toSpannable
import androidx.core.view. children
import androidx.core.view. isVisible
import network.loki.messenger.R
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import okhttp3.HttpUrl
import okhttp3.HttpUrl
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
import org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.UiModeUtilities
@ -49,7 +46,7 @@ import kotlin.math.roundToInt
class VisibleMessageContentView : LinearLayout {
class VisibleMessageContentView : LinearLayout {
private lateinit var binding : ViewVisibleMessageContentBinding
private lateinit var binding : ViewVisibleMessageContentBinding
var onContentClick : ( ( event : MotionEvent ) -> Unit ) ? = null
var onContentClick : MutableList < ( ( event : MotionEvent ) -> Unit ) > = mutableListOf ( )
var onContentDoubleTap : ( ( ) -> Unit ) ? = null
var onContentDoubleTap : ( ( ) -> Unit ) ? = null
var delegate : VisibleMessageContentViewDelegate ? = null
var delegate : VisibleMessageContentViewDelegate ? = null
var indexInAdapter : Int = - 1
var indexInAdapter : Int = - 1
@ -74,23 +71,45 @@ class VisibleMessageContentView : LinearLayout {
val filter = BlendModeColorFilterCompat . createBlendModeColorFilterCompat ( color , BlendModeCompat . SRC _IN )
val filter = BlendModeColorFilterCompat . createBlendModeColorFilterCompat ( color , BlendModeCompat . SRC _IN )
background . colorFilter = filter
background . colorFilter = filter
setBackground ( background )
setBackground ( background )
// Body
binding . mainContainer . removeAllViews ( )
val onlyBodyMessage = message is SmsMessageRecord
onContentClick = null
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message . slideDeck . thumbnailSlide != null
// reset visibilities / containers
onContentClick . clear ( )
binding . albumThumbnailView . clearViews ( )
onContentDoubleTap = null
onContentDoubleTap = null
if ( message . isDeleted ) {
if ( message . isDeleted ) {
val deletedMessageView = DeletedMessageView ( context )
binding . deletedMessageView . isVisible = true
deletedMessageView . bind ( message , getTextColor ( context , message ) )
binding . deletedMessageView . bind ( message , VisibleMessageContentView . getTextColor ( context , message ) )
binding . mainContainer . addView ( deletedMessageView )
return
} else if ( message is MmsMessageRecord && message . linkPreviews . isNotEmpty ( ) ) {
} else {
val linkPreviewView = LinkPreviewView ( context )
binding . deletedMessageView . isVisible = false
linkPreviewView . bind ( message , glide , isStartOfMessageCluster , isEndOfMessageCluster , searchQuery )
}
binding . mainContainer . addView ( linkPreviewView )
onContentClick = { event -> linkPreviewView . calculateHit ( event ) }
binding . quoteView . isVisible = message is MmsMessageRecord && message . quote != null
// Body text view is inside the link preview for layout convenience
val quoteLayoutParams = binding . quoteView . layoutParams
} else if ( message is MmsMessageRecord && message . quote != null ) {
quoteLayoutParams . width = if ( mediaThumbnailMessage ) 0 else ViewGroup . LayoutParams . WRAP _CONTENT
binding . quoteView . layoutParams = quoteLayoutParams
binding . linkPreviewView . isVisible = message is MmsMessageRecord && message . linkPreviews . isNotEmpty ( )
val linkPreviewLayout = binding . linkPreviewView . layoutParams
linkPreviewLayout . width = if ( mediaThumbnailMessage ) 0 else ViewGroup . LayoutParams . WRAP _CONTENT
binding . linkPreviewView . layoutParams = linkPreviewLayout
binding . untrustedView . isVisible = ! contactIsTrusted && message is MmsMessageRecord && message . quote == null
binding . voiceMessageView . isVisible = contactIsTrusted && message is MmsMessageRecord && message . slideDeck . audioSlide != null
binding . documentView . isVisible = contactIsTrusted && message is MmsMessageRecord && message . slideDeck . documentSlide != null
binding . albumThumbnailView . isVisible = mediaThumbnailMessage
binding . openGroupInvitationView . isVisible = message . isOpenGroupInvitation
var hideBody = false
if ( message is MmsMessageRecord && message . quote != null ) {
binding . quoteView . isVisible = true
val quote = message . quote !!
val quote = message . quote !!
val quoteView = QuoteView ( context , QuoteView . Mode . Regular )
// The max content width is the max message bubble size - 2 times the horizontal padding - 2
// The max content width is the max message bubble size - 2 times the horizontal padding - 2
// times the horizontal margin. This unfortunately has to be calculated manually
// times the horizontal margin. This unfortunately has to be calculated manually
// here to get the layout right.
// here to get the layout right.
@ -100,88 +119,91 @@ class VisibleMessageContentView : LinearLayout {
} else {
} else {
quote . text
quote . text
}
}
quoteView. bind ( quote . author . toString ( ) , quoteText , quote . attachment , thread ,
binding. quoteView. bind ( quote . author . toString ( ) , quoteText , quote . attachment , thread ,
message . isOutgoing , maxContentWidth , message . isOpenGroupInvitation , message . threadId ,
message . isOutgoing , maxContentWidth , message . isOpenGroupInvitation , message . threadId ,
quote . isOriginalMissing , glide )
quote . isOriginalMissing , glide )
binding . mainContainer . addView ( quoteView )
onContentClick . add { event ->
val bodyTextView = getBodyTextView ( context , message , searchQuery )
ViewUtil . setPaddingTop ( bodyTextView , 0 )
binding . mainContainer . addView ( bodyTextView )
onContentClick = { event ->
val r = Rect ( )
val r = Rect ( )
quoteView. getGlobalVisibleRect ( r )
binding . quoteView . getGlobalVisibleRect ( r )
if ( r . contains ( event . rawX . roundToInt ( ) , event . rawY . roundToInt ( ) ) ) {
if ( r . contains ( event . rawX . roundToInt ( ) , event . rawY . roundToInt ( ) ) ) {
delegate ?. scrollToMessageIfPossible ( quote . id )
delegate ?. scrollToMessageIfPossible ( quote . id )
} else {
bodyTextView . getIntersectedModalSpans ( event ) . forEach { span ->
span . onClick ( bodyTextView )
}
}
}
}
}
}
if ( message is MmsMessageRecord && message . linkPreviews . isNotEmpty ( ) ) {
binding . linkPreviewView . bind ( message , glide , isStartOfMessageCluster , isEndOfMessageCluster )
onContentClick . add { event -> binding . linkPreviewView . calculateHit ( event ) }
// Body text view is inside the link preview for layout convenience
} else if ( message is MmsMessageRecord && message . slideDeck . audioSlide != null ) {
} else if ( message is MmsMessageRecord && message . slideDeck . audioSlide != null ) {
hideBody = true
// Audio attachment
// Audio attachment
if ( contactIsTrusted || message . isOutgoing ) {
if ( contactIsTrusted || message . isOutgoing ) {
val voiceMessageView = VoiceMessageView ( context )
binding . voiceMessageView . indexInAdapter = indexInAdapter
voiceMessageView . indexInAdapter = indexInAdapter
binding . voiceMessageView . delegate = context as ? ConversationActivityV2
voiceMessageView . delegate = context as ? ConversationActivityV2
binding . voiceMessageView . bind ( message , isStartOfMessageCluster , isEndOfMessageCluster )
voiceMessageView . bind ( message , isStartOfMessageCluster , isEndOfMessageCluster )
binding . mainContainer . addView ( voiceMessageView )
// We have to use onContentClick (rather than a click listener directly on the voice
// 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.
// message view) so as to not interfere with all the other gestures.
onContentClick = { voiceMessageView . togglePlayback ( ) }
onContentClick . add { binding . voiceMessageView . togglePlayback ( ) }
onContentDoubleTap = { voiceMessageView. handleDoubleTap ( ) }
onContentDoubleTap = { binding. voiceMessageView. handleDoubleTap ( ) }
} else {
} else {
val untrustedView = UntrustedAttachmentView ( context )
// TODO: move this out to its own area
untrustedView . bind ( UntrustedAttachmentView . AttachmentType . AUDIO , getTextColor ( context , message ) )
binding . untrustedView . bind ( UntrustedAttachmentView . AttachmentType . AUDIO , VisibleMessageContentView . getTextColor ( context , message ) )
binding . mainContainer . addView ( untrustedView )
onContentClick . add { binding . untrustedView . showTrustDialog ( message . individualRecipient ) }
onContentClick = { untrustedView . showTrustDialog ( message . individualRecipient ) }
}
}
} else if ( message is MmsMessageRecord && message . slideDeck . documentSlide != null ) {
} else if ( message is MmsMessageRecord && message . slideDeck . documentSlide != null ) {
hideBody = true
// Document attachment
// Document attachment
if ( contactIsTrusted || message . isOutgoing ) {
if ( contactIsTrusted || message . isOutgoing ) {
val documentView = DocumentView ( context )
binding . documentView . bind ( message , VisibleMessageContentView . getTextColor ( context , message ) )
documentView . bind ( message , getTextColor ( context , message ) )
binding . mainContainer . addView ( documentView )
} else {
} else {
val untrustedView = UntrustedAttachmentView ( context )
binding . untrustedView . bind ( UntrustedAttachmentView . AttachmentType . DOCUMENT , VisibleMessageContentView . getTextColor ( context , message ) )
untrustedView . bind ( UntrustedAttachmentView . AttachmentType . DOCUMENT , getTextColor ( context , message ) )
onContentClick . add { binding . untrustedView . showTrustDialog ( message . individualRecipient ) }
binding . mainContainer . addView ( untrustedView )
onContentClick = { untrustedView . showTrustDialog ( message . individualRecipient ) }
}
}
} else if ( message is MmsMessageRecord && message . slideDeck . asAttachments ( ) . isNotEmpty ( ) ) {
} else if ( message is MmsMessageRecord && message . slideDeck . asAttachments ( ) . isNotEmpty ( ) ) {
// Images/Video attachment
/ *
* Images / Video attachment
* /
if ( contactIsTrusted || message . isOutgoing ) {
if ( contactIsTrusted || message . isOutgoing ) {
val albumThumbnailView = AlbumThumbnailView ( context )
binding . mainContainer . addView ( albumThumbnailView )
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// 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
// bind after add view because views are inflated and calculated during bind
albumThumbnailView. bind (
binding. albumThumbnailView. bind (
glideRequests = glide ,
glideRequests = glide ,
message = message ,
message = message ,
isStart = isStartOfMessageCluster ,
isStart = isStartOfMessageCluster ,
isEnd = isEndOfMessageCluster
isEnd = isEndOfMessageCluster
)
)
onContentClick = { event ->
onContentClick . add { event ->
albumThumbnailView. calculateHitObject ( event , message , thread )
binding. albumThumbnailView. calculateHitObject ( event , message , thread )
}
}
} else {
} else {
val untrustedView = UntrustedAttachmentView ( context )
hideBody = true
untrustedView. bind ( UntrustedAttachmentView . AttachmentType . MEDIA , getTextColor ( context , message ) )
binding. albumThumbnailView . clearViews ( )
binding . mainContainer. addView ( untrustedView )
binding . untrustedView. bind ( UntrustedAttachmentView . AttachmentType . MEDIA , VisibleMessageContentView . getTextColor ( context , message ) )
onContentClick = { untrustedView . showTrustDialog ( message . individualRecipient ) }
onContentClick . add { binding . untrustedView . showTrustDialog ( message . individualRecipient ) }
}
}
} else if ( message . isOpenGroupInvitation ) {
} else if ( message . isOpenGroupInvitation ) {
val openGroupInvitationView = OpenGroupInvitationView ( context )
hideBody = true
openGroupInvitationView . bind ( message , getTextColor ( context , message ) )
binding . openGroupInvitationView . bind ( message , VisibleMessageContentView . getTextColor ( context , message ) )
binding . mainContainer . addView ( openGroupInvitationView )
onContentClick . add { binding . openGroupInvitationView . joinOpenGroup ( ) }
onContentClick = { openGroupInvitationView . joinOpenGroup ( ) }
}
} else {
val bodyTextView = getBodyTextView ( context , message , searchQuery )
binding . bodyTextView . isVisible = message . body . isNotEmpty ( ) && ! hideBody
binding . mainContainer . addView ( bodyTextView )
onContentClick = { event ->
// set it to use constraints if not only a text message, otherwise wrap content to whatever width it wants
// intersectedModalSpans should only be a list of one item
val params = binding . bodyTextView . layoutParams
bodyTextView . getIntersectedModalSpans ( event ) . forEach { span ->
params . width = if ( onlyBodyMessage ) ViewGroup . LayoutParams . WRAP _CONTENT else 0
span . onClick ( bodyTextView )
binding . bodyTextView . layoutParams = params
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 ) . forEach { span ->
span . onClick ( binding . bodyTextView )
}
}
}
}
}
}
@ -207,35 +229,27 @@ class VisibleMessageContentView : LinearLayout {
}
}
fun recycle ( ) {
fun recycle ( ) {
binding . mainContainer . removeAllViews ( )
arrayOf (
binding . deletedMessageView ,
binding . untrustedView ,
binding . voiceMessageView ,
binding . openGroupInvitationView ,
binding . documentView ,
binding . quoteView ,
binding . linkPreviewView ,
binding . albumThumbnailView ,
binding . bodyTextView
) . forEach { view -> view . isVisible = false }
}
}
fun playVoiceMessage ( ) {
fun playVoiceMessage ( ) {
binding . mainContainer . children . forEach { view ->
binding . voiceMessageView . togglePlayback ( )
if ( view is VoiceMessageView ) {
return @forEach view . togglePlayback ( )
}
}
}
}
// endregion
// endregion
// region Convenience
// region Convenience
companion object {
companion object {
fun getBodyTextView ( context : Context , message : MessageRecord , searchQuery : String ? ) : TextView {
val result = EmojiTextView ( context )
val vPadding = context . resources . getDimension ( R . dimen . small _spacing ) . toInt ( )
val hPadding = toPx ( 12 , context . resources )
result . setPadding ( hPadding , vPadding , hPadding , vPadding )
result . setTextSize ( TypedValue . COMPLEX _UNIT _PX , context . resources . getDimension ( R . dimen . small _font _size ) )
val color = getTextColor ( context , message )
result . setTextColor ( color )
result . setLinkTextColor ( color )
val body = getBodySpans ( context , message , searchQuery )
result . text = body
return result
}
fun getBodySpans ( context : Context , message : MessageRecord , searchQuery : String ? ) : Spannable {
fun getBodySpans ( context : Context , message : MessageRecord , searchQuery : String ? ) : Spannable {
var body = message . body . toSpannable ( )
var body = message . body . toSpannable ( )