From ef4c35499c89a0617fd57211a62b60d62f9097c9 Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Sat, 19 Feb 2022 20:36:41 +0100 Subject: [PATCH] [bubble merge] add back SC bubbles Change-Id: Ia884fbc5d83aad8cb79d674ec7c411bff608e4e5 --- .../app/core/ui/views/BubbleDependentView.kt | 25 +- .../factory/MergedHeaderItemFactory.kt | 6 +- .../factory/ReadReceiptsItemFactory.kt | 5 +- .../helper/MessageInformationDataFactory.kt | 11 +- .../timeline/item/AbsBaseMessageItem.kt | 49 +- .../detail/timeline/item/AbsMessageItem.kt | 94 +-- .../detail/timeline/item/BaseEventItem.kt | 3 + .../detail/timeline/item/BasedMergedItem.kt | 6 + .../room/detail/timeline/item/DefaultItem.kt | 4 + .../item/MergedMembershipEventsItem.kt | 4 +- .../timeline/item/MergedRoomCreationItem.kt | 2 + .../detail/timeline/item/MessageFileItem.kt | 15 + .../timeline/item/MessageImageVideoItem.kt | 75 ++- .../detail/timeline/item/MessageTextItem.kt | 27 + .../room/detail/timeline/item/NoticeItem.kt | 4 + .../detail/timeline/item/ReadReceiptsItem.kt | 51 ++ .../timeline/style/TimelineMessageLayout.kt | 18 +- .../style/TimelineMessageLayoutFactory.kt | 63 +- .../timeline/url/AbstractPreviewUrlView.kt | 7 +- .../detail/timeline/view/MessageBubbleView.kt | 4 +- .../timeline/view/ScMessageBubbleWrapView.kt | 609 ++++++++++++++++++ .../view/TimelineMessageLayoutRenderer.kt | 32 +- .../app/features/themes/BubbleThemeUtils.kt | 62 +- ...timeline_event_sc_bubble_incoming_base.xml | 8 + ...timeline_event_sc_bubble_outgoing_base.xml | 8 + .../res/layout/view_message_bubble_sc.xml | 14 +- 26 files changed, 1071 insertions(+), 135 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/ScMessageBubbleWrapView.kt create mode 100644 vector/src/main/res/layout/item_timeline_event_sc_bubble_incoming_base.xml create mode 100644 vector/src/main/res/layout/item_timeline_event_sc_bubble_outgoing_base.xml diff --git a/vector/src/main/java/im/vector/app/core/ui/views/BubbleDependentView.kt b/vector/src/main/java/im/vector/app/core/ui/views/BubbleDependentView.kt index 74c235a3f7..9c791c12b1 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/BubbleDependentView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/BubbleDependentView.kt @@ -1,11 +1,26 @@ package im.vector.app.core.ui.views -import android.content.Context -import android.view.ViewGroup -import androidx.core.view.children -import im.vector.app.features.themes.BubbleThemeUtils +import android.content.res.Resources +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +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 { +interface BubbleDependentView { + + 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 { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 99a026a0cf..b72048579e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -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.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 org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue @@ -46,6 +47,7 @@ import javax.inject.Inject class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, private val avatarRenderer: AvatarRenderer, private val avatarSizeProvider: AvatarSizeProvider, + private val messageLayoutFactory: TimelineMessageLayoutFactory, private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper) { private val collapsedEventIds = linkedSetOf() @@ -129,7 +131,8 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde onCollapsedStateChanged = { mergeItemCollapseStates[event.localId] = it requestModelBuild() - } + }, + messageLayout = messageLayoutFactory.createDummy() ) MergedMembershipEventsItem_() .id(mergeId) @@ -206,6 +209,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde mergeItemCollapseStates[event.localId] = it requestModelBuild() }, + messageLayout = messageLayoutFactory.createDummy(), hasEncryptionEvent = hasEncryption, isEncryptionAlgorithmSecure = encryptionAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM, callback = callback, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt index d477a3d40e..5e829f1ac9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt @@ -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.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 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( eventId: String, @@ -44,6 +46,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av .id("read_receipts_$eventId") .eventId(eventId) .readReceipts(readReceiptsData) + .messageLayout(messageLayoutFactory.createDummy()) .avatarRenderer(avatarRenderer) .shouldHideReadReceipts(isFromThreadTimeLine) .clickListener { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index ef6e370a60..10b8dbd2e8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -161,16 +161,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ReferencesInfoData(verificationState) }, sentByMe = isSentByMe, - readReceiptAnonymous = if (event.root.sendState == SendState.SYNCED || event.root.sendState == SendState.SENT) { - /*if (event.readByOther) { - AnonymousReadReceipt.READ - } else { - AnonymousReadReceipt.SENT - }*/ - AnonymousReadReceipt.NONE - } else { - AnonymousReadReceipt.PROCESSING - }, + readReceiptAnonymous = BubbleThemeUtils.anonymousReadReceiptForEvent(event), senderPowerLevel = senderPowerLevel, isDirect = isEffectivelyDirect, isPublic = roomSummary?.isPublic ?: false, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt index 6a92b1b80b..29b2830959 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.content.res.Resources import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -25,13 +26,19 @@ import androidx.core.view.isVisible import im.vector.app.R import im.vector.app.core.epoxy.ClickListener 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.features.home.AvatarRenderer 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.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.scRenderMessageLayout 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 kotlin.math.ceil /** * Base timeline item with reactions and read receipts. @@ -85,23 +92,22 @@ abstract class AbsBaseMessageItem : BaseEventItem holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener) } - // SchildiChat: moved to setBubbleLayout() (called from super.bind()) - so we can do this bubble-style-specific - /* - when (baseAttributes.informationData.e2eDecoration) { - E2EDecoration.NONE -> { - holder.e2EDecorationView.render(null) - } - E2EDecoration.WARN_IN_CLEAR, - E2EDecoration.WARN_SENT_BY_UNVERIFIED, - E2EDecoration.WARN_SENT_BY_UNKNOWN -> { - holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) + if (baseAttributes.informationData.messageLayout.showE2eDecoration) { + when (baseAttributes.informationData.e2eDecoration) { + E2EDecoration.NONE -> { + holder.e2EDecorationView.render(null) + } + E2EDecoration.WARN_IN_CLEAR, + E2EDecoration.WARN_SENT_BY_UNVERIFIED, + E2EDecoration.WARN_SENT_BY_UNKNOWN -> { + holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) + } } } - */ holder.view.onClick(baseAttributes.itemClickListener) 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) { @@ -116,6 +122,25 @@ abstract class AbsBaseMessageItem : BaseEventItem 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) { val reactionsContainer by bind(R.id.reactionsContainer) val informationBottom by bind(R.id.informationBottom) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index 8fe44583b9..ff135478c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -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.TimelineEventController 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.util.MatrixItem +import kotlin.math.ceil /** * Base timeline item that adds an optional information bar with the sender avatar, name, time, send state @@ -75,40 +82,42 @@ abstract class AbsMessageItem : AbsBaseMessageItem override fun bind(holder: H) { super.bind(holder) - if (attributes.informationData.messageLayout.showAvatar) { - holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { - height = attributes.avatarSize - width = attributes.avatarSize + if ((holder.view as? ScMessageBubbleWrapView)?.customBind(this, holder, attributes, _avatarClickListener, _memberNameClickListener) != true) { + if (attributes.informationData.messageLayout.showAvatar) { + holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { + 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) - 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 + 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 } - 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 if (attributes.areThreadMessagesEnabled) { @@ -151,14 +160,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem super.unbind(holder) } - private fun Attributes.getMemberNameColor() = messageColorProvider.getMemberNameTextColor( - informationData.matrixItem, - MatrixItemColorProvider.UserInRoomInformation( - attributes.informationData.isDirect, - attributes.informationData.isPublic, - attributes.informationData.senderPowerLevel - ) - ) + override fun getInformationData(): MessageInformationData? = attributes.informationData abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) { @@ -216,10 +218,20 @@ abstract class AbsMessageItem : AbsBaseMessageItem return result } + + fun getMemberNameColor() = messageColorProvider.getMemberNameTextColor( + informationData.matrixItem, + MatrixItemColorProvider.UserInRoomInformation( + informationData.isDirect, + informationData.isPublic, + informationData.senderPowerLevel + ) + ) } 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) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt index 323d3c25cb..d9d312eab1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.item import android.content.Context import android.view.View import android.view.ViewStub +import android.widget.FrameLayout import android.widget.RelativeLayout import androidx.annotation.CallSuper 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.platform.CheckableView import im.vector.app.core.ui.views.BubbleDependentView +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout /** * Children must override getViewType() @@ -71,6 +73,7 @@ abstract class BaseEventItem : VectorEpoxyModel val leftGuideline by bind(R.id.messageStartGuideline) val contentContainer by bind(R.id.viewStubContainer) val checkableBackground by bind(R.id.messageSelectedBackground) + val viewStubContainer by bind(R.id.viewStubContainer) override fun bindView(itemView: View) { super.bindView(itemView) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt index 1c56a0809e..877ad0e7c2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BasedMergedItem.kt @@ -21,6 +21,9 @@ import android.widget.TextView import androidx.annotation.IdRes import im.vector.app.R 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 abstract class BasedMergedItem : BaseEventItem() { @@ -39,6 +42,8 @@ abstract class BasedMergedItem : BaseEventItem() holder.separatorView.visibility = View.VISIBLE holder.expandView.setText(R.string.merged_events_collapse) } + + (holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.messageLayout, this, holder) } protected val distinctMergeData by lazy { @@ -69,6 +74,7 @@ abstract class BasedMergedItem : BaseEventItem() val mergeData: List val avatarRenderer: AvatarRenderer val onCollapsedStateChanged: (Boolean) -> Unit + val messageLayout: TimelineMessageLayout } abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt index 9d437754d0..e6dfd18641 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt @@ -23,6 +23,8 @@ import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R 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) abstract class DefaultItem : BaseEventItem() { @@ -35,6 +37,8 @@ abstract class DefaultItem : BaseEventItem() { holder.messageTextView.text = attributes.text attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView) holder.view.setOnLongClickListener(attributes.itemLongClickListener) + + (holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.informationData.messageLayout, this, holder) } override fun unbind(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt index e19dc33fff..6930c6997b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt @@ -25,6 +25,7 @@ import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R 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) abstract class MergedMembershipEventsItem : BasedMergedItem() { @@ -69,6 +70,7 @@ abstract class MergedMembershipEventsItem : BasedMergedItem, override val avatarRenderer: AvatarRenderer, - override val onCollapsedStateChanged: (Boolean) -> Unit + override val onCollapsedStateChanged: (Boolean) -> Unit, + override val messageLayout: TimelineMessageLayout ) : BasedMergedItem.Attributes } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt index 9f631f7a0e..a86bd5bb34 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt @@ -37,6 +37,7 @@ import im.vector.app.core.utils.tappableMatchingText import im.vector.app.features.home.AvatarRenderer 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.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.tools.linkify import me.gujun.android.span.span import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -224,6 +225,7 @@ abstract class MergedRoomCreationItem : BasedMergedItem, override val avatarRenderer: AvatarRenderer, override val onCollapsedStateChanged: (Boolean) -> Unit, + override val messageLayout: TimelineMessageLayout, val callback: TimelineEventController.Callback? = null, val currentUserId: String, val hasEncryptionEvent: Boolean, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt index 8b6899daee..c3bd8ba56e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -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.style.TimelineMessageLayout 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) abstract class MessageFileItem : AbsMessageItem() { @@ -103,6 +106,18 @@ abstract class MessageFileItem : AbsMessageItem() { 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 class Holder : AbsMessageItem.Holder(STUB_ID) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt index 4ca5f21aba..233af786ac 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt @@ -16,9 +16,11 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.content.res.Resources import android.view.View import android.view.ViewGroup import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.core.view.isVisible 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.style.TimelineMessageLayout 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 org.matrix.android.sdk.api.util.MimeTypes @@ -57,7 +60,6 @@ abstract class MessageImageVideoItem : AbsMessageItem different footer space situation possible - /* SC-TODO - val footerMeasures = getFooterMeasures(holder) + val footerMeasures = bubbleWrapView.getFooterMeasures(attributes.informationData) forceAllowFooterOverlay = shouldAllowFooterOverlay(footerMeasures, width, height) val newShowFooterBellow = shouldShowFooterBellow(footerMeasures, width, height) if (lastAllowedFooterOverlay != forceAllowFooterOverlay || newShowFooterBellow != lastShowFooterBellow) { showFooterBellow = newShowFooterBellow - updateMessageBubble(holder.imageView.context, holder) + bubbleWrapView.renderMessageLayout(attributes.informationData.messageLayout, host, holder) } - */ } } val animate = mediaData.mimeType == MimeTypes.Gif @@ -87,11 +90,10 @@ abstract class MessageImageVideoItem : AbsMessageItem RoundedCorners(dimensionConverter.dpToPx(3)) + is TimelineMessageLayout.Bubble -> messageLayout.cornersRadius.granularRoundedCorners() + else -> RoundedCorners(dimensionConverter.dpToPx(8)) } imageContentRenderer.render(mediaData, effectiveMode, holder.imageView, imageCornerTransformation, onImageSizeListener) if (!attributes.informationData.sendState.hasFailed()) { @@ -122,7 +124,6 @@ abstract class MessageImageVideoItem : AbsMessageItem, imageWidth: Int, imageHeight: Int): Boolean { val footerWidth = footerMeasures[0] val footerHeight = footerMeasures[1] @@ -131,7 +132,6 @@ abstract class MessageImageVideoItem : AbsMessageItem 1.5*footerWidth && imageHeight > 1.5*footerHeight && (imageWidth * imageHeight > 4 * footerWidth * footerHeight) } - // SC-TODO private fun shouldShowFooterBellow(footerMeasures: Array, imageWidth: Int, imageHeight: Int): Boolean { // 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 @@ -141,6 +141,57 @@ abstract class MessageImageVideoItem : AbsMessageItem 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) { val progressLayout by bind(R.id.messageMediaUploadProgressLayout) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt index b26f81edb4..c984609e43 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -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.PreviewUrlView 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.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import io.noties.markwon.MarkwonPlugin @@ -174,6 +175,32 @@ abstract class MessageTextItem : AbsMessageItem() { } } + + 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 { private const val STUB_ID = R.id.messageContentTextStub } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt index 3c3510a073..f4626ceac3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt @@ -27,6 +27,8 @@ import im.vector.app.core.epoxy.onClick import im.vector.app.core.ui.views.ShieldImageView 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.view.TimelineMessageLayoutRenderer +import im.vector.app.features.home.room.detail.timeline.view.scOnlyRenderMessageLayout import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel @@ -53,6 +55,8 @@ abstract class NoticeItem : BaseEventItem() { holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) } } + + (holder.view as? TimelineMessageLayoutRenderer).scOnlyRenderMessageLayout(attributes.informationData.messageLayout, this, holder) } override fun unbind(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt index c2d14c93c8..eca8caec75 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt @@ -16,6 +16,9 @@ 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 com.airbnb.epoxy.EpoxyAttribute 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.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.core.ui.views.ReadReceiptsView 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) abstract class ReadReceiptsItem : EpoxyModelWithHolder(), ItemWithEvents, BubbleDependentView { @EpoxyAttribute lateinit var eventId: String @EpoxyAttribute lateinit var readReceipts: List + @EpoxyAttribute lateinit var messageLayout: TimelineMessageLayout @EpoxyAttribute var shouldHideReadReceipts: Boolean = false @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var clickListener: ClickListener @@ -46,6 +55,8 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( holder.readReceiptsView.onClick(clickListener) holder.readReceiptsView.render(readReceipts, avatarRenderer) + (messageLayout as? TimelineMessageLayout.ScBubble)?.let { applyScBubbleStyle(it, holder) } + holder.readReceiptsView.isVisible = !shouldHideReadReceipts } @@ -54,6 +65,46 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( 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() { val readReceiptsView by bind(R.id.readReceiptsView) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt index bf2b8d82b9..847d03e338 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.style import android.os.Parcelable import im.vector.app.R +import im.vector.app.features.home.room.detail.timeline.item.AnonymousReadReceipt import kotlinx.parcelize.Parcelize sealed interface TimelineMessageLayout : Parcelable { @@ -25,11 +26,13 @@ sealed interface TimelineMessageLayout : Parcelable { val showAvatar: Boolean val showDisplayName: Boolean val showTimestamp: Boolean + val showE2eDecoration: Boolean @Parcelize data class Default(override val showAvatar: Boolean, override val showDisplayName: Boolean, override val showTimestamp: Boolean, + override val showE2eDecoration: Boolean, // Keep defaultLayout generated on epoxy items override val layoutRes: Int = 0) : TimelineMessageLayout @@ -38,6 +41,7 @@ sealed interface TimelineMessageLayout : Parcelable { override val showAvatar: Boolean, override val showDisplayName: Boolean, override val showTimestamp: Boolean = true, + override val showE2eDecoration: Boolean = true, val isIncoming: Boolean, val isPseudoBubble: Boolean, val cornersRadius: CornersRadius, @@ -60,22 +64,22 @@ sealed interface TimelineMessageLayout : Parcelable { @Parcelize data class ScBubble( - // SC-TODO adapt me override val showAvatar: Boolean, override val showDisplayName: Boolean, override val showTimestamp: Boolean = true, - // SC-TODO?? Keep defaultLayout generated on epoxy items - override val layoutRes: Int = 0 - /* SC-TODO + override val showE2eDecoration: Boolean = false, val isIncoming: Boolean, + val reverseBubble: Boolean, + val singleSidedLayout: Boolean, + val isRealBubble: Boolean, val isPseudoBubble: Boolean, + val isNotice: Boolean, val timestampAsOverlay: Boolean, override val layoutRes: Int = if (isIncoming) { - R.layout.item_timeline_event_bubble_incoming_base + R.layout.item_timeline_event_sc_bubble_incoming_base } else { - R.layout.item_timeline_event_bubble_outgoing_base + R.layout.item_timeline_event_sc_bubble_outgoing_base } - */ ) : TimelineMessageLayout } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt index 2b07b5a0b7..a3b3325138 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -23,9 +23,11 @@ import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.resources.isRTL import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams 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.events.model.EventType 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.MessageVerificationRequestContent 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 localeProvider: LocaleProvider, private val resources: Resources, + private val bubbleThemeUtils: BubbleThemeUtils, private val vectorPreferences: VectorPreferences) { companion object { @@ -96,8 +99,22 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess val messageLayout = when (layoutSettingsProvider.getLayoutSettings()) { TimelineLayoutSettings.SC_BUBBLE -> { - // SC-TODO? - buildModernLayout(showInformation) + val messageContent = event.getLastMessageContent() + 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 -> { buildModernLayout(showInformation) @@ -135,6 +152,35 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess 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 { if (this == null) return false if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() @@ -156,19 +202,12 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess return false } - private fun buildModernLayout(showInformation: Boolean): TimelineMessageLayout.Default { + private fun buildModernLayout(showInformation: Boolean, forScBubbles: Boolean = false): TimelineMessageLayout.Default { return TimelineMessageLayout.Default( showAvatar = showInformation, showDisplayName = showInformation, - showTimestamp = showInformation || vectorPreferences.alwaysShowTimeStamps() - ) - } - - private fun buildScLayout(showInformation: Boolean): TimelineMessageLayout.ScBubble { - return TimelineMessageLayout.ScBubble( - showAvatar = showInformation, - showDisplayName = showInformation, - showTimestamp = true + showTimestamp = showInformation || vectorPreferences.alwaysShowTimeStamps(), + showE2eDecoration = !forScBubbles ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/AbstractPreviewUrlView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/AbstractPreviewUrlView.kt index eb3c0ef1db..5fd8c1451a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/AbstractPreviewUrlView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/AbstractPreviewUrlView.kt @@ -3,10 +3,10 @@ package im.vector.app.features.home.room.detail.timeline.url import android.view.View import androidx.core.view.isVisible 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 -interface AbstractPreviewUrlView: TimelineMessageLayoutRenderer { +interface AbstractPreviewUrlView { var isVisible: Boolean get() = (this as View).isVisible set(value) { (this as View).isVisible = value } @@ -15,4 +15,7 @@ interface AbstractPreviewUrlView: TimelineMessageLayoutRenderer { fun render(newState: PreviewUrlUiState, imageContentRenderer: ImageContentRenderer, force: Boolean = false) + + // Like upstream TimelineMessageLayoutRenderer, not like downstream one, so don't inherit + fun renderMessageLayout(messageLayout: TimelineMessageLayout) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt index 422dfb0dbd..bbbac3a5d4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt @@ -35,8 +35,10 @@ import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R 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.utils.DimensionConverter 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.shapeAppearanceModel 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 renderMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView, holder: H) { if (messageLayout !is TimelineMessageLayout.Bubble) { Timber.v("Can't render messageLayout $messageLayout") return diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/ScMessageBubbleWrapView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/ScMessageBubbleWrapView.kt new file mode 100644 index 0000000000..31a7f70912 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/ScMessageBubbleWrapView.kt @@ -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 customBind( + bubbleDependentView: BubbleDependentView, + 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() + val invisibleViews = ArrayList() + + 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 renderBaseMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView, 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 renderMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView, 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 { + val anonymousReadReceipt = BubbleThemeUtils.getVisibleAnonymousReadReceipts(informationData?.readReceiptAnonymous, informationData?.sentByMe ?: false) + return getFooterMeasures(informationData, anonymousReadReceipt) + } + + private fun getFooterMeasures(informationData: MessageInformationData?, anonymousReadReceipt: AnonymousReadReceipt): Array { + 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 getViewStubMinimumWidth(bubbleDependentView: BubbleDependentView, + 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) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt index 0c42662801..b3b3c1f829 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt @@ -16,8 +16,38 @@ 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 interface TimelineMessageLayoutRenderer { - fun renderMessageLayout(messageLayout: TimelineMessageLayout) + fun renderMessageLayout(messageLayout: TimelineMessageLayout, + bubbleDependentView: BubbleDependentView, + holder: H) + + // Variant to use from classes that do not use BaseEventItem.BaseHolder, and don't need the heavy bubble stuff + fun renderBaseMessageLayout(messageLayout: TimelineMessageLayout, + bubbleDependentView: BubbleDependentView, + holder: H) {} +} + +// Only render message layout for SC layouts - even if parent is not an ScBubble +fun TimelineMessageLayoutRenderer?.scOnlyRenderMessageLayout(messageLayout: TimelineMessageLayout, + bubbleDependentView: BubbleDependentView, + holder: H) { + if (messageLayout is TimelineMessageLayout.ScBubble) { + scRenderMessageLayout(messageLayout, bubbleDependentView, holder) + } +} + +// Also render stub in case parent is no ScBubble +fun TimelineMessageLayoutRenderer?.scRenderMessageLayout(messageLayout: TimelineMessageLayout, + bubbleDependentView: BubbleDependentView, + holder: H) { + if (this == null) { + renderStubMessageLayout(messageLayout, holder.viewStubContainer) + } else { + renderMessageLayout(messageLayout, bubbleDependentView, holder) + } } diff --git a/vector/src/main/java/im/vector/app/features/themes/BubbleThemeUtils.kt b/vector/src/main/java/im/vector/app/features/themes/BubbleThemeUtils.kt index ed8a96e7b4..1d2cbb81d3 100644 --- a/vector/src/main/java/im/vector/app/features/themes/BubbleThemeUtils.kt +++ b/vector/src/main/java/im/vector/app/features/themes/BubbleThemeUtils.kt @@ -5,6 +5,8 @@ import android.graphics.Paint import android.widget.TextView import androidx.preference.PreferenceManager 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 javax.inject.Inject @@ -22,6 +24,29 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) { const val BUBBLE_STYLE_BOTH = "both" const val BUBBLE_TIME_TOP = "top" 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 @@ -47,15 +72,6 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) { 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 fun drawsActualBubbles(bubbleStyle: String): Boolean { 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 { return isBubbleTimeLocationSettingAllowed(bubbleStyle) } @@ -96,3 +98,17 @@ class BubbleThemeUtils @Inject constructor(private val context: Context) { 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()) +} diff --git a/vector/src/main/res/layout/item_timeline_event_sc_bubble_incoming_base.xml b/vector/src/main/res/layout/item_timeline_event_sc_bubble_incoming_base.xml new file mode 100644 index 0000000000..0ff36d56ba --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_sc_bubble_incoming_base.xml @@ -0,0 +1,8 @@ + + diff --git a/vector/src/main/res/layout/item_timeline_event_sc_bubble_outgoing_base.xml b/vector/src/main/res/layout/item_timeline_event_sc_bubble_outgoing_base.xml new file mode 100644 index 0000000000..80fdc38e81 --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_sc_bubble_outgoing_base.xml @@ -0,0 +1,8 @@ + + diff --git a/vector/src/main/res/layout/view_message_bubble_sc.xml b/vector/src/main/res/layout/view_message_bubble_sc.xml index c73a7b9f08..d65e32b223 100644 --- a/vector/src/main/res/layout/view_message_bubble_sc.xml +++ b/vector/src/main/res/layout/view_message_bubble_sc.xml @@ -1,13 +1,12 @@ - + tools:parentTag="android.widget.RelativeLayout"> + tools:text="@sample/users.json/data/displayName" />