[bubble merge] add back SC bubbles

Change-Id: Ia884fbc5d83aad8cb79d674ec7c411bff608e4e5
This commit is contained in:
SpiritCroc 2022-02-19 20:36:41 +01:00
parent 1024761526
commit ef4c35499c
26 changed files with 1071 additions and 135 deletions

View File

@ -1,11 +1,26 @@
package im.vector.app.core.ui.views package im.vector.app.core.ui.views
import android.content.Context import android.content.res.Resources
import android.view.ViewGroup import im.vector.app.R
import androidx.core.view.children import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.features.themes.BubbleThemeUtils import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
interface BubbleDependentView<H> { interface BubbleDependentView<H: VectorEpoxyHolder> {
fun getScBubbleMargin(resources: Resources): Int = resources.getDimensionPixelSize(R.dimen.dual_bubble_one_side_without_avatar_margin)
fun getViewStubMinimumWidth(holder: H): Int = 0
fun allowFooterOverlay(holder: H, bubbleWrapView: ScMessageBubbleWrapView): Boolean = false
// Whether to show the footer aligned below the viewStub - requires enough width!
fun allowFooterBelow(holder: H): Boolean = true
fun needsFooterReservation(): Boolean = false
fun reserveFooterSpace(holder: H, width: Int, height: Int) {}
fun getInformationData(): MessageInformationData? = null
// TODO: overwrite for remaining setBubbleLayout()s where necessary: ReadReceiptsItem, MessageImageVideoItem
fun applyScBubbleStyle(messageLayout: TimelineMessageLayout.ScBubble, holder: H) {}
/* /*
fun messageBubbleAllowed(context: Context): Boolean { fun messageBubbleAllowed(context: Context): Boolean {

View File

@ -30,6 +30,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEve
import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem_ import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem_
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem_ import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem_
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayoutFactory
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
@ -46,6 +47,7 @@ import javax.inject.Inject
class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val messageLayoutFactory: TimelineMessageLayoutFactory,
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper) { private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper) {
private val collapsedEventIds = linkedSetOf<Long>() private val collapsedEventIds = linkedSetOf<Long>()
@ -129,7 +131,8 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
onCollapsedStateChanged = { onCollapsedStateChanged = {
mergeItemCollapseStates[event.localId] = it mergeItemCollapseStates[event.localId] = it
requestModelBuild() requestModelBuild()
} },
messageLayout = messageLayoutFactory.createDummy()
) )
MergedMembershipEventsItem_() MergedMembershipEventsItem_()
.id(mergeId) .id(mergeId)
@ -206,6 +209,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
mergeItemCollapseStates[event.localId] = it mergeItemCollapseStates[event.localId] = it
requestModelBuild() requestModelBuild()
}, },
messageLayout = messageLayoutFactory.createDummy(),
hasEncryptionEvent = hasEncryption, hasEncryptionEvent = hasEncryption,
isEncryptionAlgorithmSecure = encryptionAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM, isEncryptionAlgorithmSecure = encryptionAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM,
callback = callback, callback = callback,

View File

@ -21,10 +21,12 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem_ import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem_
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayoutFactory
import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.ReadReceipt
import javax.inject.Inject import javax.inject.Inject
class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) { class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer,
private val messageLayoutFactory: TimelineMessageLayoutFactory) {
fun create( fun create(
eventId: String, eventId: String,
@ -44,6 +46,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av
.id("read_receipts_$eventId") .id("read_receipts_$eventId")
.eventId(eventId) .eventId(eventId)
.readReceipts(readReceiptsData) .readReceipts(readReceiptsData)
.messageLayout(messageLayoutFactory.createDummy())
.avatarRenderer(avatarRenderer) .avatarRenderer(avatarRenderer)
.shouldHideReadReceipts(isFromThreadTimeLine) .shouldHideReadReceipts(isFromThreadTimeLine)
.clickListener { .clickListener {

View File

@ -161,16 +161,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
ReferencesInfoData(verificationState) ReferencesInfoData(verificationState)
}, },
sentByMe = isSentByMe, sentByMe = isSentByMe,
readReceiptAnonymous = if (event.root.sendState == SendState.SYNCED || event.root.sendState == SendState.SENT) { readReceiptAnonymous = BubbleThemeUtils.anonymousReadReceiptForEvent(event),
/*if (event.readByOther) {
AnonymousReadReceipt.READ
} else {
AnonymousReadReceipt.SENT
}*/
AnonymousReadReceipt.NONE
} else {
AnonymousReadReceipt.PROCESSING
},
senderPowerLevel = senderPowerLevel, senderPowerLevel = senderPowerLevel,
isDirect = isEffectivelyDirect, isDirect = isEffectivelyDirect,
isPublic = roomSummary?.isPublic ?: false, isPublic = roomSummary?.isPublic ?: false,

View File

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.content.res.Resources
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
@ -25,13 +26,19 @@ import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.core.ui.views.ShieldImageView import im.vector.app.core.ui.views.ShieldImageView
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer
import im.vector.app.features.home.room.detail.timeline.view.scRenderMessageLayout
import im.vector.app.features.reactions.widget.ReactionButton import im.vector.app.features.reactions.widget.ReactionButton
import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import kotlin.math.ceil
/** /**
* Base timeline item with reactions and read receipts. * Base timeline item with reactions and read receipts.
@ -85,23 +92,22 @@ abstract class AbsBaseMessageItem<H : AbsBaseMessageItem.Holder> : BaseEventItem
holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener) holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener)
} }
// SchildiChat: moved to setBubbleLayout() (called from super.bind()) - so we can do this bubble-style-specific if (baseAttributes.informationData.messageLayout.showE2eDecoration) {
/* when (baseAttributes.informationData.e2eDecoration) {
when (baseAttributes.informationData.e2eDecoration) { E2EDecoration.NONE -> {
E2EDecoration.NONE -> { holder.e2EDecorationView.render(null)
holder.e2EDecorationView.render(null) }
} E2EDecoration.WARN_IN_CLEAR,
E2EDecoration.WARN_IN_CLEAR, E2EDecoration.WARN_SENT_BY_UNVERIFIED,
E2EDecoration.WARN_SENT_BY_UNVERIFIED, E2EDecoration.WARN_SENT_BY_UNKNOWN -> {
E2EDecoration.WARN_SENT_BY_UNKNOWN -> { holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning)
holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) }
} }
} }
*/
holder.view.onClick(baseAttributes.itemClickListener) holder.view.onClick(baseAttributes.itemClickListener)
holder.view.setOnLongClickListener(baseAttributes.itemLongClickListener) holder.view.setOnLongClickListener(baseAttributes.itemLongClickListener)
(holder.view as? TimelineMessageLayoutRenderer)?.renderMessageLayout(baseAttributes.informationData.messageLayout) (holder.view as? TimelineMessageLayoutRenderer).scRenderMessageLayout(baseAttributes.informationData.messageLayout, this, holder)
} }
override fun unbind(holder: H) { override fun unbind(holder: H) {
@ -116,6 +122,25 @@ abstract class AbsBaseMessageItem<H : AbsBaseMessageItem.Holder> : BaseEventItem
failureIndicator?.isVisible = baseAttributes.informationData.sendState.hasFailed() failureIndicator?.isVisible = baseAttributes.informationData.sendState.hasFailed()
} }
override fun getScBubbleMargin(resources: Resources): Int {
return when {
(baseAttributes.informationData.messageLayout as? TimelineMessageLayout.ScBubble)?.singleSidedLayout == true -> 0
// else: dual-side bubbles (getBubbleMargin should not get called for other bubbleStyles)
// Direct chats usually have avatars hidden on both sides
baseAttributes.informationData.isDirect -> resources.getDimensionPixelSize(R.dimen.dual_bubble_both_sides_without_avatar_margin)
// No direct chat, but sent by me: other side has an avatar
baseAttributes.informationData.sentByMe -> {
resources.getDimensionPixelSize(R.dimen.dual_bubble_one_side_without_avatar_margin) +
resources.getDimensionPixelSize(R.dimen.dual_bubble_one_side_avatar_offset) +
// SC bubbles use SMALL avatars
ceil(AvatarSizeProvider.Companion.AvatarStyle.SMALL.avatarSizeDP * resources.displayMetrics.density).toInt()
}
// No direct chat, sent by other: my side has hidden avatar
else -> resources.getDimensionPixelSize(R.dimen.dual_bubble_one_side_without_avatar_margin)
}
}
abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) {
val reactionsContainer by bind<ViewGroup>(R.id.reactionsContainer) val reactionsContainer by bind<ViewGroup>(R.id.reactionsContainer)
val informationBottom by bind<ViewGroup>(R.id.informationBottom) val informationBottom by bind<ViewGroup>(R.id.informationBottom)

View File

@ -36,8 +36,15 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer
import im.vector.app.features.home.room.detail.timeline.view.canHideAvatars
import im.vector.app.features.home.room.detail.timeline.view.infoInBubbles
import im.vector.app.features.themes.guessTextWidth
import org.matrix.android.sdk.api.session.threads.ThreadDetails import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import kotlin.math.ceil
/** /**
* Base timeline item that adds an optional information bar with the sender avatar, name, time, send state * Base timeline item that adds an optional information bar with the sender avatar, name, time, send state
@ -75,40 +82,42 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
override fun bind(holder: H) { override fun bind(holder: H) {
super.bind(holder) super.bind(holder)
if (attributes.informationData.messageLayout.showAvatar) { if ((holder.view as? ScMessageBubbleWrapView)?.customBind(this, holder, attributes, _avatarClickListener, _memberNameClickListener) != true) {
holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { if (attributes.informationData.messageLayout.showAvatar) {
height = attributes.avatarSize holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply {
width = attributes.avatarSize height = attributes.avatarSize
width = attributes.avatarSize
}
attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener)
holder.avatarImageView.isVisible = true
holder.avatarImageView.onClick(_avatarClickListener)
} else {
holder.avatarImageView.setOnClickListener(null)
holder.avatarImageView.setOnLongClickListener(null)
holder.avatarImageView.isVisible = false
} }
attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView) if (attributes.informationData.messageLayout.showDisplayName) {
holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener) holder.memberNameView.isVisible = true
holder.avatarImageView.isVisible = true holder.memberNameView.text = attributes.informationData.memberName
holder.avatarImageView.onClick(_avatarClickListener) holder.memberNameView.setTextColor(attributes.getMemberNameColor())
} else { holder.memberNameView.onClick(_memberNameClickListener)
holder.avatarImageView.setOnClickListener(null) holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
holder.avatarImageView.setOnLongClickListener(null) } else {
holder.avatarImageView.isVisible = false holder.memberNameView.setOnClickListener(null)
holder.memberNameView.setOnLongClickListener(null)
holder.memberNameView.isVisible = false
}
if (attributes.informationData.messageLayout.showTimestamp) {
holder.timeView.isVisible = true
holder.timeView.text = attributes.informationData.time
} else {
holder.timeView.isVisible = false
}
// Render send state indicator
holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
} }
if (attributes.informationData.messageLayout.showDisplayName) {
holder.memberNameView.isVisible = true
holder.memberNameView.text = attributes.informationData.memberName
holder.memberNameView.setTextColor(attributes.getMemberNameColor())
holder.memberNameView.onClick(_memberNameClickListener)
holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
} else {
holder.memberNameView.setOnClickListener(null)
holder.memberNameView.setOnLongClickListener(null)
holder.memberNameView.isVisible = false
}
if (attributes.informationData.messageLayout.showTimestamp) {
holder.timeView.isVisible = true
holder.timeView.text = attributes.informationData.time
} else {
holder.timeView.isVisible = false
}
// Render send state indicator
holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
// Threads // Threads
if (attributes.areThreadMessagesEnabled) { if (attributes.areThreadMessagesEnabled) {
@ -151,14 +160,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
super.unbind(holder) super.unbind(holder)
} }
private fun Attributes.getMemberNameColor() = messageColorProvider.getMemberNameTextColor( override fun getInformationData(): MessageInformationData? = attributes.informationData
informationData.matrixItem,
MatrixItemColorProvider.UserInRoomInformation(
attributes.informationData.isDirect,
attributes.informationData.isPublic,
attributes.informationData.senderPowerLevel
)
)
abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) { abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) {
@ -216,10 +218,20 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
return result return result
} }
fun getMemberNameColor() = messageColorProvider.getMemberNameTextColor(
informationData.matrixItem,
MatrixItemColorProvider.UserInRoomInformation(
informationData.isDirect,
informationData.isPublic,
informationData.senderPowerLevel
)
)
} }
override fun ignoreMessageGuideline(context: Context): Boolean { override fun ignoreMessageGuideline(context: Context): Boolean {
return false // SC-TODO infoInBubbles(context) && canHideAvatars() val messageLayout = attributes.informationData.messageLayout as? TimelineMessageLayout.ScBubble ?: return false
return infoInBubbles(messageLayout) && canHideAvatars(attributes)
} }
} }

View File

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.item
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewStub import android.view.ViewStub
import android.widget.FrameLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.IdRes import androidx.annotation.IdRes
@ -28,6 +29,7 @@ import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.platform.CheckableView import im.vector.app.core.platform.CheckableView
import im.vector.app.core.ui.views.BubbleDependentView import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
/** /**
* Children must override getViewType() * Children must override getViewType()
@ -71,6 +73,7 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
val leftGuideline by bind<View>(R.id.messageStartGuideline) val leftGuideline by bind<View>(R.id.messageStartGuideline)
val contentContainer by bind<View>(R.id.viewStubContainer) val contentContainer by bind<View>(R.id.viewStubContainer)
val checkableBackground by bind<CheckableView>(R.id.messageSelectedBackground) val checkableBackground by bind<CheckableView>(R.id.messageSelectedBackground)
val viewStubContainer by bind<FrameLayout>(R.id.viewStubContainer)
override fun bindView(itemView: View) { override fun bindView(itemView: View) {
super.bindView(itemView) super.bindView(itemView)

View File

@ -21,6 +21,9 @@ import android.widget.TextView
import androidx.annotation.IdRes import androidx.annotation.IdRes
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer
import im.vector.app.features.home.room.detail.timeline.view.scOnlyRenderMessageLayout
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
abstract class BasedMergedItem<H : BasedMergedItem.Holder> : BaseEventItem<H>() { abstract class BasedMergedItem<H : BasedMergedItem.Holder> : BaseEventItem<H>() {
@ -39,6 +42,8 @@ abstract class BasedMergedItem<H : BasedMergedItem.Holder> : BaseEventItem<H>()
holder.separatorView.visibility = View.VISIBLE holder.separatorView.visibility = View.VISIBLE
holder.expandView.setText(R.string.merged_events_collapse) holder.expandView.setText(R.string.merged_events_collapse)
} }
(holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.messageLayout, this, holder)
} }
protected val distinctMergeData by lazy { protected val distinctMergeData by lazy {
@ -69,6 +74,7 @@ abstract class BasedMergedItem<H : BasedMergedItem.Holder> : BaseEventItem<H>()
val mergeData: List<Data> val mergeData: List<Data>
val avatarRenderer: AvatarRenderer val avatarRenderer: AvatarRenderer
val onCollapsedStateChanged: (Boolean) -> Unit val onCollapsedStateChanged: (Boolean) -> Unit
val messageLayout: TimelineMessageLayout
} }
abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) {

View File

@ -23,6 +23,8 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer
import im.vector.app.features.home.room.detail.timeline.view.scOnlyRenderMessageLayout
@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo)
abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() { abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
@ -35,6 +37,8 @@ abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
holder.messageTextView.text = attributes.text holder.messageTextView.text = attributes.text
attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView) attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.view.setOnLongClickListener(attributes.itemLongClickListener)
(holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.informationData.messageLayout, this, holder)
} }
override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {

View File

@ -25,6 +25,7 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo)
abstract class MergedMembershipEventsItem : BasedMergedItem<MergedMembershipEventsItem.Holder>() { abstract class MergedMembershipEventsItem : BasedMergedItem<MergedMembershipEventsItem.Holder>() {
@ -69,6 +70,7 @@ abstract class MergedMembershipEventsItem : BasedMergedItem<MergedMembershipEven
override val isCollapsed: Boolean, override val isCollapsed: Boolean,
override val mergeData: List<Data>, override val mergeData: List<Data>,
override val avatarRenderer: AvatarRenderer, override val avatarRenderer: AvatarRenderer,
override val onCollapsedStateChanged: (Boolean) -> Unit override val onCollapsedStateChanged: (Boolean) -> Unit,
override val messageLayout: TimelineMessageLayout
) : BasedMergedItem.Attributes ) : BasedMergedItem.Attributes
} }

View File

@ -37,6 +37,7 @@ import im.vector.app.core.utils.tappableMatchingText
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.home.room.detail.timeline.tools.linkify
import me.gujun.android.span.span import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -224,6 +225,7 @@ abstract class MergedRoomCreationItem : BasedMergedItem<MergedRoomCreationItem.H
override val mergeData: List<Data>, override val mergeData: List<Data>,
override val avatarRenderer: AvatarRenderer, override val avatarRenderer: AvatarRenderer,
override val onCollapsedStateChanged: (Boolean) -> Unit, override val onCollapsedStateChanged: (Boolean) -> Unit,
override val messageLayout: TimelineMessageLayout,
val callback: TimelineEventController.Callback? = null, val callback: TimelineEventController.Callback? = null,
val currentUserId: String, val currentUserId: String,
val hasEncryptionEvent: Boolean, val hasEncryptionEvent: Boolean,

View File

@ -33,6 +33,9 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadSt
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.themes.guessTextWidth
import kotlin.math.ceil
import kotlin.math.max
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() { abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@ -103,6 +106,18 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
contentDownloadStateTrackerBinder.unbind(mxcUrl) contentDownloadStateTrackerBinder.unbind(mxcUrl)
} }
override fun getViewStubMinimumWidth(holder: Holder): Int {
// Guess text width for name and time
// On first call, holder.fileImageView.width is not initialized yet
val imageWidth = holder.fileImageView.resources.getDimensionPixelSize(R.dimen.chat_avatar_size)
val minimumWidthWithText =
ceil(guessTextWidth(holder.filenameView, filename)).toInt() +
imageWidth +
holder.filenameView.resources.getDimensionPixelSize(R.dimen.sc_bubble_guess_minimum_width_padding)
val absoluteMinimumWidth = imageWidth*3
return max(absoluteMinimumWidth, minimumWidthWithText)
}
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {

View File

@ -16,9 +16,11 @@
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.content.res.Resources
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
@ -33,6 +35,7 @@ import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.MimeTypes
@ -57,7 +60,6 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
@EpoxyAttribute @EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
// SC-TODO
var lastAllowedFooterOverlay: Boolean = true var lastAllowedFooterOverlay: Boolean = true
var lastShowFooterBellow: Boolean = true var lastShowFooterBellow: Boolean = true
var forceAllowFooterOverlay: Boolean? = null var forceAllowFooterOverlay: Boolean? = null
@ -67,18 +69,19 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
forceAllowFooterOverlay = null forceAllowFooterOverlay = null
super.bind(holder) super.bind(holder)
val bubbleWrapView = (holder.view as? ScMessageBubbleWrapView)
val host = this
val onImageSizeListener = object: ImageContentRenderer.OnImageSizeListener { val onImageSizeListener = object: ImageContentRenderer.OnImageSizeListener {
override fun onImageSizeUpdated(width: Int, height: Int) { override fun onImageSizeUpdated(width: Int, height: Int) {
bubbleWrapView ?: return
// Image size change -> different footer space situation possible // Image size change -> different footer space situation possible
/* SC-TODO val footerMeasures = bubbleWrapView.getFooterMeasures(attributes.informationData)
val footerMeasures = getFooterMeasures(holder)
forceAllowFooterOverlay = shouldAllowFooterOverlay(footerMeasures, width, height) forceAllowFooterOverlay = shouldAllowFooterOverlay(footerMeasures, width, height)
val newShowFooterBellow = shouldShowFooterBellow(footerMeasures, width, height) val newShowFooterBellow = shouldShowFooterBellow(footerMeasures, width, height)
if (lastAllowedFooterOverlay != forceAllowFooterOverlay || newShowFooterBellow != lastShowFooterBellow) { if (lastAllowedFooterOverlay != forceAllowFooterOverlay || newShowFooterBellow != lastShowFooterBellow) {
showFooterBellow = newShowFooterBellow showFooterBellow = newShowFooterBellow
updateMessageBubble(holder.imageView.context, holder) bubbleWrapView.renderMessageLayout(attributes.informationData.messageLayout, host, holder)
} }
*/
} }
} }
val animate = mediaData.mimeType == MimeTypes.Gif val animate = mediaData.mimeType == MimeTypes.Gif
@ -87,11 +90,10 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
val messageLayout = baseAttributes.informationData.messageLayout val messageLayout = baseAttributes.informationData.messageLayout
val dimensionConverter = DimensionConverter(holder.view.resources) val dimensionConverter = DimensionConverter(holder.view.resources)
// SC-TODO handle SC bubbles val imageCornerTransformation = when (messageLayout) {
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { is TimelineMessageLayout.ScBubble -> RoundedCorners(dimensionConverter.dpToPx(3))
messageLayout.cornersRadius.granularRoundedCorners() is TimelineMessageLayout.Bubble -> messageLayout.cornersRadius.granularRoundedCorners()
} else { else -> RoundedCorners(dimensionConverter.dpToPx(8))
RoundedCorners(dimensionConverter.dpToPx(8))
} }
imageContentRenderer.render(mediaData, effectiveMode, holder.imageView, imageCornerTransformation, onImageSizeListener) imageContentRenderer.render(mediaData, effectiveMode, holder.imageView, imageCornerTransformation, onImageSizeListener)
if (!attributes.informationData.sendState.hasFailed()) { if (!attributes.informationData.sendState.hasFailed()) {
@ -122,7 +124,6 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID
// SC-TODO
private fun shouldAllowFooterOverlay(footerMeasures: Array<Int>, imageWidth: Int, imageHeight: Int): Boolean { private fun shouldAllowFooterOverlay(footerMeasures: Array<Int>, imageWidth: Int, imageHeight: Int): Boolean {
val footerWidth = footerMeasures[0] val footerWidth = footerMeasures[0]
val footerHeight = footerMeasures[1] val footerHeight = footerMeasures[1]
@ -131,7 +132,6 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
return imageWidth > 1.5*footerWidth && imageHeight > 1.5*footerHeight && (imageWidth * imageHeight > 4 * footerWidth * footerHeight) return imageWidth > 1.5*footerWidth && imageHeight > 1.5*footerHeight && (imageWidth * imageHeight > 4 * footerWidth * footerHeight)
} }
// SC-TODO
private fun shouldShowFooterBellow(footerMeasures: Array<Int>, imageWidth: Int, imageHeight: Int): Boolean { private fun shouldShowFooterBellow(footerMeasures: Array<Int>, imageWidth: Int, imageHeight: Int): Boolean {
// Only show footer bellow if the width is not the limiting factor (or it will get cut). // Only show footer bellow if the width is not the limiting factor (or it will get cut).
// Otherwise, we can not be sure in this place that we'll have enough space on the side // Otherwise, we can not be sure in this place that we'll have enough space on the side
@ -141,6 +141,57 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
return imageWidth > 1.5*footerWidth && imageHeight < 1.5*footerHeight return imageWidth > 1.5*footerWidth && imageHeight < 1.5*footerHeight
} }
override fun allowFooterOverlay(holder: Holder, bubbleWrapView: ScMessageBubbleWrapView): Boolean {
val rememberedAllowFooterOverlay = forceAllowFooterOverlay
if (rememberedAllowFooterOverlay != null) {
lastAllowedFooterOverlay = rememberedAllowFooterOverlay
return rememberedAllowFooterOverlay
}
val imageWidth = holder.imageView.width
val imageHeight = holder.imageView.height
if (imageWidth == 0 && imageHeight == 0) {
// Not initialised yet, assume true
lastAllowedFooterOverlay = true
return true
}
// If the footer covers most of the image, or is even larger than the image, move it outside
val footerMeasures = bubbleWrapView.getFooterMeasures(baseAttributes.informationData)
lastAllowedFooterOverlay = shouldAllowFooterOverlay(footerMeasures, imageWidth, imageHeight)
return lastAllowedFooterOverlay
}
override fun allowFooterBelow(holder: Holder): Boolean {
val showBellow = showFooterBellow
lastShowFooterBellow = showBellow
return showBellow
}
override fun getScBubbleMargin(resources: Resources): Int {
return 0
}
override fun applyScBubbleStyle(messageLayout: TimelineMessageLayout.ScBubble, holder: Holder) {
// Case: ImageContentRenderer.processSize only sees width=height=0 -> width of the ImageView not adapted to the actual content
// -> Align image within ImageView to same side as message bubbles
holder.imageView.scaleType = if (messageLayout.reverseBubble) ImageView.ScaleType.FIT_END else ImageView.ScaleType.FIT_START
// Case: Message information (sender name + date) makes the containing view wider than the ImageView
// -> Align ImageView within its parent to the same side as message bubbles
(holder.imageView.layoutParams as ConstraintLayout.LayoutParams).horizontalBias = if (messageLayout.reverseBubble) 1f else 0f
// Image outline
when {
!(messageLayout.isRealBubble || messageLayout.isPseudoBubble) || mode != ImageContentRenderer.Mode.THUMBNAIL -> {
// Don't show it for non-bubble layouts, don't show for Stickers, ...
holder.mediaContentView.background = null
}
attributes.informationData.sentByMe -> {
holder.mediaContentView.setBackgroundResource(R.drawable.background_image_border_outgoing)
}
else -> {
holder.mediaContentView.setBackgroundResource(R.drawable.background_image_border_incoming)
}
}
}
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout) val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)

