Merge branch 'develop' into feature/bma/swipeToReply

This commit is contained in:
Benoit Marty 2023-07-05 16:10:43 +02:00 committed by GitHub
commit 579e4df9cc
87 changed files with 1484 additions and 376 deletions

View file

@ -17,6 +17,7 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {

View file

@ -16,19 +16,25 @@
package io.element.android.features.location.api
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@Parcelize
data class Location(
val lat: Double,
val lon: Double,
val accuracy: Float,
)
fun parseGeoUri(geoUri: String): Location? {
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
return Location (
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
)
) : Parcelable {
companion object {
fun fromGeoUri(geoUri: String): Location? {
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
return Location(
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
)
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
interface ShowLocationEntryPoint : FeatureEntryPoint {
data class Inputs(val location: Location, val description: String?) : NodeInputs
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node
}

View file

@ -19,57 +19,57 @@ package io.element.android.features.location.api
import com.google.common.truth.Truth.assertThat
import org.junit.Test
internal class GeoUrisKtTest {
internal class LocationKtTest {
@Test
fun `parseGeoUri - returns null for invalid urls`() {
assertThat(parseGeoUri("")).isNull()
assertThat(parseGeoUri("http://example.com/")).isNull()
assertThat(parseGeoUri("geo:")).isNull()
assertThat(parseGeoUri("geo:1.234")).isNull()
assertThat(parseGeoUri("geo:1.234,")).isNull()
assertThat(parseGeoUri("geo:,1.234")).isNull()
assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull()
assertThat(parseGeoUri("geo:not,good")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull()
assertThat(Location.fromGeoUri("")).isNull()
assertThat(Location.fromGeoUri("http://example.com/")).isNull()
assertThat(Location.fromGeoUri("geo:")).isNull()
assertThat(Location.fromGeoUri("geo:1.234")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
assertThat(Location.fromGeoUri("geo:not,good")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
}
@Test
fun `parseGeoUri - returns location for valid urls`() {
assertThat(parseGeoUri("geo:1.234,5.678")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
lat = 1.234,
lon = 5.678,
accuracy = 0f,
))
assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
lat = 1.0,
lon = 5.0,
accuracy = 0f,
))
assertThat(parseGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
lat = 1.234,
lon = 5.678,
accuracy = 3000f,
))
assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
lat = 1.0,
lon = 5.0,
accuracy = 3000f,
))
assertThat(parseGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
lat = -1.234,
lon = -5.678,
accuracy = 9.10f,
))
assertThat(parseGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
lat = -1.0,
lon = -5.0,
accuracy = 9.10f,

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.matrixui)
implementation(projects.services.analytics.api)
implementation(libs.maplibre)
implementation(libs.maplibre.annotation)
implementation(projects.libraries.uiStrings)

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -62,6 +63,9 @@ class SendLocationPresenter @Inject constructor(
room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${event.lng}",
description = null,
zoomLevel = 15, // Send default zoom level for now.
assetType = AssetType.PIN,
)
}
}

View file

@ -24,6 +24,7 @@ import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose

View file

@ -19,6 +19,7 @@ package io.element.android.features.location.impl.map
import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@ -29,7 +30,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
@ -44,10 +47,12 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.impl.location.Location
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -69,7 +74,11 @@ fun MapView(
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(modifier = modifier)
Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[MapView]", modifier = Modifier.align(Alignment.Center))
}
return
}
@ -155,7 +164,7 @@ fun MapView(
.withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin")
.withIconSize(1.3f)
.withIconOffset(arrayOf(0f, 0.5f))
.withIconAnchor(ICON_ANCHOR_BOTTOM)
)
Timber.d("Shown pin at location: $location")
}

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocationActions @Inject constructor(
@ApplicationContext private val appContext: Context
) : LocationActions {
private var activityContext: Context? = null
override fun share(location: Location, label: String?) {
runCatching {
val uri = Uri.parse(buildUrl(location, label))
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(chooserIntent)
}.onSuccess {
Timber.v("Open location succeed")
}.onFailure {
Timber.e(it, "Open location failed")
}
}
}
@VisibleForTesting
internal fun buildUrl(
location: Location,
label: String?,
urlEncoder: (String) -> String = Uri::encode
): String {
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
return if (label == null) {
base
} else {
"%s (%s)".format(base, urlEncoder(label))
}
}

