Merge branch 'develop' into feature/fga/waiting_ss_room

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

View file

@ -6,25 +6,28 @@ labels: [T-Story]
body: body:
- type: textarea - type: textarea
attributes: 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: | value: |
As a user… As a user…
I want to… I want to…
so that I can… so that I can…
## Scope ## 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]
### Tasklist ### Tasklist
- [ ] Task 1 - [ ] Task 1
```
- [ ] QA signoff on completion - [ ] QA signoff on completion
- [ ] Design signoff on completion - [ ] Design signoff on completion
- [ ] Product signoff on completion - [ ] Product signoff on completion
```
## Stretch goals ## Stretch goals
None at this time None at this time
<!-- or add a tasklist --> <!--or add a tasklist-->
## Out of scope ## Out of scope
- -

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot 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. * 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 if (containsRoot) return
accept(NewRoot(element)) 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.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs 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.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -93,7 +92,7 @@ class LoggedInFlowNode @AssistedInject constructor(
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -105,22 +104,14 @@ class LoggedInFlowNode @AssistedInject constructor(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { isConsentAsked -> .onEach { isConsentAsked ->
if (isConsentAsked) { if (isConsentAsked) {
switchToRoomList() backstack.removeLast(NavTarget.AnalyticsOptIn)
} else { } else {
switchToAnalytics() backstack.push(NavTarget.AnalyticsOptIn)
} }
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
private fun switchToRoomList() {
backstack.safeRoot(NavTarget.RoomList)
}
private fun switchToAnalytics() {
backstack.safeRoot(NavTarget.AnalyticsSettings)
}
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() = Unit fun onOpenBugReport() = Unit
} }
@ -196,9 +187,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize @Parcelize
object Permanent : NavTarget object Permanent : NavTarget
@ -224,12 +212,11 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget object InviteList : NavTarget
@Parcelize @Parcelize
object AnalyticsSettings : NavTarget object AnalyticsOptIn : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.Permanent -> { NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext) createNode<LoggedInNode>(buildContext)
} }
@ -322,7 +309,7 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.AnalyticsSettings -> { NavTarget.AnalyticsOptIn -> {
analyticsOptInEntryPoint.createNode(this, buildContext) 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(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.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack 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.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted 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.intent.ResolvedIntent
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView 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.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService 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.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode 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.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService 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.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged 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.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.UUID
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class RootFlowNode @AssistedInject constructor( class RootFlowNode @AssistedInject constructor(
@ -74,6 +78,7 @@ class RootFlowNode @AssistedInject constructor(
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val loginUserStory: LoginUserStory,
) : ) :
BackstackNode<RootFlowNode.NavTarget>( BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -90,21 +95,15 @@ class RootFlowNode @AssistedInject constructor(
} }
private fun observeLoggedInState() { private fun observeLoggedInState() {
authenticationService.isLoggedIn() combine(
.distinctUntilChanged() cacheService.onClearedCacheEventFlow(),
.combine( isUserLoggedInFlow(),
cacheService.cacheIndex().onEach { ) { _, isLoggedIn -> isLoggedIn }
Timber.v("cacheIndex=$it") .onEach { isLoggedIn ->
matrixClientsHolder.removeAll() Timber.v("isLoggedIn=$isLoggedIn")
}
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
.onEach { pair ->
val isLoggedIn = pair.first
val cacheIndex = pair.second
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
if (isLoggedIn) { if (isLoggedIn) {
tryToRestoreLatestSession( tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it, cacheIndex) }, onSuccess = { switchToLoggedInFlow(it) },
onFailure = { switchToNotLoggedInFlow() } onFailure = { switchToNotLoggedInFlow() }
) )
} else { } else {
@ -114,8 +113,19 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope) .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() { private fun switchToNotLoggedInFlow() {
@ -123,28 +133,38 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.NotLoggedInFlow) 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( private suspend fun tryToRestoreLatestSession(
onSuccess: (UserId) -> Unit = {}, onSuccess: (SessionId) -> Unit = {},
onFailure: () -> Unit = {} onFailure: () -> Unit = {}
) { ) {
val latestKnownUserId = authenticationService.getLatestSessionId() val latestSessionId = authenticationService.getLatestSessionId()
if (latestKnownUserId == null) { if (latestSessionId == null) {
onFailure() onFailure()
return return
} }
if (matrixClientsHolder.knowSession(latestKnownUserId)) { restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
onSuccess(latestKnownUserId)
return
}
authenticationService.restoreSession(UserId(latestKnownUserId.value))
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
onSuccess(matrixClient.sessionId)
}
.onFailure {
Timber.v("Failed to restore session...")
onFailure()
}
} }
private fun onOpenBugReport() { private fun onOpenBugReport() {
@ -175,7 +195,10 @@ class RootFlowNode @AssistedInject constructor(
object NotLoggedInFlow : NavTarget object NotLoggedInFlow : NavTarget
@Parcelize @Parcelize
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget data class LoggedInFlow(
val sessionId: SessionId,
val navId: UUID = UUID.randomUUID(),
) : NavTarget
@Parcelize @Parcelize
object BugReport : NavTarget object BugReport : NavTarget
@ -186,7 +209,6 @@ class RootFlowNode @AssistedInject constructor(
is NavTarget.LoggedInFlow -> { is NavTarget.LoggedInFlow -> {
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen") Timber.w("Couldn't find any session, go through SplashScreen")
backstack.newRoot(NavTarget.SplashScreen)
} }
val inputs = LoggedInFlowNode.Inputs(matrixClient) val inputs = LoggedInFlowNode.Inputs(matrixClient)
val callback = object : LoggedInFlowNode.Callback { val callback = object : LoggedInFlowNode.Callback {
@ -247,9 +269,16 @@ class RootFlowNode @AssistedInject constructor(
} }
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
val cacheIndex = cacheService.cacheIndex().first() //TODO handle multi-session
return attachChild { return waitForChildAttached { navTarget ->
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex)) 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.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.impl.map.MapState" excludes += "io.element.android.features.location.impl.map.MapState"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
} }
bound { bound {
minValue = 90 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 { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize")
} }
android { android {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.app.Activity
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier 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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack 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.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted 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.oidc.webview.OidcNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode 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.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.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode 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.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -58,6 +60,7 @@ class LoginFlowNode @AssistedInject constructor(
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker, private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
private val customTabHandler: CustomTabHandler, private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : BackstackNode<LoginFlowNode.NavTarget>( ) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.ConfirmAccountProvider, initialElement = NavTarget.ConfirmAccountProvider,
@ -75,6 +78,11 @@ class LoginFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
override fun onBuilt() {
super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false)
}
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object ConfirmAccountProvider : NavTarget object ConfirmAccountProvider : NavTarget
@ -88,6 +96,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object LoginPassword : NavTarget object LoginPassword : NavTarget
@Parcelize
data class WaitList(val loginFormState: LoginFormState) : NavTarget
@Parcelize @Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget data class OidcView(val oidcDetails: OidcDetails) : NavTarget
} }
@ -144,12 +155,28 @@ class LoginFlowNode @AssistedInject constructor(
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback)) createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.LoginPassword -> { 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 -> { is NavTarget.OidcView -> {
val input = OidcNode.Inputs(navTarget.oidcDetails) val input = OidcNode.Inputs(navTarget.oidcDetails)
createNode<OidcNode>(buildContext, plugins = listOf(input)) 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), text = stringResource(id = R.string.screen_account_provider_continue),
showProgress = isLoading, showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled, enabled = state.submitEnabled || isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.loginContinue) .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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -33,13 +34,22 @@ class LoginPasswordNode @AssistedInject constructor(
private val presenter: LoginPasswordPresenter, private val presenter: LoginPasswordPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onWaitListError(loginFormState: LoginFormState)
}
private fun onWaitListError(loginFormState: LoginFormState) {
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LoginPasswordView( LoginPasswordView(
state = state, state = state,
modifier = modifier, 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.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -36,6 +37,7 @@ import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor( class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter<LoginPasswordState> { ) : Presenter<LoginPasswordState> {
@Composable @Composable
@ -77,6 +79,8 @@ class LoginPasswordPresenter @Inject constructor(
loggedInState.value = Async.Loading() loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password) authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId -> .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) loggedInState.value = Async.Success(sessionId)
} }
.onFailure { failure -> .onFailure { failure ->

View file

@ -16,7 +16,6 @@
package io.element.android.features.login.impl.screens.loginpassword 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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R 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.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles 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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text 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.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
@ -82,8 +82,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun LoginPasswordView( fun LoginPasswordView(
state: LoginPasswordState, state: LoginPasswordState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onWaitListError: (LoginFormState) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val isLoading by remember(state.loginAction) { val isLoading by remember(state.loginAction) {
derivedStateOf { derivedStateOf {
@ -108,53 +109,60 @@ fun LoginPasswordView(
) )
} }
) { padding -> ) { padding ->
Box( val scrollState = rememberScrollState()
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) { ) {
val scrollState = rememberScrollState() // Title
IconTitleSubtitleMolecule(
Column( 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 modifier = Modifier
.verticalScroll(state = scrollState) .fillMaxWidth()
.padding(horizontal = 16.dp), .testTag(TestTags.loginContinue)
) { )
// Title Spacer(modifier = Modifier.height(60.dp))
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))
}
if (state.loginAction is Async.Failure) { if (state.loginAction is Async.Failure) {
LoginErrorDialog(error = state.loginAction.error, onDismiss = { when {
state.eventSink(LoginPasswordEvents.ClearError) 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)) Spacer(modifier = Modifier.height(8.dp))
TextField( OutlinedTextField(
value = loginFieldState, value = loginFieldState,
readOnly = isLoading, readOnly = isLoading,
modifier = Modifier modifier = Modifier
@ -193,7 +201,7 @@ internal fun LoginForm(
loginFieldState = it loginFieldState = it
eventSink(LoginPasswordEvents.SetLogin(it)) eventSink(LoginPasswordEvents.SetLogin(it))
}), }),
label = { placeholder = {
Text(text = stringResource(R.string.screen_login_username_hint)) Text(text = stringResource(R.string.screen_login_username_hint))
}, },
onValueChange = { onValueChange = {
@ -225,7 +233,7 @@ internal fun LoginForm(
passwordVisible = false passwordVisible = false
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
TextField( OutlinedTextField(
value = passwordFieldState, value = passwordFieldState,
readOnly = isLoading, readOnly = isLoading,
modifier = Modifier modifier = Modifier
@ -240,7 +248,7 @@ internal fun LoginForm(
passwordFieldState = it passwordFieldState = it
eventSink(LoginPasswordEvents.SetPassword(it)) eventSink(LoginPasswordEvents.SetPassword(it))
}, },
label = { placeholder = {
Text(text = stringResource(R.string.screen_login_password_hint)) Text(text = stringResource(R.string.screen_login_password_hint))
}, },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@ -269,6 +277,7 @@ internal fun LoginForm(
@Composable @Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog( ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(loginError(error)), content = stringResource(loginError(error)),
onDismiss = onDismiss onDismiss = onDismiss
) )
@ -288,6 +297,7 @@ internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStatePr
private fun ContentToPreview(state: LoginPasswordState) { private fun ContentToPreview(state: LoginPasswordState) {
LoginPasswordView( LoginPasswordView(
state = state, 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_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_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_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">"Vítejte zpět!"</string>
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</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> <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_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_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_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_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string> <string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</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_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_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_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">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string> <string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</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_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_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_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_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string> <string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</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_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_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_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_login_title">"¡Hola de nuevo!"</string>
<string name="screen_change_server_submit">"Continuar"</string> <string name="screen_change_server_submit">"Continuar"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</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_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_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_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_login_title">"Heureux de vous revoir!"</string>
<string name="screen_change_server_submit">"Continuer"</string> <string name="screen_change_server_submit">"Continuer"</string>
<string name="screen_change_server_title">"Sélectionnez votre serveur"</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_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_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_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_login_title">"Bentornato!"</string>
<string name="screen_change_server_submit">"Continua"</string> <string name="screen_change_server_submit">"Continua"</string>
<string name="screen_change_server_title">"Seleziona il tuo server"</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_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_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_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">"Bine ați revenit!"</string>
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</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> <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_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_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_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_submit">"Continuați"</string>
<string name="screen_change_server_title">"Selectați serverul"</string> <string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</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_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_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_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">"Vitajte späť!"</string>
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</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> <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_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_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_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_submit">"Pokračovať"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string> <string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_password_hint">"Heslo"</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_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_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_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">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</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> <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_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_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_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_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string> <string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string> <string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continue"</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> <string name="screen_login_username_hint">"Username"</string>
</resources> </resources>

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.util.defaultAccountProvider import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -38,9 +39,11 @@ class LoginPasswordPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource() val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter( val presenter = LoginPasswordPresenter(
authenticationService, authenticationService,
accountProviderDataSource, accountProviderDataSource,
loginUserStory,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -57,9 +60,11 @@ class LoginPasswordPresenterTest {
fun `present - enter login and password`() = runTest { fun `present - enter login and password`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource() val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter( val presenter = LoginPasswordPresenter(
authenticationService, authenticationService,
accountProviderDataSource, accountProviderDataSource,
loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -81,14 +86,17 @@ class LoginPasswordPresenterTest {
fun `present - submit`() = runTest { fun `present - submit`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource() val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
val presenter = LoginPasswordPresenter( val presenter = LoginPasswordPresenter(
authenticationService, authenticationService,
accountProviderDataSource, accountProviderDataSource,
loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
@ -99,6 +107,7 @@ class LoginPasswordPresenterTest {
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
val loggedInState = awaitItem() val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID)) 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 { fun `present - submit with error`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource() val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter( val presenter = LoginPasswordPresenter(
authenticationService, authenticationService,
accountProviderDataSource, accountProviderDataSource,
loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -132,9 +143,11 @@ class LoginPasswordPresenterTest {
fun `present - clear error`() = runTest { fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource() val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter( val presenter = LoginPasswordPresenter(
authenticationService, authenticationService,
accountProviderDataSource, accountProviderDataSource,
loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { 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.accompanist.flowlayout)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.accompanist.systemui) 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint, private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>( ) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -83,7 +87,10 @@ class MessagesFlowNode @AssistedInject constructor(
data class AttachmentPreview(val attachment: Attachment) : NavTarget data class AttachmentPreview(val attachment: Attachment) : NavTarget
@Parcelize @Parcelize
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget data class LocationViewer(val location: Location, val description: String?) : NavTarget
@Parcelize
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize @Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget data class ForwardEvent(val eventId: EventId) : NavTarget
@ -117,7 +124,7 @@ class MessagesFlowNode @AssistedInject constructor(
callback?.onUserDataClicked(userId) callback?.onUserDataClicked(userId)
} }
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
} }
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
} }
is NavTarget.LocationViewer -> {
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
showLocationEntryPoint.createNode(this, buildContext, inputs)
}
is NavTarget.EventDebugInfo -> { is NavTarget.EventDebugInfo -> {
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode<EventDebugInfoNode>(buildContext, listOf(inputs)) createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
backstack.push(navTarget)
}
else -> Unit else -> Unit
} }
} }

View file

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

View file

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

View file

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

View file

@ -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.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
@ -126,6 +126,9 @@ fun MessagesView(
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
} }
fun onMoreReactionsClicked(event: TimelineItem.Event): Unit =
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@ -150,12 +153,16 @@ fun MessagesView(
onMessageLongClicked = ::onMessageLongClicked, onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onTimestampClicked = { event -> onTimestampClicked = { event ->
if (event.sendState is EventSendState.SendingFailed) { if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
} }
}, },
onReactionClicked = ::onEmojiReactionClicked, onReactionClicked = ::onEmojiReactionClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
) )
}, },
snackbarHost = { snackbarHost = {
@ -237,10 +244,12 @@ fun MessagesViewContent(
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -258,6 +267,8 @@ fun MessagesViewContent(
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onSwipeToReply = onSwipeToReply,
) )
} }
if (state.userHasPermissionToSendMessage) { 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.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -28,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -45,6 +48,10 @@ class ActionListPresenter @Inject constructor(
mutableStateOf(ActionListState.Target.None) mutableStateOf(ActionListState.Target.None)
} }
val displayEmojiReactions by remember {
derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.isRemote == true }
}
fun handleEvents(event: ActionListEvents) { fun handleEvents(event: ActionListEvents) {
when (event) { when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None ActionListEvents.Clear -> target.value = ActionListState.Target.None
@ -54,6 +61,7 @@ class ActionListPresenter @Inject constructor(
return ActionListState( return ActionListState(
target = target.value, target = target.value,
displayEmojiReactions = displayEmojiReactions,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -62,21 +70,28 @@ class ActionListPresenter @Inject constructor(
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val actions = val actions =
when (timelineItem.content) { when (timelineItem.content) {
is TimelineItemRedactedContent, is TimelineItemRedactedContent -> {
if (buildMeta.isDebuggable) {
listOf(TimelineItemAction.Developer)
} else {
emptyList()
}
}
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
buildList { buildList {
if (timelineItem.content.canBeCopied()) { add(TimelineItemAction.Copy)
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) { if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer) add(TimelineItemAction.Developer)
} }
} }
} }
else -> buildList<TimelineItemAction> { else -> buildList<TimelineItemAction> {
add(TimelineItemAction.Reply) if (timelineItem.isRemote) {
add(TimelineItemAction.Forward) // Can only reply or forward messages already uploaded to the server
if (timelineItem.isMine) { add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
}
if (timelineItem.isMine && timelineItem.isTextMessage) {
add(TimelineItemAction.Edit) add(TimelineItemAction.Edit)
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
@ -93,6 +108,10 @@ class ActionListPresenter @Inject constructor(
} }
} }
} }
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList()) if (actions.isNotEmpty()) {
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
} else {
target.value = ActionListState.Target.None
}
} }
} }

View file

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

View file

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

View file

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

View file

@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.androidutils.ui.hideKeyboard 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.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text 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() composerMode.setToNormal()
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text) is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Edit -> room.editMessage( is MessageComposerMode.Edit -> {
capturedMode.eventId, val eventId = capturedMode.eventId
text val transactionId = capturedMode.transactionId
) room.editMessage(eventId, transactionId, text)
}
is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage( is MessageComposerMode.Reply -> room.replyMessage(

View file

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

View file

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

View file

@ -80,7 +80,9 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -120,12 +122,15 @@ fun TimelineView(
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
canReply = state.canReply,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked, inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked, onReactionClick = onReactionClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = onSwipeToReply,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
onReachedLoadMore() onReachedLoadMore()
@ -145,12 +150,15 @@ fun TimelineView(
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
highlightedItem: String?, highlightedItem: String?,
canReply: Boolean,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (timelineItem) { when (timelineItem) {
@ -161,32 +169,27 @@ fun TimelineItemRow(
) )
} }
is TimelineItem.Event -> { is TimelineItem.Event -> {
fun onClick() {
onClick(timelineItem)
}
fun onLongClick() {
onLongClick(timelineItem)
}
if (timelineItem.content is TimelineItemStateContent) { if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow( TimelineItemStateEventRow(
event = timelineItem, event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick, onClick = { onClick(timelineItem) },
onLongClick = ::onLongClick, onLongClick = { onLongClick(timelineItem) },
modifier = modifier, modifier = modifier,
) )
} else { } else {
TimelineItemEventRow( TimelineItemEventRow(
event = timelineItem, event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick, canReply = canReply,
onLongClick = ::onLongClick, onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onMoreReactionsClick = onMoreReactionsClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
modifier = modifier, modifier = modifier,
) )
} }
@ -215,12 +218,15 @@ fun TimelineItemRow(
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
canReply = false,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onMoreReactionsClick = onMoreReactionsClick,
onSwipeToReply = {},
) )
} }
} }
@ -322,5 +328,7 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
onUserDataClicked = {}, onUserDataClicked = {},
onMessageLongClicked = {}, onMessageLongClicked = {},
onReactionClicked = { _, _ -> }, 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 package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -45,35 +46,47 @@ import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) { 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( Surface(
modifier = modifier.clickable(onClick = onClick::invoke), modifier = modifier
// TODO Should use compound.bgSubtlePrimary .background(Color.Transparent)
color = ElementTheme.legacyColors.gray300, // Outer border, same colour as background
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background), .border(
shape = RoundedCornerShape(corner = CornerSize(14.dp)), 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)) { Row(
val reactionModifier = if (reaction.isHighlighted) { verticalAlignment = Alignment.CenterVertically
Modifier ) {
// TODO Check the color, should use compound.borderInteractivePrimary Text(
.border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp))) text = reaction.key, fontSize = 15.sp, lineHeight = 20.sp
} else { )
Modifier if (reaction.count > 1) {
} Spacer(modifier = Modifier.width(4.dp))
Row( Text(
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp), text = reaction.count.toString(),
verticalAlignment = Alignment.CenterVertically color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
) { fontSize = 14.sp
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
)
}
} }
} }
} }

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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text 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 import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -55,7 +55,7 @@ fun TimelineEventTimestampView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime 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 isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) { 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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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> { class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event> override val values: Sequence<TimelineItem.Event>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemEvent(), aTimelineItemEvent(),
// Sending failed // Sending failed
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")), aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")),
// Edited // Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)), aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test) // Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy( aTimelineItemEvent().copy(
sendState = EventSendState.SendingFailed("AN_ERROR"), localSendState = LocalEventSendState.SendingFailed("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true), content = aTimelineItemTextContent().copy(isEdited = true),
), ),
) )

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas 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.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@ -86,12 +99,15 @@ import org.jsoup.Jsoup
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
isHighlighted: Boolean, isHighlighted: Boolean,
canReply: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -100,77 +116,55 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId) onUserDataClick(event.senderId)
} }
fun onReactionClicked(emoji: String) =
onReactionClick(emoji, event)
fun inReplyToClicked() { fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
} }
// To avoid using negative offset, we display in this Box a column with: if (canReply) {
// - Spacer to give room to the Sender information if they must be displayed; val dismissState = rememberDismissState(
// - The message bubble; confirmValueChange = {
// - Spacer for the reactions if there are some. if (it == DismissValue.DismissedToEnd) {
// Then the Sender information and the reactions are displayed on top of it. onSwipeToReply()
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row }
Box( // Do not dismiss the message, return false!
modifier = modifier false
.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))
} }
val bubbleState = BubbleState( )
groupPosition = event.groupPosition, SwipeToDismiss(
isMine = event.isMine, state = dismissState,
isHighlighted = isHighlighted, background = {
) ReplySwipeIndicator({ dismissState.toSwipeProgress() })
MessageEventBubble( },
state = bubbleState, directions = setOf(DismissDirection.StartToEnd),
interactionSource = interactionSource, dismissContent = {
onClick = onClick, TimelineItemEventRowContent(
onLongClick = onLongClick,
) {
MessageEventBubbleContent(
event = event, event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onMessageClick = onClick, onClick = onClick,
onMessageLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = ::inReplyToClicked, onTimestampClicked = onTimestampClicked,
onTimestampClicked = { inReplyToClicked = ::inReplyToClicked,
onTimestampClicked(event) onUserDataClicked = ::onUserDataClicked,
} onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
) )
} }
if (event.reactionsState.reactions.isNotEmpty()) { )
Spacer(modifier = Modifier.height(28.dp)) } else {
} TimelineItemEventRowContent(
} event = event,
// Align to the top of the box isHighlighted = isHighlighted,
if (event.showSenderInformation) { interactionSource = interactionSource,
MessageSenderInformation( onClick = onClick,
event.safeSenderName, onLongClick = onLongClick,
event.senderAvatar, onTimestampClicked = onTimestampClicked,
Modifier inReplyToClicked = ::inReplyToClicked,
.padding(horizontal = 16.dp) onUserDataClicked = ::onUserDataClicked,
.align(Alignment.TopStart) onReactionClicked = { emoji -> onReactionClick(emoji, event) },
.clickable(onClick = ::onUserDataClicked) onMoreReactionsClicked = { onMoreReactionsClick(event) },
) )
}
// 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)
)
}
} }
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage. // This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
if (event.groupPosition.isNew()) { 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 @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, sender: String,
senderAvatar: AvatarData, senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val avatarStrokeSize = 3.dp
val avatarStrokeColor = MaterialTheme.colorScheme.background val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp val avatarSize = senderAvatar.size.dp
Box( Box(
@ -449,30 +543,36 @@ private fun ContentToPreview() {
content = aTimelineItemTextContent().copy( content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" + body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors." " hopefully can be manually adjusted to test different behaviors."
) ),
), ),
isHighlighted = false, isHighlighted = false,
canReply = true,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {},
) )
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent( event = aTimelineItemEvent(
isMine = it, isMine = it,
content = aTimelineItemImageContent().copy( content = aTimelineItemImageContent().copy(
aspectRatio = 5f aspectRatio = 5f
) ),
), ),
isHighlighted = false, isHighlighted = false,
canReply = true,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {},
) )
} }
} }
@ -492,7 +592,7 @@ internal fun TimelineItemEventRowWithReplyDarkPreview() =
private fun ContentToPreviewWithReply() { private fun ContentToPreviewWithReply() {
Column { Column {
sequenceOf(false, true).forEach { sequenceOf(false, true).forEach {
val replyContent = if(it) { val replyContent = if (it) {
// Short // Short
"Message which are being replied." "Message which are being replied."
} else { } else {
@ -509,12 +609,15 @@ private fun ContentToPreviewWithReply() {
inReplyTo = aInReplyToReady(replyContent) inReplyTo = aInReplyToReady(replyContent)
), ),
isHighlighted = false, isHighlighted = false,
canReply = true,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {},
) )
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent( event = aTimelineItemEvent(
@ -525,12 +628,15 @@ private fun ContentToPreviewWithReply() {
inReplyTo = aInReplyToReady(replyContent) inReplyTo = aInReplyToReady(replyContent)
), ),
isHighlighted = false, isHighlighted = false,
canReply = true,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {},
) )
} }
} }
@ -578,14 +684,56 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
senderDisplayName = if (useDocument) "Document case" else "Text case", senderDisplayName = if (useDocument) "Document case" else "Text case",
), ),
isHighlighted = false, isHighlighted = false,
canReply = true,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onMoreReactionsClick = {},
onTimestampClicked = {}, 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.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import com.google.accompanist.flowlayout.FlowRow 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.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
@ -29,13 +30,16 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable @Composable
fun TimelineItemReactionsView( fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
mainAxisAlignment: FlowMainAxisAlignment,
onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onReactionClicked: (emoji: String) -> Unit
) { ) {
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
mainAxisSpacing = 2.dp, mainAxisSpacing = 4.dp,
crossAxisSpacing = 8.dp, crossAxisSpacing = 4.dp,
mainAxisAlignment = mainAxisAlignment,
) { ) {
reactionsState.reactions.forEach { reaction -> reactionsState.reactions.forEach { reaction ->
MessagesReactionButton( MessagesReactionButton(
@ -43,6 +47,9 @@ fun TimelineItemReactionsView(
onClick = { onReactionClicked(reaction.key) } onClick = { onReactionClicked(reaction.key) }
) )
} }
MessagesMoreReactionsButton(
onClick = onMoreReactionsClicked
)
} }
} }
@ -60,6 +67,8 @@ internal fun TimelineItemReactionsViewDarkPreview() =
private fun ContentToPreview() { private fun ContentToPreview() {
TimelineItemReactionsView( TimelineItemReactionsView(
reactionsState = aTimelineItemReactions(), 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse 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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -39,7 +39,7 @@ val noExtraPadding = ExtraPadding(0)
@Composable @Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding { fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime val formattedTime = sentTime
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse() val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
var strLen = 6 var strLen = 6

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.parseGeoUri import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
) )
} }
is LocationMessageType -> { is LocationMessageType -> {
val location = parseGeoUri(messageType.geoUri) val location = Location.fromGeoUri(messageType.geoUri)
if (location == null) { if (location == null) {
TimelineItemTextContent( TimelineItemTextContent(
body = messageType.body, body = messageType.body,
@ -79,6 +79,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemLocationContent( TimelineItemLocationContent(
body = messageType.body, body = messageType.body,
location = location, 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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat import java.text.DateFormat
@ -83,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
sentTime = sentTime, sentTime = sentTime,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(), inReplyTo = currentTimelineItem.event.inReplyTo(),
debugInfo = currentTimelineItem.event.debugInfo, 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 androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo 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.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -61,7 +62,7 @@ sealed interface TimelineItem {
val isMine: Boolean = false, val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val sendState: EventSendState, val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?, val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo, val debugInfo: TimelineItemDebugInfo,
) : TimelineItem { ) : TimelineItem {
@ -69,6 +70,12 @@ sealed interface TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderDisplayName ?: senderId.value val safeSenderName: String = senderDisplayName ?: senderId.value
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
val isRemote = eventId != null
} }
@Immutable @Immutable

View file

@ -17,7 +17,14 @@
package io.element.android.features.messages.impl.timeline.model package io.element.android.features.messages.impl.timeline.model
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class TimelineItemReactions( data class TimelineItemReactions(
val reactions: ImmutableList<AggregatedReaction> 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 file.pdf"),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"), aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(), aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(), aTimelineItemRedactedContent(),
aTimelineItemTextContent(), aTimelineItemTextContent(),

View file

@ -23,14 +23,16 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
override val values: Sequence<TimelineItemLocationContent> override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemLocationContent(), 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", body = "User location geo:52.2445,0.7186;u=5000",
location = Location( location = Location(
lat = 52.2445, lat = 52.2445,
lon = 0.7186, lon = 0.7186,
accuracy = 5000f, 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_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</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_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_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_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_invite_again_alert_title">"You are alone in this chat"</string>
<string name="screen_room_message_copied">"Message copied"</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_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_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</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> <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 var onReportContentClickedCount = 0
private set private set
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++ onShowEventDebugInfoClickedCount++
} }

View file

@ -25,9 +25,12 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -62,7 +65,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer, TimelineItemAction.Developer,
) )
) )
@ -88,7 +90,6 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer, TimelineItemAction.Developer,
) )
) )
@ -184,7 +185,6 @@ class ActionListPresenterTest {
persistentListOf( persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Developer, TimelineItemAction.Developer,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -195,6 +195,63 @@ class ActionListPresenterTest {
} }
} }
@Test
fun `present - compute for a state item in debug build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val stateEvent = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
stateEvent,
persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a state item in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val stateEvent = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
stateEvent,
persistentListOf(
TimelineItemAction.Copy,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test @Test
fun `present - compute message in non-debuggable build`() = runTest { fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false) val presenter = anActionListPresenter(isBuildDebuggable = false)
@ -226,6 +283,62 @@ class ActionListPresenterTest {
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
} }
} }
@Test
fun `present - compute message with no actions`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
val redactedEvent = aMessageEvent(
isMine = true,
content = TimelineItemRedactedContent,
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent))
awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None)
assertThat(displayEmojiReactions).isFalse()
}
}
}
@Test
fun `present - compute not sent message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
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)) 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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId 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.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.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
@ -38,6 +38,7 @@ internal fun aMessageEvent(
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event( ) = TimelineItem.Event(
id = eventId?.value.orEmpty(), id = eventId?.value.orEmpty(),
eventId = eventId, eventId = eventId,
@ -48,7 +49,7 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,
) )

View file

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

View file

@ -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.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel 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.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
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -42,7 +42,7 @@ class TimelineItemGrouperTest {
senderDisplayName = "", senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
sendState = EventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),
) )

View file

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

View file

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

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