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 8f774fc4e7
87 changed files with 1484 additions and 376 deletions

View file

@ -50,4 +50,6 @@ jobs:
USERNAME=maestroelement USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
ROOM_NAME=MyRoom ROOM_NAME=MyRoom
INVITEE1_MXID=@maestroelement2:matrix.org
INVITEE2_MXID=@maestroelement3:matrix.org
APP_ID=io.element.android.x.debug APP_ID=io.element.android.x.debug

View file

@ -23,9 +23,11 @@ From root dir of the project
```shell ```shell
maestro test \ maestro test \
-e APP_ID=io.element.android.x.debug \ -e APP_ID=io.element.android.x.debug \
-e USERNAME=user \ -e USERNAME=user1 \
-e PASSWORD=123 \ -e PASSWORD=123 \
-e ROOM_NAME="MyRoom" \ -e ROOM_NAME="MyRoom" \
-e INVITEE1_MXID=user2 \
-e INVITEE2_MXID=user3 \
.maestro/allTests.yaml .maestro/allTests.yaml
``` ```

View file

@ -1,6 +1,6 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- tapOn: "Sign in manually" - tapOn: "Continue"
- runFlow: ../assertions/assertLoginDisplayed.yaml - runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn - takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml - runFlow: changeServer.yaml

View file

@ -1,5 +1,5 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Communicate and collaborate securely" visible: "Be in your element"
timeout: 10_000 timeout: 10_000

View file

@ -0,0 +1,13 @@
appId: ${APP_ID}
---
# Purpose: Test the creation and deletion of a DM room.
- tapOn: "Create a new conversation or room"
- tapOn: "Search for someone"
- inputText: ${INVITEE1_MXID}
- tapOn:
text: ${INVITEE1_MXID}
index: 1
- takeScreenshot: build/maestro/330-createAndDeleteDM
- tapOn: "maestroelement2"
- tapOn: "Leave room"
- tapOn: "Leave"

View file

@ -0,0 +1,33 @@
appId: ${APP_ID}
---
# Purpose: Test the creation and deletion of a room
- tapOn: "Create a new conversation or room"
- tapOn: "New room"
- tapOn: "Search for someone"
- inputText: ${INVITEE1_MXID}
- tapOn:
text: ${INVITEE1_MXID}
index: 1
- tapOn: "Next"
- tapOn: "e.g. your project name"
- inputText: "aRoomName"
- tapOn: "What is this room about?"
- inputText: "aRoomTopic"
- tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom
- tapOn: "aRoomName"
- tapOn: "Invite people"
# assert there's 1 memeber and 1 invitee
- tapOn: "Search for someone"
- inputText: ${INVITEE2_MXID}
- tapOn:
text: ${INVITEE2_MXID}
index: 1
- tapOn: "Send"
- tapOn: "Back"
- tapOn: "aRoomName"
- tapOn: "People"
# assert there's 1 memeber and 2 invitees
- tapOn: "Back"
- tapOn: "Leave room"
- tapOn: "Leave"

View file

@ -0,0 +1,14 @@
appId: ${APP_ID}
---
# Purpose: Test the context menu of a room in the room list
- longPressOn: ${ROOM_NAME}
- takeScreenshot: build/maestro/310-RoomList-ContextMenu
- tapOn:
text: "Settings"
index: 0
- tapOn: "Back"
- longPressOn: ${ROOM_NAME}
- tapOn:
text: "Leave room"
index: 0
- tapOn: "Cancel"

View file

@ -1,6 +1,8 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- takeScreenshot: build/maestro/300-RoomList
- runFlow: searchRoomList.yaml - runFlow: searchRoomList.yaml
- takeScreenshot: build/maestro/300-RoomList
- runFlow: timeline/timeline.yaml - runFlow: timeline/timeline.yaml
- runFlow: roomContextMenu.yaml
- runFlow: createAndDeleteRoom.yaml
- runFlow: createAndDeleteDM.yaml

View file

@ -59,7 +59,7 @@ allprojects {
config = files("$rootDir/tools/detekt/detekt.yml") config = files("$rootDir/tools/detekt/detekt.yml")
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.10") detektPlugins("io.nlopez.compose.rules:detekt:0.1.11")
} }
// KtLint // KtLint