View File

@ -35,6 +35,7 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlViewSc import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlViewSc
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
import io.noties.markwon.MarkwonPlugin import io.noties.markwon.MarkwonPlugin
@ -174,6 +175,32 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
} }
} }
override fun allowFooterOverlay(holder: Holder, bubbleWrapView: ScMessageBubbleWrapView): Boolean {
return true
}
override fun needsFooterReservation(): Boolean {
return true
}
override fun reserveFooterSpace(holder: Holder, width: Int, height: Int) {
// Remember for PreviewUrlViewUpdater.onStateUpdated
footerWidth = width
footerHeight = height
// Reserve both in preview and in message
// User might close preview, so we still need place in the message
// if we don't want to change this afterwards
// This might be a race condition, but the UI-isssue if evaluated wrongly is negligible
if (!holder.previewUrlView.isVisible) {
holder.messageView.footerWidth = width
holder.messageView.footerHeight = height
} // else: will be handled in onStateUpdated
holder.previewUrlViewSc.footerWidth = height
holder.previewUrlViewSc.footerHeight = height
holder.previewUrlViewSc.updateFooterSpace()
}
companion object { companion object {
private const val STUB_ID = R.id.messageContentTextStub private const val STUB_ID = R.id.messageContentTextStub
} }

View File

@ -27,6 +27,8 @@ import im.vector.app.core.epoxy.onClick
import im.vector.app.core.ui.views.ShieldImageView import im.vector.app.core.ui.views.ShieldImageView
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer
import im.vector.app.features.home.room.detail.timeline.view.scOnlyRenderMessageLayout
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel
@ -53,6 +55,8 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning)
} }
} }
(holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.informationData.messageLayout, this, holder)
} }
override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {

View File

@ -16,6 +16,9 @@
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
@ -24,15 +27,21 @@ import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.resources.LocaleProvider
import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale
import im.vector.app.core.ui.views.BubbleDependentView import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.core.ui.views.ReadReceiptsView import im.vector.app.core.ui.views.ReadReceiptsView
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
import im.vector.app.features.home.room.detail.timeline.view.setFlatRtl
@EpoxyModelClass(layout = R.layout.item_timeline_event_read_receipts) @EpoxyModelClass(layout = R.layout.item_timeline_event_read_receipts)
abstract class ReadReceiptsItem : EpoxyModelWithHolder<ReadReceiptsItem.Holder>(), ItemWithEvents, BubbleDependentView<ReadReceiptsItem.Holder> { abstract class ReadReceiptsItem : EpoxyModelWithHolder<ReadReceiptsItem.Holder>(), ItemWithEvents, BubbleDependentView<ReadReceiptsItem.Holder> {
@EpoxyAttribute lateinit var eventId: String @EpoxyAttribute lateinit var eventId: String
@EpoxyAttribute lateinit var readReceipts: List<ReadReceiptData> @EpoxyAttribute lateinit var readReceipts: List<ReadReceiptData>
@EpoxyAttribute lateinit var messageLayout: TimelineMessageLayout
@EpoxyAttribute var shouldHideReadReceipts: Boolean = false @EpoxyAttribute var shouldHideReadReceipts: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var clickListener: ClickListener @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var clickListener: ClickListener
@ -46,6 +55,8 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder<ReadReceiptsItem.Holder>(
holder.readReceiptsView.onClick(clickListener) holder.readReceiptsView.onClick(clickListener)
holder.readReceiptsView.render(readReceipts, avatarRenderer) holder.readReceiptsView.render(readReceipts, avatarRenderer)
(messageLayout as? TimelineMessageLayout.ScBubble)?.let { applyScBubbleStyle(it, holder) }
holder.readReceiptsView.isVisible = !shouldHideReadReceipts holder.readReceiptsView.isVisible = !shouldHideReadReceipts
} }
@ -54,6 +65,46 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder<ReadReceiptsItem.Holder>(
super.unbind(holder) super.unbind(holder)
} }
override fun applyScBubbleStyle(messageLayout: TimelineMessageLayout.ScBubble, holder: Holder) {
val defaultDirection = LocaleProvider(holder.view.resources).getLayoutDirectionFromCurrentLocale()
val defaultRtl = defaultDirection == View.LAYOUT_DIRECTION_RTL
val reverseDirection = if (defaultRtl) View.LAYOUT_DIRECTION_LTR else View.LAYOUT_DIRECTION_RTL
/*
val receiptParent = holder.readReceiptsView.parent
if (receiptParent is LinearLayout) {
(holder.readReceiptsView.layoutParams as LinearLayout.LayoutParams).gravity = if (dualBubbles) Gravity.START else Gravity.END
(receiptParent.layoutParams as RelativeLayout.LayoutParams).removeRule(RelativeLayout.END_OF)
(receiptParent.layoutParams as RelativeLayout.LayoutParams).removeRule(RelativeLayout.ALIGN_PARENT_START)
if (dualBubbles) {
(receiptParent.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.ALIGN_PARENT_START, RelativeLayout.TRUE)
} else {
(receiptParent.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.END_OF, R.id.messageStartGuideline)
}
} else if (receiptParent is RelativeLayout) {
if (dualBubbles) {
(holder.readReceiptsView.layoutParams as RelativeLayout.LayoutParams).removeRule(RelativeLayout.ALIGN_PARENT_END)
} else {
(holder.readReceiptsView.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.ALIGN_PARENT_END)
}
} else if (receiptParent is FrameLayout) {
*/
if (messageLayout.singleSidedLayout) {
(holder.readReceiptsView.layoutParams as FrameLayout.LayoutParams).gravity = Gravity.END
} else {
(holder.readReceiptsView.layoutParams as FrameLayout.LayoutParams).gravity = Gravity.START
}
/*
} else {
Timber.e("Unsupported layout for read receipts parent: $receiptParent")
}
*/
// Also set rtl to have members fill from the natural side
setFlatRtl(holder.readReceiptsView, if (messageLayout.singleSidedLayout) defaultDirection else reverseDirection, defaultDirection)
}
class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView) val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
} }

