diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt index 1227ddec46..3feeeff57d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt @@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable { ) : ShowLocationMode @Parcelize - data object Live : ShowLocationMode + data class Live( + val senderId: UserId + ) : ShowLocationMode } 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 0657bae634..a61cbe1c24 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 @@ -9,7 +9,9 @@ package io.element.android.features.location.api import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.Extras import coil3.compose.AsyncImagePainter @@ -38,11 +42,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight /** * Shows a static map image downloaded via a third party service's static maps API. + * + * Handles 4 distinct cases: + * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching + * 2. Null location - shows blurred placeholder, no pin, no loading + * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator + * 4. Success (location != null, loaded) - shows actual map with pin */ @Composable fun StaticMapView( - lat: Double, - lon: Double, + location: Location?, zoom: Double, pinVariant: PinVariant, contentDescription: String?, @@ -56,50 +65,111 @@ fun StaticMapView( modifier = modifier, contentAlignment = Alignment.Center ) { - val context = LocalContext.current - var retryHash by remember { mutableIntStateOf(0) } - val builder = remember { StaticMapUrlBuilder() } - val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). - null - } else { - ImageRequest.Builder(context) - .data( - builder.build( - lat = lat, - lon = lon, - zoom = zoom, - darkMode = darkMode, - width = constraints.maxWidth, - height = constraints.maxHeight, - density = LocalDensity.current.density, - ) - ) - .size(width = constraints.maxWidth, height = constraints.maxHeight) - .apply { - extras.set(Extras.Key("retry_hash"), retryHash).build() - } - .build() + // Case 1: Stale location - show stale map with stale pin, no fetching + when { + pinVariant is PinVariant.StaleLocation -> { + StaleMapContent( + pinVariant = pinVariant, + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + ) } - ) + // Case 2: Null location - show blurred placeholder, no pin, no loading + location == null -> { + StaticMapPlaceholder( + painter = painterResource(R.drawable.blurred_map), + canReload = false, + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + onLoadMapClick = {} + ) + } + // Cases 3 & 4: Non-null location - fetch map + else -> LoadableMapContent( + location = location, + zoom = zoom, + pinVariant = pinVariant, + contentDescription = contentDescription, + darkMode = darkMode, + ) + } + } +} - val collectedState = painter.state.collectAsState() - if (collectedState.value is AsyncImagePainter.State.Success) { +@Composable +private fun BoxWithConstraintsScope.StaleMapContent( + pinVariant: PinVariant, + contentDescription: String?, + width: Dp, + height: Dp, +) { + Box(contentAlignment = Alignment.Center) { + Image( + painter = painterResource(R.drawable.stale_map), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = Modifier.size(width = width, height = height) + ) + LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent)) + } +} + +@Composable +private fun BoxWithConstraintsScope.LoadableMapContent( + location: Location, + zoom: Double, + pinVariant: PinVariant, + contentDescription: String?, + darkMode: Boolean, +) { + val context = LocalContext.current + var retryHash by remember { mutableIntStateOf(0) } + val builder = remember { StaticMapUrlBuilder() } + + val painter = rememberAsyncImagePainter( + model = if (constraints.isZero) { + // Avoid building a URL if any of the size constraints is zero + null + } else { + ImageRequest.Builder(context) + .data( + builder.build( + lat = location.lat, + lon = location.lon, + zoom = zoom, + darkMode = darkMode, + width = constraints.maxWidth, + height = constraints.maxHeight, + density = LocalDensity.current.density, + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .apply { + extras.set(Extras.Key("retry_hash"), retryHash).build() + } + .build() + } + ) + + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Success -> { Image( painter = painter, contentDescription = contentDescription, modifier = Modifier.size(width = maxWidth, height = maxHeight), // The returned image can be smaller than the requested size due to the static maps API having - // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. - // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + // a max width and height of 2048 px. We apply ContentScale.Fit to handle this. contentScale = ContentScale.Fit, ) LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) - } else { + } + else -> { StaticMapPlaceholder( - showProgress = collectedState.value.isLoading(), - canReload = builder.isServiceAvailable(), + painter = painterResource(R.drawable.blurred_map), + canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, @@ -109,17 +179,11 @@ fun StaticMapView( } } -private fun AsyncImagePainter.State.isLoading(): Boolean { - return this is AsyncImagePainter.State.Empty || - this is AsyncImagePainter.State.Loading -} - @PreviewsDayNight @Composable internal fun StaticMapViewPreview() = ElementPreview { StaticMapView( - lat = 0.0, - lon = 0.0, + location = Location(0.0, 0.0), zoom = 0.0, contentDescription = null, pinVariant = PinVariant.PinnedLocation, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt index 81b80c8dc3..0292ec927e 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -27,14 +28,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.R import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun StaticMapPlaceholder( - showProgress: Boolean, + painter: Painter, canReload: Boolean, contentDescription: String?, width: Dp, @@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder( contentAlignment = Alignment.Center, modifier = modifier .size(width = width, height = height) - .then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick)) + .clickable(enabled = canReload, onClick = onLoadMapClick) ) { Image( - painter = painterResource(id = R.drawable.blurred_map), + painter = painter, contentDescription = contentDescription, contentScale = ContentScale.FillBounds, modifier = Modifier.size(width = width, height = height) ) - if (showProgress) { - CircularProgressIndicator() - } else if (canReload) { + if (canReload) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview { modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - listOf( - true to false, - false to true, - false to false, - ).forEach { (showProgress, canReload) -> + listOf(false, true) + .forEach { canReload -> StaticMapPlaceholder( - showProgress = showProgress, + painter = painterResource(R.drawable.blurred_map), canReload = canReload, contentDescription = null, width = 400.dp, diff --git a/features/location/api/src/main/res/drawable-night/stale_map.png b/features/location/api/src/main/res/drawable-night/stale_map.png new file mode 100644 index 0000000000..9e36759203 Binary files /dev/null and b/features/location/api/src/main/res/drawable-night/stale_map.png differ diff --git a/features/location/api/src/main/res/drawable/stale_map.png b/features/location/api/src/main/res/drawable/stale_map.png new file mode 100644 index 0000000000..87fa0188c9 Binary files /dev/null and b/features/location/api/src/main/res/drawable/stale_map.png differ diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index b949f55c76..83db9a9c61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -91,9 +91,9 @@ fun LocationShareRow( ) } Text( - text = item.formattedTimestamp, + text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp, style = ElementTheme.typography.fontBodySmRegular, - color = ElementTheme.colors.textSecondary, + color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index fbaed9c854..13c30c28eb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing @@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState @@ -112,8 +115,11 @@ fun MapBottomSheetScaffold( modifier = Modifier, sheetPeekHeight = sheetPeekHeight, sheetContent = { - sheetContent(sheetPadding) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() + Column(modifier = Modifier.heightIn(max = maxContentHeight)) { + sheetContent(sheetPadding) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } }, scaffoldState = scaffoldState, sheetDragHandle = sheetDragHandle, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt new file mode 100644 index 0000000000..41b9bea4ca --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt @@ -0,0 +1,20 @@ +/* + * 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.features.location.impl.show + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare + +class LiveLocationShareComparator(private val currentUser: UserId) : Comparator { + override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int { + val p0IsCurrentUser = p0.userId == currentUser + val p1IsCurrentUser = p1.userId == currentUser + if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1 + return p1.startTimestamp.compareTo(p0.startTimestamp) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index a2c9a3702d..43d3aa6d00 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -29,14 +31,20 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode 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.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.getBestName +import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.combine @AssistedInject class ShowLocationPresenter( @@ -46,6 +54,7 @@ class ShowLocationPresenter( private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, + private val joinedRoom: JoinedRoom, ) : Presenter { @AssistedFactory fun interface Factory { @@ -96,9 +105,9 @@ class ShowLocationPresenter( } } - val locationShares = remember { - when (mode) { - is ShowLocationMode.Static -> { + val locationShares = when (mode) { + is ShowLocationMode.Static -> { + remember { val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) val formattedTimestamp = stringProvider.getString( CommonStrings.screen_static_location_sheet_timestamp_description, @@ -121,15 +130,59 @@ class ShowLocationPresenter( ) ) } - ShowLocationMode.Live -> persistentListOf() } + is ShowLocationMode.Live -> { + produceState(persistentListOf()) { + val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId) + val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() + val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } + combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> + liveShares + .sortedWith(comparator) + .mapNotNull { share -> + val lastLocation = share.lastLocation ?: return@mapNotNull null + val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null + val member = members.find { it.userId == share.userId } + val displayName = member?.getBestName() ?: share.userId.value + val avatarUrl = member?.avatarUrl + val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) + val formattedTimestamp = stringProvider.getString( + CommonStrings.screen_static_location_sheet_timestamp_description, + relativeTime + ) + LocationShareItem( + userId = share.userId, + displayName = displayName, + avatarData = AvatarData( + id = share.userId.value, + name = displayName, + url = avatarUrl, + size = AvatarSize.UserListItem, + ), + formattedTimestamp = formattedTimestamp, + location = location, + isLive = true, + assetType = lastLocation.assetType, + ) + } + .toImmutableList() + }.collect { value = it } + }.value + } + } + + val focusedLocation = when (mode) { + is ShowLocationMode.Static -> locationShares.firstOrNull() + is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId } } return ShowLocationState( dialogState = dialogState, locationShares = locationShares, + focusedLocation = focusedLocation, hasLocationPermission = permissionsState.isAnyGranted, isTrackMyLocation = isTrackMyLocation, + isLive = mode is ShowLocationMode.Live, appName = appName, eventSink = ::handleEvent, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 9494db12ec..b6a60f35db 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -18,14 +18,16 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( + val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, + val focusedLocation: LocationShareItem?, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, val eventSink: (ShowLocationEvent) -> Unit, ) { - val isSheetDraggable = locationShares.any { item -> item.isLive } + val isSheetDraggable = isLive && locationShares.isNotEmpty() } data class LocationShareItem( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 8bee410715..1ab2310365 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -21,6 +21,8 @@ class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aShowLocationState(), + aShowLocationState(isLive = true), + aShowLocationState(isLive = true, locationShares = emptyList()), aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, ), @@ -44,8 +46,10 @@ class ShowLocationStateProvider : PreviewParameterProvider { private const val APP_NAME = "ApplicationName" fun aShowLocationState( + isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, - locationShares: List = listOf(aLocationShareItem()), + locationShares: List = listOf(aLocationShareItem(isLive = isLive)), + focusedLocation: LocationShareItem? = locationShares.firstOrNull(), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, @@ -54,9 +58,11 @@ fun aShowLocationState( return ShowLocationState( dialogState = constraintsDialogState, locationShares = locationShares.toImmutableList(), + focusedLocation = focusedLocation, hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, appName = appName, + isLive = isLive, eventSink = eventSink, ) } @@ -70,10 +76,10 @@ fun aLocationShareItem( url = null, size = AvatarSize.UserListItem, ), - formattedTimestamp: String = "Shared 1 min ago", - location: Location = Location(1.23, 2.34, 4f), isLive: Boolean = false, assetType: AssetType? = null, + formattedTimestamp: String = "Shared 1 min ago", + location: Location = Location(1.23, 2.34, 4f), ) = LocationShareItem( userId = userId, displayName = displayName, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index ad2d4cb8ca..7ac5946723 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -12,8 +12,11 @@ package io.element.android.features.location.impl.show import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue @@ -21,11 +24,15 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -65,35 +72,33 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) - val initialPosition = remember { - if (state.locationShares.isEmpty()) { - MapDefaults.defaultCameraPosition - } else { - val firstLocation = state.locationShares.first().location - CameraPosition( - target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), + val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) + var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) } + LaunchedEffect(state.focusedLocation) { + if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { + hasAnimatedToFocusedLocation = true + val position = CameraPosition( + target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) + cameraState.position = position } } - val cameraState = rememberCameraState(firstPosition = initialPosition) - val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } + val userLocationState = rememberUserLocationState(state.hasLocationPermission) val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = - if (state.isSheetDraggable) { - SheetValue.PartiallyExpanded - } else { - SheetValue.Expanded - } - ) + bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded) ) + LaunchedEffect(state.isSheetDraggable) { + if (!state.isSheetDraggable) { + scaffoldState.bottomSheetState.expand() + } + } MapBottomSheetScaffold( sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } @@ -116,29 +121,46 @@ fun ShowLocationView( }, sheetContent = { sheetPaddings -> val coroutineScope = rememberCoroutineScope() - Spacer(Modifier.height(20.dp)) - Text( - text = stringResource(CommonStrings.screen_static_location_sheet_title), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - state.locationShares.forEach { locationShare -> - LocationShareRow( - item = locationShare, - onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, - modifier = Modifier.clickable { - state.eventSink(ShowLocationEvent.TrackMyLocation(false)) - val position = CameraPosition( - padding = sheetPaddings, - target = Position(locationShare.location.lon, locationShare.location.lat), - zoom = MapDefaults.DEFAULT_ZOOM - ) - coroutineScope.launch { - cameraState.animateTo(finalPosition = position) - } - } + if (!state.isSheetDraggable) { + // If sheet is draggable the DragHandle has already some padding + Spacer(Modifier.height(20.dp)) + } + if (state.locationShares.isEmpty()) { + Text( + text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + textAlign = TextAlign.Center, ) + } else { + Text( + text = stringResource(CommonStrings.screen_static_location_sheet_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn { + items(state.locationShares) { locationShare -> + LocationShareRow( + item = locationShare, + onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, + modifier = Modifier.clickable { + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + padding = sheetPaddings, + target = Position(locationShare.location.lon, locationShare.location.lat), + zoom = MapDefaults.DEFAULT_ZOOM + ) + coroutineScope.launch { + cameraState.animateTo(finalPosition = position) + } + } + ) + } + } } }, mapContent = { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 451531fc7e..91df447e2a 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.node.TestParentNode @@ -43,7 +44,8 @@ class DefaultShowLocationEntryPointTest { locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), dateFormatter = FakeDateFormatter(), - stringProvider = FakeStringProvider() + stringProvider = FakeStringProvider(), + joinedRoom = FakeJoinedRoom(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt new file mode 100644 index 0000000000..4042cb4c0c --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt @@ -0,0 +1,69 @@ +/* + * 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.features.location.impl.show + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import org.junit.Test + +class LiveLocationShareComparatorTest { + private val currentUser = UserId("@me:matrix.org") + private val comparator = LiveLocationShareComparator(currentUser) + + @Test + fun `compare returns zero when comparing the same current user share`() { + val share = aLiveLocationShare(userId = currentUser, startTimestamp = 123L) + + val result = comparator.compare(share, share) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `compare orders current user share before another user share`() { + val otherShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L) + val currentUserShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L) + + val sortedShares = listOf(otherShare, currentUserShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(currentUserShare, otherShare).inOrder() + } + + @Test + fun `compare orders current user shares by newest start timestamp first`() { + val newerShare = aLiveLocationShare(userId = currentUser, startTimestamp = 200L) + val olderShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L) + + val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() + } + + @Test + fun `compare orders non current user shares by newest start timestamp first`() { + val newerShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L) + val olderShare = aLiveLocationShare(userId = UserId("@bob:matrix.org"), startTimestamp = 100L) + + val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() + } +} + +private fun aLiveLocationShare( + userId: UserId, + startTimestamp: Long, +): LiveLocationShare { + return LiveLocationShare( + userId = userId, + lastLocation = null, + startTimestamp = startTimestamp, + endTimestamp = startTimestamp + 1_000L, + ) +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 931dd55cea..f38e8dae60 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -22,15 +22,23 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LastLocation +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ShowLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -51,13 +59,15 @@ class ShowLocationPresenterTest { assetType = null, ), locationActions: FakeLocationActions = fakeLocationActions, + joinedRoom: JoinedRoom = FakeJoinedRoom(), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, locationActions = locationActions, buildMeta = fakeBuildMeta, dateFormatter = fakeDateFormatter, - stringProvider = FakeStringProvider() + stringProvider = FakeStringProvider(), + joinedRoom = joinedRoom, ) @Test @@ -318,4 +328,159 @@ class ShowLocationPresenterTest { assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) } } + + @Test + fun `live mode emits empty location shares initially`() = runTest { + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")), + joinedRoom = FakeJoinedRoom(), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.locationShares).isEmpty() + assertThat(initialState.isSheetDraggable).isFalse() + } + } + + @Test + fun `live mode collects live shares from room`() = runTest { + val userId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow( + listOf( + aLiveLocationShare(userId = userId) + ) + ) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live(senderId = userId), + joinedRoom = fakeRoom, + ) + presenter.test { + // Skip initial empty state from collectAsState(initial = emptyList()) + skipItems(1) + val state = awaitItem() + + assertThat(state.locationShares).hasSize(1) + val item = state.locationShares.first() + assertThat(item.userId).isEqualTo(userId) + assertThat(item.location.lat).isEqualTo(48.8584) + assertThat(item.location.lon).isEqualTo(2.2945) + assertThat(item.isLive).isTrue() + assertThat(state.isSheetDraggable).isTrue() + } + } + + @Test + fun `live mode handles invalid geo uri gracefully`() = runTest { + val validUserId = UserId("@alice:matrix.org") + val invalidUserId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow( + listOf( + aLiveLocationShare(userId = validUserId), + aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"), + ) + ) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live(senderId = validUserId), + joinedRoom = fakeRoom, + ) + presenter.test { + // Skip initial empty state from collectAsState(initial = emptyList()) + skipItems(1) + val state = awaitItem() + + // Only the valid location share should be present + assertThat(state.locationShares).hasSize(1) + assertThat(state.locationShares.first().userId).isEqualTo(validUserId) + } + } + + @Test + fun `live mode updates when shares change`() = runTest { + val userId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow(emptyList()) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live(senderId = userId), + joinedRoom = fakeRoom, + ) + presenter.test { + // Initial state is empty + val initialState = awaitItem() + assertThat(initialState.locationShares).isEmpty() + + // Emit a new live share + liveSharesFlow.value = listOf( + aLiveLocationShare(userId = userId) + ) + + val updatedState = awaitItem() + assertThat(updatedState.locationShares).hasSize(1) + assertThat(updatedState.locationShares.first().userId).isEqualTo(userId) + } + } + + @Test + fun `static mode emits location share with correct data`() = runTest { + val senderId = UserId("@alice:matrix.org") + val senderName = "Alice" + val avatarUrl = "https://example.com/avatar.png" + val mode = ShowLocationMode.Static( + location = location, + senderName = senderName, + senderId = senderId, + senderAvatarUrl = avatarUrl, + timestamp = 0L, + assetType = AssetType.SENDER, + ) + + val presenter = createShowLocationPresenter(mode = mode) + presenter.test { + val state = awaitItem() + assertThat(state.locationShares).hasSize(1) + + val item = state.locationShares.first() + assertThat(item.userId).isEqualTo(senderId) + assertThat(item.displayName).isEqualTo(senderName) + assertThat(item.location).isEqualTo(location) + assertThat(item.isLive).isFalse() + assertThat(item.assetType).isEqualTo(AssetType.SENDER) + assertThat(item.avatarData.id).isEqualTo(senderId.value) + assertThat(item.avatarData.name).isEqualTo(senderName) + assertThat(item.avatarData.url).isEqualTo(avatarUrl) + } + } + + @Test + fun `static mode has non-draggable sheet`() = runTest { + val presenter = createShowLocationPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.isSheetDraggable).isFalse() + } + } +} + +private fun aLiveLocationShare( + userId: UserId, + geoUri: String = "geo:48.8584,2.2945", + timestamp: Long = 0L, + startTimestamp: Long = 0L, + endTimestamp: Long = Long.MAX_VALUE, + assetType: AssetType = AssetType.SENDER, +): LiveLocationShare { + return LiveLocationShare( + userId = userId, + lastLocation = LastLocation( + geoUri = geoUri, + timestamp = timestamp, + assetType = assetType, + ), + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 646a19895a..36e94ec456 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -586,17 +586,18 @@ class MessagesFlowNode( ) } is TimelineItemLocationContent -> { - val mode = ShowLocationMode.Static( - location = event.content.location, - senderName = event.safeSenderName, - senderId = event.senderId, - senderAvatarUrl = event.senderAvatar.url, - timestamp = event.sentTimeMillis, - assetType = event.content.assetType, - ) - NavTarget.LocationViewer( - mode = mode - ).takeIf { locationService.isServiceAvailable() } + val mode = when (event.content.mode) { + is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId) + is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static( + location = event.content.mode.location, + senderName = event.safeSenderName, + senderId = event.senderId, + senderAvatarUrl = event.senderAvatar.url, + timestamp = event.sentTimeMillis, + assetType = event.content.assetType, + ) + } + NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } } else -> null } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 53f15066b6..ef446e36cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -289,7 +289,11 @@ private fun MessageSummary( is TimelineItemRedactedContent, is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemLocationContent -> { - content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) } + val body = when (event.content.mode) { + is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location) + is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location) + } + content = { ContentForBody(body) } } is TimelineItemImageContent -> { content = { ContentForBody(event.content.bestDescription) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 1591cbf6cc..e9a6ce5549 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -57,4 +57,6 @@ sealed interface TimelineEvent { data class EditPoll( val pollStartId: EventId, ) : TimelineItemPollEvent + + data object StopLiveLocationShare : TimelineItemEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 8a7011552e..edd8d446dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -200,6 +200,7 @@ class TimelinePresenter( is TimelineEvent.EditPoll -> { navigator.navigateToEditPoll(event.pollStartId) } + is TimelineEvent.StopLiveLocationShare -> Unit is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch { focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) delay(event.debounce) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 0361eebc01..976fa3c17e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.mustBeProtected @@ -677,6 +678,7 @@ private fun MessageEventBubbleContent( .padding(horizontal = 8.dp, vertical = 4.dp) ) } + TimestampPosition.Hidden -> Box(modifier) { content {} } } } @@ -772,11 +774,17 @@ private fun MessageEventBubbleContent( } } - val timestampPosition = when (event.content) { - is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemStickerContent, - is TimelineItemLocationContent -> TimestampPosition.Overlay + val timestampPosition = when (val content = event.content) { + is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemStickerContent -> TimestampPosition.Overlay + is TimelineItemLocationContent -> { + val content = content.ensureActiveLiveLocation() + val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && + content.mode.isActive && + content.mode.canStop + if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay + } is TimelineItemPollContent -> TimestampPosition.Below else -> TimestampPosition.Default } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt index 605db65da3..505edeef15 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt @@ -22,7 +22,12 @@ enum class TimestampPosition { /** * Timestamp should always be rendered below the timeline event content (eg. poll). */ - Below; + Below, + + /** + * Timestamp should be hidden. + */ + Hidden; companion object { /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 4fc243864c..2044796889 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.link.Link @@ -71,10 +72,13 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier ) - is TimelineItemLocationContent -> TimelineItemLocationView( - content = content, - modifier = modifier - ) + is TimelineItemLocationContent -> { + TimelineItemLocationView( + content = content.ensureActiveLiveLocation(), + onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, + modifier = modifier + ) + } is TimelineItemImageContent -> TimelineItemImageView( content = content, hideMediaContent = hideMediaContent, 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 592b95a337..f00b6b0b5b 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,33 +8,153 @@ package io.element.android.features.messages.impl.timeline.components.event +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons 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.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemLocationView( content: TimelineItemLocationContent, + onStopLiveLocationClick: () -> Unit, modifier: Modifier = Modifier, ) { - StaticMapView( + Box(modifier = modifier.fillMaxWidth()) { + StaticMapView( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + pinVariant = content.pinVariant, + location = content.location, + zoom = 15.0, + contentDescription = content.description + ) + + if (content.mode is TimelineItemLocationContent.Mode.Live) { + LiveLocationOverlay( + mode = content.mode, + onStopClick = onStopLiveLocationClick, + modifier = Modifier.align(Alignment.BottomStart) + ) + } + } +} + +@Composable +private fun LiveLocationOverlay( + mode: TimelineItemLocationContent.Mode.Live, + onStopClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( modifier = modifier .fillMaxWidth() - .heightIn(max = 188.dp), - pinVariant = content.pinVariant, - lat = content.location.lat, - lon = content.location.lon, - zoom = 15.0, - contentDescription = content.body - ) + .background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val iconShape = RoundedCornerShape(8.dp) + Box( + modifier = Modifier + .size(32.dp) + .border( + width = 1.dp, + color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent, + shape = iconShape, + ) + .background( + color = if (mode.isActive) { + ElementTheme.colors.bgCanvasDefault + } else { + ElementTheme.colors.bgSubtleSecondary + }, + shape = iconShape + ) + ) { + if (mode.isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = ElementTheme.colors.iconSecondary, + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } else { + Icon( + imageVector = CompoundIcons.LocationPinSolid(), + contentDescription = null, + tint = if (mode.isActive) { + ElementTheme.colors.iconAccentPrimary + } else { + ElementTheme.colors.iconDisabled + }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (mode.isActive) { + stringResource(CommonStrings.common_live_location) + } else { + stringResource(CommonStrings.common_live_location_ended) + }, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + if (mode.isActive) { + Text( + text = mode.endsAt, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } + + if (mode.isActive && mode.canStop) { + IconButton( + onClick = onStopClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = ElementTheme.colors.bgCriticalPrimary, + contentColor = ElementTheme.colors.iconOnSolidPrimary, + ) + ) { + Icon( + imageVector = CompoundIcons.Stop(), + contentDescription = null, + ) + } + } + } } @PreviewsDayNight @@ -43,5 +163,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat ElementPreview { TimelineItemLocationView( content = content, + onStopLiveLocationClick = {}, ) } 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 2b5c0fa98a..fcb346ecd7 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 @@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt 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.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId @@ -36,6 +38,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider @Inject class TimelineItemContentFactory( @@ -50,6 +54,8 @@ class TimelineItemContentFactory( private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, private val sessionId: SessionId, + private val dateFormatter: DateFormatter, + private val stringProvider: StringProvider, ) { suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { return create( @@ -103,19 +109,24 @@ class TimelineItemContentFactory( 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 - } + + val endsAt = dateFormatter.format( + timestamp = itemContent.endTimestamp, + mode = DateFormatterMode.TimeOnly + ) + // Always create content, location can be null for "loading/waiting" state + TimelineItemLocationContent( + description = itemContent.description?.trimEnd(), + assetType = itemContent.assetType, + senderId = sender, + senderProfile = senderProfile, + mode = TimelineItemLocationContent.Mode.Live( + lastKnownLocation = lastKnownLocation, + isActive = itemContent.isLive, + endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt), + endTimestamp = itemContent.endTimestamp, + ), + ) } } } 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 723ab6feac..e2e5d0c03e 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 @@ -150,13 +150,11 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - body = body, - location = location, description = messageType.description, senderId = senderId, senderProfile = senderProfile, assetType = messageType.assetType, - mode = TimelineItemLocationContent.Mode.Static + mode = TimelineItemLocationContent.Mode.Static(location = location) ) } } 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 f3d70f44e7..44dd2df38d 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 @@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider mode.lastKnownLocation + is Mode.Static -> mode.location + } + + /** + * The pin variant to display on the map. + * Returns a default variant when location is null (map will show loading placeholder anyway). + */ + val pinVariant: PinVariant = when (mode) { is Mode.Live -> { if (mode.isActive) { PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) @@ -35,7 +45,7 @@ data class TimelineItemLocationContent( PinVariant.StaleLocation } } - Mode.Static -> { + is Mode.Static -> { when (assetType) { AssetType.PIN -> PinVariant.PinnedLocation AssetType.SENDER, @@ -53,9 +63,57 @@ data class TimelineItemLocationContent( ) sealed interface Mode { - data object Static : Mode - data class Live(val isActive: Boolean) : Mode + data class Static( + val location: Location, + ) : Mode + + data class Live( + val lastKnownLocation: Location?, + val isActive: Boolean, + val endsAt: String, + val endTimestamp: Long, + val canStop: Boolean = false, + ) : Mode { + val isLoading = lastKnownLocation == null && isActive + } } override val type: String = "TimelineItemLocationContent" } + +/** + * Overrides the isActive value if needed, to make sure endTimestamp is used in absence of stop event. + */ +@Composable +internal fun TimelineItemLocationContent.ensureActiveLiveLocation( + currentTimeMillis: () -> Long = System::currentTimeMillis, +): TimelineItemLocationContent { + return when (mode) { + is TimelineItemLocationContent.Mode.Live -> { + val isActive = rememberIsLiveLocationActive(mode, currentTimeMillis) + copy(mode = mode.copy(isActive = isActive)) + } + is TimelineItemLocationContent.Mode.Static -> this + } +} + +@Composable +private fun rememberIsLiveLocationActive( + mode: TimelineItemLocationContent.Mode.Live, + currentTimeMillis: () -> Long, +): Boolean { + fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean { + return isActive && endTimestamp > currentTimeMillis() + } + return produceState( + initialValue = mode.isActive(), + key1 = mode.endTimestamp, + key2 = mode.isActive, + ) { + if (mode.isActive) { + val remainingMillis = mode.endTimestamp - currentTimeMillis() + delay(remainingMillis) + } + value = false + }.value +} 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 362e9b4cda..a9cc9e59d4 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 @@ -18,24 +18,56 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider get() = sequenceOf( aTimelineItemLocationContent(), - aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), - aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + endTimestamp = 0L, + canStop = true, + lastKnownLocation = aLocation() + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + endTimestamp = 0L, + lastKnownLocation = aLocation() + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + endTimestamp = 0L, + lastKnownLocation = null + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = false, + endsAt = "", + endTimestamp = 0L, + lastKnownLocation = aLocation() + ), + ), ) } fun aTimelineItemLocationContent( - body: String = "", senderId: UserId = UserId("@sender:matrix.org"), senderProfile: ProfileDetails = aProfileDetailsReady(), - mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, + description: String? = null, + mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()), ) = TimelineItemLocationContent( - body = body, - location = Location( - lat = 52.2445, - lon = 0.7186, - accuracy = 5000f, - ), senderId = senderId, senderProfile = senderProfile, - mode = mode + description = description, + mode = mode, +) + +fun aLocation() = Location( + lat = 52.2445, + lon = 0.7186, + accuracy = 5000f, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 0aeb3bb8fc..c48f2dae40 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -41,7 +41,10 @@ class DefaultMessageSummaryFormatter( is TimelineItemTextBasedContent -> content.plainText is TimelineItemProfileChangeContent -> content.body is TimelineItemStateContent -> content.body - is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) + is TimelineItemLocationContent -> when (content.mode) { + is TimelineItemLocationContent.Mode.Live -> context.getString(CommonStrings.common_shared_live_location) + is TimelineItemLocationContent.Mode.Static -> context.getString(CommonStrings.common_shared_location) + } is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemPollContent -> content.question diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index 2f87af3df0..fb39e67fc5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope @@ -72,6 +73,8 @@ internal fun aTimelineItemContentFactory( failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), sessionId = matrixClient.sessionId, + dateFormatter = FakeDateFormatter(), + stringProvider = FakeStringProvider(), ) internal fun TestScope.aTimelineItemsFactory( 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 957b01d1ed..fa837f1492 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 @@ -78,9 +78,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -@Suppress("LargeClass") -@RunWith(RobolectricTestRunner::class) -class TimelineItemContentMessageFactoryTest { +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest { @Test fun `test create OtherMessageType`() = runTest { val sut = createTimelineItemContentMessageFactory() @@ -110,11 +108,9 @@ class TimelineItemContentMessageFactoryTest { eventId = AN_EVENT_ID, ) val expected = TimelineItemLocationContent( - body = "body", - location = Location(lat = 1.0, lon = 2.0, accuracy = null), description = "description", assetType = assetType, - mode = TimelineItemLocationContent.Mode.Static, + mode = TimelineItemLocationContent.Mode.Static(location = Location(lat = 1.0, lon = 2.0, accuracy = null)), senderId = A_USER_ID, senderProfile = aProfileDetails(), ) @@ -166,16 +162,11 @@ class TimelineItemContentMessageFactoryTest { senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) as TimelineItemTextContent - val expected = TimelineItemTextContent( - body = "https://www.example.org", - htmlDocument = null, - isEdited = false, - formattedBody = buildSpannedString { - inSpans(URLSpan("https://www.example.org")) { - append("https://www.example.org") - } + val expected = TimelineItemTextContent(body = "https://www.example.org", htmlDocument = null, isEdited = false, formattedBody = buildSpannedString { + inSpans(URLSpan("https://www.example.org")) { + append("https://www.example.org") } - ) + }) assertThat(result.body).isEqualTo(expected.body) assertThat(result.htmlDocument).isEqualTo(expected.htmlDocument) assertThat(result.plainText).isEqualTo(expected.plainText) @@ -200,9 +191,7 @@ class TimelineItemContentMessageFactoryTest { append("and manually added link") } }.toSpannable() - val sut = createTimelineItemContentMessageFactory( - domConverterTransform = { expected } - ) + val sut = createTimelineItemContentMessageFactory(domConverterTransform = { expected }) val result = sut.create( content = createMessageContent( type = TextMessageType( @@ -219,9 +208,7 @@ class TimelineItemContentMessageFactoryTest { @Test fun `test create TextMessageType with unknown formatted body does nothing`() = runTest { - val sut = createTimelineItemContentMessageFactory( - htmlConverterTransform = { it } - ) + val sut = createTimelineItemContentMessageFactory(htmlConverterTransform = { it }) val result = sut.create( content = createMessageContent( type = TextMessageType( @@ -356,10 +343,10 @@ class TimelineItemContentMessageFactoryTest { formattedCaption = null, source = MediaSource("url"), info = AudioInfo( - duration = 1.minutes, - size = 123L, - mimetype = MimeTypes.Mp3, - ) + duration = 1.minutes, + size = 123L, + mimetype = MimeTypes.Mp3, + ) ), isEdited = true, ), @@ -597,16 +584,16 @@ class TimelineItemContentMessageFactoryTest { formattedCaption = null, source = MediaSource("url"), info = FileInfo( - mimetype = MimeTypes.Pdf, - size = 123L, - thumbnailInfo = ThumbnailInfo( - height = 10L, - width = 5L, - mimetype = MimeTypes.Jpeg, - size = 111L, - ), - thumbnailSource = MediaSource("url_thumbnail"), - ) + mimetype = MimeTypes.Pdf, + size = 123L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + ) ), isEdited = true, ), 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 68dd4cd332..3ecd7819e5 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 @@ -117,7 +117,7 @@ class DefaultRoomLatestEventFormatter( message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } is LiveLocationContent -> { - val message = sp.getString(CommonStrings.common_shared_location) + val message = sp.getString(CommonStrings.common_shared_live_location) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 7e841639bd..3f9c108dc7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -15,10 +15,19 @@ import io.element.android.libraries.matrix.api.core.UserId data class LiveLocationShare( /** The user who is sharing their location. */ val userId: UserId, - /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ - val lastGeoUri: String, - /** The timestamp of the last location update. */ - val lastTimestamp: Long, - /** Whether the live location share is still active. */ - val isLive: Boolean, + /** The last known location if any. */ + val lastLocation: LastLocation?, + /** The timestamp when location sharing started, in milliseconds.*/ + val startTimestamp: Long, + /** The timestamp when location sharing ends, in milliseconds. */ + val endTimestamp: Long, +) + +data class LastLocation( + /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ + val geoUri: String, + /** The timestamp of the last location update. */ + val timestamp: Long, + /** The asset of the last location update. */ + val assetType: AssetType, ) 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 95d4327c07..abf78cd4eb 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 @@ -105,13 +105,15 @@ data class FailedToParseStateContent( ) : EventContent data class LiveLocationContent( - val body: String, val isLive: Boolean, val description: String?, + val startTimestamp: Long, val timeout: Long, val assetType: AssetType?, val locations: List, -) : EventContent +) : EventContent { + val endTimestamp = startTimestamp + timeout +} data object LegacyCallInviteContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index e6287d0d16..6507cf38c8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest +import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow +import io.element.android.libraries.matrix.impl.room.location.timedByExpiry import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService import io.element.android.libraries.matrix.impl.roomdirectory.map @@ -511,7 +513,7 @@ class JoinedRustRoom( } override fun subscribeToLiveLocationShares(): Flow> { - TODO("Not implemented yet") + return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis) } override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt new file mode 100644 index 0000000000..bae406a137 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -0,0 +1,74 @@ +/* + * 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.impl.room.location + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LastLocation +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import org.matrix.rustcomponents.sdk.LiveLocationShareListener +import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate +import org.matrix.rustcomponents.sdk.RoomInterface +import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare + +fun RoomInterface.liveLocationSharesFlow(): Flow> { + fun MutableList.applyUpdate(update: LiveLocationShareUpdate) { + when (update) { + is LiveLocationShareUpdate.Append -> addAll(update.values.map { it.into() }) + is LiveLocationShareUpdate.Clear -> clear() + is LiveLocationShareUpdate.Insert -> add(update.index.toInt(), update.value.into()) + is LiveLocationShareUpdate.PopBack -> if (isNotEmpty()) removeAt(lastIndex) + is LiveLocationShareUpdate.PopFront -> if (isNotEmpty()) removeAt(0) + is LiveLocationShareUpdate.PushBack -> add(update.value.into()) + is LiveLocationShareUpdate.PushFront -> add(0, update.value.into()) + is LiveLocationShareUpdate.Remove -> removeAt(update.index.toInt()) + is LiveLocationShareUpdate.Reset -> { + clear() + addAll(update.values.map { it.into() }) + } + is LiveLocationShareUpdate.Set -> set(update.index.toInt(), update.value.into()) + is LiveLocationShareUpdate.Truncate -> subList(update.length.toInt(), size).clear() + } + } + return callbackFlow { + val liveLocationShares = liveLocationShares() + val shares: MutableList = ArrayList() + val taskHandle = liveLocationShares.subscribe(object : LiveLocationShareListener { + override fun onUpdate(updates: List) { + for (update in updates) { + shares.applyUpdate(update) + } + trySend(shares) + } + }) + awaitClose { + taskHandle.cancelAndDestroy() + liveLocationShares.destroy() + } + }.buffer(Channel.UNLIMITED) +} + +private fun RustLiveLocationShare.into(): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastLocation = lastLocation?.let { + LastLocation( + geoUri = it.location.geoUri, + timestamp = it.ts.toLong(), + assetType = it.location.asset.into(), + ) + }, + startTimestamp = startTs.toLong(), + endTimestamp = (startTs + timeout).toLong() + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt new file mode 100644 index 0000000000..9bfddf280f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt @@ -0,0 +1,55 @@ +/* + * 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.impl.room.location + +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * Makes sure to filter and emit live location based on the endTimestamp. + */ +internal fun Flow>.timedByExpiry( + currentTimeMillis: () -> Long = System::currentTimeMillis, +): Flow> = channelFlow { + var timerJob: Job? = null + + fun List.nextExpiryAfter(timestamp: Long): Long? { + return this + .asSequence() + .map { it.endTimestamp } + .filter { it > timestamp } + .minOrNull() + } + + fun List.filterLive(): List { + val currentTimeMillis = currentTimeMillis() + return filter { it.endTimestamp > currentTimeMillis } + } + + fun reschedule(shares: List) { + timerJob?.cancel() + timerJob = launch { + val currentTimeMillis = currentTimeMillis() + val nextExpiry = shares.nextExpiryAfter(currentTimeMillis) ?: return@launch + delay((nextExpiry - currentTimeMillis).coerceAtLeast(0)) + val liveShares = shares.filterLive() + send(liveShares) + reschedule(liveShares) + } + } + + collect { shares -> + val liveShares = shares.filterLive() + send(liveShares) + reschedule(liveShares) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index d617df60db..85b53bc6e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.timeline.item.event import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary @@ -19,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent 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.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.PollContent @@ -33,8 +35,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.poll.map import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.location.into import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap +import org.matrix.rustcomponents.sdk.BeaconInfo import org.matrix.rustcomponents.sdk.EmbeddedEventDetails import org.matrix.rustcomponents.sdk.MsgLikeContent import org.matrix.rustcomponents.sdk.MsgLikeKind @@ -108,8 +112,14 @@ class TimelineEventContentMapper( ) } is MsgLikeKind.LiveLocation -> { - // Live location messages are a special kind of message that we want to treat as unknown content for now - UnknownContent + LiveLocationContent( + isLive = kind.content.isLive, + startTimestamp = kind.content.ts.toLong(), + description = kind.content.description, + timeout = kind.content.timeoutMs.toLong(), + assetType = kind.content.assetType.into(), + locations = kind.content.locations.map { location -> location.map() } + ) } is MsgLikeKind.Other -> UnknownContent } @@ -259,3 +269,11 @@ private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data { RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown } } + +private fun BeaconInfo.map(): LiveLocationInfo { + return LiveLocationInfo( + description = description, + geoUri = geoUri, + timestamp = ts.toLong(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt new file mode 100644 index 0000000000..41627396ad --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -0,0 +1,147 @@ +/* + * 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.impl.room.location + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TimedLiveLocationSharesFlowTest { + @Test + fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest { + val shares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), + aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000), + ) + + flowOf(shares) + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares) + + advanceTimeBy(1_000) + assertThat(awaitItem()).isEqualTo(shares.drop(1)) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEqualTo(shares.drop(2)) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it does not double-emit when a share is already expired on receipt`() = runTest { + val shares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 500), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), + ) + + flowOf(shares) + .timedByExpiry(currentTimeMillis = { 1_000 + testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares.drop(1)) + expectNoEvents() + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it reschedules timed emission when upstream shares change`() = runTest { + val upstream = MutableSharedFlow>(extraBufferCapacity = 1) + val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000)) + val updatedShares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000), + ) + + upstream + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + upstream.emit(initialShares) + assertThat(awaitItem()).isEqualTo(initialShares) + + advanceTimeBy(5_000) + upstream.emit(updatedShares) + assertThat(awaitItem()).isEqualTo(updatedShares) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEqualTo(updatedShares.take(1)) + + advanceTimeBy(3_999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + } + } + + @Test + fun `it completes after the last scheduled re-emission when upstream completes`() = runTest { + val shares = listOf(aLiveLocationShare(endTimestamp = 1_000)) + flowOf(shares) + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares) + + advanceTimeBy(1_000) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it completes immediately when upstream emits nothing`() = runTest { + emptyFlow>() + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + awaitComplete() + } + } +} + +private fun aLiveLocationShare( + userId: String = "@user:server", + endTimestamp: Long, +): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastLocation = null, + startTimestamp = 0L, + endTimestamp = endTimestamp, + ) +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 6502882d7d..d78f570a31 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity import androidx.annotation.StringRes import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription @@ -60,3 +61,8 @@ fun AndroidComposeTestRule.assertNoNodeWith val text = activity.getString(res) onNodeWithText(text).assertDoesNotExist() } + +fun AndroidComposeTestRule.assertNodeWithTextIsDisplayed(@StringRes res: Int) { + val text = activity.getString(res) + onNodeWithText(text).assertIsDisplayed() +} diff --git a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png index dc7182b71e..aee573a6fc 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c2adbbce73e57c601821ea9b511913b3df0efbdd3d42643fabe0d39655e04e6 -size 438908 +oid sha256:855f37c9ca2dc6ddc9a699e42a1a3c784c26911552174735f46f31ad2588e977 +size 295172 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png index 41f55afa15..b8ff9c5b98 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3baf32fc12535ffe246264dfe1f20db1fddddb970f86953e9877251dc3f74d3d -size 173356 +oid sha256:e99b3d1c62e4907f55beefa2414ff6324c62133ab723c13b68c906331e8ee072 +size 118086 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png index 1735586b26..cedf0b1b7c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:359960ea4b7be5ff9d766565007291f8585223052483736e17d4532c5f8af0c6 -size 252728 +oid sha256:56a86695d2c25c94a8e79c27c8f525229cc348201073566909f83a681b37ac30 +size 251329 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png index b18c069686..dea0d3f21c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b775500b1fdc6294d94dcb1f07f74c515c3eae31bc55d0faacfd86ff7bd1da3 -size 105526 +oid sha256:0c2b2a7071bd1c21ff706525e2416169507fef364485c651c429baefa15d4c9f +size 104240 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png index e48b3cd8ab..cae8f75b66 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863 -size 16578 +oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798 +size 17155 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png index 0f17f6d6a1..29f70fc9b1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec -size 15959 +oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09 +size 16460 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png index ff0295d9ac..65720f93f6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb -size 37572 +oid sha256:ba0285628cb8f18c5d666e6727b213d3c7674d1782a7364c7b72ff906ad57eff +size 19684 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png index 6f440d71d6..26571d8a30 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 -size 35976 +oid sha256:ed64e57bea072d5bdf232133c365db043f117e35ef180aa12c9b05bff40a1a92 +size 16437 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png index 964ad077b5..ff0295d9ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 -size 31530 +oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb +size 37572 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png index 46226555db..6f440d71d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 -size 19104 +oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 +size 35976 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png index ceb1513af6..964ad077b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 -size 19228 +oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 +size 31530 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png new file mode 100644 index 0000000000..46226555db --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png new file mode 100644 index 0000000000..ceb1513af6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 +size 19228 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png index 6c424a1ffe..4e96ed69ce 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 -size 36084 +oid sha256:4356f7de986f803b890d3bc95afdefd01e9eb42d775836c647a6d9fafe3bcc4a +size 19189 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png index 72196c0b11..cfa358b892 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed -size 34500 +oid sha256:0ce9e4f5911a6dfd35655f362d122f95690a651d7c05e5ef6c4ea0621be2e628 +size 15783 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png index da90a76ab1..6c424a1ffe 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a -size 30345 +oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 +size 36084 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png index eed60f472d..72196c0b11 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 -size 18715 +oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed +size 34500 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png index d3ee3b9e22..da90a76ab1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b -size 18842 +oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a +size 30345 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png new file mode 100644 index 0000000000..eed60f472d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png new file mode 100644 index 0000000000..d3ee3b9e22 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b +size 18842 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png index 57b0b89912..c11e6b978e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:3daba890afd2533e1746f1ffd0c535674a4a238e4591da2a6bcbe31716d26858 +size 143676 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png index 57b0b89912..bdb2a689ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:82ed52f907048490ffb63ea9b33274703c1d92d4131cca0320816cc6949bea5e +size 113727 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png index 57b0b89912..5d9f6ab2ba 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74 +size 119927 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png new file mode 100644 index 0000000000..01d01e6806 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:757d555f948a637952aed4b0ad2745212f6a9ab279d172b0a1649fdf547c5a0c +size 120027 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png new file mode 100644 index 0000000000..40384d68cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f0cac0a30818ae7a2e1439d403654052bad931c8ba136bdaf37f16272a0b858 +size 20160 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png index 74add72a23..b9280ec225 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:463a2e544b2806f4082c813ec3ba8c6ce0ad0ebcd8ee32666806757a90c248f5 +size 57131 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png index 74add72a23..6962ed5f4d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:fb0d86877e5d049d618836846cd8ea97b40d1d66e7bd8d50639280fb3e829372 +size 38499 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png index 74add72a23..7b0ba78a2c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71 +size 40696 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png new file mode 100644 index 0000000000..8ee52bca11 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38fa52e6e755d86aa79a029427f04146a3939ac2038cc15717f5aec84ce3be20 +size 40870 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png new file mode 100644 index 0000000000..01b3bc1f3f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09893d3f2bfa2c1f5b99368c21d070ea1aa460d576722410fc2ca85c4eab238c +size 15361 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png index b3255ab5b5..72f4fa03b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:4a0827eb23b48bd03fde5078598cb7827459a213ff0a693890581628330f6939 +size 66845 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png index b3255ab5b5..adf8d255e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:a8fa43d296a984d242ed56f7879de4c38f3da94147c365c424248eba2c4ad61d +size 371263 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png index fc5e6d8e98..3ad7246822 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:bea070091132156a809da1c185e2f94333e62f8ea9ae7d6d1b789adffb869522 +size 55538 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png index fc5e6d8e98..7b5512dcc7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:bcd9e0934b5f6808d58a3d3506641f173c4b223d78c605de09832d3741e8834f +size 149300