Merge branch 'develop' into feature/fga/waiting_ss_room

This commit is contained in:
ganfra
2023-07-07 11:34:45 +02:00
249 changed files with 3147 additions and 677 deletions

View File

@@ -6,25 +6,28 @@ labels: [T-Story]
body:
- type: textarea
attributes:
label: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
label: Story
description: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
value: |
As a user…
I want to…
so that I can…
## Scope
<!-- These should be a list of technical tasks which take ½-1 day to complete -->
<!--These should be a list of technical tasks which take ½-1 day to complete-->
```[tasklist]
### Tasklist
- [ ] Task 1
```
- [ ] QA signoff on completion
- [ ] Design signoff on completion
- [ ] Product signoff on completion
```
## Stretch goals
None at this time
<!-- or add a tasklist -->
<!--or add a tasklist-->
## Out of scope
-

View File

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

View File

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

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}
---
- takeScreenshot: build/maestro/300-RoomList
- runFlow: searchRoomList.yaml
- takeScreenshot: build/maestro/300-RoomList
- runFlow: timeline/timeline.yaml
- runFlow: roomContextMenu.yaml
- runFlow: createAndDeleteRoom.yaml
- runFlow: createAndDeleteDM.yaml

View File

@@ -18,6 +18,7 @@ package io.element.android.appnav
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Remove
/**
* Don't process NewRoot if the nav target already exists in the stack.
@@ -29,3 +30,14 @@ fun <T : Any> BackStack<T>.safeRoot(element: T) {
if (containsRoot) return
accept(NewRoot(element))
}
/**
* Remove the last element on the backstack equals to the given one.
*/
fun <T : Any> BackStack<T>.removeLast(element: T) {
val lastExpectedNavElement = elements.value.lastOrNull {
it.key.navTarget == element
} ?: return
accept(Remove(lastExpectedNavElement.key))
}

View File

@@ -58,7 +58,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
@@ -93,7 +92,7 @@ class LoggedInFlowNode @AssistedInject constructor(
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@@ -105,22 +104,14 @@ class LoggedInFlowNode @AssistedInject constructor(
.distinctUntilChanged()
.onEach { isConsentAsked ->
if (isConsentAsked) {
switchToRoomList()
backstack.removeLast(NavTarget.AnalyticsOptIn)
} else {
switchToAnalytics()
backstack.push(NavTarget.AnalyticsOptIn)
}
}
.launchIn(lifecycleScope)
}
private fun switchToRoomList() {
backstack.safeRoot(NavTarget.RoomList)
}
private fun switchToAnalytics() {
backstack.safeRoot(NavTarget.AnalyticsSettings)
}
interface Callback : Plugin {
fun onOpenBugReport() = Unit
}
@@ -196,9 +187,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize
object Permanent : NavTarget
@@ -224,12 +212,11 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget
@Parcelize
object AnalyticsSettings : NavTarget
object AnalyticsOptIn : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext)
}
@@ -322,7 +309,7 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
NavTarget.AnalyticsSettings -> {
NavTarget.AnalyticsOptIn -> {
analyticsOptInEntryPoint.createNode(this, buildContext)
}
}
@@ -341,12 +328,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
private fun splashNode(buildContext: BuildContext) = node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {

View File

@@ -31,7 +31,6 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
@@ -42,6 +41,7 @@ import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService
@@ -49,19 +49,23 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.UUID
@ContributesNode(AppScope::class)
class RootFlowNode @AssistedInject constructor(
@@ -74,6 +78,7 @@ class RootFlowNode @AssistedInject constructor(
private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val loginUserStory: LoginUserStory,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@@ -90,21 +95,15 @@ class RootFlowNode @AssistedInject constructor(
}
private fun observeLoggedInState() {
authenticationService.isLoggedIn()
.distinctUntilChanged()
.combine(
cacheService.cacheIndex().onEach {
Timber.v("cacheIndex=$it")
matrixClientsHolder.removeAll()
}
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
.onEach { pair ->
val isLoggedIn = pair.first
val cacheIndex = pair.second
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
combine(
cacheService.onClearedCacheEventFlow(),
isUserLoggedInFlow(),
) { _, isLoggedIn -> isLoggedIn }
.onEach { isLoggedIn ->
Timber.v("isLoggedIn=$isLoggedIn")
if (isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it, cacheIndex) },
onSuccess = { switchToLoggedInFlow(it) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
@@ -114,8 +113,19 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
}
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
}
private fun switchToNotLoggedInFlow() {
@@ -123,28 +133,38 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.NotLoggedInFlow)
}
private suspend fun restoreSessionIfNeeded(
sessionId: SessionId,
onFailure: () -> Unit = {},
onSuccess: (SessionId) -> Unit = {},
) {
// If the session is already known it'll be restored by the node hierarchy
if (matrixClientsHolder.knowSession(sessionId)) {
Timber.v("Session $sessionId already alive, no need to restore.")
return
}
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}
.onFailure {
Timber.v("Failed to restore session $sessionId")
onFailure()
}
}
private suspend fun tryToRestoreLatestSession(
onSuccess: (UserId) -> Unit = {},
onSuccess: (SessionId) -> Unit = {},
onFailure: () -> Unit = {}
) {
val latestKnownUserId = authenticationService.getLatestSessionId()
if (latestKnownUserId == null) {
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
onFailure()
return
}
if (matrixClientsHolder.knowSession(latestKnownUserId)) {
onSuccess(latestKnownUserId)
return
}
authenticationService.restoreSession(UserId(latestKnownUserId.value))
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
onSuccess(matrixClient.sessionId)
}
.onFailure {
Timber.v("Failed to restore session...")
onFailure()
}
restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
}
private fun onOpenBugReport() {
@@ -175,7 +195,10 @@ class RootFlowNode @AssistedInject constructor(
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget
data class LoggedInFlow(
val sessionId: SessionId,
val navId: UUID = UUID.randomUUID(),
) : NavTarget
@Parcelize
object BugReport : NavTarget
@@ -186,7 +209,6 @@ class RootFlowNode @AssistedInject constructor(
is NavTarget.LoggedInFlow -> {
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen")
backstack.newRoot(NavTarget.SplashScreen)
}
val inputs = LoggedInFlowNode.Inputs(matrixClient)
val callback = object : LoggedInFlowNode.Callback {
@@ -247,9 +269,16 @@ class RootFlowNode @AssistedInject constructor(
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
val cacheIndex = cacheService.cacheIndex().first()
return attachChild {
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
//TODO handle multi-session
return waitForChildAttached { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
}
}
private fun CacheService.onClearedCacheEventFlow(): Flow<Unit> {
return clearedCacheEventFlow
.onEach { sessionId -> matrixClientsHolder.remove(sessionId) }
.map { }
.onStart { emit((Unit)) }
}
}

View File

@@ -249,6 +249,7 @@ koverMerged {
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.impl.map.MapState"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
}
bound {
minValue = 90

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

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

1
changelog.d/792.bugfix Normal file
View File

@@ -0,0 +1 @@
Use the `Outlined` version of M3 textfields for the login screen.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ package io.element.android.features.location.impl.map
import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -29,7 +30,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
@@ -44,10 +48,12 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.impl.location.Location
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -69,7 +75,11 @@ fun MapView(
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(modifier = modifier)
Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[MapView]", modifier = Modifier.align(Alignment.Center))
}
return
}
@@ -80,11 +90,14 @@ fun MapView(
}
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
val attributionColour = ElementTheme.colors.iconPrimary
// Build map
LaunchedEffect(darkMode) {
mapView.awaitMap().let { map ->
map.uiSettings.apply {
attributionGravity = Gravity.TOP
setAttributionTintColor(attributionColour.toArgb())
logoGravity = Gravity.TOP
isCompassEnabled = false
isRotateGesturesEnabled = false
@@ -155,7 +168,7 @@ fun MapView(
.withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin")
.withIconSize(1.3f)
.withIconOffset(arrayOf(0f, 0.5f))
.withIconAnchor(ICON_ANCHOR_BOTTOM)
)
Timber.d("Shown pin at location: $location")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
/*
* 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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.libraries.architecture.Presenter
class ShowLocationPresenter @AssistedInject constructor(
private val actions: LocationActions,
@Assisted private val location: Location,
@Assisted private val description: String?
) : Presenter<ShowLocationState> {
@AssistedFactory
interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter
}
@Composable
override fun present(): ShowLocationState {
return ShowLocationState(
location = location,
description = description
) {
when (it) {
ShowLocationEvents.Share -> actions.share(location, description)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
/*
* 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.login.api
import kotlinx.coroutines.flow.StateFlow
interface LoginUserStory {
val loginFlowIsDone: StateFlow<Boolean>
}

View File

@@ -0,0 +1,35 @@
/*
* 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.login.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.LoginUserStory
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLoginUserStory @Inject constructor() : LoginUserStory {
// True by default, will be set to false when the login user story is started, and set to true again once it's done.
override val loginFlowIsDone: MutableStateFlow<Boolean> = MutableStateFlow(true)
fun setLoginFlowIsDone(value: Boolean) {
loginFlowIsDone.value = value
}
}

View File

@@ -18,7 +18,6 @@ package io.element.android.features.login.impl
import android.app.Activity
import android.os.Parcelable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
@@ -28,6 +27,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted
@@ -39,8 +39,10 @@ import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
import io.element.android.features.login.impl.oidc.webview.OidcNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -58,6 +60,7 @@ class LoginFlowNode @AssistedInject constructor(
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfirmAccountProvider,
@@ -75,6 +78,11 @@ class LoginFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs()
override fun onBuilt() {
super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false)
}
sealed interface NavTarget : Parcelable {
@Parcelize
object ConfirmAccountProvider : NavTarget
@@ -88,6 +96,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
object LoginPassword : NavTarget
@Parcelize
data class WaitList(val loginFormState: LoginFormState) : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
@@ -144,12 +155,28 @@ class LoginFlowNode @AssistedInject constructor(
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
val callback = object : LoginPasswordNode.Callback {
override fun onWaitListError(loginFormState: LoginFormState) {
backstack.newRoot(NavTarget.WaitList(loginFormState))
}
}
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.OidcView -> {
val input = OidcNode.Inputs(navTarget.oidcDetails)
createNode<OidcNode>(buildContext, plugins = listOf(input))
}
is NavTarget.WaitList -> {
val inputs = WaitListNode.Inputs(
loginFormState = navTarget.loginFormState,
)
val callback = object : WaitListNode.Callback {
override fun onCancelClicked() {
navigateUp()
}
}
createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs))
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* 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.login.impl.error
import io.element.android.libraries.core.bool.orFalse
fun Throwable.isWaitListError(): Boolean {
return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse()
}

View File

@@ -91,7 +91,7 @@ fun ConfirmAccountProviderView(
text = stringResource(id = R.string.screen_account_provider_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled,
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -33,13 +34,22 @@ class LoginPasswordNode @AssistedInject constructor(
private val presenter: LoginPasswordPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onWaitListError(loginFormState: LoginFormState)
}
private fun onWaitListError(loginFormState: LoginFormState) {
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LoginPasswordView(
state = state,
modifier = modifier,
onBackPressed = ::navigateUp
onBackPressed = ::navigateUp,
onWaitListError = ::onWaitListError,
)
}
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
@@ -36,6 +37,7 @@ import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter<LoginPasswordState> {
@Composable
@@ -77,6 +79,8 @@ class LoginPasswordPresenter @Inject constructor(
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
// We will not navigate to the WaitList screen, so the login user story is done
defaultLoginUserStory.setLoginFlowIsDone(true)
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->

View File

@@ -16,7 +16,6 @@
package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
@@ -56,6 +55,7 @@ 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.login.impl.R
import io.element.android.features.login.impl.error.isWaitListError
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
@@ -68,9 +68,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
@@ -82,8 +82,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoginPasswordView(
state: LoginPasswordState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
onWaitListError: (LoginFormState) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginAction) {
derivedStateOf {
@@ -108,53 +109,60 @@ fun LoginPasswordView(
)
}
) { padding ->
Box(
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
val scrollState = rememberScrollState()
Column(
// Title
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.AccountCircle,
title = stringResource(
id = R.string.screen_account_provider_signin_title,
state.accountProvider.title
),
subTitle = stringResource(id = R.string.screen_login_subtitle)
)
Spacer(Modifier.height(40.dp))
LoginForm(
state = state,
isLoading = isLoading,
onSubmit = ::submit
)
// Min spacing
Spacer(Modifier.height(24.dp))
// Flexible spacing to keep the submit button at the bottom
Spacer(modifier = Modifier.weight(1f))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
// Title
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 20.dp),
iconImageVector = Icons.Filled.AccountCircle,
title = stringResource(
id = R.string.screen_account_provider_signin_title,
state.accountProvider.title
),
subTitle = stringResource(id = R.string.screen_login_form_header)
)
Spacer(Modifier.height(32.dp))
LoginForm(state = state,
isLoading = isLoading,
onSubmit = ::submit
)
Spacer(Modifier.height(28.dp))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(60.dp))
if (state.loginAction is Async.Failure) {
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
state.eventSink(LoginPasswordEvents.ClearError)
})
when {
state.loginAction.error.isWaitListError() -> {
onWaitListError(state.formState)
}
else -> {
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
state.eventSink(LoginPasswordEvents.ClearError)
})
}
}
}
}
}
@@ -182,7 +190,7 @@ internal fun LoginForm(
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
OutlinedTextField(
value = loginFieldState,
readOnly = isLoading,
modifier = Modifier
@@ -193,7 +201,7 @@ internal fun LoginForm(
loginFieldState = it
eventSink(LoginPasswordEvents.SetLogin(it))
}),
label = {
placeholder = {
Text(text = stringResource(R.string.screen_login_username_hint))
},
onValueChange = {
@@ -225,7 +233,7 @@ internal fun LoginForm(
passwordVisible = false
}
Spacer(Modifier.height(20.dp))
TextField(
OutlinedTextField(
value = passwordFieldState,
readOnly = isLoading,
modifier = Modifier
@@ -240,7 +248,7 @@ internal fun LoginForm(
passwordFieldState = it
eventSink(LoginPasswordEvents.SetPassword(it))
},
label = {
placeholder = {
Text(text = stringResource(R.string.screen_login_password_hint))
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@@ -269,6 +277,7 @@ internal fun LoginForm(
@Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(loginError(error)),
onDismiss = onDismiss
)
@@ -288,6 +297,7 @@ internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStatePr
private fun ContentToPreview(state: LoginPasswordState) {
LoginPasswordView(
state = state,
onBackPressed = {}
onBackPressed = {},
onWaitListError = {},
)
}

View File

@@ -0,0 +1,23 @@
/*
* 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.login.impl.screens.waitlistscreen
sealed interface WaitListEvents {
object AttemptLogin : WaitListEvents
object ClearError : WaitListEvents
object Continue : WaitListEvents
}

View File

@@ -0,0 +1,62 @@
/*
* 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.login.impl.screens.waitlistscreen
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class WaitListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: WaitListPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val loginFormState: LoginFormState) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.loginFormState)
interface Callback : Plugin {
fun onCancelClicked()
}
private fun onCancelClicked() {
plugins<Callback>().forEach { it.onCancelClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
WaitListView(
state = state,
onCancelClicked = ::onCancelClicked,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,96 @@
/*
* 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.login.impl.screens.waitlistscreen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
class WaitListPresenter @AssistedInject constructor(
@Assisted private val formState: LoginFormState,
private val buildMeta: BuildMeta,
private val authenticationService: MatrixAuthenticationService,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter<WaitListState> {
@AssistedFactory
interface Factory {
fun create(loginFormState: LoginFormState): WaitListPresenter
}
@Composable
override fun present(): WaitListState {
val coroutineScope = rememberCoroutineScope()
val homeserverUrl = remember {
authenticationService.getHomeserverDetails().value?.url ?: "server"
}
val loginAction: MutableState<Async<SessionId>> = remember {
mutableStateOf(Async.Uninitialized)
}
val attemptNumber: MutableState<Int> = remember { mutableStateOf(0) }
fun handleEvents(event: WaitListEvents) {
when (event) {
WaitListEvents.AttemptLogin -> {
// Do not attempt to login on first resume of the View.
attemptNumber.value++
if (attemptNumber.value > 1) {
coroutineScope.loginAttempt(formState, loginAction)
}
}
WaitListEvents.ClearError -> loginAction.value = Async.Uninitialized
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true)
}
}
return WaitListState(
appName = buildMeta.applicationName,
serverName = homeserverUrl,
loginAction = loginAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch {
Timber.w("Attempt to login...")
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->
loggedInState.value = Async.Failure(failure)
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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.login.impl.screens.waitlistscreen
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.SessionId
// Do not use default value, so no member get forgotten in the presenters.
data class WaitListState(
val appName: String,
val serverName: String,
val loginAction: Async<SessionId>,
val eventSink: (WaitListEvents) -> Unit
)

View File

@@ -0,0 +1,44 @@
/*
* 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.login.impl.screens.waitlistscreen
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.SessionId
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> {
override val values: Sequence<WaitListState>
get() = sequenceOf(
aWaitListState(loginAction = Async.Uninitialized),
aWaitListState(loginAction = Async.Loading()),
aWaitListState(loginAction = Async.Failure(Throwable())),
aWaitListState(loginAction = Async.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))),
aWaitListState(loginAction = Async.Success(SessionId("@alice:element.io"))),
// Add other state here
)
}
fun aWaitListState(
appName: String = "Element X",
serverName: String = "server.org",
loginAction: Async<SessionId> = Async.Uninitialized,
) = WaitListState(
appName = appName,
serverName = serverName,
loginAction = loginAction,
eventSink = {}
)

View File

@@ -0,0 +1,266 @@
/*
* 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.login.impl.screens.waitlistscreen
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAbsoluteAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.isWaitListError
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode.
@Composable
fun WaitListView(
state: WaitListState,
onCancelClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin)
else -> Unit
}
}
Box(modifier = modifier) {
WaitListBackground()
WaitListContent(state, onCancelClicked)
WaitListError(state)
}
}
@Composable
private fun WaitListError(state: WaitListState) {
// Display a dialog for error other than the waitlist error
state.loginAction.errorOrNull()?.let { error ->
if (error.isWaitListError().not()) {
RetryDialog(
content = stringResource(id = loginError(error)),
onRetry = {
state.eventSink.invoke(WaitListEvents.AttemptLogin)
},
onDismiss = {
state.eventSink.invoke(WaitListEvents.ClearError)
}
)
}
}
}
@Composable
private fun WaitListBackground(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.3f)
.background(Color.White)
)
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(id = R.drawable.light_dark),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.7f)
.background(Color(0xFF121418))
)
}
}
@Composable
private fun WaitListContent(
state: WaitListState,
onCancelClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
if (state.loginAction !is Async.Success) {
TextButton(
onClick = onCancelClicked,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black,
disabledContainerColor = Color.White,
disabledContentColor = Color.Black,
),
) {
Text(
text = stringResource(CommonStrings.action_cancel),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAbsoluteAlignment(
horizontalBias = 0f,
verticalBias = -0.05f
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (state.loginAction.isLoading()) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = Color.White
)
} else {
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height(18.dp))
val titleRes = when (state.loginAction) {
is Async.Success -> R.string.screen_waitlist_title_success
else -> R.string.screen_waitlist_title
}
Text(
text = withColoredPeriod(titleRes),
style = ElementTheme.typography.fontHeadingXlBold,
textAlign = TextAlign.Center,
color = Color.White,
)
Spacer(modifier = Modifier.height(8.dp))
val subtitle = when (state.loginAction) {
is Async.Success -> stringResource(
id = R.string.screen_waitlist_message_success,
state.appName,
)
else -> stringResource(
id = R.string.screen_waitlist_message,
state.appName,
state.serverName,
)
}
Text(
modifier = Modifier.widthIn(max = 360.dp),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = Color.White,
)
}
}
if (state.loginAction is Async.Success) {
Button(
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black,
disabledContainerColor = Color.White,
disabledContentColor = Color.Black,
),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp)
) {
Text(
text = stringResource(id = CommonStrings.action_continue),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
}
}
}
@Composable
private fun withColoredPeriod(
@StringRes textRes: Int,
) = buildAnnotatedString {
val text = stringResource(textRes)
append(text)
if (text.endsWith(".")) {
addStyle(
style = SpanStyle(
// Light.colorGreen700
color = Color(0xff0bc491),
),
start = text.length - 1,
end = text.length,
)
}
}
@Preview
@Composable
internal fun WaitListViewLightPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun WaitListViewDarkPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: WaitListState) {
WaitListView(
state = state,
onCancelClicked = {},
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -24,7 +24,6 @@
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
<string name="screen_login_form_header">"Zadejte své údaje"</string>
<string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
<string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string>
@@ -33,6 +32,12 @@
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s"</string>
<string name="screen_waitlist_title">"Jste v pořadníku!"</string>
<string name="screen_waitlist_title_success">"Jdete do toho!"</string>
<string name="screen_change_server_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>

View File

@@ -24,7 +24,6 @@
<string name="screen_login_error_invalid_user_id">"Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."</string>
<string name="screen_login_form_header">"Gib deine Daten ein"</string>
<string name="screen_login_server_header">"Wo deine Gespräche leben"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
@@ -33,6 +32,12 @@
<string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
<string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string>
<string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string>
<string name="screen_waitlist_message">"Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut.
Vielen Dank für deine Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Du hast es fast geschafft!"</string>
<string name="screen_waitlist_title_success">"Du bist dabei."</string>
<string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string>

View File

@@ -10,7 +10,6 @@
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
<string name="screen_login_form_header">"Introduce tus datos"</string>
<string name="screen_login_server_header">"Donde viven tus conversaciones"</string>
<string name="screen_login_title">"¡Hola de nuevo!"</string>
<string name="screen_change_server_submit">"Continuar"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</string>

View File

@@ -22,7 +22,6 @@
<string name="screen_login_error_invalid_user_id">"Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string>
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
<string name="screen_login_server_header">"Où se déroulent vos conversations"</string>
<string name="screen_login_title">"Heureux de vous revoir!"</string>
<string name="screen_change_server_submit">"Continuer"</string>
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>

View File

@@ -10,7 +10,6 @@
<string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string>
<string name="screen_login_form_header">"Inserisci i tuoi dati"</string>
<string name="screen_login_server_header">"Dove vivono le tue conversazioni"</string>
<string name="screen_login_title">"Bentornato!"</string>
<string name="screen_change_server_submit">"Continua"</string>
<string name="screen_change_server_title">"Seleziona il tuo server"</string>

View File

@@ -24,7 +24,6 @@
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
<string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string>
<string name="screen_login_form_header">"Introduceți detaliile"</string>
<string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string>
<string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
<string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string>
@@ -33,6 +32,12 @@
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"</string>
<string name="screen_waitlist_message_success">"Bun venit la %1$s"</string>
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
<string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</string>

View File

@@ -24,7 +24,6 @@
<string name="screen_login_error_invalid_user_id">"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server."</string>
<string name="screen_login_form_header">"Zadajte svoje údaje"</string>
<string name="screen_login_server_header">"Kde žijú vaše rozhovory"</string>
<string name="screen_login_title">"Vitajte späť!"</string>
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string>
<string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string>
@@ -33,6 +32,12 @@
<string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s"</string>
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
<string name="screen_waitlist_title_success">"Ste dnu!"</string>
<string name="screen_change_server_submit">"Pokračovať"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>

View File

@@ -24,7 +24,6 @@
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: @user:homeserver.org"</string>
<string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string>
<string name="screen_login_form_header">"Enter your details"</string>
<string name="screen_login_server_header">"Where your conversations live"</string>
<string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
@@ -33,9 +32,16 @@
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"</string>
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
<string name="screen_waitlist_title">"Youre almost there."</string>
<string name="screen_waitlist_title_success">"You\'re in."</string>
<string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continue"</string>
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_login_username_hint">"Username"</string>
</resources>

View File

@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.architecture.Async
@@ -38,9 +39,11 @@ class LoginPasswordPresenterTest {
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -57,9 +60,11 @@ class LoginPasswordPresenterTest {
fun `present - enter login and password`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
@@ -81,14 +86,17 @@ class LoginPasswordPresenterTest {
fun `present - submit`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
val initialState = awaitItem()
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
@@ -99,6 +107,7 @@ class LoginPasswordPresenterTest {
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID))
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
}
}
@@ -106,9 +115,11 @@ class LoginPasswordPresenterTest {
fun `present - submit with error`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
@@ -132,9 +143,11 @@ class LoginPasswordPresenterTest {
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {

View File

@@ -0,0 +1,118 @@
/*
* 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.login.impl.screens.waitlistscreen
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.coroutines.test.runTest
import org.junit.Test
class WaitListPresenterTest {
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService().apply {
givenHomeserver(A_HOMESERVER)
}
val loginUserStory = DefaultLoginUserStory()
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(applicationName = "Application Name"),
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("Application Name")
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL)
assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - attempt login with error`() = runTest {
val authenticationService = FakeAuthenticationService().apply {
givenLoginError(A_THROWABLE)
}
val loginUserStory = DefaultLoginUserStory()
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(),
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// First usage of AttemptLogin, nothing should happen
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
expectNoEvents()
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.loginAction).isEqualTo(Async.Failure<SessionId>(A_THROWABLE))
// Assert the error can be cleared
errorState.eventSink(WaitListEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - attempt login with success`() = runTest {
val authenticationService = FakeAuthenticationService()
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(),
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
val initialState = awaitItem()
// First usage of AttemptLogin, nothing should happen
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
expectNoEvents()
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.loginAction).isEqualTo(Async.Success(A_USER_ID))
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
successState.eventSink.invoke(WaitListEvents.Continue)
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
}
}
}

View File

@@ -54,6 +54,8 @@ dependencies {
implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.accompanist.systemui)

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
@@ -126,6 +126,9 @@ fun MessagesView(
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
}
fun onMoreReactionsClicked(event: TimelineItem.Event): Unit =
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
@@ -150,12 +153,16 @@ fun MessagesView(
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = { event ->
if (event.sendState is EventSendState.SendingFailed) {
if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
},
onReactionClicked = ::onEmojiReactionClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onSendLocationClicked = onSendLocationClicked,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
)
},
snackbarHost = {
@@ -237,10 +244,12 @@ fun MessagesViewContent(
onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) {
Column(
modifier = modifier
@@ -258,6 +267,8 @@ fun MessagesViewContent(
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onSwipeToReply = onSwipeToReply,
)
}
if (state.userHasPermissionToSendMessage) {

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
@@ -28,44 +29,67 @@ import kotlinx.collections.immutable.persistentListOf
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
override val values: Sequence<ActionListState>
get() = sequenceOf(
anActionListState(),
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVideoContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemFileContent()),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()),
actions = aTimelineItemActionList(),
)
),
)
get() {
val reactionsState = aTimelineItemReactions(1, isHighlighted = true)
return sequenceOf(
anActionListState(),
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent().copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemActionList(),
),
displayEmojiReactions = false,
),
)
}
}
fun anActionListState() = ActionListState(
target = ActionListState.Target.None,
displayEmojiReactions = true,
eventSink = {}
)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -46,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -78,7 +80,9 @@ import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -175,13 +179,16 @@ private fun SheetContent(
Divider()
}
}
item {
EmojiReactionsRow(
onEmojiReactionClicked = onEmojiReactionClicked,
onCustomReactionClicked = onCustomReactionClicked,
modifier = Modifier.fillMaxWidth(),
)
Divider()
if (state.displayEmojiReactions) {
item {
EmojiReactionsRow(
highlightedEmojis = target.event.reactionsState.highlightedKeys,
onEmojiReactionClicked = onEmojiReactionClicked,
onCustomReactionClicked = onCustomReactionClicked,
modifier = Modifier.fillMaxWidth(),
)
Divider()
}
}
items(
items = actions,
@@ -320,6 +327,7 @@ private val emojiRippleRadius = 24.dp
@Composable
internal fun EmojiReactionsRow(
highlightedEmojis: ImmutableList<String>,
onEmojiReactionClicked: (String) -> Unit,
onCustomReactionClicked: () -> Unit,
modifier: Modifier = Modifier,
@@ -333,7 +341,8 @@ internal fun EmojiReactionsRow(
"👍", "👎", "🔥", "❤️", "👏"
)
for (emoji in defaultEmojis) {
EmojiButton(emoji, onEmojiReactionClicked)
val isHighlighted = highlightedEmojis.contains(emoji)
EmojiButton(emoji, isHighlighted, onEmojiReactionClicked)
}
Icon(
@@ -356,19 +365,34 @@ internal fun EmojiReactionsRow(
@Composable
private fun EmojiButton(
emoji: String,
isHighlighted: Boolean,
onClicked: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
emoji,
fontSize = 28.dp.toSp(),
modifier = modifier.clickable(
enabled = true,
onClick = { onClicked(emoji) },
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() }
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.bgActionPrimaryRest
} else {
Color.Transparent
}
Box(
modifier = modifier
.size(48.dp)
.background(backgroundColor, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Text(
emoji,
fontSize = 28.dp.toSp(),
color = Color.White,
modifier = Modifier
.clickable(
enabled = true,
onClick = { onClicked(emoji) },
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
interactionSource = remember { MutableInteractionSource() }
)
)
)
}
}
@Preview

View File

@@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@@ -129,3 +131,12 @@ internal fun AttachmentSourcePickerMenu(
)
}
}
@DayNightPreviews
@Composable
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
AttachmentSourcePickerMenu(
eventSink = {},
onSendLocationClicked = {},
)
}

View File

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

View File

@@ -30,7 +30,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@@ -44,7 +46,7 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
room: MatrixRoom,
private val room: MatrixRoom,
) : Presenter<TimelineState> {
private val timeline = room.timeline
@@ -62,6 +64,9 @@ class TimelinePresenter @Inject constructor(
val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState)
@@ -92,6 +97,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
canReply = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
eventSink = ::handleEvents

View File

@@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val highlightedEventId: EventId?,
val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val eventSink: (TimelineEvents) -> Unit
)

View File

@@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
highlightedEventId = null,
canReply = true,
eventSink = {}
)
@@ -58,7 +59,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.Middle,
sendState = EventSendState.SendingFailed("Message failed to send"),
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
),
aTimelineItemEvent(
isMine = false,
@@ -81,7 +82,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.Middle,
sendState = EventSendState.SendingFailed("Message failed to send"),
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
),
aTimelineItemEvent(
isMine = true,
@@ -111,7 +112,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: EventSendState = EventSendState.Sent(eventId),
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
@@ -128,7 +129,7 @@ internal fun aTimelineItemEvent(
isMine = isMine,
senderDisplayName = "Sender",
groupPosition = groupPosition,
sendState = sendState,
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
)
@@ -138,10 +139,12 @@ fun aTimelineItemReactions(
count: Int = 1,
isHighlighted: Boolean = false,
): TimelineItemReactions {
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
return TimelineItemReactions(
reactions = buildList {
repeat(count) {
add(AggregatedReaction(key = "👍", count = 1 + it, isHighlighted = isHighlighted))
repeat(count) { index ->
val key = emojis[index % emojis.size]
add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted))
}
}.toPersistentList()
)

View File

@@ -80,7 +80,9 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
fun onReachedLoadMore() {
@@ -120,12 +122,15 @@ fun TimelineView(
TimelineItemRow(
timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value,
canReply = state.canReply,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = onSwipeToReply,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@@ -145,12 +150,15 @@ fun TimelineView(
fun TimelineItemRow(
timelineItem: TimelineItem,
highlightedItem: String?,
canReply: Boolean,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
@@ -161,32 +169,27 @@ fun TimelineItemRow(
)
}
is TimelineItem.Event -> {
fun onClick() {
onClick(timelineItem)
}
fun onLongClick() {
onLongClick(timelineItem)
}
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
canReply = canReply,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onMoreReactionsClick = onMoreReactionsClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
modifier = modifier,
)
}
@@ -215,12 +218,15 @@ fun TimelineItemRow(
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
canReply = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onMoreReactionsClick = onMoreReactionsClick,
onSwipeToReply = {},
)
}
}
@@ -322,5 +328,7 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onUserDataClicked = {},
onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
onMoreReactionsClicked = {},
onSwipeToReply = {},
)
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.theme.ElementTheme
@Composable
fun MessagesMoreReactionsButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
val buttonColor = ElementTheme.colors.bgSubtleSecondary
Surface(
modifier = modifier
.background(Color.Transparent)
// Outer border, same colour as background
.border(
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(14.dp))
)
.padding(vertical = 2.dp, horizontal = 2.dp)
// Clip click indicator inside the outer border
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
.clickable(onClick = onClick)
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
.padding(vertical = 4.dp, horizontal = 10.dp),
color = buttonColor
) {
Icon(
imageVector = Icons.Outlined.AddReaction,
contentDescription = "Add emoji",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
// Same size as the line height of reaction emoji text
.size(with(LocalDensity.current) { 20.sp.toDp() })
)
}
}
@Preview
@Composable
internal fun MessagesMoreReactionsButtonLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun MessagesMoreReactionsButtonDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
MessagesMoreReactionsButton(onClick = {})
}

View File

@@ -17,9 +17,9 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
@@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -45,35 +46,47 @@ import io.element.android.libraries.theme.ElementTheme
@Composable
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
// First Surface is to render a border with the same background color as the background
val buttonColor = if (reaction.isHighlighted) {
ElementTheme.colors.bgSubtlePrimary
} else {
ElementTheme.colors.bgSubtleSecondary
}
val borderColor = if (reaction.isHighlighted) {
ElementTheme.colors.borderInteractivePrimary
} else {
buttonColor
}
Surface(
modifier = modifier.clickable(onClick = onClick::invoke),
// TODO Should use compound.bgSubtlePrimary
color = ElementTheme.legacyColors.gray300,
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(14.dp)),
modifier = modifier
.background(Color.Transparent)
// Outer border, same colour as background
.border(
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
shape = RoundedCornerShape(corner = CornerSize(14.dp))
)
.padding(vertical = 2.dp, horizontal = 2.dp)
// Clip click indicator inside the outer border
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
.clickable(onClick = onClick)
// Inner border, to highlight when selected
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
.padding(vertical = 4.dp, horizontal = 10.dp),
color = buttonColor
) {
Box(modifier = Modifier.padding(2.dp)) {
val reactionModifier = if (reaction.isHighlighted) {
Modifier
// TODO Check the color, should use compound.borderInteractivePrimary
.border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
} else {
Modifier
}
Row(
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = reaction.key, fontSize = 15.sp)
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = reaction.count.toString(),
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
fontSize = 14.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = reaction.key, fontSize = 15.sp, lineHeight = 20.sp
)
if (reaction.count > 1) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = reaction.count.toString(),
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
fontSize = 14.sp
)
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.messages.impl.timeline.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
/**
* A swipe indicator that appears when swiping to reply to a message.
*
* @param swipeProgress the progress of the swipe, between 0 and X. When swipeProgress >= 1 the swipe will be detected.
* @param modifier the modifier to apply to this Composable root.
*/
@Composable
fun RowScope.ReplySwipeIndicator(
swipeProgress: () -> Float,
modifier: Modifier = Modifier,
) {
Icon(
modifier = modifier
.align(Alignment.CenterVertically)
.graphicsLayer {
translationX = 36.dp.toPx() * swipeProgress().coerceAtMost(1f)
alpha = swipeProgress()
},
contentDescription = null,
resourceId = VectorIcons.Reply,
)
}
@Preview
@Composable
internal fun ReplySwipeIndicatorLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun ReplySwipeIndicatorDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column(modifier = Modifier.fillMaxWidth()) {
for (i in 0..8) {
Row { ReplySwipeIndicator(swipeProgress = { i / 8f }) }
}
Row { ReplySwipeIndicator(swipeProgress = { 1.5f }) }
Row { ReplySwipeIndicator(swipeProgress = { 2f }) }
Row { ReplySwipeIndicator(swipeProgress = { 3f }) }
}
}

View File

@@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class)
@@ -55,7 +55,7 @@ fun TimelineEventTimestampView(
modifier: Modifier = Modifier,
) {
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) {

View File

@@ -20,19 +20,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event>
get() = sequenceOf(
aTimelineItemEvent(),
// Sending failed
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")),
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")),
// Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy(
sendState = EventSendState.SendingFailed("AN_ERROR"),
localSendState = LocalEventSendState.SendingFailed("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
)

View File

@@ -14,6 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas
@@ -33,7 +35,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -46,8 +54,13 @@ 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 androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@@ -86,12 +99,15 @@ import org.jsoup.Jsoup
fun TimelineItemEventRow(
event: TimelineItem.Event,
isHighlighted: Boolean,
canReply: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
@@ -100,77 +116,55 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId)
}
fun onReactionClicked(emoji: String) =
onReactionClick(emoji, event)
fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
// To avoid using negative offset, we display in this Box a column with:
// - Spacer to give room to the Sender information if they must be displayed;
// - The message bubble;
// - Spacer for the reactions if there are some.
// Then the Sender information and the reactions are displayed on top of it.
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column {
if (event.showSenderInformation) {
Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
if (canReply) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToEnd) {
onSwipeToReply()
}
// Do not dismiss the message, return false!
false
}
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
) {
MessageEventBubbleContent(
)
SwipeToDismiss(
state = dismissState,
background = {
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
},
directions = setOf(DismissDirection.StartToEnd),
dismissContent = {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
if (event.reactionsState.reactions.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
}
}
// Align to the top of the box
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier
.padding(horizontal = 16.dp)
.align(Alignment.TopStart)
.clickable(onClick = ::onUserDataClicked)
)
}
// Align to the bottom of the box
if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView(
reactionsState = event.reactionsState,
onReactionClicked = ::onReactionClicked,
modifier = Modifier
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
)
}
)
} else {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
if (event.groupPosition.isNew()) {
@@ -180,13 +174,113 @@ fun TimelineItemEventRow(
}
}
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
end.linkTo(parent.end)
} else {
start.linkTo(parent.start)
}
ConstraintLayout(
modifier = modifier
.wrapContentHeight()
.fillMaxWidth(),
) {
val (sender, message, reactions) = createRefs()
// Sender
val avatarStrokeSize = 3.dp
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
avatarStrokeSize,
Modifier
.constrainAs(sender) {
top.linkTo(parent.top)
}
.padding(horizontal = 16.dp)
.zIndex(1f)
.clickable(onClick = onUserDataClicked)
)
}
// Message bubble
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble(
modifier = Modifier
.constrainAs(message) {
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp)
this.linkStartOrEnd(event)
},
state = bubbleState,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
)
}
// Reactions
if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactionsView(
reactionsState = event.reactionsState,
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
onReactionClicked = onReactionClicked,
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
modifier = Modifier
.constrainAs(reactions) {
top.linkTo(message.bottom, margin = (-4).dp)
this.linkStartOrEnd(event)
}
.zIndex(1f)
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
)
}
}
}
private fun DismissState.toSwipeProgress(): Float {
return when (targetValue) {
DismissValue.Default -> 0f
DismissValue.DismissedToEnd -> progress * 3
DismissValue.DismissedToStart -> progress * 3
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier
) {
val avatarStrokeSize = 3.dp
val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
Box(
@@ -449,30 +543,36 @@ private fun ContentToPreview() {
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
)
),
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
)
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
)
),
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
)
}
}
@@ -492,7 +592,7 @@ internal fun TimelineItemEventRowWithReplyDarkPreview() =
private fun ContentToPreviewWithReply() {
Column {
sequenceOf(false, true).forEach {
val replyContent = if(it) {
val replyContent = if (it) {
// Short
"Message which are being replied."
} else {
@@ -509,12 +609,15 @@ private fun ContentToPreviewWithReply() {
inReplyTo = aInReplyToReady(replyContent)
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
)
TimelineItemEventRow(
event = aTimelineItemEvent(
@@ -525,12 +628,15 @@ private fun ContentToPreviewWithReply() {
inReplyTo = aInReplyToReady(replyContent)
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
)
}
}
@@ -578,14 +684,56 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
senderDisplayName = if (useDocument) "Document case" else "Text case",
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
)
}
}
}
}
@Preview
@Composable
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
ElementPreviewLight { ContentWithManyReactionsToPreview() }
@Preview
@Composable
internal fun TimelineItemEventRowWithManyReactionsDarkPreview() =
ElementPreviewDark { ContentWithManyReactionsToPreview() }
@Composable
private fun ContentWithManyReactionsToPreview() {
Column {
listOf(false, true).forEach { isMine ->
TimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemTextContent().copy(
body = "A couple of multi-line messages with many reactions attached." +
" One sent by me and another from someone else."
),
timelineItemReactions = aTimelineItemReactions(count = 20),
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
)
}
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
@@ -29,13 +30,16 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable
fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions,
mainAxisAlignment: FlowMainAxisAlignment,
onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: () -> Unit,
modifier: Modifier = Modifier,
onReactionClicked: (emoji: String) -> Unit
) {
FlowRow(
modifier = modifier,
mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp,
mainAxisSpacing = 4.dp,
crossAxisSpacing = 4.dp,
mainAxisAlignment = mainAxisAlignment,
) {
reactionsState.reactions.forEach { reaction ->
MessagesReactionButton(
@@ -43,6 +47,9 @@ fun TimelineItemReactionsView(
onClick = { onReactionClicked(reaction.key) }
)
}
MessagesMoreReactionsButton(
onClick = onMoreReactionsClicked
)
}
}
@@ -60,6 +67,8 @@ internal fun TimelineItemReactionsViewDarkPreview() =
private fun ContentToPreview() {
TimelineItemReactionsView(
reactionsState = aTimelineItemReactions(),
onReactionClicked = { }
mainAxisAlignment = FlowMainAxisAlignment.Center,
onReactionClicked = {},
onMoreReactionsClicked = {},
)
}

View File

@@ -22,7 +22,7 @@ import androidx.compose.ui.unit.TextUnit
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@@ -39,7 +39,7 @@ val noExtraPadding = ExtraPadding(0)
@Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
var strLen = 6

View File

@@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@@ -28,21 +30,31 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemLocationView(
content: TimelineItemLocationContent,
modifier: Modifier = Modifier,
) {
StaticMapView(
modifier = modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.body
)
Column(modifier = modifier.fillMaxWidth()) {
content.description?.let {
Text(
text = it,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
)
}
StaticMapView(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.body
)
}
}
@Preview

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
@@ -83,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
debugInfo = currentTimelineItem.event.debugInfo,
)

View File

@@ -18,12 +18,13 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
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.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList
@@ -61,7 +62,7 @@ sealed interface TimelineItem {
val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions,
val sendState: EventSendState,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo,
) : TimelineItem {
@@ -69,6 +70,12 @@ sealed interface TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderDisplayName ?: senderId.value
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
val isRemote = eventId != null
}
@Immutable

View File

@@ -17,7 +17,14 @@
package io.element.android.features.messages.impl.timeline.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class TimelineItemReactions(
val reactions: ImmutableList<AggregatedReaction>
)
) {
val highlightedKeys: ImmutableList<String>
get() = reactions
.filter { it.isHighlighted }
.map { it.key }
.toPersistentList()
}

View File

@@ -29,6 +29,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),
aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(),
aTimelineItemTextContent(),

View File

@@ -23,14 +23,16 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent("This is a description!"),
)
}
fun aTimelineItemLocationContent() = TimelineItemLocationContent(
fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent(
body = "User location geo:52.2445,0.7186;u=5000",
location = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
)
),
description = description,
)

View File

@@ -10,11 +10,14 @@
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
<string name="screen_room_message_copied">"Message copied"</string>
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
<string name="screen_room_reactions_show_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@@ -38,6 +38,7 @@ internal fun aMessageEvent(
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event(
id = eventId?.value.orEmpty(),
eventId = eventId,
@@ -48,7 +49,7 @@ internal fun aMessageEvent(
sentTime = "",
isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID),
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
)

View File

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

View File

@@ -24,7 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -42,7 +42,7 @@ class TimelineItemGrouperTest {
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(),
)

View File

@@ -23,4 +23,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
api(projects.libraries.matrix.api)
}

View File

@@ -16,13 +16,13 @@
package io.element.android.features.preferences.api
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
interface CacheService {
/**
* Returns a flow of the current cache index, can let the app to know when the
* cache has been cleared, for instance to restart the app.
* Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared.
* A flow of [SessionId], can let the app to know when the
* cache has been cleared for a given session, for instance to restart the app.
*/
fun cacheIndex(): Flow<Int>
val clearedCacheEventFlow: Flow<SessionId>
}

Some files were not shown because too many files have changed in this diff Show More