View file

@ -14,13 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.location.impl.location
package io.element.android.features.location.impl.show
/**
* Represents a location sample emitted by the device's location subsystem.
*/
data class Location(
val lat: Double,
val lon: Double,
val accuracy: Float,
)
import io.element.android.features.location.api.Location
interface LocationActions {
fun share(location: Location, label: String?)
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class ShowLocationEntryPointImpl @Inject constructor() : ShowLocationEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ShowLocationEntryPoint.Inputs): Node {
return parentNode.createNode<ShowLocationNode>(buildContext, listOf(inputs))
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
sealed interface ShowLocationEvents {
object Share : ShowLocationEvents
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class ShowLocationNode @AssistedInject constructor(
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView))
}
)
}
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.location, inputs.description)
@Composable
override fun View(modifier: Modifier) {
ShowLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp
)
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.libraries.architecture.Presenter
class ShowLocationPresenter @AssistedInject constructor(
private val actions: LocationActions,
@Assisted private val location: Location,
@Assisted private val description: String?
) : Presenter<ShowLocationState> {
@AssistedFactory
interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter
}
@Composable
override fun present(): ShowLocationState {
return ShowLocationState(
location = location,
description = description
) {
when (it) {
ShowLocationEvents.Share -> actions.share(location, description)
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
data class ShowLocationState(
val location: Location,
val description: String?,
val eventSink: (ShowLocationEvents) -> Unit,
)

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState>
get() = sequenceOf(
ShowLocationState(
Location(1.23, 2.34, 4f),
description = null,
eventSink = {},
),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = "My favourite place!",
eventSink = {},
),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = "For some reason I decided to write a small essay in the location description. " +
"It is so long that it will wrap onto more than two lines!",
eventSink = {},
),
)
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.impl.map.MapState
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.compound.generated.TypographyTokens
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ShowLocationView(
state: ShowLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState(
location = state.location,
position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0),
)
Scaffold(modifier,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_view_location_title),
style = TypographyTokens.fontBodyLgMedium,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
actions = {
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
) {
state.description?.let {
Text(
text = it,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TypographyTokens.fontBodyMdRegular,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
MapView(
mapState = mapState,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Preview
@Composable
internal fun ShowLocationViewLightPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun ShowLocationViewDarkPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ShowLocationState) {
ShowLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -16,18 +16,19 @@
package io.element.android.features.location.impl.location
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow {
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
while (true) {
delay(1_000)
emit(aLocation())
}
}
private fun aLocation() = io.element.android.features.location.impl.location.Location(
private fun aLocation() = Location(
lat = 51.49404,
lon = -0.25484,
accuracy = 5f

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import org.junit.Test
import java.net.URLEncoder
internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
private fun urlEncoder(input: String) = URLEncoder.encode(input, "US-ASCII")
@Test
fun `buildUrl - truncates excessive decimals to 6dp`() {
val location = Location(
lat = 1.234567890123,
lon = 123.456789012345,
accuracy = 0f
)
val actual = buildUrl(location, null, ::urlEncoder)
val expected = "geo:0,0?q=1.234568,123.456789"
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - appends label if set`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
val actual = buildUrl(location, "point", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (point)"
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - URL encodes label`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
class FakeLocationActions : LocationActions {
var sharedLocation: Location? = null
private set
var sharedLabel: String? = null
private set
override fun share(location: Location, label: String?) {
sharedLocation = location
sharedLabel = label
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.location.api.Location
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ShowLocationPresenterTest {
private val actions = FakeLocationActions()
private val location = Location(1.23, 4.56, 7.8f)
@Test
fun `emits initial state`() = runTest {
val presenter = ShowLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
}
}
@Test
fun `uses action to share location`() = runTest {
val presenter = ShowLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.Share)
Truth.assertThat(actions.sharedLocation).isEqualTo(location)
Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION)
}
}
companion object {
private const val A_DESCRIPTION = "My happy place"
}
}

View file

@ -29,7 +29,9 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -83,7 +87,10 @@ class MessagesFlowNode @AssistedInject constructor(
data class AttachmentPreview(val attachment: Attachment) : NavTarget
@Parcelize
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
data class LocationViewer(val location: Location, val description: String?) : NavTarget
@Parcelize
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
@ -117,7 +124,7 @@ class MessagesFlowNode @AssistedInject constructor(
callback?.onUserDataClicked(userId)
}
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
}
is NavTarget.LocationViewer -> {
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
showLocationEntryPoint.createNode(this, buildContext, inputs)
}
is NavTarget.EventDebugInfo -> {
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
)
backstack.push(navTarget)
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
backstack.push(navTarget)
}
else -> Unit
}
}

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
interface MessagesNavigator {
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportContentClicked(eventId: EventId, senderId: UserId)
}

View file

@ -54,7 +54,7 @@ class MessagesNode @AssistedInject constructor(
fun onEventClicked(event: TimelineItem.Event)
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClicked(userId: UserId)
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
@ -83,7 +83,7 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
@ -94,7 +94,7 @@ class MessagesNode @AssistedInject constructor(
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
callback?.onReportMessage(eventId, senderId)
}
private fun onSendLocationClicked() {
callback?.onSendLocationClicked()
}

View file

@ -227,15 +227,19 @@ class MessagesPresenter @AssistedInject constructor(
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
if (event.eventId == null) return
room.redactEvent(event.eventId)
if (event.failedToSend) {
// If the message hasn't been sent yet, just cancel it
event.transactionId?.let { room.cancelSend(it) }
} else if (event.eventId != null) {
room.redactEvent(event.eventId)
}
}
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
@ -288,7 +292,6 @@ class MessagesPresenter @AssistedInject constructor(
}
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
}

View file

@ -18,6 +18,8 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -28,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -45,6 +48,10 @@ class ActionListPresenter @Inject constructor(
mutableStateOf(ActionListState.Target.None)
}
val displayEmojiReactions by remember {
derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.sendState is EventSendState.Sent }
}
fun handleEvents(event: ActionListEvents) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
@ -54,29 +61,37 @@ class ActionListPresenter @Inject constructor(
return ActionListState(
target = target.value,
displayEmojiReactions = displayEmojiReactions,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
val itemSent = timelineItem.sendState is EventSendState.Sent
val actions =
when (timelineItem.content) {
is TimelineItemRedactedContent,
is TimelineItemRedactedContent -> {
if (buildMeta.isDebuggable) {
listOf(TimelineItemAction.Developer)
} else {
emptyList()
}
}
is TimelineItemStateContent -> {
buildList {
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
add(TimelineItemAction.Copy)
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
}
}
else -> buildList<TimelineItemAction> {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
if (timelineItem.isMine) {
if (itemSent) {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
}
if (timelineItem.isMine && timelineItem.isTextMessage) {
add(TimelineItemAction.Edit)
}
if (timelineItem.content.canBeCopied()) {
@ -93,6 +108,10 @@ class ActionListPresenter @Inject constructor(
}
}
}
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
if (actions.isNotEmpty()) {
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
} else {
target.value = ActionListState.Target.None
}
}
}

View file

@ -24,6 +24,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
data class ActionListState(
val target: Target,
val displayEmojiReactions: Boolean,
val eventSink: (ActionListEvents) -> Unit,
) {
sealed interface Target {

View file

@ -61,11 +61,19 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()),
actions = aTimelineItemActionList(),
),
displayEmojiReactions = false,
),
)
}
fun anActionListState() = ActionListState(
target = ActionListState.Target.None,
displayEmojiReactions = true,
eventSink = {}
)

View file

@ -175,13 +175,15 @@ private fun SheetContent(
Divider()
}
}
item {
EmojiReactionsRow(
onEmojiReactionClicked = onEmojiReactionClicked,
onCustomReactionClicked = onCustomReactionClicked,
modifier = Modifier.fillMaxWidth(),
)
Divider()
if (state.displayEmojiReactions) {
item {
EmojiReactionsRow(
onEmojiReactionClicked = onEmojiReactionClicked,
onCustomReactionClicked = onCustomReactionClicked,
modifier = Modifier.fillMaxWidth(),
)
Divider()
}
}
items(
items = actions,

View file

@ -196,10 +196,11 @@ class MessageComposerPresenter @Inject constructor(
composerMode.setToNormal()
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Edit -> room.editMessage(
capturedMode.eventId,
text
)
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, text)
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(

View file

@ -166,20 +166,12 @@ fun TimelineItemRow(
)
}
is TimelineItem.Event -> {
fun onClick() {
onClick(timelineItem)
}
fun onLongClick() {
onLongClick(timelineItem)
}
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
modifier = modifier,
)
} else {
@ -187,8 +179,8 @@ fun TimelineItemRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = canReply,
onClick = ::onClick,
onLongClick = ::onLongClick,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,

View file

@ -37,7 +37,7 @@ class EventDebugInfoNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val eventId: EventId,
val eventId: EventId?,
val timelineItemDebugInfo: TimelineItemDebugInfo,
) : NodeInputs

View file

@ -70,7 +70,7 @@ import io.element.android.libraries.matrix.api.core.EventId
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun EventDebugInfoView(
eventId: EventId,
eventId: EventId?,
model: String,
originalJson: String?,
latestEditedJson: String?,
@ -99,7 +99,7 @@ fun EventDebugInfoView(
item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:")
CopyableText(text = eventId.value)
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
}
}
item {
@ -142,7 +142,7 @@ private fun CollapsibleSection(
)
}
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
CopyableText(text = text)
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
}
}
}