View File

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.style
import android.os.Parcelable import android.os.Parcelable
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.AnonymousReadReceipt
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface TimelineMessageLayout : Parcelable { sealed interface TimelineMessageLayout : Parcelable {
@ -25,11 +26,13 @@ sealed interface TimelineMessageLayout : Parcelable {
val showAvatar: Boolean val showAvatar: Boolean
val showDisplayName: Boolean val showDisplayName: Boolean
val showTimestamp: Boolean val showTimestamp: Boolean
val showE2eDecoration: Boolean
@Parcelize @Parcelize
data class Default(override val showAvatar: Boolean, data class Default(override val showAvatar: Boolean,
override val showDisplayName: Boolean, override val showDisplayName: Boolean,
override val showTimestamp: Boolean, override val showTimestamp: Boolean,
override val showE2eDecoration: Boolean,
// Keep defaultLayout generated on epoxy items // Keep defaultLayout generated on epoxy items
override val layoutRes: Int = 0) : TimelineMessageLayout override val layoutRes: Int = 0) : TimelineMessageLayout
@ -38,6 +41,7 @@ sealed interface TimelineMessageLayout : Parcelable {
override val showAvatar: Boolean, override val showAvatar: Boolean,
override val showDisplayName: Boolean, override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true, override val showTimestamp: Boolean = true,
override val showE2eDecoration: Boolean = true,
val isIncoming: Boolean, val isIncoming: Boolean,
val isPseudoBubble: Boolean, val isPseudoBubble: Boolean,
val cornersRadius: CornersRadius, val cornersRadius: CornersRadius,
@ -60,22 +64,22 @@ sealed interface TimelineMessageLayout : Parcelable {
@Parcelize @Parcelize
data class ScBubble( data class ScBubble(
// SC-TODO adapt me
override val showAvatar: Boolean, override val showAvatar: Boolean,
override val showDisplayName: Boolean, override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true, override val showTimestamp: Boolean = true,
// SC-TODO?? Keep defaultLayout generated on epoxy items override val showE2eDecoration: Boolean = false,
override val layoutRes: Int = 0
/* SC-TODO
val isIncoming: Boolean, val isIncoming: Boolean,
val reverseBubble: Boolean,
val singleSidedLayout: Boolean,
val isRealBubble: Boolean,
val isPseudoBubble: Boolean, val isPseudoBubble: Boolean,
val isNotice: Boolean,
val timestampAsOverlay: Boolean, val timestampAsOverlay: Boolean,
override val layoutRes: Int = if (isIncoming) { override val layoutRes: Int = if (isIncoming) {
R.layout.item_timeline_event_bubble_incoming_base R.layout.item_timeline_event_sc_bubble_incoming_base
} else { } else {
R.layout.item_timeline_event_bubble_outgoing_base R.layout.item_timeline_event_sc_bubble_outgoing_base
} }
*/
) : TimelineMessageLayout ) : TimelineMessageLayout
} }

View File

@ -23,9 +23,11 @@ import im.vector.app.core.resources.LocaleProvider
import im.vector.app.core.resources.isRTL import im.vector.app.core.resources.isRTL
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.themes.BubbleThemeUtils
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageNoticeContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -37,6 +39,7 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
private val layoutSettingsProvider: TimelineLayoutSettingsProvider, private val layoutSettingsProvider: TimelineLayoutSettingsProvider,
private val localeProvider: LocaleProvider, private val localeProvider: LocaleProvider,
private val resources: Resources, private val resources: Resources,
private val bubbleThemeUtils: BubbleThemeUtils,
private val vectorPreferences: VectorPreferences) { private val vectorPreferences: VectorPreferences) {
companion object { companion object {
@ -96,8 +99,22 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
val messageLayout = when (layoutSettingsProvider.getLayoutSettings()) { val messageLayout = when (layoutSettingsProvider.getLayoutSettings()) {
TimelineLayoutSettings.SC_BUBBLE -> { TimelineLayoutSettings.SC_BUBBLE -> {
// SC-TODO? val messageContent = event.getLastMessageContent()
buildModernLayout(showInformation) val isBubble = event.shouldBuildBubbleLayout()
val singleSidedLayout = bubbleThemeUtils.getBubbleStyle() == BubbleThemeUtils.BUBBLE_STYLE_START
val pseudoBubble = messageContent.isPseudoBubble()
return TimelineMessageLayout.ScBubble(
showAvatar = showInformation,
showDisplayName = showInformation,
showTimestamp = !singleSidedLayout || vectorPreferences.alwaysShowTimeStamps(),
isIncoming = !isSentByMe,
isNotice = messageContent is MessageNoticeContent,
reverseBubble = isSentByMe && !singleSidedLayout,
singleSidedLayout = singleSidedLayout,
isRealBubble = isBubble && !pseudoBubble,
isPseudoBubble = pseudoBubble,
timestampAsOverlay = messageContent.timestampAsOverlay()
)
} }
TimelineLayoutSettings.MODERN -> { TimelineLayoutSettings.MODERN -> {
buildModernLayout(showInformation) buildModernLayout(showInformation)
@ -135,6 +152,35 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
return messageLayout return messageLayout
} }
/**
* Just a dumb layout setting, so we get basic ScBubble settings in strictly-non-bubble classes as well
*/
fun createDummy(): TimelineMessageLayout {
return when (layoutSettingsProvider.getLayoutSettings()) {
TimelineLayoutSettings.SC_BUBBLE -> {
val singleSidedLayout = bubbleThemeUtils.getBubbleStyle() == BubbleThemeUtils.BUBBLE_STYLE_START
return TimelineMessageLayout.ScBubble(
showAvatar = false,
showDisplayName = false,
showTimestamp = true,
isIncoming = false,
isNotice = false,
reverseBubble = false,
singleSidedLayout = singleSidedLayout,
isRealBubble = false,
isPseudoBubble = false,
timestampAsOverlay = false
)
}
else -> TimelineMessageLayout.Default(
showAvatar = false,
showDisplayName = false,
showTimestamp = vectorPreferences.alwaysShowTimeStamps(),
showE2eDecoration = false
)
}
}
private fun MessageContent?.isPseudoBubble(): Boolean { private fun MessageContent?.isPseudoBubble(): Boolean {
if (this == null) return false if (this == null) return false
if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline()
@ -156,19 +202,12 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
return false return false
} }
private fun buildModernLayout(showInformation: Boolean): TimelineMessageLayout.Default { private fun buildModernLayout(showInformation: Boolean, forScBubbles: Boolean = false): TimelineMessageLayout.Default {
return TimelineMessageLayout.Default( return TimelineMessageLayout.Default(
showAvatar = showInformation, showAvatar = showInformation,
showDisplayName = showInformation, showDisplayName = showInformation,
showTimestamp = showInformation || vectorPreferences.alwaysShowTimeStamps() showTimestamp = showInformation || vectorPreferences.alwaysShowTimeStamps(),
) showE2eDecoration = !forScBubbles
}
private fun buildScLayout(showInformation: Boolean): TimelineMessageLayout.ScBubble {
return TimelineMessageLayout.ScBubble(
showAvatar = showInformation,
showDisplayName = showInformation,
showTimestamp = true
) )
} }

View File

@ -3,10 +3,10 @@ package im.vector.app.features.home.room.detail.timeline.url
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
interface AbstractPreviewUrlView: TimelineMessageLayoutRenderer { interface AbstractPreviewUrlView {
var isVisible: Boolean var isVisible: Boolean
get() = (this as View).isVisible get() = (this as View).isVisible
set(value) { (this as View).isVisible = value } set(value) { (this as View).isVisible = value }
@ -15,4 +15,7 @@ interface AbstractPreviewUrlView: TimelineMessageLayoutRenderer {
fun render(newState: PreviewUrlUiState, fun render(newState: PreviewUrlUiState,
imageContentRenderer: ImageContentRenderer, imageContentRenderer: ImageContentRenderer,
force: Boolean = false) force: Boolean = false)
// Like upstream TimelineMessageLayoutRenderer, not like downstream one, so don't inherit
fun renderMessageLayout(messageLayout: TimelineMessageLayout)
} }

View File

@ -35,8 +35,10 @@ import com.google.android.material.shape.MaterialShapeDrawable
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.resources.LocaleProvider
import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale
import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewMessageBubbleBinding import im.vector.app.databinding.ViewMessageBubbleBinding
import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.shapeAppearanceModel import im.vector.app.features.home.room.detail.timeline.style.shapeAppearanceModel
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
@ -93,7 +95,7 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
} }
} }
override fun renderMessageLayout(messageLayout: TimelineMessageLayout) { override fun <H: BaseEventItem.BaseHolder>renderMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView<H>, holder: H) {
if (messageLayout !is TimelineMessageLayout.Bubble) { if (messageLayout !is TimelineMessageLayout.Bubble) {
Timber.v("Can't render messageLayout $messageLayout") Timber.v("Can't render messageLayout $messageLayout")
return return

View File

@ -0,0 +1,609 @@
package im.vector.app.features.home.room.detail.timeline.view
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.content.withStyledAttributes
import androidx.core.view.children
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.resources.LocaleProvider
import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale
import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.databinding.ViewMessageBubbleScBinding
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.AnonymousReadReceipt
import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.themes.BubbleThemeUtils
import im.vector.app.features.themes.BubbleThemeUtils.Companion.BUBBLE_TIME_BOTTOM
import im.vector.app.features.themes.BubbleThemeUtils.Companion.BUBBLE_TIME_TOP
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.themes.guessTextWidth
import timber.log.Timber
import kotlin.math.ceil
import kotlin.math.max
class ScMessageBubbleWrapView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) :
RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
private var isIncoming: Boolean = false
private lateinit var views: ViewMessageBubbleScBinding
init {
inflate(context, R.layout.view_message_bubble_sc, this)
context.withStyledAttributes(attrs, R.styleable.MessageBubble) {
isIncoming = getBoolean(R.styleable.MessageBubble_incoming_style, false)
}
}
override fun onFinishInflate() {
super.onFinishInflate()
views = ViewMessageBubbleScBinding.bind(this)
// SC-TODO ... ?
}
fun <H : VectorEpoxyHolder> customBind(
bubbleDependentView: BubbleDependentView<H>,
holder: H,
attributes: AbsMessageItem.Attributes,
_avatarClickListener: ClickListener,
_memberNameClickListener: ClickListener): Boolean {
if (attributes.informationData.messageLayout !is TimelineMessageLayout.ScBubble) {
Timber.v("Can't render messageLayout ${attributes.informationData.messageLayout}")
return false
}
val contentInBubble = infoInBubbles(attributes.informationData.messageLayout)
val senderInBubble = senderNameInBubble(attributes.informationData.messageLayout)
val avatarImageView: ImageView?
var memberNameView: TextView?
var timeView: TextView?
val hiddenViews = ArrayList<View>()
val invisibleViews = ArrayList<View>()
val canHideAvatar = canHideAvatars(attributes)
val canHideSender = canHideSender(attributes)
// Select which views are visible, based on bubble style and other criteria
if (attributes.informationData.messageLayout.showDisplayName) {
if (senderInBubble) {
memberNameView = views.bubbleMessageMemberNameView
hiddenViews.add(views.messageMemberNameView)
} else {
memberNameView = views.messageMemberNameView
hiddenViews.add(views.bubbleMessageMemberNameView)
}
if (contentInBubble) {
timeView = views.bubbleMessageTimeView
hiddenViews.add(views.messageTimeView)
} else {
timeView = views.messageTimeView
hiddenViews.add(views.bubbleMessageTimeView)
}
} else if (attributes.informationData.messageLayout.showTimestamp) {
memberNameView = null
//hiddenViews.add(views.memberNameView) // this one get's some special hiding treatment below
hiddenViews.add(views.bubbleMessageMemberNameView)
if (contentInBubble) {
timeView = views.bubbleMessageTimeView
hiddenViews.add(views.messageTimeView)
hiddenViews.add(views.messageMemberNameView)
} else {
timeView = views.messageTimeView
hiddenViews.add(views.bubbleMessageTimeView)
// Set to INVISIBLE instead of adding to hiddenViews, which are set to GONE
// (upstream sets memberNameView.isInvisible = true here, which is effectively the same)
invisibleViews.add(views.messageMemberNameView)
}
} else {
memberNameView = null
hiddenViews.add(views.messageMemberNameView)
hiddenViews.add(views.bubbleMessageMemberNameView)
timeView = null
hiddenViews.add(views.messageTimeView)
hiddenViews.add(views.bubbleMessageTimeView)
}
if (timeView === views.bubbleMessageTimeView) {
// We have two possible bubble time view locations
// For code readability, we don't inline this setting in the above cases
if (getBubbleTimeLocation(attributes.informationData.messageLayout) == BubbleThemeUtils.BUBBLE_TIME_BOTTOM) {
timeView = views.bubbleFooterMessageTimeView
if (attributes.informationData.messageLayout.showDisplayName) {
if (canHideSender) {
// In the case of footer time, we can also hide the names without making it look awkward
if (memberNameView != null) {
hiddenViews.add(memberNameView)
memberNameView = null
}
hiddenViews.add(views.bubbleMessageTimeView)
} else if (!senderInBubble) {
// We don't need to reserve space here
hiddenViews.add(views.bubbleMessageTimeView)
} else {
// Don't completely remove, just hide, so our relative layout rules still work
invisibleViews.add(views.bubbleMessageTimeView)
}
} else {
// Do hide, or we accidentally reserve space
hiddenViews.add(views.bubbleMessageTimeView)
}
} else {
hiddenViews.add(views.bubbleFooterMessageTimeView)
}
}
// Dual-side bubbles: hide own avatar, and all in direct chats
if ((!attributes.informationData.messageLayout.showAvatar) ||
(contentInBubble && canHideAvatar)) {
avatarImageView = null
hiddenViews.add(views.messageAvatarImageView)
} else {
avatarImageView = views.messageAvatarImageView
}
// Views available in upstream Element
avatarImageView?.layoutParams = avatarImageView?.layoutParams?.apply {
height = attributes.avatarSize
width = attributes.avatarSize
}
avatarImageView?.visibility = View.VISIBLE
avatarImageView?.onClick(_avatarClickListener)
memberNameView?.visibility = View.VISIBLE
memberNameView?.onClick(_memberNameClickListener)
timeView?.visibility = View.VISIBLE
timeView?.text = attributes.informationData.time
memberNameView?.text = attributes.informationData.memberName
memberNameView?.setTextColor(attributes.getMemberNameColor())
if (avatarImageView != null) attributes.avatarRenderer.render(attributes.informationData.matrixItem, avatarImageView)
avatarImageView?.setOnLongClickListener(attributes.itemLongClickListener)
memberNameView?.setOnLongClickListener(attributes.itemLongClickListener)
// More extra views added by Schildi
if (senderInBubble) {
views.viewStubContainer.root.minimumWidth = getViewStubMinimumWidth(bubbleDependentView, holder, attributes, contentInBubble, canHideSender)
} else {
views.viewStubContainer.root.minimumWidth = 0
}
if (contentInBubble) {
views.bubbleFootView.visibility = View.VISIBLE
} else {
hiddenViews.add(views.bubbleFootView)
}
// Actually hide all unnecessary views
hiddenViews.forEach {
// Same as it.isVisible = false
it.visibility = View.GONE
}
invisibleViews.forEach {
// Same as it.isInvisible = true
it.visibility = View.INVISIBLE
}
// Render send state indicator
if (contentInBubble) {
// Bubbles have their own decoration in the anonymous read receipt (in the message footer)
views.messageSendStateImageView.isVisible = false
views.eventSendingIndicator.isVisible = false
} else {
views.messageSendStateImageView.render(attributes.informationData.sendStateDecoration)
views.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
}
return true
}
override fun <H : VectorEpoxyHolder> renderBaseMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView<H>, holder: H) {
if (messageLayout !is TimelineMessageLayout.ScBubble) {
Timber.v("Can't render messageLayout $messageLayout")
return
}
bubbleDependentView.applyScBubbleStyle(messageLayout, holder)
renderStubMessageLayout(messageLayout, views.viewStubContainer.root)
// Padding for views that align with the bubble (should be roughly the bubble tail width)
val bubbleStartAlignWidth = views.informationBottom.resources.getDimensionPixelSize(R.dimen.sc_bubble_tail_size)
if (messageLayout.reverseBubble) {
// Align reactions container to bubble
views.informationBottom.setPaddingRelative(
0,
0,
bubbleStartAlignWidth,
0
)
} else {
// Align reactions container to bubble
views.informationBottom.setPaddingRelative(
bubbleStartAlignWidth,
0,
0,
0
)
}
}
override fun <H : BaseEventItem.BaseHolder> renderMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView<H>, holder: H) {
if (messageLayout !is TimelineMessageLayout.ScBubble) {
Timber.v("Can't render messageLayout $messageLayout")
return
}
renderBaseMessageLayout(messageLayout, bubbleDependentView, holder)
val bubbleView = views.bubbleView
val informationData = bubbleDependentView.getInformationData()
val contentInBubble = infoInBubbles(messageLayout)
val defaultDirection = LocaleProvider(resources).getLayoutDirectionFromCurrentLocale()
val defaultRtl = defaultDirection == View.LAYOUT_DIRECTION_RTL
val reverseDirection = if (defaultRtl) View.LAYOUT_DIRECTION_LTR else View.LAYOUT_DIRECTION_RTL
// Notice formatting - also relevant if no actual bubbles are shown
bubbleView.alpha = if (messageLayout.isNotice) 0.65f else 1f
if (messageLayout.isRealBubble || messageLayout.isPseudoBubble) {
// Padding for bubble content: long for side with tail, short for other sides
val longPaddingDp: Int
val shortPaddingDp: Int
if (!messageLayout.isPseudoBubble) {
val bubbleRes = if (messageLayout.showAvatar) { // tail
if (messageLayout.reverseBubble) { // outgoing
R.drawable.msg_bubble_text_outgoing
} else { // incoming
R.drawable.msg_bubble_text_incoming
}
} else { // notail
if (messageLayout.reverseBubble) { // outgoing
R.drawable.msg_bubble_text_outgoing_notail
} else { // incoming
R.drawable.msg_bubble_text_incoming_notail
}
}
bubbleView.setBackgroundResource(bubbleRes)
longPaddingDp = bubbleView.resources.getDimensionPixelSize(R.dimen.sc_bubble_inner_padding_long_side)
shortPaddingDp = bubbleView.resources.getDimensionPixelSize(R.dimen.sc_bubble_inner_padding_short_side)
} else {
longPaddingDp = bubbleView.resources.getDimensionPixelSize(R.dimen.sc_bubble_tail_size)
shortPaddingDp = 0//if (attributes.informationData.showInformation && !hideSenderInformation()) { 8 } else { 0 }
}
if (messageLayout.reverseBubble != defaultRtl) {
// Use left/right instead of start/end: bubbleView is always LTR
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = bubbleDependentView.getScBubbleMargin(bubbleView.resources)
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).rightMargin = 0
} else {
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).leftMargin = 0
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).rightMargin = bubbleDependentView.getScBubbleMargin(bubbleView.resources)
}
if (messageLayout.reverseBubble != defaultRtl) {
// Use left/right instead of start/end: bubbleView is always LTR
bubbleView.setPadding(
shortPaddingDp,
shortPaddingDp,
longPaddingDp,
shortPaddingDp
)
} else {
bubbleView.setPadding(
longPaddingDp,
shortPaddingDp,
shortPaddingDp,
shortPaddingDp
)
}
if (contentInBubble) {
val anonymousReadReceipt = BubbleThemeUtils.getVisibleAnonymousReadReceipts(
informationData?.readReceiptAnonymous, !messageLayout.isIncoming)
when (anonymousReadReceipt) {
AnonymousReadReceipt.PROCESSING -> {
views.bubbleFooterReadReceipt.visibility = View.VISIBLE
views.bubbleFooterReadReceipt.setImageResource(R.drawable.ic_processing_msg)
}
else -> {
views.bubbleFooterReadReceipt.visibility = View.GONE
}
}
// We can't use end and start because of our weird layout RTL tricks
val alignEnd = if (defaultRtl) RelativeLayout.ALIGN_LEFT else RelativeLayout.ALIGN_RIGHT
val alignStart = if (defaultRtl) RelativeLayout.ALIGN_RIGHT else RelativeLayout.ALIGN_LEFT
val startOf = if (defaultRtl) RelativeLayout.RIGHT_OF else RelativeLayout.LEFT_OF
val endOf = if (defaultRtl) RelativeLayout.LEFT_OF else RelativeLayout.RIGHT_OF
val footerLayoutParams = views.bubbleFootView.layoutParams as RelativeLayout.LayoutParams
var footerMarginStartDp = views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_margin_start)
var footerMarginEndDp = views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_margin_end)
if (bubbleDependentView.allowFooterOverlay(holder, this)) {
footerLayoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, R.id.viewStubContainer)
footerLayoutParams.addRule(alignEnd, R.id.viewStubContainer)
footerLayoutParams.removeRule(alignStart)
footerLayoutParams.removeRule(RelativeLayout.BELOW)
footerLayoutParams.removeRule(endOf)
footerLayoutParams.removeRule(startOf)
if (bubbleDependentView.needsFooterReservation()) {
// Remove style used when not having reserved space
removeFooterOverlayStyle()
// Calculate required footer space
val footerMeasures = getFooterMeasures(informationData, anonymousReadReceipt)
val footerWidth = footerMeasures[0]
val footerHeight = footerMeasures[1]
bubbleDependentView.reserveFooterSpace(holder, footerWidth, footerHeight)
} else {
// We have no reserved space -> style it to ensure readability on arbitrary backgrounds
styleFooterOverlay()
}
} else {
when {
bubbleDependentView.allowFooterBelow(holder) -> {
footerLayoutParams.addRule(RelativeLayout.BELOW, R.id.viewStubContainer)
footerLayoutParams.addRule(alignEnd, R.id.viewStubContainer)
footerLayoutParams.removeRule(alignStart)
footerLayoutParams.removeRule(RelativeLayout.ALIGN_BOTTOM)
footerLayoutParams.removeRule(endOf)
footerLayoutParams.removeRule(startOf)
footerLayoutParams.removeRule(RelativeLayout.START_OF)
}
messageLayout.reverseBubble -> /* force footer on the left / at the start */ {
footerLayoutParams.addRule(RelativeLayout.START_OF, R.id.viewStubContainer)
footerLayoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, R.id.viewStubContainer)
footerLayoutParams.removeRule(alignEnd)
footerLayoutParams.removeRule(alignStart)
footerLayoutParams.removeRule(endOf)
footerLayoutParams.removeRule(startOf)
footerLayoutParams.removeRule(RelativeLayout.BELOW)
// Reverse margins
footerMarginStartDp = views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_reverse_margin_start)
footerMarginEndDp = views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_reverse_margin_end)
}
else -> /* footer on the right / at the end */ {
footerLayoutParams.addRule(endOf, R.id.viewStubContainer)
footerLayoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, R.id.viewStubContainer)
footerLayoutParams.removeRule(startOf)
footerLayoutParams.removeRule(alignEnd)
footerLayoutParams.removeRule(alignStart)
footerLayoutParams.removeRule(RelativeLayout.BELOW)
footerLayoutParams.removeRule(RelativeLayout.START_OF)
}
}
removeFooterOverlayStyle()
}
if (defaultRtl) {
footerLayoutParams.rightMargin = footerMarginStartDp
footerLayoutParams.leftMargin = footerMarginEndDp
views.bubbleMessageMemberNameView.gravity = Gravity.RIGHT
} else {
footerLayoutParams.leftMargin = footerMarginStartDp
footerLayoutParams.rightMargin = footerMarginEndDp
views.bubbleMessageMemberNameView.gravity = Gravity.LEFT
}
}
if (messageLayout.isPseudoBubble) {
// We need to align the non-bubble member name view to pseudo bubbles
if (messageLayout.reverseBubble) {
views.messageMemberNameView.setPaddingRelative(
shortPaddingDp,
0,
longPaddingDp,
0
)
} else {
views.messageMemberNameView.setPaddingRelative(
longPaddingDp,
0,
shortPaddingDp,
0
)
}
}
} else { // no bubbles
bubbleView.background = null
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).marginStart = 0
(bubbleView.layoutParams as ViewGroup.MarginLayoutParams).marginEnd = 0
/*
(bubbleView.layoutParams as RelativeLayout.LayoutParams).marginStart = 0
(bubbleView.layoutParams as RelativeLayout.LayoutParams).topMargin = 0
(bubbleView.layoutParams as RelativeLayout.LayoutParams).bottomMargin = 0
*/
bubbleView.setPadding(0, 0, 0, 0)
views.messageMemberNameView.setPadding(0, 0, 0, 0)
}
/*
holder.eventBaseView.layoutDirection = if (shouldRtl) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
setRtl(shouldRtl)
*/
(views.bubbleView.layoutParams as FrameLayout.LayoutParams).gravity = if (messageLayout.reverseBubble) Gravity.END else Gravity.START
//holder.informationBottom.layoutDirection = if (shouldRtl) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
setFlatRtl(views.reactionsContainer, if (messageLayout.reverseBubble) reverseDirection else defaultDirection, defaultDirection)
// Layout is broken if bubbleView is RTL (for some reason, Android uses left/end padding for right/start as well...)
setFlatRtl(views.bubbleView, View.LAYOUT_DIRECTION_LTR, defaultDirection)
}
private fun tintFooter(color: Int) {
val tintList = ColorStateList(arrayOf(intArrayOf(0)), intArrayOf(color))
views.bubbleFooterReadReceipt.imageTintList = tintList
views.bubbleFooterMessageTimeView.setTextColor(tintList)
}
private fun styleFooterOverlay() {
views.bubbleFootView.setBackgroundResource(R.drawable.timestamp_overlay)
tintFooter(ThemeUtils.getColor(views.bubbleFootView.context, R.attr.timestamp_overlay_fg))
val padding = views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_overlay_padding)
views.bubbleFootView.setPaddingRelative(
padding,
padding,
// compensate from inner view padding on the other side
padding + views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_padding_compensation),
padding
)
}
private fun removeFooterOverlayStyle() {
views.bubbleFootView.background = null
tintFooter(ThemeUtils.getColor(views.bubbleFootView.context, R.attr.vctr_content_secondary))
views.bubbleFootView.setPaddingRelative(
0,
views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_noverlay_padding_top),
0,
views.bubbleFootView.resources.getDimensionPixelSize(R.dimen.sc_footer_noverlay_padding_bottom)
)
}
fun getFooterMeasures(informationData: MessageInformationData?): Array<Int> {
val anonymousReadReceipt = BubbleThemeUtils.getVisibleAnonymousReadReceipts(informationData?.readReceiptAnonymous, informationData?.sentByMe ?: false)
return getFooterMeasures(informationData, anonymousReadReceipt)
}
private fun getFooterMeasures(informationData: MessageInformationData?, anonymousReadReceipt: AnonymousReadReceipt): Array<Int> {
if (informationData == null) {
Timber.e("Calculating footer measures without information data")
}
val timeWidth: Int
val timeHeight: Int
if (informationData?.messageLayout is TimelineMessageLayout.ScBubble &&
getBubbleTimeLocation(informationData.messageLayout as TimelineMessageLayout.ScBubble) == BubbleThemeUtils.BUBBLE_TIME_BOTTOM) {
// Guess text width for name and time
timeWidth = ceil(guessTextWidth(views.bubbleFooterMessageTimeView, informationData.time.toString())).toInt() +
views.bubbleFooterMessageTimeView.paddingLeft +
views.bubbleFooterMessageTimeView.paddingRight
timeHeight = ceil(views.bubbleFooterMessageTimeView.textSize).toInt() +
views.bubbleFooterMessageTimeView.paddingTop +
views.bubbleFooterMessageTimeView.paddingBottom
} else {
timeWidth = 0
timeHeight = 0
}
val readReceiptWidth: Int
val readReceiptHeight: Int
if (anonymousReadReceipt == AnonymousReadReceipt.NONE) {
readReceiptWidth = 0
readReceiptHeight = 0
} else {
readReceiptWidth = views.bubbleFooterReadReceipt.maxWidth +
views.bubbleFooterReadReceipt.paddingLeft +
views.bubbleFooterReadReceipt.paddingRight
readReceiptHeight = views.bubbleFooterReadReceipt.maxHeight +
views.bubbleFooterReadReceipt.paddingTop +
views.bubbleFooterReadReceipt.paddingBottom
}
var footerWidth = timeWidth + readReceiptWidth
var footerHeight = max(timeHeight, readReceiptHeight)
// Reserve extra padding, if we do have actual content
if (footerWidth > 0) {
footerWidth += views.bubbleFootView.paddingLeft + views.bubbleFootView.paddingRight
}
if (footerHeight > 0) {
footerHeight += views.bubbleFootView.paddingTop + views.bubbleFootView.paddingBottom
}
return arrayOf(footerWidth, footerHeight)
}
fun <H : VectorEpoxyHolder> getViewStubMinimumWidth(bubbleDependentView: BubbleDependentView<H>,
holder: H,
attributes: AbsMessageItem.Attributes,
contentInBubble: Boolean,
canHideSender: Boolean): Int {
val messageLayout = attributes.informationData.messageLayout as? TimelineMessageLayout.ScBubble ?: return 0
val memberName = attributes.informationData.memberName.toString()
val time = attributes.informationData.time.toString()
val result = if (contentInBubble) {
if (getBubbleTimeLocation(messageLayout) == BUBBLE_TIME_BOTTOM) {
if (attributes.informationData.messageLayout.showDisplayName && canHideSender) {
// Since timeView automatically gets enough space, either within or outside the viewStub, we just need to ensure the member name view has enough space
// Somehow not enough without extra space...
ceil(guessTextWidth(views.bubbleMessageMemberNameView, "$memberName ")).toInt()
} else {
// wrap_content works!
0
}
} else if (attributes.informationData.messageLayout.showTimestamp) {
// Guess text width for name and time next to each other
val text = if (attributes.informationData.messageLayout.showDisplayName) {
"$memberName $time"
} else {
time
}
val textSize = if (attributes.informationData.messageLayout.showDisplayName) {
max(views.bubbleMessageMemberNameView.textSize, views.bubbleMessageTimeView.textSize)
} else {
views.bubbleMessageTimeView.textSize
}
ceil(guessTextWidth(textSize, text)).toInt()
} else {
// Not showing any header, use wrap_content of content only
0
}
} else {
0
}
return max(result, bubbleDependentView.getViewStubMinimumWidth(holder))
}
}
fun canHideAvatars(attributes: AbsMessageItem.Attributes): Boolean {
return attributes.informationData.sentByMe || attributes.informationData.isDirect
}
fun canHideSender(attributes: AbsMessageItem.Attributes): Boolean {
return attributes.informationData.sentByMe ||
(attributes.informationData.isDirect && attributes.informationData.senderId == attributes.informationData.dmChatPartnerId)
}
fun infoInBubbles(messageLayout: TimelineMessageLayout.ScBubble): Boolean {
return (!messageLayout.singleSidedLayout) &&
(messageLayout.isRealBubble || messageLayout.isPseudoBubble)
}
fun senderNameInBubble(messageLayout: TimelineMessageLayout.ScBubble): Boolean {
return infoInBubbles(messageLayout) && !messageLayout.isPseudoBubble
}
fun getBubbleTimeLocation(messageLayout: TimelineMessageLayout.ScBubble): String {
return if (messageLayout.singleSidedLayout) BUBBLE_TIME_TOP else BUBBLE_TIME_BOTTOM
}
fun setFlatRtl(layout: ViewGroup, direction: Int, childDirection: Int, depth: Int = 1) {
layout.layoutDirection = direction
for (child in layout.children) {
if (depth > 1 && child is ViewGroup) {
setFlatRtl(child, direction, childDirection, depth-1)
} else {
child.layoutDirection = childDirection
}
}
}
// Static to use from classes that use simplified/non-sc layouts, e.g. item_timeline_event_base_noinfo
fun renderStubMessageLayout(messageLayout: TimelineMessageLayout, viewStubContainer: FrameLayout) {
if (messageLayout !is TimelineMessageLayout.ScBubble) {
return
}
// Remove Element's TimelineContentStubContainerParams paddings, we don't want these
viewStubContainer.setPadding(0, 0, 0, 0)
}

