diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index c58cadd66b..7d7752ba62 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -32,6 +32,8 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -45,6 +47,7 @@ fun StaticMapView( lat: Double, lon: Double, zoom: Double, + pinVariant: PinVariant, contentDescription: String?, modifier: Modifier = Modifier, darkMode: Boolean = !ElementTheme.isLightTheme, @@ -95,12 +98,7 @@ fun StaticMapView( // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. contentScale = ContentScale.Fit, ) - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.centerBottomEdge(this), - ) + LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) } else { StaticMapPlaceholder( showProgress = collectedState.value.isLoading(), @@ -127,6 +125,7 @@ internal fun StaticMapViewPreview() = ElementPreview { lon = 0.0, zoom = 0.0, contentDescription = null, + pinVariant = PinVariant.PinnedLocation, modifier = Modifier.size(400.dp), ) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index 839cda0237..666ca07a08 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -58,7 +58,7 @@ internal class MapTilerStaticMapUrlBuilder( // image smaller than the available space in pixels. // The resulting image will have to be scaled to fit the available space in order // to keep the perceived content size constant at the expense of sharpness. - return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft" + return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=topright" } override fun isServiceAvailable() = apiKey.isNotEmpty() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index adfaa93cce..184acf1386 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -39,7 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -166,7 +166,7 @@ internal fun aTimelineItemEvent( isMine = isMine, isEditable = isEditable, canBeRepliedTo = canBeRepliedTo, - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayName = senderDisplayName, displayNameAmbiguous = displayNameAmbiguous, ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 9ebe35a51b..b5c0152685 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -8,10 +8,8 @@ package io.element.android.features.messages.impl.timeline.components.event -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -19,33 +17,28 @@ import androidx.compose.ui.unit.dp import io.element.android.features.location.api.StaticMapView import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider +import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemLocationView( content: TimelineItemLocationContent, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - content.description?.let { - Text( - text = it, - modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), - ) - } - - StaticMapView( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 188.dp), - lat = content.location.lat, - lon = content.location.lon, - zoom = 15.0, - contentDescription = content.body - ) - } + StaticMapView( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + pinVariant = content.pinVariant, + lat = content.location.lat, + lon = content.location.lon, + zoom = 15.0, + contentDescription = content.body + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 31cf689ef8..2b5c0fa98a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.factories.event import dev.zacsweers.metro.Inject +import io.element.android.features.location.api.Location import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.libraries.matrix.api.core.EventId @@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -70,10 +73,10 @@ class TimelineItemContentFactory( is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is MessageContent -> { - val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) messageFactory.create( + senderId = sender, + senderProfile = senderProfile, content = itemContent, - senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, eventId = eventId, ) } @@ -96,6 +99,24 @@ class TimelineItemContentFactory( is UnableToDecryptContent -> utdFactory.create(itemContent) is CallNotifyContent -> TimelineItemRtcNotificationContent() is UnknownContent -> TimelineItemUnknownContent + is LiveLocationContent -> { + val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> + Location.fromGeoUri(beacon.geoUri) + }.lastOrNull() + if (lastKnownLocation != null) { + TimelineItemLocationContent( + body = itemContent.body.trimEnd(), + description = itemContent.description?.trimEnd(), + assetType = itemContent.assetType, + senderId = sender, + senderProfile = senderProfile, + location = lastKnownLocation, + mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive) + ) + } else { + TimelineItemUnknownContent + } + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 1db6ad304c..829c11df6e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -29,8 +29,13 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -39,10 +44,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import kotlinx.collections.immutable.persistentListOf @@ -65,11 +73,13 @@ class TimelineItemContentMessageFactory( ) { fun create( content: MessageContent, - senderDisambiguatedDisplayName: String, + senderId: UserId, + senderProfile: ProfileDetails, eventId: EventId?, ): TimelineItemEventContent { return when (val messageType = content.type) { is EmoteMessageType -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId) val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val dom = messageType.formatted?.toHtmlDocument( permalinkParser = permalinkParser, @@ -135,8 +145,8 @@ class TimelineItemContentMessageFactory( } is LocationMessageType -> { val location = Location.fromGeoUri(messageType.geoUri) + val body = messageType.body.trimEnd() if (location == null) { - val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, htmlDocument = null, @@ -145,9 +155,13 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - body = messageType.body.trimEnd(), + body = body, location = location, - description = messageType.description + description = messageType.description, + senderId = senderId, + senderProfile = senderProfile, + assetType = messageType.assetType, + mode = TimelineItemLocationContent.Mode.Static ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 12c457175f..6f369417dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyCon import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { RedactedContent, is StickerContent, is PollContent, - is UnableToDecryptContent -> true + is UnableToDecryptContent, + is LiveLocationContent -> true // Can't be grouped is FailedToParseStateContent, is ProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index ac93a0ac4f..017fba1902 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -28,7 +28,8 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { + if (mode.isActive) { + PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) + } else { + PinVariant.StaleLocation + } + } + Mode.Static -> { + when (assetType) { + AssetType.PIN -> PinVariant.PinnedLocation + AssetType.SENDER, + null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false) + } + } + } + + private fun senderAvatar() = AvatarData( + senderId.value, + name = senderProfile.getDisplayName(), + url = senderProfile.getAvatarUrl(), + // Size is irrelevant as the PinMarker will override anyway. + size = AvatarSize.TimelineSender + ) + + sealed interface Mode { + data object Static : Mode + data class Live(val isActive: Boolean) : Mode + } + override val type: String = "TimelineItemLocationContent" } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 0fd3f5f41b..07ab392f1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -10,21 +10,34 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady open class TimelineItemLocationContentProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aTimelineItemLocationContent(), - aTimelineItemLocationContent("This is a description!"), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), ) } -fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent( - body = "User location geo:52.2445,0.7186;u=5000", +fun aTimelineItemLocationContent( + body: String = "", + senderId: UserId = UserId("@sender:matrix.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, +) = TimelineItemLocationContent( + body = body, location = Location( lat = 52.2445, lon = 0.7186, accuracy = 5000f, ), - description = description, + senderId = senderId, + senderProfile = senderProfile, + mode = mode ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 516cd9ea77..1b9394f2ee 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList internal fun aMessageEvent( @@ -52,7 +52,7 @@ internal fun aMessageEvent( eventId = eventId, transactionId = transactionId, senderId = A_USER_ID, - senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), + senderProfile = aProfileDetailsReady(displayName = A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), content = content, sentTime = "", diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 24f8c30ab1..957b01d1ed 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -60,8 +60,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aProfileDetails import io.element.android.libraries.matrix.test.timeline.aStickerContent import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation @@ -84,7 +86,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -102,14 +105,18 @@ class TimelineItemContentMessageFactoryTest { val assetType = AssetType.SENDER val result = sut.create( content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemLocationContent( body = "body", - location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), + location = Location(lat = 1.0, lon = 2.0, accuracy = null), description = "description", assetType = assetType, + mode = TimelineItemLocationContent.Mode.Static, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), ) assertThat(result).isEqualTo(expected) } @@ -119,7 +126,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = LocationMessageType("body", "", null, null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -136,7 +144,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -153,7 +162,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("https://www.example.org", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) as TimelineItemTextContent val expected = TimelineItemTextContent( @@ -200,7 +210,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, expected.toString()) ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) @@ -218,7 +229,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) @@ -229,7 +241,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -282,7 +295,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -312,7 +326,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -348,7 +363,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -371,7 +387,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -413,7 +430,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -438,7 +456,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -518,7 +537,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -547,7 +567,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -589,7 +610,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -612,7 +634,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = NoticeMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemNoticeContent( @@ -634,7 +657,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted")) @@ -645,7 +669,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = EmoteMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) val expected = TimelineItemEmoteContent( @@ -667,7 +692,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) @@ -693,7 +719,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -718,7 +745,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -744,7 +772,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 7a7d4cdfd4..726646f5e9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.FakeSendHandle -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList import org.junit.Test @@ -34,7 +34,7 @@ class TimelineItemGrouperTest { id = UniqueId("0"), senderId = A_USER_ID, senderAvatar = anAvatarData(), - senderProfile = aProfileTimelineDetailsReady(displayName = ""), + senderProfile = aProfileDetailsReady(displayName = ""), content = TimelineItemStateEventContent(body = "a state event"), reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 009547f9eb..68dd4cd332 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageType @@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter( val message = sp.getString(CommonStrings.common_unsupported_event) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } + is LiveLocationContent -> { + val message = sp.getString(CommonStrings.common_shared_location) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) }?.take(DEFAULT_SAFE_LENGTH) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index 9657f87bd0..ff5cce7a59 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter( is MessageContent, is FailedToParseMessageLikeContent, is FailedToParseStateContent, + is LiveLocationContent, is UnknownContent -> { if (buildMeta.isDebuggable) { error("You should not use this formatter for this event content: $content") diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt new file mode 100644 index 0000000000..50b5a0ec82 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.location + +data class LiveLocationInfo( + val description: String?, + val geoUri: String, + val timestamp: Long, +) + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index b6ed7dc602..e7b61e8dd6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -102,6 +104,15 @@ data class FailedToParseStateContent( val error: String ) : EventContent +data class LiveLocationContent( + val body: String, + val isLive: Boolean, + val description: String?, + val timeout: Long, + val assetType: AssetType?, + val locations: List, +): EventContent + data object LegacyCallInviteContent : EventContent data object CallNotifyContent : EventContent diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index 4b2f18a362..5ddf57b723 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -152,13 +152,13 @@ private fun aInReplyToDetails( eventId = EventId("\$event"), eventContent = eventContent, senderId = UserId("@Sender:domain"), - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayNameAmbiguous = displayNameAmbiguous, ), textContent = (eventContent as? MessageContent)?.body.orEmpty(), ) -fun aProfileTimelineDetailsReady( +fun aProfileDetailsReady( displayName: String? = "Sender", displayNameAmbiguous: Boolean = false, avatarUrl: String? = null, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt index 4d0a0f045b..9e5e468cd9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent @@ -32,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Text +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Thumbnail import io.element.android.libraries.ui.strings.CommonStrings @Immutable @@ -60,7 +63,7 @@ internal sealed interface InReplyToMetadata { @Composable internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) { is MessageContent -> when (val type = eventContent.type) { - is ImageMessageType -> InReplyToMetadata.Thumbnail( + is ImageMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage }, textContent = eventContent.body, @@ -68,7 +71,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is VideoMessageType -> InReplyToMetadata.Thumbnail( + is VideoMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, @@ -76,34 +79,34 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is FileMessageType -> InReplyToMetadata.Thumbnail( + is FileMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.File, ) ) - is LocationMessageType -> InReplyToMetadata.Thumbnail( + is LocationMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_shared_location), type = AttachmentThumbnailType.Location, ) ) - is AudioMessageType -> InReplyToMetadata.Thumbnail( + is AudioMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.body, type = AttachmentThumbnailType.Audio, ) ) - is VoiceMessageType -> InReplyToMetadata.Thumbnail( + is VoiceMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_voice_message), type = AttachmentThumbnailType.Voice, ) ) - else -> InReplyToMetadata.Text(textContent ?: eventContent.body) + else -> Text(textContent ?: eventContent.body) } - is StickerContent -> InReplyToMetadata.Thumbnail( + is StickerContent -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = eventContent.source.takeUnless { hideImage }, textContent = eventContent.body, @@ -111,7 +114,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = eventContent.info.blurhash, ) ) - is PollContent -> InReplyToMetadata.Thumbnail( + is PollContent -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.question, type = AttachmentThumbnailType.Poll, @@ -127,5 +130,6 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad UnknownContent, is LegacyCallInviteContent, is CallNotifyContent, + is LiveLocationContent, null -> null } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index edbf9dc51e..67b73d616d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -75,6 +76,7 @@ class EventItemFactory( is StateContent, is StickerContent, is UnableToDecryptContent, + is LiveLocationContent, UnknownContent -> { Timber.w("Should not happen: ${content.javaClass.simpleName}") null