Merge pull request #5722 from vector-im/feature/mna/PSF-883-start-live-message

[Location sharing] - Show message on start of a live
This commit is contained in:
Maxime NATUREL 2022-04-13 11:17:22 +02:00 committed by GitHub
commit c832c1b848
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 376 additions and 90 deletions

1
changelog.d/5710.feature Normal file
View file

@ -0,0 +1 @@
Live Location Sharing - Show message on start of a live

View file

@ -423,4 +423,5 @@ fun Event.getPollContent(): MessagePollContent? {
return content.toModel<MessagePollContent>()
}
fun Event.supportsNotification() = this.getClearType() in EventType.MESSAGE + EventType.POLL_START
fun Event.supportsNotification() =
this.getClearType() in EventType.MESSAGE + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO

View file

@ -18,12 +18,26 @@ package org.matrix.android.sdk.api.session.room.model.livelocation
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.room.model.message.LocationAsset
import org.matrix.android.sdk.api.session.room.model.message.LocationAssetType
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLiveLocationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
@JsonClass(generateAdapter = true)
data class LiveLocationBeaconContent(
/**
* Local message type, not from server
*/
@Transient
override val msgType: String = MessageType.MSGTYPE_LIVE_LOCATION_STATE,
@Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
/**
* Indicates user's intent to share ephemeral location.
*/
@ -43,8 +57,13 @@ data class LiveLocationBeaconContent(
/**
* Client side tracking of the last location
*/
var lastLocationContent: MessageLiveLocationContent? = null
) {
var lastLocationContent: MessageLiveLocationContent? = null,
/**
* Client side tracking of whether the beacon has timed out.
*/
var hasTimedOut: Boolean = false
) : MessageContent {
fun getBestBeaconInfo() = beaconInfo ?: unstableBeaconInfo

View file

@ -41,5 +41,6 @@ object MessageType {
const val MSGTYPE_SNOWFALL = "io.element.effect.snowfall"
// Fake message types for live location events to be able to inherit them from MessageContent
const val MSGTYPE_LIVE_LOCATION_STATE = "org.matrix.android.sdk.livelocation.state"
const val MSGTYPE_LIVE_LOCATION = "org.matrix.android.sdk.livelocation"
}

View file

@ -33,5 +33,5 @@ object RoomSummaryConstants {
EventType.ENCRYPTED,
EventType.STICKER,
EventType.REACTION
) + EventType.POLL_START
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
}

View file

@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationBeaconContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
@ -136,9 +137,10 @@ fun TimelineEvent.getEditedEventId(): String? {
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<LiveLocationBeaconContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}

View file

@ -57,6 +57,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) {
in EventType.POLL_START,
in EventType.STATE_ROOM_BEACON_INFO,
EventType.MESSAGE,
EventType.REDACTION,
EventType.ENCRYPTED,

View file

@ -41,6 +41,7 @@ internal class DefaultLiveLocationAggregationProcessor @Inject constructor() : L
}
// A beacon info state event has to be sent before sending location
// TODO handle missing check of m_relatesTo field
var beaconInfoEntity: CurrentStateEventEntity? = null
val eventTypesIterator = EventType.STATE_ROOM_BEACON_INFO.iterator()
while (beaconInfoEntity == null && eventTypesIterator.hasNext()) {
@ -66,11 +67,11 @@ internal class DefaultLiveLocationAggregationProcessor @Inject constructor() : L
// Check if beacon info is outdated
if (isBeaconInfoOutdated(beaconInfoContent, content)) {
Timber.v("## LIVE LOCATION. Beacon info has timeout")
return
beaconInfoContent.hasTimedOut = true
} else {
beaconInfoContent.lastLocationContent = content
}
// Update last location info of the beacon state event
beaconInfoContent.lastLocationContent = content
beaconInfoEntity.root?.content = ContentMapper.map(beaconInfoContent.toContent())
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationBeaconContent
import javax.inject.Inject
class LiveLocationMessageItemFactory @Inject constructor(
private val dimensionConverter: DimensionConverter,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarSizeProvider: AvatarSizeProvider,
) {
fun create(
liveLocationContent: LiveLocationBeaconContent,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): VectorEpoxyModel<*>? {
// TODO handle location received and stopped states
return when {
isLiveRunning(liveLocationContent) -> buildStartLiveItem(highlight, attributes)
else -> null
}
}
private fun isLiveRunning(liveLocationContent: LiveLocationBeaconContent): Boolean {
return liveLocationContent.getBestBeaconInfo()?.isLive.orFalse() && liveLocationContent.hasTimedOut.not()
}
private fun buildStartLiveItem(
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageLiveLocationStartItem {
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
return MessageLiveLocationStartItem_()
.attributes(attributes)
.mapWidth(width)
.mapHeight(height)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
}

View file

@ -96,6 +96,7 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationBeaconContent
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
@ -145,6 +146,7 @@ class MessageItemFactory @Inject constructor(
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
private val liveLocationMessageItemFactory: LiveLocationMessageItemFactory,
) {
// TODO inject this properly?
@ -212,6 +214,7 @@ class MessageItemFactory @Inject constructor(
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
}
is LiveLocationBeaconContent -> liveLocationMessageItemFactory.create(messageContent, highlight, attributes)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
return messageItem?.apply {
@ -226,7 +229,7 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes,
): MessageLocationItem? {
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(200)
val height = dimensionConverter.dpToPx(MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
val locationUrl = locationContent.toLocationData()?.let {
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
@ -774,5 +777,6 @@ class MessageItemFactory @Inject constructor(
companion object {
private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5
const val MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP = 200
}
}

View file

@ -26,17 +26,19 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber
import javax.inject.Inject
class TimelineItemFactory @Inject constructor(private val messageItemFactory: MessageItemFactory,
private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory,
private val encryptionItemFactory: EncryptionItemFactory,
private val roomCreateItemFactory: RoomCreateItemFactory,
private val widgetItemFactory: WidgetItemFactory,
private val verificationConclusionItemFactory: VerificationItemFactory,
private val callItemFactory: CallItemFactory,
private val decryptionFailureTracker: DecryptionFailureTracker,
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper) {
class TimelineItemFactory @Inject constructor(
private val messageItemFactory: MessageItemFactory,
private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory,
private val encryptionItemFactory: EncryptionItemFactory,
private val roomCreateItemFactory: RoomCreateItemFactory,
private val widgetItemFactory: WidgetItemFactory,
private val verificationConclusionItemFactory: VerificationItemFactory,
private val callItemFactory: CallItemFactory,
private val decryptionFailureTracker: DecryptionFailureTracker,
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper,
) {
/**
* Reminder: nextEvent is older and prevEvent is newer.
@ -75,16 +77,17 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.STATE_ROOM_ALIASES,
EventType.STATE_SPACE_CHILD,
EventType.STATE_SPACE_PARENT,
EventType.STATE_ROOM_POWER_LEVELS -> {
EventType.STATE_ROOM_POWER_LEVELS -> {
noticeItemFactory.create(params)
}
EventType.STATE_ROOM_WIDGET_LEGACY,
EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(params)
EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(params)
EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(params)
EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(params)
// State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params)
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params)
in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params)
// Unhandled state event types
else -> {
else -> {
// Should only happen when shouldShowHiddenEvents() settings is ON
Timber.v("State event type ${event.root.type} not handled")
defaultItemFactory.create(params)

View file

@ -59,7 +59,7 @@ class DisplayableEventFormatter @Inject constructor(
val senderName = timelineEvent.senderInfo.disambiguatedDisplayName
return when (timelineEvent.root.getClearType()) {
EventType.MESSAGE -> {
EventType.MESSAGE -> {
timelineEvent.getLastMessageContent()?.let { messageContent ->
when (messageContent.msgType) {
MessageType.MSGTYPE_TEXT -> {
@ -100,17 +100,17 @@ class DisplayableEventFormatter @Inject constructor(
}
} ?: span { }
}
EventType.STICKER -> {
EventType.STICKER -> {
simpleFormat(senderName, stringProvider.getString(R.string.send_a_sticker), appendAuthor)
}
EventType.REACTION -> {
EventType.REACTION -> {
timelineEvent.root.getClearContent().toModel<ReactionContent>()?.relatesTo?.let {
val emojiSpanned = emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key))
simpleFormat(senderName, emojiSpanned, appendAuthor)
} ?: span { }
}
EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_DONE -> {
EventType.KEY_VERIFICATION_DONE -> {
// cancel and done can appear in timeline, so should have representation
simpleFormat(senderName, stringProvider.getString(R.string.sent_verification_conclusion), appendAuthor)
}
@ -119,20 +119,23 @@ class DisplayableEventFormatter @Inject constructor(
EventType.KEY_VERIFICATION_MAC,
EventType.KEY_VERIFICATION_KEY,
EventType.KEY_VERIFICATION_READY,
EventType.CALL_CANDIDATES -> {
EventType.CALL_CANDIDATES -> {
span { }
}
in EventType.POLL_START -> {
in EventType.POLL_START -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?: stringProvider.getString(R.string.sent_a_poll)
}
in EventType.POLL_RESPONSE -> {
in EventType.POLL_RESPONSE -> {
stringProvider.getString(R.string.poll_response_room_list_preview)
}
in EventType.POLL_END -> {
in EventType.POLL_END -> {
stringProvider.getString(R.string.poll_end_room_list_preview)
}
else -> {
in EventType.STATE_ROOM_BEACON_INFO -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_live_location), appendAuthor)
}
else -> {
span {
text = noticeEventFormatter.format(timelineEvent, isDm) ?: ""
textStyle = "italic"
@ -167,7 +170,7 @@ class DisplayableEventFormatter @Inject constructor(
}
return when (event.getClearType()) {
EventType.MESSAGE -> {
EventType.MESSAGE -> {
(event.getClearContent().toModel() as? MessageContent)?.let { messageContent ->
when (messageContent.msgType) {
MessageType.MSGTYPE_TEXT -> {
@ -208,25 +211,28 @@ class DisplayableEventFormatter @Inject constructor(
}
} ?: span { }
}
EventType.STICKER -> {
EventType.STICKER -> {
stringProvider.getString(R.string.send_a_sticker)
}
EventType.REACTION -> {
EventType.REACTION -> {
event.getClearContent().toModel<ReactionContent>()?.relatesTo?.let {
emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key))
} ?: span { }
}
in EventType.POLL_START -> {
in EventType.POLL_START -> {
event.getClearContent().toModel<MessagePollContent>(catchError = true)?.pollCreationInfo?.question?.question
?: stringProvider.getString(R.string.sent_a_poll)
}
in EventType.POLL_RESPONSE -> {
in EventType.POLL_RESPONSE -> {
stringProvider.getString(R.string.poll_response_room_list_preview)
}
in EventType.POLL_END -> {
in EventType.POLL_END -> {
stringProvider.getString(R.string.poll_end_room_list_preview)
}
else -> {
in EventType.STATE_ROOM_BEACON_INFO -> {
stringProvider.getString(R.string.sent_live_location)
}
else -> {
span {
}
}

View file

@ -50,8 +50,8 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_JOIN_RULES,
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL
) + EventType.POLL_START
EventType.KEY_VERIFICATION_CANCEL,
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
}
fun TimelineEvent.canBeMerged(): Boolean {
@ -71,7 +71,7 @@ fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean {
EventType.STATE_ROOM_CANONICAL_ALIAS,
EventType.STATE_ROOM_POWER_LEVELS,
EventType.STATE_ROOM_ENCRYPTION -> true
EventType.STATE_ROOM_MEMBER -> {
EventType.STATE_ROOM_MEMBER -> {
// Keep only room member events regarding the room creator (when he joined the room),
// but exclude events where the room creator invite others, or where others join
roomCreatorUserId != null && root.stateKey == roomCreatorUserId

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import android.graphics.drawable.ColorDrawable
import android.widget.ImageView
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
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.themes.ThemeUtils
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLiveLocationStartItem : AbsMessageItem<MessageLiveLocationStartItem.Holder>() {
@EpoxyAttribute
var mapWidth: Int = 0
@EpoxyAttribute
var mapHeight: Int = 0
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.view, null)
bindMap(holder)
bindBottomBanner(holder)
}
private fun bindMap(holder: Holder) {
val messageLayout = attributes.informationData.messageLayout
val mapCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
messageLayout.cornersRadius.granularRoundedCorners()
} else {
RoundedCorners(getDefaultLayoutCornerRadiusInDp(holder))
}
holder.noLocationMapImageView.updateLayoutParams {
width = mapWidth
height = mapHeight
}
GlideApp.with(holder.noLocationMapImageView)
.load(R.drawable.bg_no_location_map)
.transform(mapCornerTransformation)
.into(holder.noLocationMapImageView)
}
private fun bindBottomBanner(holder: Holder) {
val messageLayout = attributes.informationData.messageLayout
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
GranularRoundedCorners(0f, 0f, messageLayout.cornersRadius.bottomEndRadius, messageLayout.cornersRadius.bottomStartRadius)
} else {
val bottomCornerRadius = getDefaultLayoutCornerRadiusInDp(holder).toFloat()
GranularRoundedCorners(0f, 0f, bottomCornerRadius, bottomCornerRadius)
}
GlideApp.with(holder.bannerImageView)
.load(ColorDrawable(ThemeUtils.getColor(holder.bannerImageView.context, R.attr.colorSurface)))
.transform(imageCornerTransformation)
.into(holder.bannerImageView)
}
private fun getDefaultLayoutCornerRadiusInDp(holder: Holder): Int {
val dimensionConverter = DimensionConverter(holder.view.resources)
return dimensionConverter.dpToPx(8)
}
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val bannerImageView by bind<ImageView>(R.id.locationLiveStartBanner)
val noLocationMapImageView by bind<ImageView>(R.id.locationLiveStartMap)
}
companion object {
private const val STUB_ID = R.id.messageContentLiveLocationStartStub
}
}

View file

@ -38,19 +38,20 @@ sealed interface TimelineMessageLayout : Parcelable {
@Parcelize
data class Bubble(
override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true,
val addTopMargin: Boolean = false,
val isIncoming: Boolean,
val isPseudoBubble: Boolean,
val cornersRadius: CornersRadius,
val timestampAsOverlay: Boolean,
override val layoutRes: Int = if (isIncoming) {
R.layout.item_timeline_event_bubble_incoming_base
} else {
R.layout.item_timeline_event_bubble_outgoing_base
},
override val showAvatar: Boolean,
override val showDisplayName: Boolean,
override val showTimestamp: Boolean = true,
val addTopMargin: Boolean = false,
val isIncoming: Boolean,
val isPseudoBubble: Boolean,
val cornersRadius: CornersRadius,
val timestampInsideMessage: Boolean,
val addMessageOverlay: Boolean,
override val layoutRes: Int = if (isIncoming) {
R.layout.item_timeline_event_bubble_incoming_base
} else {
R.layout.item_timeline_event_bubble_outgoing_base
},
) : TimelineMessageLayout {
@Parcelize

View file

@ -46,7 +46,7 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
EventType.MESSAGE,
EventType.ENCRYPTED,
EventType.STICKER
) + EventType.POLL_START
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
// Can't be rendered in bubbles, so get back to default layout
private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf(
@ -58,10 +58,13 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_STICKER_LOCAL,
MessageType.MSGTYPE_EMOTE
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_LIVE_LOCATION_STATE,
)
private val MSG_TYPES_WITH_TIMESTAMP_AS_OVERLAY = setOf(
MessageType.MSGTYPE_IMAGE, MessageType.MSGTYPE_VIDEO
private val MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE = setOf(
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_LIVE_LOCATION_STATE,
)
}
@ -70,7 +73,7 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
}
private val isRTL: Boolean by lazy {
localeProvider.isRTL()
localeProvider.isRTL()
}
fun create(params: TimelineItemFactoryParams): TimelineMessageLayout {
@ -123,7 +126,8 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
isIncoming = !isSentByMe,
cornersRadius = cornersRadius,
isPseudoBubble = messageContent.isPseudoBubble(),
timestampAsOverlay = messageContent.timestampAsOverlay()
timestampInsideMessage = messageContent.timestampInsideMessage(),
addMessageOverlay = messageContent.shouldAddMessageOverlay(),
)
} else {
buildModernLayout(showInformation)
@ -139,10 +143,18 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
return this.msgType in MSG_TYPES_WITH_PSEUDO_BUBBLE_LAYOUT
}
private fun MessageContent?.timestampAsOverlay(): Boolean {
private fun MessageContent?.timestampInsideMessage(): Boolean {
if (this == null) return false
if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline()
return this.msgType in MSG_TYPES_WITH_TIMESTAMP_AS_OVERLAY
return this.msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE
}
private fun MessageContent?.shouldAddMessageOverlay(): Boolean {
return when {
this == null || msgType == MessageType.MSGTYPE_LIVE_LOCATION_STATE -> false
msgType == MessageType.MSGTYPE_LOCATION -> vectorPreferences.labsRenderLocationsInTimeline()
else -> msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE
}
}
private fun TimelineEvent.shouldBuildBubbleLayout(): Boolean {

View file

@ -43,9 +43,9 @@ import im.vector.app.features.themes.ThemeUtils
import timber.log.Timber
class MessageBubbleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
private var isIncoming: Boolean = false
@ -89,21 +89,21 @@ class MessageBubbleView @JvmOverloads constructor(
outlineProvider = ViewOutlineProvider.BACKGROUND
clipToOutline = true
background = RippleDrawable(
ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
bubbleDrawable,
rippleMaskDrawable)
ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
bubbleDrawable,
rippleMaskDrawable)
}
}
override fun renderMessageLayout(messageLayout: TimelineMessageLayout) {
(messageLayout as? TimelineMessageLayout.Bubble)
?.updateDrawables()
?.setConstraintsAndColor()
?.toggleMessageOverlay()
?.setPadding()
?.setMargins()
?.setAdditionalTopSpace()
?: Timber.v("Can't render messageLayout $messageLayout")
?.updateDrawables()
?.setConstraints()
?.toggleMessageOverlay()
?.setPadding()
?.setMargins()
?.setAdditionalTopSpace()
?: Timber.v("Can't render messageLayout $messageLayout")
}
private fun TimelineMessageLayout.Bubble.updateDrawables() = apply {
@ -121,17 +121,13 @@ class MessageBubbleView @JvmOverloads constructor(
rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel
}
private fun TimelineMessageLayout.Bubble.setConstraintsAndColor() = apply {
private fun TimelineMessageLayout.Bubble.setConstraints() = apply {
ConstraintSet().apply {
clone(views.bubbleView)
clear(R.id.viewStubContainer, ConstraintSet.END)
if (timestampAsOverlay) {
val timeColor = ContextCompat.getColor(context, R.color.palette_white)
views.messageTimeView.setTextColor(timeColor)
if (timestampInsideMessage) {
connect(R.id.viewStubContainer, ConstraintSet.END, R.id.parent, ConstraintSet.END, 0)
} else {
val timeColor = ThemeUtils.getColor(context, R.attr.vctr_content_tertiary)
views.messageTimeView.setTextColor(timeColor)
connect(R.id.viewStubContainer, ConstraintSet.END, R.id.messageTimeView, ConstraintSet.START, 0)
}
applyTo(views.bubbleView)
@ -139,16 +135,20 @@ class MessageBubbleView @JvmOverloads constructor(
}
private fun TimelineMessageLayout.Bubble.toggleMessageOverlay() = apply {
if (timestampAsOverlay) {
if (addMessageOverlay) {
val timeColor = ContextCompat.getColor(context, R.color.palette_white)
views.messageTimeView.setTextColor(timeColor)
views.messageOverlayView.isVisible = true
(views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = cornersRadius.toFloatArray()
} else {
val timeColor = ThemeUtils.getColor(context, R.attr.vctr_content_tertiary)
views.messageTimeView.setTextColor(timeColor)
views.messageOverlayView.isVisible = false
}
}
private fun TimelineMessageLayout.Bubble.setPadding() = apply {
if (isPseudoBubble && timestampAsOverlay) {
if (isPseudoBubble && timestampInsideMessage) {
views.viewStubContainer.root.setPadding(0, 0, 0, 0)
} else {
views.viewStubContainer.root.setPadding(horizontalStubPadding, verticalStubPadding, horizontalStubPadding, verticalStubPadding)

View file

@ -155,7 +155,8 @@ class NotifiableEventResolver @Inject constructor(
// only convert encrypted messages to NotifiableMessageEvents
when (event.root.getClearType()) {
EventType.MESSAGE,
in EventType.POLL_START -> {
in EventType.POLL_START,
in EventType.STATE_ROOM_BEACON_INFO -> {
val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString()
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
@ -187,7 +188,7 @@ class NotifiableEventResolver @Inject constructor(
soundName = null
)
}
else -> null
else -> null
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- Size will be overrode -->
<ImageView
android:id="@+id/locationLiveStartMap"
android:layout_width="300dp"
android:layout_height="200dp"
android:contentDescription="@string/a11y_static_map_image"
android:src="@drawable/bg_no_location_map"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/locationLiveStartBanner"
android:layout_width="0dp"
android:layout_height="48dp"
android:alpha="0.85"
android:src="?colorSurface"
app:layout_constraintBottom_toBottomOf="@id/locationLiveStartMap"
app:layout_constraintEnd_toEndOf="@id/locationLiveStartMap"
app:layout_constraintStart_toStartOf="@id/locationLiveStartMap"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/locationLiveStartIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginHorizontal="8dp"
android:background="@drawable/circle"
android:backgroundTint="?vctr_content_quaternary"
android:padding="3dp"
app:layout_constraintBottom_toBottomOf="@id/locationLiveStartBanner"
app:layout_constraintStart_toStartOf="@id/locationLiveStartBanner"
app:layout_constraintTop_toTopOf="@id/locationLiveStartBanner"
app:srcCompat="@drawable/ic_attachment_location_live_white"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/locationLiveStartTitle"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:text="@string/location_share_live_started"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toBottomOf="@id/locationLiveStartBanner"
app:layout_constraintStart_toEndOf="@id/locationLiveStartIcon"
app:layout_constraintTop_toTopOf="@id/locationLiveStartBanner" />
<ProgressBar
android:id="@+id/locationLiveStartLoader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="?vctr_content_quaternary"
app:layout_constraintBottom_toTopOf="@id/locationLiveStartBanner"
app:layout_constraintEnd_toEndOf="@id/locationLiveStartMap"
app:layout_constraintStart_toStartOf="@id/locationLiveStartMap"
app:layout_constraintTop_toTopOf="@id/locationLiveStartMap" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -59,6 +59,11 @@
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_location_stub" />
<ViewStub
android:id="@+id/messageContentLiveLocationStartStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_live_location_start_stub" />
</FrameLayout>

View file

@ -2121,6 +2121,7 @@
<string name="sent_a_reaction">Reacted with: %s</string>
<string name="sent_verification_conclusion">Verification Conclusion</string>
<string name="sent_location">Shared their location</string>
<string name="sent_live_location">Shared their live location</string>
<string name="verification_request_waiting">Waiting…</string>
<string name="verification_request_other_cancelled">%s cancelled</string>
@ -2998,6 +2999,7 @@
<string name="labs_render_locations_in_timeline">Render user locations in the timeline</string>
<string name="location_timeline_failed_to_load_map">Failed to load map</string>
<string name="location_share_live_enabled">Live location enabled</string>
<string name="location_share_live_started">Loading live location…</string>
<string name="location_share_live_stop">Stop</string>
<string name="live_location_sharing_notification_title">${app_name} Live Location</string>
<string name="live_location_sharing_notification_description">Location sharing is in progress</string>