1
changelog.d/712.bugfix Normal file
View file

@ -0,0 +1 @@
Fix actions for redacted, not sent and media messages

View file

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

View file

@ -16,19 +16,25 @@
package io.element.android.features.location.api 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+)?))?""" private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@Parcelize
data class Location( data class Location(
val lat: Double, val lat: Double,
val lon: Double, val lon: Double,
val accuracy: Float, val accuracy: Float,
) ) : Parcelable {
companion object {
fun parseGeoUri(geoUri: String): Location? { fun fromGeoUri(geoUri: String): Location? {
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
return Location ( return Location(
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, 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 com.google.common.truth.Truth.assertThat
import org.junit.Test import org.junit.Test
internal class GeoUrisKtTest { internal class LocationKtTest {
@Test @Test
fun `parseGeoUri - returns null for invalid urls`() { fun `parseGeoUri - returns null for invalid urls`() {
assertThat(parseGeoUri("")).isNull() assertThat(Location.fromGeoUri("")).isNull()
assertThat(parseGeoUri("http://example.com/")).isNull() assertThat(Location.fromGeoUri("http://example.com/")).isNull()
assertThat(parseGeoUri("geo:")).isNull() assertThat(Location.fromGeoUri("geo:")).isNull()
assertThat(parseGeoUri("geo:1.234")).isNull() assertThat(Location.fromGeoUri("geo:1.234")).isNull()
assertThat(parseGeoUri("geo:1.234,")).isNull() assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
assertThat(parseGeoUri("geo:,1.234")).isNull() assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull() assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,5.678")).isNull() assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull() assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
assertThat(parseGeoUri("geo:not,good")).isNull() assertThat(Location.fromGeoUri("geo:not,good")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull() assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull() assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
} }
@Test @Test
fun `parseGeoUri - returns location for valid urls`() { 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, lat = 1.234,
lon = 5.678, lon = 5.678,
accuracy = 0f, accuracy = 0f,
)) ))
assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
lat = 1.0, lat = 1.0,
lon = 5.0, lon = 5.0,
accuracy = 0f, 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, lat = 1.234,
lon = 5.678, lon = 5.678,
accuracy = 3000f, accuracy = 3000f,
)) ))
assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
lat = 1.0, lat = 1.0,
lon = 5.0, lon = 5.0,
accuracy = 3000f, 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, lat = -1.234,
lon = -5.678, lon = -5.678,
accuracy = 9.10f, 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, lat = -1.0,
lon = -5.0, lon = -5.0,
accuracy = 9.10f, accuracy = 9.10f,

View file

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

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -62,6 +63,9 @@ class SendLocationPresenter @Inject constructor(
room.sendLocation( room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}", body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${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.LocationListenerCompat
import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat import androidx.core.location.LocationRequestCompat
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose 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.annotation.SuppressLint
import android.view.Gravity import android.view.Gravity
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -29,7 +30,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview 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.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions 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.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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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 io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -69,7 +74,11 @@ fun MapView(
// When in preview, early return a Box with the received modifier preserving layout // When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. @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 return
} }
@ -155,7 +164,7 @@ fun MapView(
.withLatLng(LatLng(location.lat, location.lon)) .withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin") .withIconImage("pin")
.withIconSize(1.3f) .withIconSize(1.3f)
.withIconOffset(arrayOf(0f, 0.5f)) .withIconAnchor(ICON_ANCHOR_BOTTOM)
) )
Timber.d("Shown pin at location: $location") 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. * limitations under the License.
*/ */
package io.element.android.features.location.impl.location package io.element.android.features.location.impl.show
/** import io.element.android.features.location.api.Location
* Represents a location sample emitted by the device's location subsystem.
*/ interface LocationActions {
data class Location( fun share(location: Location, label: String?)
val lat: Double, }
val lon: Double,
val accuracy: Float,
)

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 package io.element.android.features.location.impl.location
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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) { while (true) {
delay(1_000) delay(1_000)
emit(aLocation()) emit(aLocation())
} }
} }
private fun aLocation() = io.element.android.features.location.impl.location.Location( private fun aLocation() = Location(
lat = 51.49404, lat = 51.49404,
lon = -0.25484, lon = -0.25484,
accuracy = 5f 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.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.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint, private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>( ) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -83,7 +87,10 @@ class MessagesFlowNode @AssistedInject constructor(
data class AttachmentPreview(val attachment: Attachment) : NavTarget data class AttachmentPreview(val attachment: Attachment) : NavTarget
@Parcelize @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 @Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget data class ForwardEvent(val eventId: EventId) : NavTarget
@ -117,7 +124,7 @@ class MessagesFlowNode @AssistedInject constructor(
callback?.onUserDataClicked(userId) callback?.onUserDataClicked(userId)
} }
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
} }
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
} }
is NavTarget.LocationViewer -> {
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
showLocationEntryPoint.createNode(this, buildContext, inputs)
}
is NavTarget.EventDebugInfo -> { is NavTarget.EventDebugInfo -> {
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode<EventDebugInfoNode>(buildContext, listOf(inputs)) createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
backstack.push(navTarget)
}
else -> Unit 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 import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
interface MessagesNavigator { interface MessagesNavigator {
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId) fun onForwardEventClicked(eventId: EventId)
fun onReportContentClicked(eventId: EventId, senderId: UserId) fun onReportContentClicked(eventId: EventId, senderId: UserId)
} }

View file

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

View file

@ -227,15 +227,19 @@ class MessagesPresenter @AssistedInject constructor(
} }
private suspend fun handleActionRedact(event: TimelineItem.Event) { private suspend fun handleActionRedact(event: TimelineItem.Event) {
if (event.eventId == null) return if (event.failedToSend) {
room.redactEvent(event.eventId) // 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) { private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return
val composerMode = MessageComposerMode.Edit( val composerMode = MessageComposerMode.Edit(
targetEvent.eventId, targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty() (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
targetEvent.transactionId,
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvents.SetMode(composerMode) MessageComposerEvents.SetMode(composerMode)
@ -288,7 +292,6 @@ class MessagesPresenter @AssistedInject constructor(
} }
private fun handleShowDebugInfoAction(event: TimelineItem.Event) { private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo) 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.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta 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.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -45,6 +48,10 @@ class ActionListPresenter @Inject constructor(
mutableStateOf(ActionListState.Target.None) mutableStateOf(ActionListState.Target.None)
} }
val displayEmojiReactions by remember {
derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.sendState is EventSendState.Sent }
}
fun handleEvents(event: ActionListEvents) { fun handleEvents(event: ActionListEvents) {
when (event) { when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None ActionListEvents.Clear -> target.value = ActionListState.Target.None
@ -54,29 +61,37 @@ class ActionListPresenter @Inject constructor(
return ActionListState( return ActionListState(
target = target.value, target = target.value,
displayEmojiReactions = displayEmojiReactions,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch { private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val itemSent = timelineItem.sendState is EventSendState.Sent
val actions = val actions =
when (timelineItem.content) { when (timelineItem.content) {
is TimelineItemRedactedContent, is TimelineItemRedactedContent -> {
if (buildMeta.isDebuggable) {
listOf(TimelineItemAction.Developer)
} else {
emptyList()
}
}
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
buildList { buildList {
if (timelineItem.content.canBeCopied()) { add(TimelineItemAction.Copy)
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) { if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer) add(TimelineItemAction.Developer)
} }
} }
} }
else -> buildList<TimelineItemAction> { else -> buildList<TimelineItemAction> {
add(TimelineItemAction.Reply) if (itemSent) {
add(TimelineItemAction.Forward) add(TimelineItemAction.Reply)
if (timelineItem.isMine) { add(TimelineItemAction.Forward)
}
if (timelineItem.isMine && timelineItem.isTextMessage) {
add(TimelineItemAction.Edit) add(TimelineItemAction.Edit)
} }
if (timelineItem.content.canBeCopied()) { 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 @Immutable
data class ActionListState( data class ActionListState(
val target: Target, val target: Target,
val displayEmojiReactions: Boolean,
val eventSink: (ActionListEvents) -> Unit, val eventSink: (ActionListEvents) -> Unit,
) { ) {
sealed interface Target { sealed interface Target {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,7 +70,7 @@ import io.element.android.libraries.matrix.api.core.EventId
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun EventDebugInfoView( fun EventDebugInfoView(
eventId: EventId, eventId: EventId?,
model: String, model: String,
originalJson: String?, originalJson: String?,
latestEditedJson: String?, latestEditedJson: String?,
@ -99,7 +99,7 @@ fun EventDebugInfoView(
item { item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:") Text(text = "Event ID:")
CopyableText(text = eventId.value) CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
} }
} }
item { item {
@ -142,7 +142,7 @@ private fun CollapsibleSection(
) )
} }
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { 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 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.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
) )
} }
is LocationMessageType -> { is LocationMessageType -> {
val location = parseGeoUri(messageType.geoUri) val location = Location.fromGeoUri(messageType.geoUri)
if (location == null) { if (location == null) {
TimelineItemTextContent( TimelineItemTextContent(
body = messageType.body, body = messageType.body,

View file

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

View file

@ -31,7 +31,7 @@ class FakeMessagesNavigator : MessagesNavigator {
var onReportContentClickedCount = 0 var onReportContentClickedCount = 0
private set private set
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++ 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.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState 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.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.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.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.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -62,7 +65,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer, TimelineItemAction.Developer,
) )
) )
@ -88,7 +90,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer, TimelineItemAction.Developer,
) )
) )
@ -184,7 +185,6 @@ class ActionListPresenterTest {
persistentListOf( persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Developer, TimelineItemAction.Developer,
TimelineItemAction.Redact, 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 @Test
fun `present - compute message in non-debuggable build`() = runTest { fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false) val presenter = anActionListPresenter(isBuildDebuggable = false)
@ -226,6 +283,62 @@ class ActionListPresenterTest {
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) 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)) 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), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: EventSendState = EventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event( ) = TimelineItem.Event(
id = eventId?.value.orEmpty(), id = eventId?.value.orEmpty(),
eventId = eventId, eventId = eventId,
@ -48,7 +49,7 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), sendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE 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_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.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -193,7 +195,7 @@ class MessageComposerPresenterTest {
} }
@Test @Test
fun `present - edit message`() = runTest { fun `present - edit sent message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom() val fakeMatrixRoom = FakeMatrixRoom()
val presenter = createPresenter( val presenter = createPresenter(
this, this,
@ -219,7 +221,38 @@ class MessageComposerPresenterTest {
val messageSentState = awaitItem() val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence("")) assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse() 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 aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -17,7 +17,7 @@ recyclerview = "1.3.0"
lifecycle = "2.6.1" lifecycle = "2.6.1"
activity = "1.7.2" activity = "1.7.2"
startup = "1.1.1" startup = "1.1.1"
media3 = "1.0.2" media3 = "1.1.0"
browser = "1.5.0" browser = "1.5.0"
# Compose # Compose
@ -160,7 +160,7 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0"
# Analytics # Analytics
posthog = "com.posthog.android:posthog:2.0.3" posthog = "com.posthog.android:posthog:2.0.3"
sentry_android = "io.sentry:sentry-android:6.24.0" sentry_android = "io.sentry:sentry-android:6.25.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8"
# Di # Di

Binary file not shown.

View file

@ -14,10 +14,11 @@
# limitations under the License. # limitations under the License.
# #
#Fri Oct 07 15:02:00 CEST 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionSha256Sum=2cbafcd2c47a101cb2165f636b4677fac0b954949c9429c1c988da399defe6a9
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-all.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists distributionSha256Sum=5022b0b25fe182b0e50867e77f484501dba44feeea88f5c1f13b6b4660463640
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

293
gradlew vendored
View file

@ -1,7 +1,7 @@
#!/usr/bin/env sh #!/bin/sh
# #
# Copyright 2015 the original author or authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -17,67 +17,98 @@
# #
############################################################################## ##############################################################################
## #
## Gradle start up script for UN*X # Gradle start up script for POSIX generated by Gradle.
## #
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
PRG="$0" app_path=$0
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do # Need this for daisy-chained symlinks.
ls=`ls -ld "$PRG"` while
link=`expr "$ls" : '.*-> \(.*\)$'` APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
if expr "$link" : '/.*' > /dev/null; then [ -h "$app_path" ]
PRG="$link" do
else ls=$( ls -ld "$app_path" )
PRG=`dirname "$PRG"`"/$link" link=${ls#*' -> '}
fi case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" # This is normally unused
APP_BASE_NAME=`basename "$0"` # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum" MAX_FD=maximum
warn () { warn () {
echo "$*" echo "$*"
} } >&2
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} } >&2
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "`uname`" in case "$( uname )" in #(
CYGWIN* ) CYGWIN* ) cygwin=true ;; #(
cygwin=true Darwin* ) darwin=true ;; #(
;; MSYS* | MINGW* ) msys=true ;; #(
Darwin* ) NONSTOP* ) nonstop=true ;;
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@ -87,9 +118,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java" JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD="$JAVA_HOME/bin/java" JAVACMD=$JAVA_HOME/bin/java
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -98,88 +129,120 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
else else
JAVACMD="java" JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
MAX_FD_LIMIT=`ulimit -H -n` case $MAX_FD in #(
if [ $? -eq 0 ] ; then max*)
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
MAX_FD="$MAX_FD_LIMIT" # shellcheck disable=SC3045
fi MAX_FD=$( ulimit -H -n ) ||
ulimit -n $MAX_FD warn "Could not query maximum file descriptor limit"
if [ $? -ne 0 ] ; then esac
warn "Could not set maximum file descriptor limit: $MAX_FD" case $MAX_FD in #(
fi '' | soft) :;; #(
else *)
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
fi # shellcheck disable=SC3045
fi ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac esac
fi fi
# Escape application args # Collect all arguments for the java command, stacking in reverse order:
save () { # * args from the command line
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done # * the main class name
echo " " # * -classpath
} # * -D...appname settings
APP_ARGS=`save "$@"` # * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules # For Cygwin or MSYS, switch paths to Windows format before running java
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

15
gradlew.bat vendored
View file

@ -14,7 +14,7 @@
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%" == "" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp"
android:height="54dp"
android:viewportWidth="50"
android:viewportHeight="54">
<path
android:pathData="M25,54L18.938,48L31.062,48L25,54Z"
android:fillColor="#EBEEF2"/>
<path
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0"
android:fillColor="#EBEEF2"/>
<group>
<clip-path
android:pathData="M13,13h24v24h-24z"/>
<path
android:pathData="M25,13C20.356,13 16.6,16.858 16.6,21.629C16.6,26.769 21.904,33.857 24.088,36.556C24.568,37.148 25.444,37.148 25.924,36.556C28.096,33.857 33.4,26.769 33.4,21.629C33.4,16.858 29.644,13 25,13ZM25,24.71C23.344,24.71 22,23.33 22,21.629C22,19.928 23.344,18.547 25,18.547C26.656,18.547 28,19.928 28,21.629C28,23.33 26.656,24.71 25,24.71Z"
android:fillColor="#101317"/>
</group>
</vector>

View file

@ -3,21 +3,17 @@
android:height="54dp" android:height="54dp"
android:viewportWidth="50" android:viewportWidth="50"
android:viewportHeight="54"> android:viewportHeight="54">
<path
android:pathData="M25,54L18.938,48L31.062,48L25,54Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0"
android:fillColor="#1B1D22"/>
<group> <group>
<clip-path <clip-path
android:pathData="M0,0h50v108h-50z"/> android:pathData="M13,13h24v24h-24z"/>
<path <path
android:pathData="M25,54L18.94,48L31.06,48L25,54Z" android:pathData="M25,13C20.356,13 16.6,16.858 16.6,21.629C16.6,26.769 21.904,33.857 24.088,36.556C24.568,37.148 25.444,37.148 25.924,36.556C28.096,33.857 33.4,26.769 33.4,21.629C33.4,16.858 29.644,13 25,13ZM25,24.71C23.344,24.71 22,23.33 22,21.629C22,19.928 23.344,18.547 25,18.547C26.656,18.547 28,19.928 28,21.629C28,23.33 26.656,24.71 25,24.71Z"
android:fillColor="#1B1D22"/> android:fillColor="#ffffff"/>
<path
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0"
android:fillColor="#1B1D22"/>
<group>
<clip-path
android:pathData="M13,13h24v24h-24z"/>
<path
android:pathData="M25,13C20.36,13 16.6,16.86 16.6,21.63C16.6,26.77 21.9,33.86 24.09,36.56C24.57,37.15 25.44,37.15 25.92,36.56C28.1,33.86 33.4,26.77 33.4,21.63C33.4,16.86 29.64,13 25,13ZM25,24.71C23.34,24.71 22,23.33 22,21.63C22,19.93 23.34,18.55 25,18.55C26.66,18.55 28,19.93 28,21.63C28,23.33 26.66,24.71 25,24.71Z"
android:fillColor="#ffffff"/>
</group>
</group> </group>
</vector> </vector>

View file

@ -25,8 +25,8 @@ import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo 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.media.VideoInfo
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
@ -71,7 +71,7 @@ interface MatrixRoom : Closeable {
suspend fun sendMessage(message: String): Result<Unit> suspend fun sendMessage(message: String): Result<Unit>
suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit>
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
@ -123,6 +123,16 @@ interface MatrixRoom : Closeable {
* @param body A human readable textual representation of the location. * @param body A human readable textual representation of the location.
* @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`. * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
* Respectively: latitude, longitude, and (optional) uncertainty. * Respectively: latitude, longitude, and (optional) uncertainty.
* @param description Optional description of the location to display to the user.
* @param zoomLevel Optional zoom level to display the map at.
* @param assetType Optional type of the location asset.
* Set to SENDER if sharing own location. Set to PIN if sharing any location.
*/ */
suspend fun sendLocation(body: String, geoUri: String): Result<Unit> suspend fun sendLocation(
body: String,
geoUri: String,
description: String? = null,
zoomLevel: Int? = null,
assetType: AssetType? = null,
): Result<Unit>
} }

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.matrix.api.room.location
enum class AssetType {
SENDER,
PIN
}

View file

@ -31,9 +31,9 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
return RoomSummaryDetails( return RoomSummaryDetails(
roomId = RoomId(roomListItem.id()), roomId = RoomId(roomListItem.id()),
name = roomListItem.name() ?: roomListItem.id(), name = roomListItem.name() ?: roomListItem.id(),
canonicalAlias = room?.canonicalAlias(), canonicalAlias = roomListItem.canonicalAlias(),
isDirect = room?.isDirect() ?: false, isDirect = roomListItem.isDirect(),
avatarURLString = room?.avatarUrl(), avatarURLString = roomListItem.avatarUrl(),
unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() }, unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() },
lastMessage = latestRoomMessage, lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs, lastMessageTimestamp = latestRoomMessage?.originServerTs,

View file

@ -21,8 +21,10 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -31,6 +33,7 @@ class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow<List<RoomSummary>>, private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
private val shouldFetchFullRoom: Boolean = false,
) { ) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>() private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
@ -113,7 +116,7 @@ class RoomSummaryListProcessor(
private fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary { private fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem -> val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
roomListItem.fullRoom().use { fullRoom -> roomListItem.fullRoomOrNull().use { fullRoom ->
RoomSummary.Filled( RoomSummary.Filled(
details = roomSummaryDetailsFactory.create(roomListItem, fullRoom) details = roomSummaryDetailsFactory.create(roomListItem, fullRoom)
) )
@ -123,6 +126,14 @@ class RoomSummaryListProcessor(
return builtRoomSummary return builtRoomSummary
} }
private fun RoomListItem.fullRoomOrNull(): Room? {
return if (shouldFetchFullRoom) {
fullRoom()
} else {
null
}
}
private suspend fun updateRoomSummaries(block: MutableList<RoomSummary>.() -> Unit) = private suspend fun updateRoomSummaries(block: MutableList<RoomSummary>.() -> Unit) =
mutex.withLock { mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList() val mutableRoomSummaries = roomSummaries.value.toMutableList()

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
@ -33,8 +34,10 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
@ -44,6 +47,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -207,11 +211,16 @@ class RustMatrixRoom(
} }
} }
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId() if (originalEventId != null) {
// val content = messageEventContentFromMarkdown(message) runCatching {
runCatching { innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId)
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId) }
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId())
}
} }
} }
@ -368,13 +377,22 @@ class RustMatrixRoom(
} }
} }
//TODO expose inner parameters
override suspend fun sendLocation( override suspend fun sendLocation(
body: String, body: String,
geoUri: String geoUri: String,
description: String?,
zoomLevel: Int?,
assetType: AssetType?,
): Result<Unit> = withContext(coroutineDispatchers.io) { ): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
innerRoom.sendLocation(body, geoUri, null, null, null, genTransactionId()) innerRoom.sendLocation(
body = body,
geoUri = geoUri,
description = description,
zoomLevel = zoomLevel?.toUByte(),
assetType = assetType?.toInner(),
txnId = genTransactionId()
)
} }
} }
} }

View file

@ -49,8 +49,8 @@ internal class RustRoomSummaryDataSource(
private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList()) private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList())
private val allRoomsLoadingState: MutableStateFlow<RoomSummaryDataSource.LoadingState> = MutableStateFlow(RoomSummaryDataSource.LoadingState.NotLoaded) private val allRoomsLoadingState: MutableStateFlow<RoomSummaryDataSource.LoadingState> = MutableStateFlow(RoomSummaryDataSource.LoadingState.NotLoaded)
private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, roomListService, roomSummaryDetailsFactory) private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false)
private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, roomListService, roomSummaryDetailsFactory) private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true)
init { init {
sessionCoroutineScope.launch(coroutineDispatchers.computation) { sessionCoroutineScope.launch(coroutineDispatchers.computation) {

View file

@ -0,0 +1,24 @@
/*
* 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.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.room.location.AssetType
fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) {
AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER
AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN
}

View file

@ -110,4 +110,8 @@ class RustMatrixTimeline(
innerRoom.sendReadReceipt(eventId = eventId.value) innerRoom.sendReadReceipt(eventId = eventId.value)
} }
} }
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
}
} }

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
@ -80,6 +81,7 @@ class FakeMatrixRoom(
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit) private var sendLocationResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>() private var progressCallbackValues = emptyList<Pair<Long, Long>>()
val editMessageCalls = mutableListOf<String>()
var sendMediaCount = 0 var sendMediaCount = 0
private set private set
@ -173,11 +175,8 @@ class FakeMatrixRoom(
return cancelSendResult return cancelSendResult
} }
var editMessageParameter: String? = null override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> {
private set editMessageCalls += message
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
editMessageParameter = message
return Result.success(Unit) return Result.success(Unit)
} }
@ -286,7 +285,10 @@ class FakeMatrixRoom(
override suspend fun sendLocation( override suspend fun sendLocation(
body: String, body: String,
geoUri: String geoUri: String,
description: String?,
zoomLevel: Int?,
assetType: AssetType?,
): Result<Unit> = simulateLongTask { ): Result<Unit> = simulateLongTask {
sendLocationCount++ sendLocationCount++
return sendLocationResult return sendLocationResult

View file

@ -25,11 +25,11 @@ sealed interface MessageComposerMode : Parcelable {
@Parcelize @Parcelize
data class Normal(val content: CharSequence?) : MessageComposerMode data class Normal(val content: CharSequence?) : MessageComposerMode
sealed class Special(open val eventId: EventId, open val defaultContent: CharSequence) : sealed class Special(open val eventId: EventId?, open val defaultContent: CharSequence) :
MessageComposerMode MessageComposerMode
@Parcelize @Parcelize
data class Edit(override val eventId: EventId, override val defaultContent: CharSequence) : data class Edit(override val eventId: EventId?, override val defaultContent: CharSequence, val transactionId: String?) :
Special(eventId, defaultContent) Special(eventId, defaultContent)
@Parcelize @Parcelize

View file

@ -473,7 +473,7 @@ private fun EditContentToPreview() {
TextComposer( TextComposer(
onSendMessage = {}, onSendMessage = {},
onComposerTextChange = {}, onComposerTextChange = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", "1234"),
onResetComposerMode = {}, onResetComposerMode = {},
composerCanSendMessage = true, composerCanSendMessage = true,
composerText = "A message", composerText = "A message",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:74409b405f143793ac5641bb16f66731d7fa96d513a85e5e38c368b295b297c7 oid sha256:dcd8ccab99efd822f085614edb7296e5d73f3c1ae8d84ec0ccf930ead56bfa13
size 4965 size 7803

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b oid sha256:e6040743cf442f6e3069eb77f562d6803eb9243edc959c0db7b5631ad00c583b
size 4457 size 7103

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1f40593f1075d245f98547fea35ab6861858cb02568efd1e9c6d1f1e07d85a9
size 10193

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f571102bed06886257392347b27a935d3d34187de1004311395ee6f849e44a2
size 13504

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e13f80abff301cf0d345434a2010c7aa9e84f65514842712b1c8be64fde81160
size 23003

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:779f80e6cc4cb9ca9c3dd113cfaaeb528244b48d3b915f262ae5c797ad0b6524
size 10101

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:57852301ef65b259a7b3a28943918fc37261c3d183d5b543e246ef9fd07b542f
size 13937

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f1d57e7cbabd106d236a9afb49cfbaf5546c1f2d6b490d11a5d313d101632b4
size 24994

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:46073600d49a438279e3fb891573a88eb17dd7c74f853078ce7d33dadc81c298 oid sha256:fd4a7d61010660b1c9081c48e562b219fd454e6ef23088bf0a6a1529d5fa67f6
size 14260 size 17379

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5bd98280b4cfbd97bfebe731a772874cef0a50ceea363d0d365318d38d164774 oid sha256:7204bf50f6f7352160f9f45dbbecebbb2186b0d2e59d51e4d5237b536f345867
size 16249 size 18062

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:32c0ace0df1b31d9e47aaa4fcfc4fa7eb0d7e7d9cde321bc3e13ffd85e991f22 oid sha256:a90e03f54c92c91388b3218942e633ced908fd3b77defbeefc492d5c97de97f5
size 39371 size 39371

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12f2f8146898375b4556dfd0718b02b18ac99859efe7e857914b2cf424ca17ba
size 25792

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c3a0506c870eb5f940821e5f05357f098f18fa177b143828e3e2afad09732dc oid sha256:884654e20bd77864e1f7b7f137a539f8111ea50b8dd9c17ab689e246c70b2ead
size 40704 size 40705

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2822e203b85195a5584fa0d3e0be2b260ff233e725ae104f237c46978f6ae068
size 27337

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:15c77e539cc793ab0875813ac98eab14412a0619ca16afc90513e11f41462e07 oid sha256:b28bdd7f227340b1af4456f0e24a78b8b279819b7e41edca11c0f2bc9a14a15b
size 32575 size 32894

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:047b5e935803b2c7b12ebb1d86feabccac9d6291de3c14fea1185c2279db8d57 oid sha256:f7d39ebcf85878e3869d369a10f95efe6c786db44c098864473b3e5bda51bd83
size 35318 size 35141

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bdca76cdbf676d1b5dfd4bdf3871913a3fca0a9efafbf962e461a45278c9df13 oid sha256:c81885a3cffdb92e8282bfd511ac4efff4b02b1de2d6a37c01f9e41b94841e25
size 5426 size 5429

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:73750e1428e85548a8a8add1193d3c32709d79ffdc6c18125e3fbbc13d078c73 oid sha256:d08207b517c75145a791e0d1872bb65eb24cfd8c5c9364fb620e392ed9025c51
size 5683 size 5678

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:870b7a0002ec6fd77bfe071a64b7c5faf98062227800ca55dd1c21f517025a48 oid sha256:7d83815b1834a70e52fd55ab3558cb3f705abeba74474f4b23f6aafbb028a5ff
size 77684 size 77687

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:582d2f55a8f6707f1e4a55b4eadb7b19f18f3f78a097300151753c66e737e8e2 oid sha256:116fb7199e2577efa0d1b3de60f70054697671d554bfb2f695fb229511c03c2f
size 80326 size 80331