View File

@ -16,8 +16,38 @@
package im.vector.app.features.home.room.detail.timeline.view package im.vector.app.features.home.room.detail.timeline.view
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.ui.views.BubbleDependentView
import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
interface TimelineMessageLayoutRenderer { interface TimelineMessageLayoutRenderer {
fun renderMessageLayout(messageLayout: TimelineMessageLayout) fun <H: BaseEventItem.BaseHolder>renderMessageLayout(messageLayout: TimelineMessageLayout,
bubbleDependentView: BubbleDependentView<H>,
holder: H)
// Variant to use from classes that do not use BaseEventItem.BaseHolder, and don't need the heavy bubble stuff
fun <H: VectorEpoxyHolder>renderBaseMessageLayout(messageLayout: TimelineMessageLayout,
bubbleDependentView: BubbleDependentView<H>,
holder: H) {}
}
// Only render message layout for SC layouts - even if parent is not an ScBubble
fun <H: BaseEventItem.BaseHolder>TimelineMessageLayoutRenderer?.scOnlyRenderMessageLayout(messageLayout: TimelineMessageLayout,
bubbleDependentView: BubbleDependentView<H>,
holder: H) {
if (messageLayout is TimelineMessageLayout.ScBubble) {
scRenderMessageLayout(messageLayout, bubbleDependentView, holder)
}
}
// Also render stub in case parent is no ScBubble
fun <H: BaseEventItem.BaseHolder>TimelineMessageLayoutRenderer?.scRenderMessageLayout(messageLayout: TimelineMessageLayout,
bubbleDependentView: BubbleDependentView<H>,
holder: H) {
if (this == null) {
renderStubMessageLayout(messageLayout, holder.viewStubContainer)
} else {
renderMessageLayout(messageLayout, bubbleDependentView, holder)
}
} }