View file

@ -16,7 +16,7 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.parseGeoUri
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
)
}
is LocationMessageType -> {
val location = parseGeoUri(messageType.geoUri)
val location = Location.fromGeoUri(messageType.geoUri)
if (location == null) {
TimelineItemTextContent(
body = messageType.body,

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
@ -69,6 +70,10 @@ sealed interface TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderDisplayName ?: senderId.value
val failedToSend: Boolean = sendState is EventSendState.SendingFailed
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
}
@Immutable

View file

@ -31,7 +31,7 @@ class FakeMessagesNavigator : MessagesNavigator {
var onReportContentClickedCount = 0
private set
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++
}

View file

@ -25,9 +25,12 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf
@ -62,7 +65,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
@ -88,7 +90,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
@ -184,7 +185,6 @@ class ActionListPresenterTest {
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Developer,
TimelineItemAction.Redact,
)
@ -195,6 +195,63 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for a state item in debug build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val stateEvent = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
stateEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a state item in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val stateEvent = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
stateEvent,
persistentListOf(
TimelineItemAction.Copy,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
@ -226,6 +283,62 @@ class ActionListPresenterTest {
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message with no actions`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
val redactedEvent = aMessageEvent(
isMine = true,
content = TimelineItemRedactedContent,
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent))
awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None)
assertThat(displayEmojiReactions).isFalse()
}
}
}
@Test
fun `present - compute not sent message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
sendState = EventSendState.NotSentYet,
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isFalse()
}
}
}
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))

View file

@ -38,6 +38,7 @@ internal fun aMessageEvent(
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: EventSendState = EventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event(
id = eventId?.value.orEmpty(),
eventId = eventId,
@ -48,7 +49,7 @@ internal fun aMessageEvent(
sentTime = "",
isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID),
sendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
)

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
@ -193,7 +195,7 @@ class MessageComposerPresenterTest {
}
@Test
fun `present - edit message`() = runTest {
fun `present - edit sent message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = createPresenter(
this,
@ -219,7 +221,38 @@ class MessageComposerPresenterTest {
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageParameter).isEqualTo(ANOTHER_MESSAGE)
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
}
@Test
fun `present - edit not sent message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = createPresenter(
this,
fakeMatrixRoom,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
}
@ -474,6 +507,10 @@ class MessageComposerPresenterTest {
)
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
fun anEditMode(
eventId: EventId? = AN_EVENT_ID,
message: String = A_MESSAGE,
transactionId: String? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)