View File

@ -5,6 +5,8 @@ import android.graphics.Paint
import android.widget.TextView import android.widget.TextView
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import im.vector.app.features.home.room.detail.timeline.item.AnonymousReadReceipt import im.vector.app.features.home.room.detail.timeline.item.AnonymousReadReceipt
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -22,6 +24,29 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) {
const val BUBBLE_STYLE_BOTH = "both" const val BUBBLE_STYLE_BOTH = "both"
const val BUBBLE_TIME_TOP = "top" const val BUBBLE_TIME_TOP = "top"
const val BUBBLE_TIME_BOTTOM = "bottom" const val BUBBLE_TIME_BOTTOM = "bottom"
fun getVisibleAnonymousReadReceipts(readReceipt: AnonymousReadReceipt?, sentByMe: Boolean): AnonymousReadReceipt {
readReceipt ?: return AnonymousReadReceipt.NONE
// TODO
return if (sentByMe && (/*TODO setting?*/ true || readReceipt == AnonymousReadReceipt.PROCESSING)) {
readReceipt
} else {
AnonymousReadReceipt.NONE
}
}
fun anonymousReadReceiptForEvent(event: TimelineEvent): AnonymousReadReceipt {
return if (event.root.sendState == SendState.SYNCED || event.root.sendState == SendState.SENT) {
/*if (event.readByOther) {
AnonymousReadReceipt.READ
} else {
AnonymousReadReceipt.SENT
}*/
AnonymousReadReceipt.NONE
} else {
AnonymousReadReceipt.PROCESSING
}
}
} }
// Special case of BUBBLE_STYLE_BOTH, to allow non-bubble items align to the sender either way // Special case of BUBBLE_STYLE_BOTH, to allow non-bubble items align to the sender either way
@ -47,15 +72,6 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) {
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(BUBBLE_STYLE_KEY, value).apply() PreferenceManager.getDefaultSharedPreferences(context).edit().putString(BUBBLE_STYLE_KEY, value).apply()
} }
fun getVisibleAnonymousReadReceipts(readReceipt: AnonymousReadReceipt, sentByMe: Boolean): AnonymousReadReceipt {
// TODO
return if (sentByMe && (/*TODO setting*/ true || readReceipt == AnonymousReadReceipt.PROCESSING)) {
readReceipt
} else {
AnonymousReadReceipt.NONE
}
}
/* SC-TODO /* SC-TODO
fun drawsActualBubbles(bubbleStyle: String): Boolean { fun drawsActualBubbles(bubbleStyle: String): Boolean {
return bubbleStyle == BUBBLE_STYLE_START || bubbleStyle == BUBBLE_STYLE_BOTH return bubbleStyle == BUBBLE_STYLE_START || bubbleStyle == BUBBLE_STYLE_BOTH
@ -66,20 +82,6 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) {
} }
*/ */
fun guessTextWidth(view: TextView): Float {
return guessTextWidth(view, view.text)
}
fun guessTextWidth(view: TextView, text: CharSequence): Float {
return guessTextWidth(view.textSize, text);
}
fun guessTextWidth(textSize: Float, text: CharSequence): Float {
val paint = Paint()
paint.textSize = textSize
return paint.measureText(text.toString())
}
fun forceAlwaysShowTimestamps(bubbleStyle: String): Boolean { fun forceAlwaysShowTimestamps(bubbleStyle: String): Boolean {
return isBubbleTimeLocationSettingAllowed(bubbleStyle) return isBubbleTimeLocationSettingAllowed(bubbleStyle)
} }
@ -96,3 +98,17 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) {
return isBubbleTimeLocationSettingAllowed(getBubbleStyle()) return isBubbleTimeLocationSettingAllowed(getBubbleStyle())
} }
} }
fun guessTextWidth(view: TextView): Float {
return guessTextWidth(view, view.text)
}
fun guessTextWidth(view: TextView, text: CharSequence): Float {
return guessTextWidth(view.textSize, text);
}
fun guessTextWidth(textSize: Float, text: CharSequence): Float {
val paint = Paint()
paint.textSize = textSize
return paint.measureText(text.toString())
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:addStatesFromChildren="true"
app:incoming_style="true" />

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:addStatesFromChildren="true"
app:incoming_style="false" />

View File

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/eventBaseView" android:id="@+id/eventBaseView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:addStatesFromChildren="true"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
tools:viewBindingIgnore="true"> tools:parentTag="android.widget.RelativeLayout">
<im.vector.app.core.platform.CheckableView <im.vector.app.core.platform.CheckableView
android:id="@+id/messageSelectedBackground" android:id="@+id/messageSelectedBackground"
@ -116,7 +115,7 @@
android:textColor="?vctr_content_primary" android:textColor="?vctr_content_primary"
android:textSize="15sp" android:textSize="15sp"
android:textStyle="bold" android:textStyle="bold"
tools:text="@sample/matrix.json/data/displayName" /> tools:text="@sample/users.json/data/displayName" />
<TextView <TextView
android:id="@+id/bubbleMessageTimeView" android:id="@+id/bubbleMessageTimeView"
@ -136,7 +135,10 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/bubbleMessageTimeView" android:layout_below="@id/bubbleMessageTimeView"
android:layout_margin="0dp" android:layout_marginStart="0dp"
android:layout_marginTop="0dp"
android:layout_marginEnd="0dp"
android:layout_marginBottom="0dp"
android:addStatesFromChildren="true" /> android:addStatesFromChildren="true" />
<!-- <!--
@ -278,4 +280,4 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</RelativeLayout> </merge>