{
+ return buildList {
+ if (data.contains(".")) {
+ // TLD detected?
+ } else {
+ add("${data}.org")
+ add("${data}.com")
+ add("${data}.io")
+ }
+ // Always try what the user has entered
+ add(data)
+ }
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt
new file mode 100644
index 0000000000..7de6d26f10
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.resolver.network
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.network.RetrofitFactory
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultWellknownRequest @Inject constructor(
+ private val retrofitFactory: RetrofitFactory,
+) : WellknownRequest {
+ /**
+ * Return the WellKnown data, if found.
+ * @param baseUrl for instance https://matrix.org
+ */
+ override suspend fun execute(baseUrl: String): WellKnown {
+ val wellknownApi = retrofitFactory.create(baseUrl)
+ .create(WellknownAPI::class.java)
+ return wellknownApi.getWellKnown()
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt
new file mode 100644
index 0000000000..63b6e7d189
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.resolver.network
+
+import io.element.android.libraries.core.bool.orFalse
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
+ *
+ * {
+ * "m.homeserver": {
+ * "base_url": "https://matrix.org"
+ * },
+ * "m.identity_server": {
+ * "base_url": "https://vector.im"
+ * },
+ * "org.matrix.msc3575.proxy": {
+ * "url": "https://slidingsync.lab.matrix.org"
+ * }
+ * }
+ *
+ * .
+ */
+@Serializable
+data class WellKnown(
+ @SerialName("m.homeserver")
+ val homeServer: WellKnownBaseConfig? = null,
+
+ @SerialName("m.identity_server")
+ val identityServer: WellKnownBaseConfig? = null,
+
+ @SerialName("org.matrix.msc3575.proxy")
+ val slidingSyncProxy: WellKnownSlidingSyncConfig? = null,
+) {
+ fun isValid(): Boolean {
+ return homeServer?.baseURL?.isNotBlank().orFalse()
+ }
+
+ fun supportSlidingSync(): Boolean {
+ return slidingSyncProxy?.url?.isNotBlank().orFalse()
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt
new file mode 100644
index 0000000000..87b86736fa
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt
@@ -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.resolver.network
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
+ *
+ * {
+ * "base_url": "https://element.io"
+ * }
+ *
+ * .
+ */
+@Serializable
+data class WellKnownBaseConfig(
+ @SerialName("base_url")
+ val baseURL: String? = null
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt
new file mode 100644
index 0000000000..98c712d9ac
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.resolver.network
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WellKnownSlidingSyncConfig(
+ @SerialName("url")
+ val url: String? = null,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt
new file mode 100644
index 0000000000..04a0dfb803
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.login.impl.resolver.network
+
+import retrofit2.http.GET
+
+internal interface WellknownAPI {
+ @GET(".well-known/matrix/client")
+ suspend fun getWellKnown(): WellKnown
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt
new file mode 100644
index 0000000000..570b621b83
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.element.android.features.login.impl.resolver.network
+
+interface WellknownRequest {
+ /**
+ * Return the WellKnown data, or throw an error if not found.
+ * @param baseUrl for instance https://matrix.org
+ */
+ suspend fun execute(baseUrl: String): WellKnown
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt
deleted file mode 100644
index f55c2030e7..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- * 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.root
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.saveable.rememberSaveable
-import io.element.android.features.login.api.oidc.OidcAction
-import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
-import io.element.android.features.login.impl.util.LoginConstants
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.architecture.execute
-import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-class LoginRootPresenter @Inject constructor(
- private val authenticationService: MatrixAuthenticationService,
- private val defaultOidcActionFlow: DefaultOidcActionFlow,
-) : Presenter {
-
- @Composable
- override fun present(): LoginRootState {
- val localCoroutineScope = rememberCoroutineScope()
- val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
- val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
- val getHomeServerDetailsAction: MutableState> = remember {
- if (currentHomeServerDetails != null) {
- mutableStateOf(Async.Success(currentHomeServerDetails))
- } else {
- mutableStateOf(Async.Uninitialized)
- }
- }
-
- LaunchedEffect(Unit) {
- if (currentHomeServerDetails == null) {
- getHomeServerDetails(homeserver, getHomeServerDetailsAction)
- }
- }
-
- val loggedInState: MutableState = remember {
- mutableStateOf(LoggedInState.NotLoggedIn)
- }
- val formState = rememberSaveable {
- mutableStateOf(LoginFormState.Default)
- }
-
- LaunchedEffect(Unit) {
- launch {
- defaultOidcActionFlow.collect {
- onOidcAction(it, loggedInState)
- }
- }
- }
-
- fun handleEvents(event: LoginRootEvents) {
- when (event) {
- LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
- is LoginRootEvents.SetLogin -> updateFormState(formState) {
- copy(login = event.login)
- }
- is LoginRootEvents.SetPassword -> updateFormState(formState) {
- copy(password = event.password)
- }
- LoginRootEvents.Submit -> {
- val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
- when {
- homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState)
- homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
- }
- }
- LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
- }
- }
-
- return LoginRootState(
- homeserverUrl = homeserver,
- homeserverDetails = getHomeServerDetailsAction.value,
- loggedInState = loggedInState.value,
- formState = formState.value,
- eventSink = ::handleEvents
- )
- }
-
- private fun CoroutineScope.getHomeServerDetails(
- homeserver: String,
- state: MutableState>,
- ) = launch {
- suspend {
- authenticationService.setHomeserver(homeserver)
- .map {
- authenticationService.getHomeserverDetails().value!!
- }
- .getOrThrow()
- }.execute(state)
- }
-
- private fun CoroutineScope.submitOidc(loggedInState: MutableState) = launch {
- loggedInState.value = LoggedInState.LoggingIn
- authenticationService.getOidcUrl()
- .onSuccess {
- loggedInState.value = LoggedInState.OidcStarted(it)
- }
- .onFailure { failure ->
- loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
- }
- }
-
- private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState) = launch {
- loggedInState.value = LoggedInState.LoggingIn
- authenticationService.login(formState.login.trim(), formState.password)
- .onSuccess { sessionId ->
- loggedInState.value = LoggedInState.LoggedIn(sessionId)
- }
- .onFailure { failure ->
- loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
- }
- }
-
- private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) {
- formState.value = updateLambda(formState.value)
- }
-
- private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState) {
- oidcAction ?: return
- loggedInState.value = LoggedInState.LoggingIn
- when (oidcAction) {
- OidcAction.GoBack -> {
- authenticationService.cancelOidcLogin()
- .onSuccess {
- loggedInState.value = LoggedInState.NotLoggedIn
- }
- .onFailure { failure ->
- loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
- }
- }
- is OidcAction.Success -> {
- authenticationService.loginWithOidc(oidcAction.url)
- .onSuccess { sessionId ->
- loggedInState.value = LoggedInState.LoggedIn(sessionId)
- }
- .onFailure { failure ->
- loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
- }
- }
- }
- defaultOidcActionFlow.reset()
- }
-}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt
deleted file mode 100644
index 5f6d7c1f3a..0000000000
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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.root
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
-import io.element.android.libraries.matrix.api.core.SessionId
-
-open class LoginRootStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aLoginRootState(),
- aLoginRootState().copy(
- homeserverDetails = Async.Success(
- MatrixHomeServerDetails(
- "some-custom-server.com",
- supportsPasswordLogin = true,
- supportsOidcLogin = false
- )
- )
- ),
- aLoginRootState().copy(formState = LoginFormState("user", "pass")),
- aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
- aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
- aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
- // Oidc
- aLoginRootState().copy(
- homeserverUrl = "server-with-oidc.org",
- homeserverDetails = Async.Success(
- MatrixHomeServerDetails(
- "server-with-oidc.org",
- supportsPasswordLogin = false,
- supportsOidcLogin = true
- )
- )
- ),
- // No password, no oidc support
- aLoginRootState().copy(
- homeserverUrl = "wrong.org",
- homeserverDetails = Async.Success(
- MatrixHomeServerDetails(
- "wrong.org",
- supportsPasswordLogin = false,
- supportsOidcLogin = false
- )
- )
- ),
- // Loading
- aLoginRootState().copy(homeserverDetails = Async.Loading()),
- //Error
- aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
- )
-}
-
-fun aLoginRootState() = LoginRootState(
- homeserverUrl = "matrix.org",
- homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)),
- loggedInState = LoggedInState.NotLoggedIn,
- formState = LoginFormState.Default,
- eventSink = {}
-)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
similarity index 66%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
index 11952ce5a8..45bf4489b5 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt
@@ -14,49 +14,52 @@
* limitations under the License.
*/
-package io.element.android.features.login.impl.changeserver
+package io.element.android.features.login.impl.screens.changeaccountprovider
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.util.LoginConstants
-import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
-class ChangeServerNode @AssistedInject constructor(
+class ChangeAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: ChangeServerPresenter,
+ private val presenter: ChangeAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
- private fun onSuccess() {
- navigateUp()
+
+ interface Callback : Plugin {
+ fun onDone()
+ fun onOtherClicked()
}
- private fun openLearnMorePage(context: Context) {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
- tryOrNull { context.startActivity(intent) }
+ private fun onDone() {
+ plugins().forEach { it.onDone() }
+ }
+
+ private fun onOtherClicked() {
+ plugins().forEach { it.onOtherClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
- ChangeServerView(
+ ChangeAccountProviderView(
state = state,
modifier = modifier,
- onChangeServerSuccess = this::onSuccess,
- onBackPressed = { navigateUp() },
+ onBackPressed = ::navigateUp,
onLearnMoreClicked = { openLearnMorePage(context) },
+ onDone = ::onDone,
+ onOtherProviderClicked = ::onOtherClicked,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
new file mode 100644
index 0000000000..dfdb7dcf99
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.changeaccountprovider
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.libraries.architecture.Presenter
+import javax.inject.Inject
+
+class ChangeAccountProviderPresenter @Inject constructor(
+ private val changeServerPresenter: ChangeServerPresenter,
+) : Presenter {
+
+ @Composable
+ override fun present(): ChangeAccountProviderState {
+ val changeServerState = changeServerPresenter.present()
+ return ChangeAccountProviderState(
+ // Just matrix.org by default for now
+ accountProviders = listOf(
+ AccountProvider(
+ title = "matrix.org",
+ subtitle = null,
+ isPublic = true,
+ isMatrixOrg = true,
+ isValid = true,
+ supportSlidingSync = true,
+ )
+ ),
+ changeServerState = changeServerState,
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
new file mode 100644
index 0000000000..806ce5bc64
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.changeaccountprovider
+
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.changeserver.ChangeServerState
+
+// Do not use default value, so no member get forgotten in the presenters.
+data class ChangeAccountProviderState constructor(
+ val accountProviders: List,
+ val changeServerState: ChangeServerState,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
new file mode 100644
index 0000000000..403746f227
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.changeaccountprovider
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.login.impl.accountprovider.anAccountProvider
+import io.element.android.features.login.impl.changeserver.aChangeServerState
+
+open class ChangeAccountProviderStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aChangeAccountProviderState(),
+ // Add other state here
+ )
+}
+
+fun aChangeAccountProviderState() = ChangeAccountProviderState(
+ accountProviders = listOf(
+ anAccountProvider()
+ ),
+ changeServerState = aChangeServerState(),
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
new file mode 100644
index 0000000000..0f444350c9
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt
@@ -0,0 +1,147 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+
+package io.element.android.features.login.impl.screens.changeaccountprovider
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.login.impl.R
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.accountprovider.AccountProviderView
+import io.element.android.features.login.impl.changeserver.ChangeServerEvents
+import io.element.android.features.login.impl.changeserver.ChangeServerView
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+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.Scaffold
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+
+/**
+ * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
+ */
+@Composable
+fun ChangeAccountProviderView(
+ state: ChangeAccountProviderState,
+ onBackPressed: () -> Unit,
+ onLearnMoreClicked: () -> Unit,
+ onDone: () -> Unit,
+ onOtherProviderClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = { BackButton(onClick = onBackPressed) }
+ )
+ }
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ ) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(state = rememberScrollState())
+ ) {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
+ iconImageVector = Icons.Filled.Home,
+ iconTint = MaterialTheme.colorScheme.primary,
+ title = stringResource(id = R.string.screen_change_account_provider_title),
+ subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
+ )
+
+ state.accountProviders.forEach { item ->
+ val alteredItem = if (item.isMatrixOrg) {
+ // Set the subtitle from the resource
+ item.copy(
+ subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
+ )
+ } else {
+ item
+ }
+ AccountProviderView(
+ item = alteredItem,
+ onClick = {
+ state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
+ }
+ )
+ }
+ // Other
+ AccountProviderView(
+ item = AccountProvider(
+ title = stringResource(id = R.string.screen_change_account_provider_other),
+ ),
+ onClick = onOtherProviderClicked
+ )
+ Spacer(Modifier.height(32.dp))
+ }
+ ChangeServerView(
+ state = state.changeServerState,
+ onLearnMoreClicked = onLearnMoreClicked,
+ onDone = onDone,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ChangeAccountProviderState) {
+ ChangeAccountProviderView(
+ state = state,
+ onBackPressed = { },
+ onLearnMoreClicked = { },
+ onDone = { },
+ onOtherProviderClicked = { },
+ )
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
new file mode 100644
index 0000000000..1ba3cc3028
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.login.impl.screens.confirmaccountprovider
+
+sealed interface ConfirmAccountProviderEvents {
+ object Continue : ConfirmAccountProviderEvents
+ object ClearError : ConfirmAccountProviderEvents
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
new file mode 100644
index 0000000000..7cef986013
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.confirmaccountprovider
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+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.util.openLearnMorePage
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.auth.OidcDetails
+
+@ContributesNode(AppScope::class)
+class ConfirmAccountProviderNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: ConfirmAccountProviderPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+
+ data class Inputs(
+ val isAccountCreation: Boolean,
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+ private val presenter = presenterFactory.create(
+ ConfirmAccountProviderPresenter.Params(
+ isAccountCreation = inputs.isAccountCreation,
+ )
+ )
+
+ interface Callback : Plugin {
+ fun onLoginPasswordNeeded()
+ fun onOidcDetails(oidcDetails: OidcDetails)
+ fun onChangeAccountProvider()
+ }
+
+ private fun onOidcDetails(data: OidcDetails) {
+ plugins().forEach { it.onOidcDetails(data) }
+ }
+
+ private fun onLoginPasswordNeeded() {
+ plugins().forEach { it.onLoginPasswordNeeded() }
+ }
+
+ private fun onChangeAccountProvider() {
+ plugins().forEach { it.onChangeAccountProvider() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ val context = LocalContext.current
+ ConfirmAccountProviderView(
+ state = state,
+ modifier = modifier,
+ onOidcDetails = ::onOidcDetails,
+ onLoginPasswordNeeded = ::onLoginPasswordNeeded,
+ onChange = ::onChangeAccountProvider,
+ onLearnMoreClicked = { openLearnMorePage(context) },
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
new file mode 100644
index 0000000000..2626e56365
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.confirmaccountprovider
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.error.ChangeServerError
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.execute
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import java.net.URL
+
+class ConfirmAccountProviderPresenter @AssistedInject constructor(
+ @Assisted private val params: Params,
+ private val accountProviderDataSource: AccountProviderDataSource,
+ private val authenticationService: MatrixAuthenticationService
+) : Presenter {
+
+ data class Params(
+ val isAccountCreation: Boolean,
+ )
+
+ @AssistedFactory
+ interface Factory {
+ fun create(params: Params): ConfirmAccountProviderPresenter
+ }
+
+ @Composable
+ override fun present(): ConfirmAccountProviderState {
+ val accountProvider by accountProviderDataSource.flow().collectAsState()
+ val localCoroutineScope = rememberCoroutineScope()
+
+ val loginFlowAction: MutableState> = remember {
+ mutableStateOf(Async.Uninitialized)
+ }
+
+ fun handleEvents(event: ConfirmAccountProviderEvents) {
+ when (event) {
+ ConfirmAccountProviderEvents.Continue -> {
+ localCoroutineScope.submit(accountProvider.title, loginFlowAction)
+ }
+ ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
+ }
+ }
+
+ return ConfirmAccountProviderState(
+ accountProvider = accountProvider,
+ isAccountCreation = params.isAccountCreation,
+ loginFlow = loginFlowAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.submit(
+ homeserverUrl: String,
+ loginFlowAction: MutableState>,
+ ) = launch {
+ suspend {
+ val domain = tryOrNull { URL(homeserverUrl) }?.host ?: homeserverUrl
+ authenticationService.setHomeserver(domain).map {
+ val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
+ if (matrixHomeServerDetails.supportsOidcLogin) {
+ // Retrieve the details right now
+ LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
+ } else if (matrixHomeServerDetails.supportsPasswordLogin) {
+ LoginFlow.PasswordLogin
+ } else {
+ throw IllegalStateException("Unsupported login flow")
+ }
+ }.getOrThrow()
+ }.execute(loginFlowAction, errorMapping = ChangeServerError::from)
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
new file mode 100644
index 0000000000..a870b88c58
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.confirmaccountprovider
+
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.auth.OidcDetails
+
+// Do not use default value, so no member get forgotten in the presenters.
+data class ConfirmAccountProviderState(
+ val accountProvider: AccountProvider,
+ val isAccountCreation: Boolean,
+ val loginFlow: Async,
+ val eventSink: (ConfirmAccountProviderEvents) -> Unit
+) {
+ val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
+}
+
+sealed interface LoginFlow {
+ object PasswordLogin : LoginFlow
+ data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
new file mode 100644
index 0000000000..d5f98f5716
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.confirmaccountprovider
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.login.impl.accountprovider.anAccountProvider
+import io.element.android.libraries.architecture.Async
+
+open class ConfirmAccountProviderStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aConfirmAccountProviderState(),
+ // Add other state here
+ )
+}
+
+fun aConfirmAccountProviderState() = ConfirmAccountProviderState(
+ accountProvider = anAccountProvider(),
+ isAccountCreation = false,
+ loginFlow = Async.Uninitialized,
+ eventSink = {}
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
new file mode 100644
index 0000000000..e6e1ce8e83
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
@@ -0,0 +1,163 @@
+/*
+ * 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.confirmaccountprovider
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.login.impl.R
+import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
+import io.element.android.features.login.impl.error.ChangeServerError
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
+
+@Composable
+fun ConfirmAccountProviderView(
+ state: ConfirmAccountProviderState,
+ onOidcDetails: (OidcDetails) -> Unit,
+ onLoginPasswordNeeded: () -> Unit,
+ onLearnMoreClicked: () -> Unit,
+ onChange: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val isLoading by remember(state.loginFlow) {
+ derivedStateOf {
+ state.loginFlow is Async.Loading
+ }
+ }
+ val eventSink = state.eventSink
+
+ HeaderFooterPage(
+ modifier = modifier,
+ header = {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier.padding(top = 60.dp),
+ iconImageVector = Icons.Filled.AccountCircle,
+ title = stringResource(
+ id = if (state.isAccountCreation) {
+ R.string.screen_account_provider_signup_title
+ } else {
+ R.string.screen_account_provider_signin_title
+ },
+ state.accountProvider.title
+ ),
+ subTitle = stringResource(
+ id = if (state.isAccountCreation) {
+ R.string.screen_account_provider_signup_subtitle
+ } else {
+ R.string.screen_account_provider_signin_subtitle
+ },
+ )
+ )
+ },
+ footer = {
+ ButtonColumnMolecule {
+ ButtonWithProgress(
+ text = stringResource(id = R.string.screen_account_provider_continue),
+ showProgress = isLoading,
+ onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
+ enabled = state.submitEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.loginContinue)
+ )
+ TextButton(
+ onClick = onChange,
+ enabled = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.loginChangeServer)
+ ) {
+ Text(text = stringResource(id = R.string.screen_account_provider_change))
+ }
+ }
+ }
+ ) {
+ when (state.loginFlow) {
+ is Async.Failure -> {
+ when (val error = state.loginFlow.error) {
+ is ChangeServerError.Error -> {
+ ErrorDialog(
+ content = error.message(),
+ onDismiss = {
+ eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
+ }
+ )
+ }
+ is ChangeServerError.SlidingSyncAlert -> {
+ SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
+ onLearnMoreClicked()
+ eventSink(ConfirmAccountProviderEvents.ClearError)
+ }, onDismiss = {
+ eventSink(ConfirmAccountProviderEvents.ClearError)
+ })
+ }
+ }
+ }
+ is Async.Loading -> Unit // The Continue button shows the loading state
+ is Async.Success -> {
+ when (val loginFlowState = state.loginFlow.state) {
+ is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
+ LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
+ }
+ }
+ Async.Uninitialized -> Unit
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ConfirmAccountProviderState) {
+ ConfirmAccountProviderView(
+ state = state,
+ onOidcDetails = {},
+ onLoginPasswordNeeded = {},
+ onLearnMoreClicked = {},
+ onChange = {},
+ )
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt
new file mode 100644
index 0000000000..e6f23ca418
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.login.impl.screens.loginpassword
+
+sealed interface LoginPasswordEvents {
+ data class SetLogin(val login: String) : LoginPasswordEvents
+ data class SetPassword(val password: String) : LoginPasswordEvents
+ object Submit : LoginPasswordEvents
+ object ClearError : LoginPasswordEvents
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt
new file mode 100644
index 0000000000..630b08570c
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.loginpassword
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.AppScope
+
+@ContributesNode(AppScope::class)
+class LoginPasswordNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LoginPasswordPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LoginPasswordView(
+ state = state,
+ modifier = modifier,
+ onBackPressed = ::navigateUp
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
new file mode 100644
index 0000000000..1fc4a10bbb
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.loginpassword
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+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 javax.inject.Inject
+
+class LoginPasswordPresenter @Inject constructor(
+ private val authenticationService: MatrixAuthenticationService,
+ private val accountProviderDataSource: AccountProviderDataSource,
+) : Presenter {
+
+ @Composable
+ override fun present(): LoginPasswordState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val loginAction: MutableState> = remember {
+ mutableStateOf(Async.Uninitialized)
+ }
+
+ val formState = rememberSaveable {
+ mutableStateOf(LoginFormState.Default)
+ }
+ val accountProvider by accountProviderDataSource.flow().collectAsState()
+
+ fun handleEvents(event: LoginPasswordEvents) {
+ when (event) {
+ is LoginPasswordEvents.SetLogin -> updateFormState(formState) {
+ copy(login = event.login)
+ }
+ is LoginPasswordEvents.SetPassword -> updateFormState(formState) {
+ copy(password = event.password)
+ }
+ LoginPasswordEvents.Submit -> {
+ localCoroutineScope.submit(formState.value, loginAction)
+ }
+ LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized
+ }
+ }
+
+ return LoginPasswordState(
+ accountProvider = accountProvider,
+ formState = formState.value,
+ loginAction = loginAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState>) = launch {
+ 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)
+ }
+ }
+
+ private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) {
+ formState.value = updateLambda(formState.value)
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt
similarity index 50%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt
index 45eafa744c..c8fa2f4ad3 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt
@@ -14,36 +14,23 @@
* limitations under the License.
*/
-package io.element.android.features.login.impl.root
+package io.element.android.features.login.impl.screens.loginpassword
import android.os.Parcelable
+import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.core.bool.orFalse
-import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
-import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
-data class LoginRootState(
- val homeserverUrl: String,
- val homeserverDetails: Async,
- val loggedInState: LoggedInState,
+data class LoginPasswordState(
+ val accountProvider: AccountProvider,
val formState: LoginFormState,
- val eventSink: (LoginRootEvents) -> Unit
+ val loginAction: Async,
+ val eventSink: (LoginPasswordEvents) -> Unit
) {
- val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
- val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse()
val submitEnabled: Boolean
- get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
- ((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
-}
-
-sealed interface LoggedInState {
- object NotLoggedIn : LoggedInState
- object LoggingIn : LoggedInState
- data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
- data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
- data class LoggedIn(val sessionId: SessionId) : LoggedInState
+ get() = loginAction !is Async.Failure &&
+ ((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
}
@Parcelize
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt
new file mode 100644
index 0000000000..b4f5a84691
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.loginpassword
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.login.impl.accountprovider.anAccountProvider
+import io.element.android.libraries.architecture.Async
+
+open class LoginPasswordStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLoginPasswordState(),
+ // Loading
+ aLoginPasswordState().copy(loginAction = Async.Loading()),
+ // Error
+ aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))),
+ )
+}
+
+fun aLoginPasswordState() = LoginPasswordState(
+ accountProvider = anAccountProvider(),
+ formState = LoginFormState.Default,
+ loginAction = Async.Uninitialized,
+ eventSink = {}
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
similarity index 60%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
index 2444418725..9154a0792f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 New Vector Ltd
+ * 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.
@@ -14,15 +14,11 @@
* limitations under the License.
*/
-package io.element.android.features.login.impl.root
+package io.element.android.features.login.impl.screens.loginpassword
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -30,31 +26,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
@@ -62,7 +52,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
-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
@@ -70,8 +59,7 @@ import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
-import io.element.android.libraries.designsystem.components.async.AsyncFailure
-import io.element.android.libraries.designsystem.components.async.AsyncLoading
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@@ -86,23 +74,20 @@ import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
-import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
-fun LoginRootView(
- state: LoginRootState,
+fun LoginPasswordView(
+ state: LoginPasswordState,
modifier: Modifier = Modifier,
- onChangeServer: () -> Unit = {},
- onOidcDetails: (OidcDetails) -> Unit = {},
onBackPressed: () -> Unit,
) {
- val isLoading by remember(state.loggedInState) {
+ val isLoading by remember(state.loginAction) {
derivedStateOf {
- state.loggedInState == LoggedInState.LoggingIn
+ state.loginAction is Async.Loading
}
}
val focusManager = LocalFocusManager.current
@@ -111,10 +96,11 @@ fun LoginRootView(
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
- state.eventSink(LoginRootEvents.Submit)
+ state.eventSink(LoginPasswordEvents.Submit)
}
Scaffold(
+ modifier = modifier,
topBar = {
TopAppBar(
title = {},
@@ -123,7 +109,7 @@ fun LoginRootView(
}
) { padding ->
Box(
- modifier = modifier
+ modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
@@ -136,142 +122,48 @@ fun LoginRootView(
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
- Spacer(Modifier.height(16.dp))
// Title
- Text(
- text = stringResource(id = R.string.screen_login_title),
+ IconTitleSubtitleMolecule(
+ modifier = Modifier.padding(top = 20.dp),
+ iconImageVector = Icons.Filled.AccountCircle,
+ title = stringResource(
+ id = R.string.screen_account_provider_signin_title,
+ state.accountProvider.title
+ ),
+ subTitle = stringResource(id = R.string.screen_login_form_header)
+ )
+ Spacer(Modifier.height(32.dp))
+ LoginForm(state = state,
+ isLoading = isLoading,
+ onSubmit = ::submit
+ )
+ Spacer(Modifier.height(28.dp))
+ // Submit
+ ButtonWithProgress(
+ text = stringResource(R.string.screen_login_submit),
+ showProgress = isLoading,
+ onClick = ::submit,
+ enabled = state.submitEnabled,
modifier = Modifier
- .fillMaxWidth(),
- style = ElementTextStyles.Bold.title1,
- color = MaterialTheme.colorScheme.primary,
+ .fillMaxWidth()
+ .testTag(TestTags.loginContinue)
)
- Spacer(Modifier.height(32.dp))
-
- ChangeServerSection(
- interactionEnabled = !isLoading,
- homeserver = state.homeserverUrl,
- onChangeServer = onChangeServer
- )
-
- Spacer(Modifier.height(32.dp))
-
- when (state.homeserverDetails) {
- Async.Uninitialized,
- is Async.Loading -> AsyncLoading()
- is Async.Failure -> AsyncFailure(
- throwable = state.homeserverDetails.error,
- onRetry = {
- state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
- }
- )
- is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
- }
+ Spacer(modifier = Modifier.height(32.dp))
}
- when (val loggedInState = state.loggedInState) {
- is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
- else -> Unit
+
+ if (state.loginAction is Async.Failure) {
+ LoginErrorDialog(error = state.loginAction.error, onDismiss = {
+ state.eventSink(LoginPasswordEvents.ClearError)
+ })
}
}
}
-
- if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
- LoginErrorDialog(error = state.loggedInState.failure, onDismiss = {
- state.eventSink(LoginRootEvents.ClearError)
- })
- }
-}
-
-@Composable
-fun ServerDetailForm(
- state: LoginRootState,
- isLoading: Boolean,
- submit: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- when {
- state.supportOidcLogin -> {
- // Oidc, in this case, just display a Spacer and the submit button
- Spacer(modifier.height(28.dp))
- }
- state.supportPasswordLogin -> {
- LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
- }
- else -> {
- Text(modifier = modifier, text = "No supported login flow")
- }
- }
-
- Spacer(Modifier.height(28.dp))
-
- if (state.supportOidcLogin || state.supportPasswordLogin) {
- // 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))
- }
-}
-
-@Composable
-internal fun ChangeServerSection(
- interactionEnabled: Boolean,
- homeserver: String,
- onChangeServer: () -> Unit,
- modifier: Modifier = Modifier
-) {
- Column(modifier) {
- Text(
- modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
- text = stringResource(id = R.string.screen_login_server_header),
- style = ElementTextStyles.Regular.formHeader,
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant)
- .testTag(TestTags.loginChangeServer)
- .clickable {
- if (interactionEnabled) {
- onChangeServer()
- }
- },
- horizontalArrangement = Arrangement.End,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = homeserver,
- style = ElementTextStyles.Bold.body,
- textAlign = TextAlign.Start,
- modifier = Modifier
- .weight(1f)
- .padding(horizontal = 16.dp, vertical = 16.dp)
- )
- IconButton(
- modifier = Modifier.size(24.dp),
- onClick = {
- if (interactionEnabled) {
- onChangeServer()
- }
- }
- ) {
- Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary)
- }
- Spacer(Modifier.width(8.dp))
- }
- }
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
- state: LoginRootState,
+ state: LoginPasswordState,
isLoading: Boolean,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
@@ -299,14 +191,14 @@ internal fun LoginForm(
.testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it
- eventSink(LoginRootEvents.SetLogin(it))
+ eventSink(LoginPasswordEvents.SetLogin(it))
}),
label = {
Text(text = stringResource(R.string.screen_login_username_hint))
},
onValueChange = {
loginFieldState = it
- eventSink(LoginRootEvents.SetLogin(it))
+ eventSink(LoginPasswordEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
@@ -316,7 +208,6 @@ internal fun LoginForm(
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
- maxLines = 1,
trailingIcon = if (loginFieldState.isNotEmpty()) {
{
IconButton(onClick = {
@@ -329,7 +220,7 @@ internal fun LoginForm(
)
var passwordVisible by remember { mutableStateOf(false) }
- if (state.loggedInState is LoggedInState.LoggingIn) {
+ if (state.loginAction is Async.Loading) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
@@ -343,11 +234,11 @@ internal fun LoginForm(
.testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it
- eventSink(LoginRootEvents.SetPassword(it))
+ eventSink(LoginPasswordEvents.SetPassword(it))
}),
onValueChange = {
passwordFieldState = it
- eventSink(LoginRootEvents.SetPassword(it))
+ eventSink(LoginPasswordEvents.SetPassword(it))
},
label = {
Text(text = stringResource(R.string.screen_login_password_hint))
@@ -371,7 +262,6 @@ internal fun LoginForm(
onDone = { onSubmit() }
),
singleLine = true,
- maxLines = 1,
)
}
}
@@ -386,17 +276,17 @@ internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
@Preview
@Composable
-internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
+internal fun LoginPasswordViewLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
-internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
+internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
-private fun ContentToPreview(state: LoginRootState) {
- LoginRootView(
+private fun ContentToPreview(state: LoginPasswordState) {
+ LoginPasswordView(
state = state,
onBackPressed = {}
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
new file mode 100644
index 0000000000..53ee45f644
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt
@@ -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.login.impl.screens.searchaccountprovider
+
+sealed interface SearchAccountProviderEvents {
+ /**
+ * The user has typed something, expect to get a list of matching account provider results
+ * in the state.
+ */
+ data class UserInput(val input: String) : SearchAccountProviderEvents
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
similarity index 67%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt
rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
index 787f5d0b48..7178a105f6 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt
@@ -14,10 +14,11 @@
* limitations under the License.
*/
-package io.element.android.features.login.impl.root
+package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -25,38 +26,34 @@ 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.util.openLearnMorePage
import io.element.android.libraries.di.AppScope
-import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
-class LoginRootNode @AssistedInject constructor(
+class SearchAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: LoginRootPresenter,
+ private val presenter: SearchAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
- fun onChangeHomeServer()
- fun onOidcDetails(oidcDetails: OidcDetails)
+ fun onDone()
}
- private fun onChangeHomeServer() {
- plugins().forEach { it.onChangeHomeServer() }
- }
-
- private fun onOidcDetails(oidcDetails: OidcDetails) {
- plugins().forEach { it.onOidcDetails(oidcDetails) }
+ private fun onDone() {
+ plugins().forEach { it.onDone() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
- LoginRootView(
+ val context = LocalContext.current
+ SearchAccountProviderView(
state = state,
modifier = modifier,
- onChangeServer = ::onChangeHomeServer,
- onOidcDetails = ::onOidcDetails,
- onBackPressed = ::navigateUp
+ onBackPressed = ::navigateUp,
+ onLearnMoreClicked = { openLearnMorePage(context) },
+ onDone = ::onDone,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
new file mode 100644
index 0000000000..1d8271e394
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.searchaccountprovider
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.features.login.impl.resolver.HomeserverData
+import io.element.android.features.login.impl.resolver.HomeserverResolver
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class SearchAccountProviderPresenter @Inject constructor(
+ private val homeserverResolver: HomeserverResolver,
+ private val changeServerPresenter: ChangeServerPresenter,
+) : Presenter {
+
+ @Composable
+ override fun present(): SearchAccountProviderState {
+ var userInput by rememberSaveable {
+ mutableStateOf("")
+ }
+ val changeServerState = changeServerPresenter.present()
+
+ val data: MutableState>> = remember {
+ mutableStateOf(Async.Uninitialized)
+ }
+
+ LaunchedEffect(userInput) {
+ onUserInput(userInput, data)
+ }
+
+ fun handleEvents(event: SearchAccountProviderEvents) {
+ when (event) {
+ is SearchAccountProviderEvents.UserInput -> {
+ userInput = event.input
+ }
+ }
+ }
+
+ return SearchAccountProviderState(
+ userInput = userInput,
+ userInputResult = data.value,
+ changeServerState = changeServerState,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch {
+ data.value = Async.Uninitialized
+ // Debounce
+ delay(300)
+ data.value = Async.Loading()
+ homeserverResolver.resolve(userInput).collect {
+ data.value = Async.Success(it)
+ }
+ if (data.value !is Async.Success) {
+ data.value = Async.Uninitialized
+ }
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
new file mode 100644
index 0000000000..15859afde1
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt
@@ -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.login.impl.screens.searchaccountprovider
+
+import io.element.android.features.login.impl.changeserver.ChangeServerState
+import io.element.android.features.login.impl.resolver.HomeserverData
+import io.element.android.libraries.architecture.Async
+
+// Do not use default value, so no member get forgotten in the presenters.
+data class SearchAccountProviderState(
+ val userInput: String,
+ val userInputResult: Async>,
+ val changeServerState: ChangeServerState,
+ val eventSink: (SearchAccountProviderEvents) -> Unit
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
new file mode 100644
index 0000000000..b6ffac8bd1
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
@@ -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.login.impl.screens.searchaccountprovider
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.login.impl.changeserver.aChangeServerState
+import io.element.android.features.login.impl.resolver.HomeserverData
+import io.element.android.libraries.architecture.Async
+
+open class SearchAccountProviderStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aSearchAccountProviderState(),
+ aSearchAccountProviderState(userInputResult = Async.Success(aHomeserverDataList())),
+ // Add other state here
+ )
+}
+
+fun aSearchAccountProviderState(
+ userInput: String = "",
+ userInputResult: Async> = Async.Uninitialized,
+) = SearchAccountProviderState(
+ userInput = userInput,
+ userInputResult = userInputResult,
+ changeServerState = aChangeServerState(),
+ eventSink = {}
+)
+
+fun aHomeserverDataList(): List {
+ return listOf(
+ aHomeserverData(isWellknownValid = true, supportSlidingSync = true),
+ aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true, supportSlidingSync = false),
+ aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false, supportSlidingSync = false),
+ )
+}
+
+fun aHomeserverData(
+ homeserverUrl: String = "https://matrix.org",
+ isWellknownValid: Boolean = true,
+ supportSlidingSync: Boolean = true,
+): HomeserverData {
+ return HomeserverData(
+ homeserverUrl = homeserverUrl,
+ isWellknownValid = isWellknownValid,
+ supportSlidingSync = supportSlidingSync,
+ )
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
new file mode 100644
index 0000000000..5c280cba0e
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
@@ -0,0 +1,230 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+
+package io.element.android.features.login.impl.screens.searchaccountprovider
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.login.impl.R
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.accountprovider.AccountProviderView
+import io.element.android.features.login.impl.changeserver.ChangeServerEvents
+import io.element.android.features.login.impl.changeserver.ChangeServerView
+import io.element.android.features.login.impl.resolver.HomeserverData
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.form.textFieldState
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
+import io.element.android.libraries.ui.strings.R as StringR
+
+/**
+ * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
+ */
+@Composable
+fun SearchAccountProviderView(
+ state: SearchAccountProviderState,
+ onBackPressed: () -> Unit,
+ onLearnMoreClicked: () -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val eventSink = state.eventSink
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = { BackButton(onClick = onBackPressed) }
+ )
+ }
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ ) {
+ LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
+ item {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
+ iconImageVector = Icons.Filled.Search,
+ title = stringResource(id = R.string.screen_account_provider_form_title),
+ subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
+ )
+ }
+ item {
+ // TextInput
+ var userInputState by textFieldState(stateValue = state.userInput)
+ val focusManager = LocalFocusManager.current
+ OutlinedTextField(
+ value = userInputState,
+ // readOnly = isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
+ .onTabOrEnterKeyFocusNext(focusManager)
+ .testTag(TestTags.changeServerServer),
+ onValueChange = {
+ userInputState = it
+ eventSink(SearchAccountProviderEvents.UserInput(it))
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(onDone = {
+ focusManager.moveFocus(FocusDirection.Down)
+ }),
+ singleLine = true,
+ trailingIcon = if (userInputState.isNotEmpty()) {
+ {
+ IconButton(onClick = {
+ userInputState = ""
+ eventSink(SearchAccountProviderEvents.UserInput(""))
+ }) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(StringR.string.action_clear)
+ )
+ }
+ }
+ } else null,
+ supportingText = {
+ Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary)
+ }
+ )
+ }
+
+ when (state.userInputResult) {
+ is Async.Failure -> {
+ // Ignore errors (let the user type more chars)
+ }
+ is Async.Loading -> {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ }
+ is Async.Success -> {
+ items(state.userInputResult.state) { homeserverData ->
+ val item = homeserverData.toAccountProvider()
+ AccountProviderView(
+ item = item,
+ onClick = {
+ state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
+ }
+ )
+ }
+ }
+ Async.Uninitialized -> Unit
+ }
+ item {
+ Spacer(Modifier.height(32.dp))
+ }
+ }
+ ChangeServerView(
+ state = state.changeServerState,
+ onLearnMoreClicked = onLearnMoreClicked,
+ onDone = onDone,
+ )
+ }
+ }
+}
+
+@Composable
+private fun HomeserverData.toAccountProvider(): AccountProvider {
+ val isMatrixOrg = homeserverUrl == "https://matrix.org"
+ return AccountProvider(
+ title = homeserverUrl.removePrefix("http://").removePrefix("https://"),
+ subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
+ isPublic = isMatrixOrg, // There is no need to know for other servers right now
+ isMatrixOrg = isMatrixOrg,
+ isValid = isWellknownValid,
+ supportSlidingSync = supportSlidingSync,
+ )
+}
+
+@Preview
+@Composable
+fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: SearchAccountProviderState) {
+ SearchAccountProviderView(
+ state = state,
+ onBackPressed = {},
+ onLearnMoreClicked = {},
+ onDone = {},
+ )
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
index cb01f8095a..e8bcea990e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
@@ -16,8 +16,18 @@
package io.element.android.features.login.impl.util
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+
object LoginConstants {
+ const val MATRIX_ORG_URL = "matrix.org"
const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}
+
+val defaultAccountProvider = AccountProvider(
+ title = LoginConstants.DEFAULT_HOMESERVER_URL,
+ subtitle = null,
+ isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
+ isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt
new file mode 100644
index 0000000000..261b02c1b8
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.util
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import io.element.android.libraries.core.data.tryOrNull
+
+fun openLearnMorePage(context: Context) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
+ tryOrNull { context.startActivity(intent) }
+}
diff --git a/features/login/impl/src/main/res/drawable/ic_matrix.xml b/features/login/impl/src/main/res/drawable/ic_matrix.xml
new file mode 100644
index 0000000000..dbc788a031
--- /dev/null
+++ b/features/login/impl/src/main/res/drawable/ic_matrix.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/features/login/impl/src/main/res/drawable/ic_public.xml b/features/login/impl/src/main/res/drawable/ic_public.xml
new file mode 100644
index 0000000000..fc1eacbc9f
--- /dev/null
+++ b/features/login/impl/src/main/res/drawable/ic_public.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml
index 03ef5b6868..300d851693 100644
--- a/features/login/impl/src/main/res/values-cs/translations.xml
+++ b/features/login/impl/src/main/res/values-cs/translations.xml
@@ -1,5 +1,19 @@
+ "Změna poskytovatele účtu"
+ "Pokračovat"
+ "Adresa domovského serveru"
+ "Zadejte hledaný výraz nebo adresu domény."
+ "Vyhledejte společnost, komunitu nebo soukromý server."
+ "Najít poskytovatele účtu"
+ "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."
+ "Chystáte se přihlásit do %s"
+ "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."
+ "Chystáte se vytvořit účet na %s"
+ "Matrix.org je otevřená síť pro bezpečnou, decentralizovanou komunikaci."
+ "Jiný"
+ "Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet."
+ "Změnit poskytovatele účtu"
"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."
"Tento server v současné době nepodporuje klouzavou synchronizaci."
"Adresa URL domovského serveru"
@@ -13,9 +27,15 @@
"Kde budou vaše konverzace probíhat"
"Vítejte zpět!"
"Přihlaste se k %1$s"
+ "Změnit poskytovatele účtu"
+ "Soukromý server pro zaměstnance Elementu."
+ "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."
+ "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."
+ "Chystáte se přihlásit do služby %1$s"
+ "Chystáte se vytvořit účet na %1$s"
"Pokračovat"
"Vyberte svůj server"
"Heslo"
"Pokračovat"
"Uživatelské jméno"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index df6d1c38ab..d1393dbaf3 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -1,5 +1,18 @@
+ "Kontoanbieter wechseln"
+ "Weiter"
+ "Adresse des Homeservers"
+ "Geben Sie einen Suchbegriff oder eine Domainadresse ein."
+ "Suche nach einem Unternehmen, einer Community oder einem privaten Server."
+ "Finde einen Accountanbieter"
+ "Du bist dabei dich bei %s anzumelden"
+ "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."
+ "Du bist dabei einen Account auf %s zu erstellen"
+ "Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation."
+ "Andere"
+ "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."
+ "Kontoanbieter ändern"
"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."
"Dieser Server unterstützt derzeit keine Sliding Sync."
"Homeserver-URL"
@@ -13,9 +26,15 @@
"Wo deine Gespräche leben"
"Willkommen zurück!"
"Bei %1$s anmelden"
+ "Kontoanbieter wechseln"
+ "Ein privater Server für Element-Mitarbeiter."
+ "Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"
+ "Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."
+ "Du bist dabei dich bei %1$s anzumelden"
+ "Du bist dabei einen Account auf %1$s zu erstellen"
"Weiter"
"Wählen deinen Server"
"Passwort"
"Weiter"
"Benutzername"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml
index 284527c2f5..0e55589556 100644
--- a/features/login/impl/src/main/res/values-es/translations.xml
+++ b/features/login/impl/src/main/res/values-es/translations.xml
@@ -17,4 +17,4 @@
"Contraseña"
"Continuar"
"Usuario"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 9d8f50e979..d56836e360 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -17,4 +17,4 @@
"Mot de passe"
"Continuer"
"Nom d\'utilisateur"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml
index b11875a18e..feb74db373 100644
--- a/features/login/impl/src/main/res/values-it/translations.xml
+++ b/features/login/impl/src/main/res/values-it/translations.xml
@@ -17,4 +17,4 @@
"Password"
"Continua"
"Nome utente"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index 349e3ddc04..7d0c25b97e 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -1,5 +1,18 @@
+ "Schimbați furnizorul contului"
+ "Continuați"
+ "Adresa Homeserver-ului"
+ "Introduceţi un termen de căutare sau o adresă de domeniu."
+ "Căutați o companie, o comunitate sau un server privat."
+ "Găsiți un furnizor de cont"
+ "Sunteți pe cale să vă conectați la %s"
+ "Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."
+ "Sunteți pe cale să creați un cont pe %s"
+ "Matrix.org este o rețea deschisă pentru o comunicare sigură și descentralizată."
+ "Altul"
+ "Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."
+ "Schimbați furnizorul contului"
"Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar."
"Momentan acest server nu oferă suport pentru sliding sync."
"Adresa URL a homeserver-ului"
@@ -12,9 +25,16 @@
"Introduceți detaliile"
"Locul unde trăiesc conversațiile tale"
"Bine ați revenit!"
+ "Conectați-vă la %1$s"
+ "Schimbați furnizorul contului"
+ "Un server privat pentru angajații Element."
+ "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."
+ "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."
+ "Sunteți pe cale să vă conectați la %1$s"
+ "Sunteți pe cale să creați un cont pe %1$s"
"Continuați"
"Selectați serverul"
"Parola"
"Continuați"
"Utilizator"
-
\ No newline at end of file
+
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index cf59844e89..145ac2d238 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -1,5 +1,19 @@
+ "Change account provider"
+ "Continue"
+ "Homeserver address"
+ "Enter a search term or a domain address."
+ "Search for a company, community, or private server."
+ "Find an account provider"
+ "This is where you conversations will live — just like you would use an email provider to keep your emails."
+ "You’re about to sign in to %s"
+ "This is where you conversations will live — just like you would use an email provider to keep your emails."
+ "You’re about to create an account on %s"
+ "Matrix.org is an open network for secure, decentralized communication."
+ "Other"
+ "Use a different account provider, such as your own private server or a work account."
+ "Change account provider"
"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."
"This server currently doesn’t support sliding sync."
"Homeserver URL"
@@ -13,6 +27,12 @@
"Where your conversations live"
"Welcome back!"
"Sign in to %1$s"
+ "Change account provider"
+ "A private server for Element employees."
+ "Matrix is an open network for secure, decentralised communication."
+ "This is where your conversations will live — just like you would use an email provider to keep your emails."
+ "You’re about to sign in to %1$s"
+ "You’re about to create an account on %1$s"
"Continue"
"Select your server"
"Password"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index a30bd9449c..9aefafb382 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -20,147 +20,72 @@ 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.util.LoginConstants
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
-import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
-import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ChangeServerPresenterTest {
@Test
- fun `present - should start with default homeserver`() = runTest {
+ fun `present - initial state`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
+ AccountProviderDataSource()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
- assertThat(initialState.submitEnabled).isTrue()
+ assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
}
}
@Test
- fun `present - authentication service can provide a homeserver`() = runTest {
- val presenter = ChangeServerPresenter(
- FakeAuthenticationService().apply {
- givenHomeserver(A_HOMESERVER.copy(url = A_HOMESERVER_URL_2))
- },
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL_2)
- assertThat(initialState.submitEnabled).isTrue()
- }
- }
-
- @Test
- fun `present - disable if empty or not correct`() = runTest {
- val presenter = ChangeServerPresenter(
- FakeAuthenticationService(),
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(ChangeServerEvents.SetServer(""))
- val emptyState = awaitItem()
- assertThat(emptyState.homeserver).isEqualTo("")
- assertThat(emptyState.submitEnabled).isFalse()
- }
- }
-
- @Test
- fun `present - submit`() = runTest {
- val presenter = ChangeServerPresenter(
- FakeAuthenticationService(),
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(ChangeServerEvents.Submit)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
- }
- }
-
- @Test
- fun `present - submit parses URL`() = runTest {
- val presenter = ChangeServerPresenter(
- FakeAuthenticationService(),
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val longUrl = "https://matrix.org/.well-known/"
- val initialState = awaitItem()
- initialState.eventSink.invoke(ChangeServerEvents.SetServer(longUrl))
- awaitItem()
- initialState.eventSink.invoke(ChangeServerEvents.Submit)
- val loadingState = awaitItem()
- assertThat(loadingState.submitEnabled).isTrue()
- assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
- awaitItem() // Skip changing the url to the parsed domain
- val successState = awaitItem()
- assertThat(successState.submitEnabled).isFalse()
- assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
- assertThat(successState.homeserver).isEqualTo("matrix.org")
- }
- }
-
- @Test
- fun `present - submit fails`() = runTest {
- val authServer = FakeAuthenticationService()
- val presenter = ChangeServerPresenter(authServer)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- authServer.givenChangeServerError(Throwable())
- initialState.eventSink.invoke(ChangeServerEvents.Submit)
- skipItems(1) // Loading
- val failureState = awaitItem()
- assertThat(failureState.submitEnabled).isFalse()
- assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
- }
- }
-
- @Test
- fun `present - clear error`() = runTest {
+ fun `present - change server ok`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = ChangeServerPresenter(
authenticationService,
+ AccountProviderDataSource()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
+ assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
+ authenticationService.givenHomeserver(A_HOMESERVER)
+ initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL)))
+ val loadingState = awaitItem()
+ assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.changeServerAction).isEqualTo(Async.Success(Unit))
+ }
+ }
- // Submit will return an error
- authenticationService.givenChangeServerError(A_THROWABLE)
- initialState.eventSink(ChangeServerEvents.Submit)
-
- skipItems(1) // Loading
-
- // Check an error was returned
- val submittedState = awaitItem()
- assertThat(submittedState.changeServerAction).isInstanceOf(Async.Failure::class.java)
-
- // Assert the error is then cleared
- submittedState.eventSink(ChangeServerEvents.ClearError)
- val clearedState = awaitItem()
- assertThat(clearedState.changeServerAction).isEqualTo(Async.Uninitialized)
+ @Test
+ fun `present - change server error`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val presenter = ChangeServerPresenter(
+ authenticationService,
+ AccountProviderDataSource()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
+ initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL)))
+ val loadingState = awaitItem()
+ assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
+ val failureState = awaitItem()
+ assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
+ // Clear error
+ failureState.eventSink.invoke(ChangeServerEvents.ClearError)
+ val finalState = awaitItem()
+ assertThat(finalState.changeServerAction).isEqualTo(Async.Uninitialized)
}
}
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt
new file mode 100644
index 0000000000..58c2bf82a3
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt
@@ -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.resolver.network
+
+class FakeWellknownRequest : WellknownRequest {
+ private var resultMap: Map = emptyMap()
+ fun givenResultMap(map: Map) {
+ resultMap = map
+ }
+
+ override suspend fun execute(baseUrl: String): WellKnown {
+ return resultMap[baseUrl] ?: error("No result provided for $baseUrl")
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt
deleted file mode 100644
index 0dee8d47c0..0000000000
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt
+++ /dev/null
@@ -1,308 +0,0 @@
-/*
- * 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.root
-
-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.api.oidc.OidcAction
-import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
-import io.element.android.features.login.impl.util.LoginConstants
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
-import io.element.android.libraries.matrix.test.A_HOMESERVER
-import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
-import io.element.android.libraries.matrix.test.A_PASSWORD
-import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.libraries.matrix.test.A_USER_NAME
-import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
-import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class LoginRootPresenterTest {
- @Test
- fun `present - initial state`() = runTest {
- val presenter = LoginRootPresenter(
- FakeAuthenticationService(),
- DefaultOidcActionFlow(),
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
- assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
- assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
- assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
- assertThat(initialState.submitEnabled).isFalse()
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `present - initial state server load`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
- assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
- assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
- assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
- assertThat(initialState.submitEnabled).isFalse()
- val loadingState = awaitItem()
- assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading())
- authenticationService.givenHomeserver(A_HOMESERVER)
- skipItems(1)
- val loadedState = awaitItem()
- assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
- }
- }
-
- @Test
- fun `present - initial state server load error and retry`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
- assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
- assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
- assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
- assertThat(initialState.submitEnabled).isFalse()
- val loadingState = awaitItem()
- assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading())
- val aThrowable = Throwable("Error")
- authenticationService.givenChangeServerError(aThrowable)
- val errorState = awaitItem()
- assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure(aThrowable))
- // Retry
- errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
- val loadingState2 = awaitItem()
- assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading())
- authenticationService.givenChangeServerError(null)
- authenticationService.givenHomeserver(A_HOMESERVER)
- skipItems(1)
- val loadedState = awaitItem()
- assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
- }
- }
-
- @Test
- fun `present - enter login and password`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
- val loginState = awaitItem()
- assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = ""))
- assertThat(loginState.submitEnabled).isFalse()
- initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
- val loginAndPasswordState = awaitItem()
- assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD))
- assertThat(loginAndPasswordState.submitEnabled).isTrue()
- }
- }
-
- @Test
- fun `present - oidc login`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.submitEnabled).isTrue()
- initialState.eventSink.invoke(LoginRootEvents.Submit)
- val oidcState = awaitItem()
- assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
- }
- }
-
- @Test
- fun `present - oidc login error`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
- authenticationService.givenOidcError(A_THROWABLE)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.submitEnabled).isTrue()
- initialState.eventSink.invoke(LoginRootEvents.Submit)
- val oidcState = awaitItem()
- assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
- }
- }
-
- @Test
- fun `present - oidc custom tab login`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.submitEnabled).isTrue()
- initialState.eventSink.invoke(LoginRootEvents.Submit)
- val oidcState = awaitItem()
- assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
- // Oidc cancel, sdk error
- authenticationService.givenOidcCancelError(A_THROWABLE)
- oidcActionFlow.post(OidcAction.GoBack)
- val stateCancelSdkError = awaitItem()
- assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
- // Oidc cancel, sdk OK
- authenticationService.givenOidcCancelError(null)
- oidcActionFlow.post(OidcAction.GoBack)
- val stateCancel = awaitItem()
- assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
- // Oidc success, sdk error
- authenticationService.givenLoginError(A_THROWABLE)
- oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
- val stateSuccessSdkErrorLoading = awaitItem()
- assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn)
- val stateSuccessSdkError = awaitItem()
- assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
- // Oidc success
- authenticationService.givenLoginError(null)
- oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
- val stateSuccess = awaitItem()
- assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn)
- val stateSuccessLoggedIn = awaitItem()
- assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
- }
- }
-
- @Test
- fun `present - submit`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
- initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
- skipItems(1)
- val loginAndPasswordState = awaitItem()
- loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
- val submitState = awaitItem()
- assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
- val loggedInState = awaitItem()
- assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
- }
- }
-
- @Test
- fun `present - submit with error`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
- initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
- skipItems(1)
- val loginAndPasswordState = awaitItem()
- authenticationService.givenLoginError(A_THROWABLE)
- loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
- val submitState = awaitItem()
- assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
- val loggedInState = awaitItem()
- assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
- }
- }
-
- @Test
- fun `present - clear error`() = runTest {
- val authenticationService = FakeAuthenticationService()
- val oidcActionFlow = DefaultOidcActionFlow()
- val presenter = LoginRootPresenter(
- authenticationService,
- oidcActionFlow,
- )
- authenticationService.givenHomeserver(A_HOMESERVER)
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- // Submit will return an error
- authenticationService.givenLoginError(A_THROWABLE)
- initialState.eventSink(LoginRootEvents.Submit)
- awaitItem() // Skip LoggingIn state
-
- // Check an error was returned
- val submittedState = awaitItem()
- assertThat(submittedState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
-
- // Assert the error is then cleared
- submittedState.eventSink(LoginRootEvents.ClearError)
- val clearedState = awaitItem()
- assertThat(clearedState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
- }
- }
-}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
new file mode 100644
index 0000000000..086428257a
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.changeaccountprovider
+
+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.accountprovider.AccountProvider
+import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ChangeAccountProviderPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = ChangeAccountProviderPresenter(
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.accountProviders).isEqualTo(
+ listOf(
+ AccountProvider(
+ title = "matrix.org",
+ subtitle = null,
+ isPublic = true,
+ isMatrixOrg = true,
+ isValid = true,
+ supportSlidingSync = true,
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
new file mode 100644
index 0000000000..131d0d9298
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -0,0 +1,150 @@
+/*
+ * 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.confirmaccountprovider
+
+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.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.util.defaultAccountProvider
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.test.A_HOMESERVER
+import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ConfirmAccountProviderPresenterTest {
+ @Test
+ fun `present - initial test`() = runTest {
+ val presenter = ConfirmAccountProviderPresenter(
+ ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
+ AccountProviderDataSource(),
+ FakeAuthenticationService(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isAccountCreation).isFalse()
+ assertThat(initialState.submitEnabled).isTrue()
+ assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider)
+ assertThat(initialState.loginFlow).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - continue password login`() = runTest {
+ val authServer = FakeAuthenticationService()
+ val presenter = ConfirmAccountProviderPresenter(
+ ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
+ AccountProviderDataSource(),
+ authServer,
+ )
+ authServer.givenHomeserver(A_HOMESERVER)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
+ val loadingState = awaitItem()
+ assertThat(loadingState.submitEnabled).isTrue()
+ assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.submitEnabled).isFalse()
+ assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java)
+ assertThat(successState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.PasswordLogin)
+ }
+ }
+
+ @Test
+ fun `present - continue oidc`() = runTest {
+ val authServer = FakeAuthenticationService()
+ val presenter = ConfirmAccountProviderPresenter(
+ ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
+ AccountProviderDataSource(),
+ authServer,
+ )
+ authServer.givenHomeserver(A_HOMESERVER_OIDC)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
+ val loadingState = awaitItem()
+ assertThat(loadingState.submitEnabled).isTrue()
+ assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.submitEnabled).isFalse()
+ assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java)
+ assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
+ }
+ }
+
+ @Test
+ fun `present - submit fails`() = runTest {
+ val authServer = FakeAuthenticationService()
+ val presenter = ConfirmAccountProviderPresenter(
+ ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
+ AccountProviderDataSource(),
+ authServer,
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ authServer.givenChangeServerError(Throwable())
+ initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
+ skipItems(1) // Loading
+ val failureState = awaitItem()
+ assertThat(failureState.submitEnabled).isFalse()
+ assertThat(failureState.loginFlow).isInstanceOf(Async.Failure::class.java)
+ }
+ }
+
+ @Test
+ fun `present - clear error`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val presenter = ConfirmAccountProviderPresenter(
+ ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
+ AccountProviderDataSource(),
+ authenticationService,
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+
+ // Submit will return an error
+ authenticationService.givenChangeServerError(A_THROWABLE)
+ initialState.eventSink(ConfirmAccountProviderEvents.Continue)
+
+ skipItems(1) // Loading
+
+ // Check an error was returned
+ val submittedState = awaitItem()
+ assertThat(submittedState.loginFlow).isInstanceOf(Async.Failure::class.java)
+
+ // Assert the error is then cleared
+ submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
+ val clearedState = awaitItem()
+ assertThat(clearedState.loginFlow).isEqualTo(Async.Uninitialized)
+ }
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
new file mode 100644
index 0000000000..c4c8a97155
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.loginpassword
+
+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.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.util.defaultAccountProvider
+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_PASSWORD
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.A_USER_NAME
+import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LoginPasswordPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val accountProviderDataSource = AccountProviderDataSource()
+ val presenter = LoginPasswordPresenter(
+ authenticationService,
+ accountProviderDataSource,
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider)
+ assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
+ assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized)
+ assertThat(initialState.submitEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - enter login and password`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val accountProviderDataSource = AccountProviderDataSource()
+ val presenter = LoginPasswordPresenter(
+ authenticationService,
+ accountProviderDataSource,
+ )
+ authenticationService.givenHomeserver(A_HOMESERVER)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ val loginState = awaitItem()
+ assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = ""))
+ assertThat(loginState.submitEnabled).isFalse()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ val loginAndPasswordState = awaitItem()
+ assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD))
+ assertThat(loginAndPasswordState.submitEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - submit`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val accountProviderDataSource = AccountProviderDataSource()
+ val presenter = LoginPasswordPresenter(
+ authenticationService,
+ accountProviderDataSource,
+ )
+ authenticationService.givenHomeserver(A_HOMESERVER)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ skipItems(1)
+ val loginAndPasswordState = awaitItem()
+ loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
+ val submitState = awaitItem()
+ assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
+ val loggedInState = awaitItem()
+ assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID))
+ }
+ }
+
+ @Test
+ fun `present - submit with error`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val accountProviderDataSource = AccountProviderDataSource()
+ val presenter = LoginPasswordPresenter(
+ authenticationService,
+ accountProviderDataSource,
+ )
+ authenticationService.givenHomeserver(A_HOMESERVER)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ skipItems(1)
+ val loginAndPasswordState = awaitItem()
+ authenticationService.givenLoginError(A_THROWABLE)
+ loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
+ val submitState = awaitItem()
+ assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
+ val loggedInState = awaitItem()
+ assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE))
+ }
+ }
+
+ @Test
+ fun `present - clear error`() = runTest {
+ val authenticationService = FakeAuthenticationService()
+ val accountProviderDataSource = AccountProviderDataSource()
+ val presenter = LoginPasswordPresenter(
+ authenticationService,
+ accountProviderDataSource,
+ )
+ authenticationService.givenHomeserver(A_HOMESERVER)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
+ initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
+ skipItems(1)
+ val loginAndPasswordState = awaitItem()
+ authenticationService.givenLoginError(A_THROWABLE)
+ loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
+ val submitState = awaitItem()
+ assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
+ val loggedInState = awaitItem()
+ // Check an error was returned
+ assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE))
+ // Assert the error is then cleared
+ loggedInState.eventSink(LoginPasswordEvents.ClearError)
+ val clearedState = awaitItem()
+ assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
new file mode 100644
index 0000000000..9163f247f5
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
@@ -0,0 +1,195 @@
+/*
+ * 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.searchaccountprovider
+
+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.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
+import io.element.android.features.login.impl.resolver.HomeserverResolver
+import io.element.android.features.login.impl.resolver.network.FakeWellknownRequest
+import io.element.android.features.login.impl.resolver.network.WellKnown
+import io.element.android.features.login.impl.resolver.network.WellKnownBaseConfig
+import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
+import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SearchAccountProviderPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val fakeWellknownRequest = FakeWellknownRequest()
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = SearchAccountProviderPresenter(
+ HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.userInput).isEmpty()
+ assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - enter text no result`() = runTest {
+ val fakeWellknownRequest = FakeWellknownRequest()
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = SearchAccountProviderPresenter(
+ HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
+ val withInputState = awaitItem()
+ assertThat(withInputState.userInput).isEqualTo("test")
+ assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized)
+ assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().userInputResult).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - enter valid url no wellknown`() = runTest {
+ val fakeWellknownRequest = FakeWellknownRequest()
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = SearchAccountProviderPresenter(
+ HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org"))
+ val withInputState = awaitItem()
+ assertThat(withInputState.userInput).isEqualTo("https://test.org")
+ assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized)
+ assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().userInputResult).isEqualTo(
+ Async.Success(
+ listOf(
+ aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = false, supportSlidingSync = false)
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - enter text one result no sliding sync`() = runTest {
+ val fakeWellknownRequest = FakeWellknownRequest()
+ fakeWellknownRequest.givenResultMap(
+ mapOf(
+ "https://test.org" to aWellKnown().copy(slidingSyncProxy = null),
+ )
+ )
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = SearchAccountProviderPresenter(
+ HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
+ val withInputState = awaitItem()
+ assertThat(withInputState.userInput).isEqualTo("test")
+ assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized)
+ assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().userInputResult).isEqualTo(
+ Async.Success(
+ listOf(
+ aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = true, supportSlidingSync = false)
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - enter text one result with sliding sync`() = runTest {
+ val fakeWellknownRequest = FakeWellknownRequest()
+ fakeWellknownRequest.givenResultMap(
+ mapOf(
+ "https://test.io" to aWellKnown(),
+ )
+ )
+ val changeServerPresenter = ChangeServerPresenter(
+ FakeAuthenticationService(),
+ AccountProviderDataSource()
+ )
+ val presenter = SearchAccountProviderPresenter(
+ HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
+ changeServerPresenter
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
+ val withInputState = awaitItem()
+ assertThat(withInputState.userInput).isEqualTo("test")
+ assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized)
+ assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().userInputResult).isEqualTo(
+ Async.Success(
+ listOf(
+ aHomeserverData(homeserverUrl = "https://test.io")
+ )
+ )
+ )
+ }
+ }
+
+ private fun aWellKnown(): WellKnown {
+ return WellKnown(
+ homeServer = WellKnownBaseConfig(
+ baseURL = A_HOMESERVER_URL
+ ),
+ identityServer = WellKnownBaseConfig(
+ baseURL = A_HOMESERVER_URL
+ ),
+ slidingSyncProxy = WellKnownSlidingSyncConfig(
+ url = A_HOMESERVER_URL
+ )
+ )
+ }
+}
diff --git a/features/logout/api/src/main/res/values-cs/translations.xml b/features/logout/api/src/main/res/values-cs/translations.xml
index 31761ee2d9..20be439d90 100644
--- a/features/logout/api/src/main/res/values-cs/translations.xml
+++ b/features/logout/api/src/main/res/values-cs/translations.xml
@@ -5,4 +5,4 @@
"Odhlašování…"
"Odhlásit se"
"Odhlásit se"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/api/src/main/res/values-de/translations.xml
index 5b9fac1031..0cd8ac389a 100644
--- a/features/logout/api/src/main/res/values-de/translations.xml
+++ b/features/logout/api/src/main/res/values-de/translations.xml
@@ -5,4 +5,4 @@
"Abmeldung läuft…"
"Abmelden"
"Abmelden"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/api/src/main/res/values-es/translations.xml
index 8028039235..5ac0656935 100644
--- a/features/logout/api/src/main/res/values-es/translations.xml
+++ b/features/logout/api/src/main/res/values-es/translations.xml
@@ -5,4 +5,4 @@
"Cerrando sesión…"
"Cerrar sesión"
"Cerrar sesión"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values-fr/translations.xml b/features/logout/api/src/main/res/values-fr/translations.xml
index 9d9ad724df..b6d5137072 100644
--- a/features/logout/api/src/main/res/values-fr/translations.xml
+++ b/features/logout/api/src/main/res/values-fr/translations.xml
@@ -5,4 +5,4 @@
"Déconnexion en cours…"
"Se déconnecter"
"Se déconnecter"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/api/src/main/res/values-it/translations.xml
index 351a5e208b..4e8217a7f2 100644
--- a/features/logout/api/src/main/res/values-it/translations.xml
+++ b/features/logout/api/src/main/res/values-it/translations.xml
@@ -5,4 +5,4 @@
"Uscita in corso…"
"Esci"
"Esci"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/api/src/main/res/values-ro/translations.xml
index bb1e36b426..4b2c7fbe7b 100644
--- a/features/logout/api/src/main/res/values-ro/translations.xml
+++ b/features/logout/api/src/main/res/values-ro/translations.xml
@@ -5,4 +5,4 @@
"Deconectare în curs…"
"Deconectați-vă"
"Deconectați-vă"
-
\ No newline at end of file
+
diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml
index 514c002567..9ea4bb77fd 100644
--- a/features/logout/api/src/main/res/values/localazy.xml
+++ b/features/logout/api/src/main/res/values/localazy.xml
@@ -5,4 +5,4 @@
"Signing out…"
"Sign out"
"Sign out"
-
\ No newline at end of file
+
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
index 7a3556389f..bed33006d6 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
@@ -23,7 +23,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutPreferenceEvents
import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
@@ -33,7 +32,7 @@ class LogoutPreferencePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -46,7 +45,7 @@ class LogoutPreferencePresenterTest {
@Test
fun `present - logout`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -62,7 +61,7 @@ class LogoutPreferencePresenterTest {
@Test
fun `present - logout with error`() = runTest {
- val matrixClient = FakeMatrixClient(A_SESSION_ID)
+ val matrixClient = FakeMatrixClient()
val presenter = DefaultLogoutPreferencePresenter(
matrixClient,
)
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
index f1ed5c18dd..482dfad8ea 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
@@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
interface MessagesEntryPoint : FeatureEntryPoint {
@@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId)
+ fun onForwardedToSingleRoom(roomId: RoomId)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt
index 4dbf6d42d9..2d8af99fcd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt
@@ -23,4 +23,5 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents
+ object Dismiss : MessagesEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index ec4d2f31d3..901716451f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -32,17 +32,21 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
+import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
+import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -78,6 +82,12 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
+
+ @Parcelize
+ data class ForwardEvent(val eventId: EventId) : NavTarget
+
+ @Parcelize
+ data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
}
private val callback = plugins().firstOrNull()
@@ -105,6 +115,14 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
+
+ override fun onForwardEventClicked(eventId: EventId) {
+ backstack.push(NavTarget.ForwardEvent(eventId))
+ }
+
+ override fun onReportMessage(eventId: EventId, senderId: UserId) {
+ backstack.push(NavTarget.ReportMessage(eventId, senderId))
+ }
}
createNode(buildContext, listOf(callback))
}
@@ -124,6 +142,19 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode(buildContext, listOf(inputs))
}
+ is NavTarget.ForwardEvent -> {
+ val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
+ val callback = object : ForwardMessagesNode.Callback {
+ override fun onForwardedToSingleRoom(roomId: RoomId) {
+ this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId)
+ }
+ }
+ createNode(buildContext, listOf(inputs, callback))
+ }
+ is NavTarget.ReportMessage -> {
+ val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
+ createNode(buildContext, listOf(inputs))
+ }
}
}
@@ -138,7 +169,7 @@ class MessagesFlowNode @AssistedInject constructor(
fileExtension = event.content.fileExtension
),
mediaSource = event.content.mediaSource,
- thumbnailSource = event.content.mediaSource,
+ thumbnailSource = event.content.thumbnailSource,
)
backstack.push(navTarget)
}
@@ -179,6 +210,7 @@ class MessagesFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
new file mode 100644
index 0000000000..201173a0bf
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
@@ -0,0 +1,27 @@
+/*
+ * 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
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+
+interface MessagesNavigator {
+ fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
+ fun onForwardEventClicked(eventId: EventId)
+ fun onReportContentClicked(eventId: EventId, senderId: UserId)
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index 58d73a10f7..651ae8670b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: MessagesPresenter,
-) : Node(buildContext, plugins = plugins) {
+ private val presenterFactory: MessagesPresenter.Factory,
+) : Node(buildContext, plugins = plugins), MessagesNavigator {
+ private val presenter = presenterFactory.create(this)
private val callback = plugins().firstOrNull()
interface Callback : Plugin {
@@ -48,6 +49,8 @@ class MessagesNode @AssistedInject constructor(
fun onPreviewAttachments(attachments: ImmutableList)
fun onUserDataClicked(userId: UserId)
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
+ fun onForwardEventClicked(eventId: EventId)
+ fun onReportMessage(eventId: EventId, senderId: UserId)
}
private fun onRoomDetailsClicked() {
@@ -65,11 +68,18 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
-
- private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
+ override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
+ override fun onForwardEventClicked(eventId: EventId) {
+ callback?.onForwardEventClicked(eventId)
+ }
+
+ override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
+ callback?.onReportMessage(eventId, senderId)
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -80,7 +90,6 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
- onItemDebugInfoClicked = this::onShowEventDebugInfoClicked,
modifier = modifier,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index de2916a6b4..e305b916cd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -25,6 +25,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+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.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -32,6 +36,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@@ -52,33 +58,46 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
-class MessagesPresenter @Inject constructor(
+class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
+ private val customReactionPresenter: CustomReactionPresenter,
+ private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
+ @Assisted private val navigator: MessagesNavigator,
) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: MessagesNavigator): MessagesPresenter
+ }
+
@Composable
override fun present(): MessagesState {
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()
val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present()
+ val customReactionState = customReactionPresenter.present()
+ val retryState = retrySendMenuPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
+ val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val roomName: MutableState = rememberSaveable {
mutableStateOf(null)
}
@@ -105,17 +124,25 @@ class MessagesPresenter @Inject constructor(
}
fun handleEvents(event: MessagesEvents) {
when (event) {
- is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
- is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId)
+ is MessagesEvents.HandleAction -> {
+ localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
+ }
+ is MessagesEvents.SendReaction -> {
+ localCoroutineScope.sendReaction(event.emoji, event.eventId)
+ }
+ is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
}
}
return MessagesState(
roomId = room.roomId,
roomName = roomName.value,
roomAvatar = roomAvatar.value,
+ userHasPermissionToSendMessage = userHasPermissionToSendMessage,
composerState = composerState,
timelineState = timelineState,
actionListState = actionListState,
+ customReactionState = customReactionState,
+ retrySendMenuState = retryState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
@@ -129,12 +156,12 @@ class MessagesPresenter @Inject constructor(
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
- TimelineItemAction.Forward -> notImplementedYet()
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
- TimelineItemAction.Developer -> Unit // Handled at UI level
- TimelineItemAction.ReportContent -> notImplementedYet()
+ TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
+ TimelineItemAction.Forward -> handleForwardAction(targetEvent)
+ TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
}
}
@@ -204,4 +231,19 @@ class MessagesPresenter @Inject constructor(
MessageComposerEvents.SetMode(composerMode)
)
}
+
+ private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
+ if (event.eventId == null) return
+ navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
+ }
+
+ private fun handleForwardAction(event: TimelineItem.Event) {
+ if (event.eventId == null) return
+ navigator.onForwardEventClicked(event.eventId)
+ }
+
+ private fun handleReportAction(event: TimelineItem.Event) {
+ if (event.eventId == null) return
+ navigator.onReportContentClicked(event.eventId, event.senderId)
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index 8c876ea49c..38c9101c9e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -20,6 +20,8 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@@ -29,9 +31,12 @@ data class MessagesState(
val roomId: RoomId,
val roomName: String?,
val roomAvatar: AvatarData?,
+ val userHasPermissionToSendMessage: Boolean,
val composerState: MessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
+ val customReactionState: CustomReactionState,
+ val retrySendMenuState: RetrySendMenuState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index e37fd11540..7691788e58 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -21,6 +21,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -33,6 +35,7 @@ open class MessagesStateProvider : PreviewParameterProvider {
aMessagesState(),
aMessagesState().copy(hasNetworkConnection = false),
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
+ aMessagesState().copy(userHasPermissionToSendMessage = false),
)
}
@@ -40,6 +43,7 @@ fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"),
roomName = "Room name",
roomAvatar = AvatarData("!id:domain", "Room name"),
+ userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy(
text = StableCharSequence("Hello"),
isFullScreen = false,
@@ -48,7 +52,15 @@ fun aMessagesState() = MessagesState(
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
+ retrySendMenuState = RetrySendMenuState(
+ selectedEvent = null,
+ eventSink = {},
+ ),
actionListState = anActionListState(),
+ customReactionState = CustomReactionState(
+ selectedEventId = null,
+ eventSink = {},
+ ),
hasNetworkConnection = true,
snackbarMessage = null,
eventSink = {}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 542f48e1ae..d9504894e8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -16,7 +16,9 @@
package io.element.android.features.messages.impl
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
@@ -34,39 +36,33 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.SheetState
-import androidx.compose.material3.SheetValue
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.actionlist.ActionListEvents
-import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView
-import io.element.android.features.messages.impl.timeline.components.CustomReactionBottomSheet
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
@@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
-import kotlinx.coroutines.launch
import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR
@@ -99,16 +95,9 @@ fun MessagesView(
onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList) -> Unit,
- onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
modifier: Modifier = Modifier,
) {
- val coroutineScope = rememberCoroutineScope()
- val actionListViewBottomSheetState = rememberModalBottomSheetState()
- val customReactionBottomSheetState = rememberModalBottomSheetState()
-
LogCompositions(tag = "MessagesScreen", msg = "Root")
- var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
- var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
@@ -128,28 +117,14 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
- isMessageActionsBottomSheetVisible = true
- }
-
- suspend fun onDismissActionListBottomSheet() {
- state.actionListState.eventSink(ActionListEvents.Clear)
- actionListViewBottomSheetState.hide()
- isMessageActionsBottomSheetVisible = false
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
- coroutineScope.launch { onDismissActionListBottomSheet() }
- when (action) {
- is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) {
- onItemDebugInfoClicked(event.eventId, event.debugInfo)
- }
- else -> state.eventSink(MessagesEvents.HandleAction(action, event))
- }
+ state.eventSink(MessagesEvents.HandleAction(action, event))
}
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return
- coroutineScope.launch { onDismissActionListBottomSheet() }
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId))
}
@@ -176,6 +151,11 @@ fun MessagesView(
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
+ onTimestampClicked = { event ->
+ if (event.sendState is EventSendState.SendingFailed) {
+ state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
+ }
+ }
)
},
snackbarHost = {
@@ -186,45 +166,28 @@ fun MessagesView(
},
)
- var reactingToEventId: EventId? by remember { mutableStateOf(null) }
ActionListView(
state = state.actionListState,
- sheetState = actionListViewBottomSheetState,
- isVisible = isMessageActionsBottomSheetVisible,
- onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } },
onActionSelected = ::onActionSelected,
onCustomReactionClicked = { event ->
- reactingToEventId = event.eventId
- coroutineScope.launch {
- onDismissActionListBottomSheet()
- isCustomReactionBottomSheetVisible = true
- }
+ state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
},
onEmojiReactionClicked = ::onEmojiReactionClicked,
)
CustomReactionBottomSheet(
- isVisible = isCustomReactionBottomSheetVisible,
- sheetState = customReactionBottomSheetState,
- onDismiss = {
- reactingToEventId = null
- coroutineScope.launch {
- customReactionBottomSheetState.hide()
- isCustomReactionBottomSheetVisible = false
- }
- },
+ state = state.customReactionState,
onEmojiSelected = { emoji ->
- val eventId = reactingToEventId
- if (eventId != null) {
+ state.customReactionState.selectedEventId?.let { eventId ->
state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId))
- reactingToEventId = null
- coroutineScope.launch {
- customReactionBottomSheetState.hide()
- isCustomReactionBottomSheetVisible = false
- }
+ state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
}
}
)
+
+ RetrySendMessageMenu(
+ state = state.retrySendMenuState
+ )
}
@Composable
@@ -244,10 +207,11 @@ private fun AttachmentStateView(
@Composable
fun MessagesViewContent(
state: MessagesState,
+ onMessageClicked: (TimelineItem.Event) -> Unit,
+ onUserDataClicked: (UserId) -> Unit,
+ onMessageLongClicked: (TimelineItem.Event) -> Unit,
+ onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
- onMessageClicked: (TimelineItem.Event) -> Unit = {},
- onUserDataClicked: (UserId) -> Unit = {},
- onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
Column(
modifier = modifier
@@ -263,14 +227,19 @@ fun MessagesViewContent(
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
+ onTimestampClicked = onTimestampClicked,
)
}
- MessageComposerView(
- state = state.composerState,
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight(Alignment.Bottom)
- )
+ if (state.userHasPermissionToSendMessage) {
+ MessageComposerView(
+ state = state.composerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(Alignment.Bottom)
+ )
+ } else {
+ CantSendMessageBanner()
+ }
}
}
@@ -315,6 +284,28 @@ fun MessagesViewTopBar(
)
}
+@Composable
+fun CantSendMessageBanner(
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.secondary)
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringResource(id = R.string.screen_room_no_permission_to_post),
+ color = MaterialTheme.colorScheme.onSecondary,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ fontStyle = FontStyle.Italic,
+ )
+ }
+}
+
@Preview
@Composable
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
@@ -334,6 +325,5 @@ private fun ContentToPreview(state: MessagesState) {
onEventClicked = {},
onPreviewAttachments = {},
onUserDataClicked = {},
- onItemDebugInfoClicked = { _, _ -> },
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
index 27ec05fb51..4a88a23fa2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
@@ -42,7 +42,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -74,6 +77,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
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.hide
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@@ -82,37 +86,51 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@Composable
fun ActionListView(
state: ActionListState,
- isVisible: Boolean,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit,
onCustomReactionClicked: (TimelineItem.Event) -> Unit,
- onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState()
) {
+ val coroutineScope = rememberCoroutineScope()
val targetItem = (state.target as? ActionListState.Target.Success)?.event
fun onItemActionClicked(
itemAction: TimelineItemAction
) {
if (targetItem == null) return
- onActionSelected(itemAction, targetItem)
+ sheetState.hide(coroutineScope) {
+ state.eventSink(ActionListEvents.Clear)
+ onActionSelected(itemAction, targetItem)
+ }
}
fun onEmojiReactionClicked(emoji: String) {
if (targetItem == null) return
- onEmojiReactionClicked(emoji, targetItem)
+ sheetState.hide(coroutineScope) {
+ state.eventSink(ActionListEvents.Clear)
+ onEmojiReactionClicked(emoji, targetItem)
+ }
}
fun onCustomReactionClicked() {
if (targetItem == null) return
- onCustomReactionClicked(targetItem)
+ sheetState.hide(coroutineScope) {
+ state.eventSink(ActionListEvents.Clear)
+ onCustomReactionClicked(targetItem)
+ }
}
- if (isVisible) {
+ fun onDismiss() {
+ sheetState.hide(coroutineScope) {
+ state.eventSink(ActionListEvents.Clear)
+ }
+ }
+
+ if (targetItem != null) {
ModalBottomSheet(
sheetState = sheetState,
- onDismissRequest = onDismiss
+ onDismissRequest = ::onDismiss,
) {
SheetContent(
state = state,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt
new file mode 100644
index 0000000000..6b74918d71
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt
@@ -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.messages.impl.forward
+
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+
+sealed interface ForwardMessagesEvents {
+ data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
+ // TODO remove to restore multi-selection
+ object RemoveSelectedRoom : ForwardMessagesEvents
+ object ToggleSearchActive : ForwardMessagesEvents
+ data class UpdateQuery(val query: String) : ForwardMessagesEvents
+ object ForwardEvent : ForwardMessagesEvents
+ object ClearError : ForwardMessagesEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt
new file mode 100644
index 0000000000..13d26b9881
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.forward
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+
+@ContributesNode(RoomScope::class)
+class ForwardMessagesNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: ForwardMessagesPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onForwardedToSingleRoom(roomId: RoomId)
+ }
+
+ data class Inputs(val eventId: EventId) : NodeInputs
+
+ private val inputs = inputs()
+ private val presenter = presenterFactory.create(inputs.eventId.value)
+ private val callbacks = plugins.filterIsInstance()
+
+ private fun onSucceeded(roomIds: ImmutableList) {
+ navigateUp()
+ if (roomIds.size == 1) {
+ val targetRoomId = roomIds.first()
+ callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ForwardMessagesView(
+ state = state,
+ onDismiss = ::navigateUp,
+ onForwardingSucceeded = ::onSucceeded,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
new file mode 100644
index 0000000000..17494f892e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.forward
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.isLoading
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.RoomSummary
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class ForwardMessagesPresenter @AssistedInject constructor(
+ @Assisted eventId: String,
+ private val room: MatrixRoom,
+ private val matrixCoroutineScope: CoroutineScope,
+ private val client: MatrixClient,
+) : Presenter {
+
+ private val eventId: EventId = EventId(eventId)
+
+ @AssistedFactory
+ interface Factory {
+ fun create(eventId: String): ForwardMessagesPresenter
+ }
+
+ @Composable
+ override fun present(): ForwardMessagesState {
+ var selectedRooms by remember { mutableStateOf(persistentListOf()) }
+ var query by remember { mutableStateOf("") }
+ var isSearchActive by remember { mutableStateOf(false) }
+ var results: SearchBarResultState> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
+ val forwardingActionState: MutableState>> = remember { mutableStateOf(Async.Uninitialized) }
+
+ val summaries by client.roomSummaryDataSource.roomSummaries().collectAsState()
+
+ LaunchedEffect(query, summaries) {
+ val filteredSummaries = summaries.filterIsInstance()
+ .map { it.details }
+ .filter { it.name.contains(query, ignoreCase = true) }
+ .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
+ .toPersistentList()
+ results = if (filteredSummaries.isNotEmpty()) {
+ SearchBarResultState.Results(filteredSummaries)
+ } else {
+ SearchBarResultState.NoResults()
+ }
+ }
+
+ val forwardingSucceeded by remember {
+ derivedStateOf { forwardingActionState.value.dataOrNull() }
+ }
+
+ fun handleEvents(event: ForwardMessagesEvents) {
+ when (event) {
+ is ForwardMessagesEvents.SetSelectedRoom -> {
+ selectedRooms = persistentListOf(event.room)
+ // Restore for multi-selection
+// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
+// selectedRooms = if (index >= 0) {
+// selectedRooms.removeAt(index)
+// } else {
+// selectedRooms.add(event.room)
+// }
+ }
+ ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
+ is ForwardMessagesEvents.UpdateQuery -> query = event.query
+ ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
+ ForwardMessagesEvents.ForwardEvent -> {
+ isSearchActive = false
+ val roomIds = selectedRooms.map { it.roomId }.toPersistentList()
+ matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState)
+ }
+ ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
+ }
+ }
+
+ return ForwardMessagesState(
+ resultState = results,
+ query = query,
+ isSearchActive = isSearchActive,
+ selectedRooms = selectedRooms,
+ isForwarding = forwardingActionState.value.isLoading(),
+ error = (forwardingActionState.value as? Async.Failure)?.error,
+ forwardingSucceeded = forwardingSucceeded,
+ eventSink = { handleEvents(it) }
+ )
+ }
+
+ private fun CoroutineScope.forwardEvent(
+ eventId: EventId,
+ roomIds: ImmutableList,
+ isForwardMessagesState: MutableState>>,
+ ) = launch {
+ isForwardMessagesState.value = Async.Loading()
+ room.forwardEvent(eventId, roomIds).fold(
+ { isForwardMessagesState.value = Async.Success(roomIds) },
+ { isForwardMessagesState.value = Async.Failure(it) }
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt
new file mode 100644
index 0000000000..7540766097
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt
@@ -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.messages.impl.forward
+
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+import kotlinx.collections.immutable.ImmutableList
+
+data class ForwardMessagesState(
+ val resultState: SearchBarResultState>,
+ val query: String,
+ val isSearchActive: Boolean,
+ val selectedRooms: ImmutableList,
+ val isForwarding: Boolean,
+ val error: Throwable?,
+ val forwardingSucceeded: ImmutableList?,
+ val eventSink: (ForwardMessagesEvents) -> Unit
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt
new file mode 100644
index 0000000000..75aacea616
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.forward
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+import io.element.android.libraries.matrix.api.room.message.RoomMessage
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+open class ForwardMessagesStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aForwardMessagesState(),
+ aForwardMessagesState(query = "Test"),
+ aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
+ aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"),
+ aForwardMessagesState(
+ resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ query = "Test",
+ selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain")))
+ ),
+ aForwardMessagesState(
+ resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ query = "Test",
+ selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
+ isForwarding = true,
+ ),
+ aForwardMessagesState(
+ resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ query = "Test",
+ selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
+ forwardingSucceeded = persistentListOf(RoomId("!room2:domain")),
+ ),
+ aForwardMessagesState(
+ resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
+ query = "Test",
+ selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
+ error = Throwable("error"),
+ ),
+ // Add other states here
+ )
+}
+
+fun aForwardMessagesState(
+ resultState: SearchBarResultState> = SearchBarResultState.NotSearching(),
+ query: String = "",
+ isSearchActive: Boolean = false,
+ selectedRooms: ImmutableList = persistentListOf(),
+ isForwarding: Boolean = false,
+ error: Throwable? = null,
+ forwardingSucceeded: ImmutableList? = null,
+) = ForwardMessagesState(
+ resultState = resultState,
+ query = query,
+ isSearchActive = isSearchActive,
+ selectedRooms = selectedRooms,
+ isForwarding = isForwarding,
+ error = error,
+ forwardingSucceeded = forwardingSucceeded,
+ eventSink = {}
+)
+
+internal fun aForwardMessagesRoomList() = listOf(
+ aRoomDetailsState(),
+ aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"),
+)
+
+fun aRoomDetailsState(
+ roomId: RoomId = RoomId("!room:domain"),
+ name: String = "roomName",
+ canonicalAlias: String? = null,
+ isDirect: Boolean = true,
+ avatarURLString: String? = null,
+ lastMessage: RoomMessage? = null,
+ lastMessageTimestamp: Long? = null,
+ unreadNotificationCount: Int = 0,
+ inviter: RoomMember? = null,
+) = RoomSummaryDetails(
+ roomId = roomId,
+ name = name,
+ canonicalAlias = canonicalAlias,
+ isDirect = isDirect,
+ avatarURLString = avatarURLString,
+ lastMessage = lastMessage,
+ lastMessageTimestamp = lastMessageTimestamp,
+ unreadNotificationCount = unreadNotificationCount,
+ inviter = inviter,
+ )
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt
new file mode 100644
index 0000000000..329aff2881
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt
@@ -0,0 +1,292 @@
+/*
+ * 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.forward
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.element.android.libraries.designsystem.ElementTextStyles
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
+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.Divider
+import io.element.android.libraries.designsystem.theme.components.RadioButton
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.SearchBar
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.roomListRoomMessage
+import io.element.android.libraries.designsystem.theme.roomListRoomName
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+import io.element.android.libraries.matrix.ui.components.SelectedRoom
+import kotlinx.collections.immutable.ImmutableList
+import io.element.android.libraries.ui.strings.R as StringR
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun ForwardMessagesView(
+ state: ForwardMessagesState,
+ onDismiss: () -> Unit,
+ onForwardingSucceeded: (ImmutableList) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (state.forwardingSucceeded != null) {
+ onForwardingSucceeded(state.forwardingSucceeded)
+ return
+ }
+
+ fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
+ // TODO toggle selection when multi-selection is enabled
+ state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
+ }
+
+ @Composable
+ fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList) {
+ if (isForwarding) return
+ SelectedRooms(
+ selectedRooms = selectedRooms,
+ onRoomRemoved = ::onRoomRemoved,
+ modifier = Modifier.padding(vertical = 16.dp)
+ )
+ }
+
+ fun onBackButton(state: ForwardMessagesState) {
+ if (state.isSearchActive) {
+ state.eventSink(ForwardMessagesEvents.ToggleSearchActive)
+ } else {
+ onDismiss()
+ }
+ }
+
+ BackHandler(onBack = { onBackButton(state) })
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) },
+ navigationIcon = {
+ BackButton(onClick = { onBackButton(state) })
+ },
+ actions = {
+ TextButton(
+ enabled = state.selectedRooms.isNotEmpty(),
+ onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) }
+ ) {
+ Text(text = stringResource(StringR.string.action_send))
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ ) {
+ SearchBar>(
+ placeHolderTitle = stringResource(StringR.string.action_search),
+ query = state.query,
+ onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
+ active = state.isSearchActive,
+ onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) },
+ resultState = state.resultState,
+ showBackButton = false,
+ ) { summaries ->
+ LazyColumn {
+ item {
+ SelectedRoomsHelper(
+ isForwarding = state.isForwarding,
+ selectedRooms = state.selectedRooms
+ )
+ }
+ items(summaries, key = { it.roomId.value }) { roomSummary ->
+ Column {
+ RoomSummaryView(
+ roomSummary,
+ isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
+ onSelection = { roomSummary ->
+ state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
+ }
+ )
+ Divider(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ }
+ }
+
+ if (!state.isSearchActive) {
+ // TODO restore for multi-selection
+// SelectedRoomsHelper(
+// isForwarding = state.isForwarding,
+// selectedRooms = state.selectedRooms
+// )
+ Spacer(modifier = Modifier.height(20.dp))
+
+ if (state.resultState is SearchBarResultState.Results) {
+ LazyColumn {
+ items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
+ Column {
+ RoomSummaryView(
+ roomSummary,
+ isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
+ onSelection = { roomSummary ->
+ state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
+ }
+ )
+ Divider(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ }
+ }
+ }
+
+ if (state.isForwarding) {
+ ProgressDialog()
+ }
+
+ if (state.error != null) {
+ ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) })
+ }
+ }
+ }
+}
+
+@Composable
+internal fun SelectedRooms(
+ selectedRooms: ImmutableList,
+ onRoomRemoved: (RoomSummaryDetails) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyRow(
+ modifier,
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(32.dp)
+ ) {
+ items(selectedRooms, key = { it.roomId.value }) { roomSummary ->
+ SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved)
+ }
+ }
+}
+
+@Composable
+internal fun RoomSummaryView(
+ summary: RoomSummaryDetails,
+ isSelected: Boolean,
+ onSelection: (RoomSummaryDetails) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .clickable { onSelection(summary) }
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val roomAlias = summary.canonicalAlias ?: summary.roomId.value
+ Avatar(
+ avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString),
+ )
+ Column(
+ modifier = Modifier
+ .padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
+ .alignByBaseline()
+ .weight(1f)
+ ) {
+ // Name
+ Text(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ text = summary.name,
+ color = MaterialTheme.roomListRoomName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ // Id
+ Text(
+ text = roomAlias,
+ color = MaterialTheme.roomListRoomMessage(),
+ fontSize = 14.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ RadioButton(selected = isSelected, onClick = { onSelection(summary) })
+ }
+}
+
+@Composable
+private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
+ ErrorDialog(
+ content = ErrorDialogDefaults.title,
+ onDismiss = onDismiss,
+ modifier = modifier,
+ )
+}
+
+@Preview
+@Composable
+fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ForwardMessagesState) {
+ ForwardMessagesView(
+ state = state,
+ onDismiss = {},
+ onForwardingSucceeded = {}
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt
index afbb9bb331..9cc1ed69fd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt
@@ -54,9 +54,11 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaView
+import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
+import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -140,6 +142,7 @@ fun MediaViewerView(
mediaInfo = state.mediaInfo,
)
ThumbnailView(
+ mediaInfo = state.mediaInfo,
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
)
@@ -211,6 +214,7 @@ private fun MediaViewerTopBar(
private fun ThumbnailView(
thumbnailSource: MediaSource?,
showThumbnail: Boolean,
+ mediaInfo: MediaInfo,
) {
AnimatedVisibility(
visible = showThumbnail,
@@ -223,7 +227,7 @@ private fun ThumbnailView(
) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
- kind = MediaRequestData.Kind.Content
+ kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType)
)
AsyncImage(
modifier = Modifier.fillMaxSize(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt
new file mode 100644
index 0000000000..ed5ee029e7
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.report
+
+sealed interface ReportMessageEvents {
+ data class UpdateReason(val reason: String) : ReportMessageEvents
+ object ToggleBlockUser : ReportMessageEvents
+ object Report : ReportMessageEvents
+ object ClearError : ReportMessageEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt
new file mode 100644
index 0000000000..1be4571161
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.report
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UserId
+
+@ContributesNode(RoomScope::class)
+class ReportMessageNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: ReportMessagePresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+
+ data class Inputs(
+ val eventId: EventId,
+ val senderId: UserId,
+ ) : NodeInputs
+
+ private val inputs = inputs()
+
+ private val presenter = presenterFactory.create(
+ ReportMessagePresenter.Inputs(inputs.eventId, inputs.senderId)
+ )
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ReportMessageView(
+ state = state,
+ onBackClicked = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt
new file mode 100644
index 0000000000..09cad8e33b
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.report
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.executeResult
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.SnackbarMessage
+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.room.MatrixRoom
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import io.element.android.libraries.ui.strings.R as StringR
+
+class ReportMessagePresenter @AssistedInject constructor(
+ private val room: MatrixRoom,
+ @Assisted private val inputs: Inputs,
+ private val snackbarDispatcher: SnackbarDispatcher,
+) : Presenter {
+
+ data class Inputs(
+ val eventId: EventId,
+ val senderId: UserId,
+ )
+
+ @AssistedFactory
+ interface Factory {
+ fun create(inputs: Inputs): ReportMessagePresenter
+ }
+
+ @Composable
+ override fun present(): ReportMessageState {
+ val coroutineScope = rememberCoroutineScope()
+ var reason by rememberSaveable { mutableStateOf("") }
+ var blockUser by rememberSaveable { mutableStateOf(false) }
+ var result: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+
+ fun handleEvents(event: ReportMessageEvents) {
+ when (event) {
+ is ReportMessageEvents.UpdateReason -> reason = event.reason
+ ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser
+ ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result)
+ ReportMessageEvents.ClearError -> result.value = Async.Uninitialized
+ }
+ }
+
+ return ReportMessageState(
+ reason = reason,
+ blockUser = blockUser,
+ result = result.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.report(
+ eventId: EventId,
+ userId: UserId,
+ reason: String,
+ blockUser: Boolean,
+ result: MutableState>,
+ ) = launch {
+ suspend {
+ val userIdToBlock = userId.takeIf { blockUser }
+ room.reportContent(eventId, reason, userIdToBlock)
+ .onSuccess {
+ snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted))
+ }
+ }.executeResult(result)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt
new file mode 100644
index 0000000000..809668c88f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.report
+
+import io.element.android.libraries.architecture.Async
+
+data class ReportMessageState(
+ val reason: String,
+ val blockUser: Boolean,
+ val result: Async,
+ val eventSink: (ReportMessageEvents) -> Unit
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt
new file mode 100644
index 0000000000..89e6d7a220
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt
@@ -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.messages.impl.report
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.Async
+
+open class ReportMessageStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aReportMessageState(),
+ aReportMessageState(reason = "This user is making the chat very toxic."),
+ aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
+ aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()),
+ aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable())),
+ aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)),
+ // Add other states here
+ )
+}
+
+fun aReportMessageState(
+ reason: String = "",
+ blockUser: Boolean = false,
+ result: Async = Async.Uninitialized,
+) = ReportMessageState(
+ reason = reason,
+ blockUser = blockUser,
+ result = result,
+ eventSink = {}
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt
new file mode 100644
index 0000000000..1beee54519
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt
@@ -0,0 +1,186 @@
+/*
+ * 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.report
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+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 io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.ElementTextStyles
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+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.OutlinedTextField
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.R as StringR
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun ReportMessageView(
+ state: ReportMessageState,
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+ val isSending = state.result is Async.Loading
+ when (state.result) {
+ is Async.Success -> {
+ LaunchedEffect(state.result) {
+ onBackClicked()
+ }
+ return
+ }
+ is Async.Failure -> {
+ ErrorDialog(
+ content = stringResource(StringR.string.error_unknown),
+ onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
+ )
+ }
+ else -> Unit
+ }
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ stringResource(StringR.string.action_report_content),
+ style = ElementTextStyles.Regular.callout,
+ fontWeight = FontWeight.Medium,
+ )
+ },
+ navigationIcon = {
+ BackButton(onClick = onBackClicked)
+ }
+ )
+ },
+ modifier = modifier
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .imePadding()
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(20.dp))
+
+ OutlinedTextField(
+ value = state.reason,
+ onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
+ placeholder = { Text(stringResource(StringR.string.report_content_hint)) },
+ enabled = !isSending,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 90.dp)
+ )
+ Text(
+ text = stringResource(StringR.string.report_content_explanation),
+ style = ElementTextStyles.Regular.caption1,
+ color = MaterialTheme.colorScheme.secondary,
+ textAlign = TextAlign.Start,
+ modifier = Modifier.padding(top = 4.dp, bottom = 24.dp, start = 16.dp, end = 16.dp)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = stringResource(StringR.string.screen_report_content_block_user),
+ style = ElementTextStyles.Regular.callout,
+ )
+ Text(
+ text = stringResource(StringR.string.screen_report_content_block_user_hint),
+ style = ElementTextStyles.Regular.bodyMD,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+ Switch(
+ enabled = !isSending,
+ checked = state.blockUser,
+ onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ ButtonWithProgress(
+ text = stringResource(StringR.string.action_send),
+ enabled = state.reason.isNotBlank() && !isSending,
+ showProgress = isSending,
+ onClick = {
+ focusManager.clearFocus(force = true)
+ state.eventSink(ReportMessageEvents.Report)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ReportMessageViewLightPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun ReportMessageViewDarkPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ReportMessageState) {
+ ReportMessageView(
+ onBackClicked = {},
+ state = state,
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index 47e09fc577..c170886672 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -94,6 +94,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
+ transactionId: String? = null,
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@@ -104,6 +105,7 @@ internal fun aTimelineItemEvent(
return TimelineItem.Event(
id = eventId.value,
eventId = eventId,
+ transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"),
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index 4c6416ec67..c73cce50f4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -68,10 +68,11 @@ import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
+ onUserDataClicked: (UserId) -> Unit,
+ onMessageClicked: (TimelineItem.Event) -> Unit,
+ onMessageLongClicked: (TimelineItem.Event) -> Unit,
+ onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
- onUserDataClicked: (UserId) -> Unit = {},
- onMessageClicked: (TimelineItem.Event) -> Unit = {},
- onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
@@ -102,6 +103,7 @@ fun TimelineView(
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
+ onTimestampClicked = onTimestampClicked,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@@ -125,6 +127,7 @@ fun TimelineItemRow(
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
+ onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
@@ -159,6 +162,7 @@ fun TimelineItemRow(
onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
+ onTimestampClicked = onTimestampClicked,
modifier = modifier,
)
}
@@ -191,6 +195,7 @@ fun TimelineItemRow(
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
+ onTimestampClicked = onTimestampClicked,
)
}
}
@@ -276,6 +281,10 @@ fun TimelineViewDarkPreview(
private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = aTimelineItemList(content)
TimelineView(
- state = aTimelineState(timelineItems)
+ state = aTimelineState(timelineItems),
+ onMessageClicked = {},
+ onTimestampClicked = {},
+ onUserDataClicked = {},
+ onMessageLongClicked = {},
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt
index ca96df7e7d..8c1d5e2f31 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt
@@ -32,8 +32,6 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.ripple.rememberRipple
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.SheetState
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.runtime.Composable
@@ -49,26 +47,9 @@ import com.vanniktech.emoji.google.GoogleEmojiProvider
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.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun CustomReactionBottomSheet(
- isVisible: Boolean,
- sheetState: SheetState,
- onDismiss: () -> Unit,
- onEmojiSelected: (Emoji) -> Unit,
- modifier: Modifier = Modifier,
-) {
- if (isVisible) {
- ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
- EmojiPicker(onEmojiSelected = onEmojiSelected, modifier = Modifier.fillMaxSize())
- }
- }
-}
-
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EmojiPicker(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
index ed4febf982..530f62a188 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
@@ -17,12 +17,15 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -54,7 +57,15 @@ fun TimelineEventTimestampView(
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(
- modifier = modifier.clickable(onClick = onClick),
+ modifier = Modifier
+ .clickable(
+ onClick = onClick,
+ enabled = true,
+ indication = rememberRipple(bounded = false),
+ interactionSource = MutableInteractionSource()
+ )
+ .padding(start = 16.dp) // Add extra padding for touch target size
+ .then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index f84a899c6b..285b5cab6b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -75,6 +75,7 @@ fun TimelineItemEventRow(
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
+ onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
@@ -83,7 +84,7 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId)
}
- fun inReplayToClicked() {
+ fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
@@ -131,7 +132,10 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
- inReplyToClick = ::inReplayToClicked,
+ inReplyToClick = ::inReplyToClicked,
+ onTimestampClicked = {
+ onTimestampClicked(event)
+ }
)
}
TimelineItemReactionsView(
@@ -177,6 +181,7 @@ private fun MessageEventBubbleContent(
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
+ onTimestampClicked: () -> Unit,
modifier: Modifier = Modifier
) {
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
@@ -207,7 +212,7 @@ private fun MessageEventBubbleContent(
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
- onClick = onMessageClick,
+ onClick = onTimestampClicked,
modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
@@ -220,7 +225,7 @@ private fun MessageEventBubbleContent(
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
TimelineEventTimestampView(
event = event,
- onClick = onMessageClick,
+ onClick = onTimestampClicked,
modifier = timestampModifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)
@@ -243,7 +248,7 @@ private fun MessageEventBubbleContent(
) {
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
if (inReplyToDetails != null) {
- val senderName = event.senderDisplayName ?: event.senderId.value
+ val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
ReplyToContent(
senderName = senderName,
@@ -251,6 +256,7 @@ private fun MessageEventBubbleContent(
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
+ .clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
)
}
@@ -295,7 +301,6 @@ private fun ReplyToContent(
}
Row(
modifier
- .clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt
new file mode 100644
index 0000000000..6ea290cdb8
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.customreaction
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import com.vanniktech.emoji.Emoji
+import io.element.android.features.messages.impl.timeline.components.EmojiPicker
+import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
+import io.element.android.libraries.designsystem.theme.components.hide
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomReactionBottomSheet(
+ state: CustomReactionState,
+ onEmojiSelected: (Emoji) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState()
+ val coroutineScope = rememberCoroutineScope()
+
+ fun onDismiss() {
+ sheetState.hide(coroutineScope) {
+ state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
+ }
+ }
+
+ fun onEmojiSelectedDismiss(emoji: Emoji) {
+ sheetState.hide(coroutineScope) {
+ state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
+ onEmojiSelected(emoji)
+ }
+ }
+
+ val isVisible = state.selectedEventId != null
+ if (isVisible) {
+ ModalBottomSheet(
+ onDismissRequest = ::onDismiss,
+ sheetState = sheetState,
+ modifier = modifier
+ ) {
+ EmojiPicker(
+ onEmojiSelected = ::onEmojiSelectedDismiss,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt
new file mode 100644
index 0000000000..b7c210553e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt
@@ -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.messages.impl.timeline.components.customreaction
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+sealed interface CustomReactionEvents {
+ data class UpdateSelectedEvent(val eventId: EventId?) : CustomReactionEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
new file mode 100644
index 0000000000..0a23d42085
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
@@ -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.messages.impl.timeline.components.customreaction
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.core.EventId
+import javax.inject.Inject
+
+class CustomReactionPresenter @Inject constructor() : Presenter {
+
+ @Composable
+ override fun present(): CustomReactionState {
+ var selectedEventId by remember { mutableStateOf(null) }
+
+ fun handleEvents(event: CustomReactionEvents) {
+ when (event) {
+ is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId
+ }
+ }
+
+ return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt
new file mode 100644
index 0000000000..6c0c7f3599
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components.customreaction
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+data class CustomReactionState(
+ val selectedEventId: EventId?,
+ val eventSink: (CustomReactionEvents) -> Unit,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
index 043019cc7f..f733913bc8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
@@ -24,22 +24,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import kotlin.math.min
+
+private const val MAX_HEIGHT_IN_DP = 360f
+private const val MIN_ASPECT_RATIO = 0.6f
+private const val MAX_ASPECT_RATIO = 4f
+private const val DEFAULT_ASPECT_RATIO = 1.33f
@Composable
fun TimelineItemAspectRatioBox(
- height: Int?,
- aspectRatio: Float,
+ aspectRatio: Float?,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
- content: @Composable BoxScope.() -> Unit,
+ content: @Composable (BoxScope.() -> Unit),
) {
- // TODO should probably be moved to an ElementTheme.dimensions
- val maxHeight = min(300, height ?: 0)
+ val safeAspectRatio = (aspectRatio ?: DEFAULT_ASPECT_RATIO).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
Box(
modifier = modifier
- .heightIn(max = maxHeight.dp)
- .aspectRatio(aspectRatio, matchHeightConstraintsFirst = true),
+ .heightIn(max = MAX_HEIGHT_IN_DP.dp)
+ .aspectRatio(safeAspectRatio, true),
contentAlignment = contentAlignment,
content = content
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
index 566e899a36..6c7b51ddfa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
@@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -28,25 +27,20 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
-import kotlin.math.max
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
modifier: Modifier = Modifier,
) {
- // TODO place this value somewhere else?
- val minHeight = max(100, content.height ?: 0)
TimelineItemAspectRatioBox(
- height = minHeight,
aspectRatio = content.aspectRatio,
modifier = modifier
) {
BlurHashAsyncImage(
- model = MediaRequestData(content.mediaSource, MediaRequestData.Kind.Content),
+ model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurhash,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Fit,
+ contentScale = ContentScale.Crop,
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
index aa024e1033..aeb2e7145e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
@@ -43,16 +43,14 @@ fun TimelineItemVideoView(
modifier: Modifier = Modifier,
) {
TimelineItemAspectRatioBox(
- height = content.height,
aspectRatio = content.aspectRatio,
modifier = modifier,
contentAlignment = Alignment.Center,
) {
BlurHashAsyncImage(
- model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.Content),
+ model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurHash,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Fit,
+ contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier.roundedBackground(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt
new file mode 100644
index 0000000000..ab6e32f078
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.retrysendmenu
+
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+
+sealed interface RetrySendMenuEvents {
+ data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
+ object RetrySend : RetrySendMenuEvents
+ object RemoveFailed : RetrySendMenuEvents
+ object Dismiss: RetrySendMenuEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
new file mode 100644
index 0000000000..237dc5683d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
@@ -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.messages.impl.timeline.components.retrysendmenu
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class RetrySendMenuPresenter @Inject constructor(
+ private val room: MatrixRoom,
+) : Presenter {
+
+ @Composable
+ override fun present(): RetrySendMenuState {
+ val coroutineScope = rememberCoroutineScope()
+ var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
+
+ fun handleEvent(event: RetrySendMenuEvents) {
+ when (event) {
+ is RetrySendMenuEvents.EventSelected -> {
+ selectedEvent = event.event
+ }
+ RetrySendMenuEvents.RetrySend -> {
+ coroutineScope.launch {
+ selectedEvent?.transactionId?.let { transactionId ->
+ room.retrySendMessage(transactionId)
+ }
+ selectedEvent = null
+ }
+ }
+ RetrySendMenuEvents.RemoveFailed -> {
+ coroutineScope.launch {
+ selectedEvent?.transactionId?.let { transactionId ->
+ room.cancelSend(transactionId)
+ }
+ selectedEvent = null
+ }
+ }
+ RetrySendMenuEvents.Dismiss -> {
+ selectedEvent = null
+ }
+ }
+ }
+
+ return RetrySendMenuState(
+ selectedEvent = selectedEvent,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt
new file mode 100644
index 0000000000..e10e9c752c
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.retrysendmenu
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+
+@Immutable
+data class RetrySendMenuState(
+ val selectedEvent: TimelineItem.Event?,
+ val eventSink: (RetrySendMenuEvents) -> Unit,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt
new file mode 100644
index 0000000000..ccb5c26982
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.retrysendmenu
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+
+class RetrySendMenuStateProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ aRetrySendMenuState(event = null),
+ aRetrySendMenuState(event = aTimelineItemEvent()),
+ )
+}
+
+fun aRetrySendMenuState(event: TimelineItem.Event? = aTimelineItemEvent()) =
+ RetrySendMenuState(selectedEvent = event, eventSink = {})
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt
new file mode 100644
index 0000000000..9d8c8283ca
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt
@@ -0,0 +1,168 @@
+/*
+ * 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.retrysendmenu
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.LocalColors
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.features.messages.impl.R
+import kotlinx.coroutines.launch
+
+@Composable
+internal fun RetrySendMessageMenu(
+ state: RetrySendMenuState,
+ modifier: Modifier = Modifier,
+) {
+ val isVisible = state.selectedEvent != null
+
+ fun onDismiss() {
+ state.eventSink(RetrySendMenuEvents.Dismiss)
+ }
+
+ fun onRetry() {
+ state.eventSink(RetrySendMenuEvents.RetrySend)
+ }
+
+ fun onRemoveFailed() {
+ state.eventSink(RetrySendMenuEvents.RemoveFailed)
+ }
+
+ RetrySendMessageMenuBottomSheet(
+ modifier = modifier,
+ isVisible = isVisible,
+ onRetry = ::onRetry,
+ onRemoveFailed = ::onRemoveFailed,
+ onDismiss = ::onDismiss
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun RetrySendMessageMenuBottomSheet(
+ isVisible: Boolean,
+ onRetry: () -> Unit,
+ onRemoveFailed: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState()
+ val coroutineScope = rememberCoroutineScope()
+
+ if (isVisible) {
+ ModalBottomSheet(
+ modifier = modifier,
+// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
+// .imePadding()
+ sheetState = sheetState,
+ onDismissRequest = {
+ coroutineScope.launch {
+ sheetState.hide()
+ onDismiss()
+ }
+ }
+ ) {
+ RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed)
+ // FIXME remove after https://issuetracker.google.com/issues/275849044
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ColumnScope.RetrySendMenuContents(
+ onRetry: () -> Unit,
+ onRemoveFailed: () -> Unit,
+ sheetState: SheetState = rememberModalBottomSheetState(),
+) {
+ val coroutineScope = rememberCoroutineScope()
+
+ ListItem(headlineContent = {
+ Text(stringResource(R.string.screen_room_retry_send_menu_title), fontWeight = FontWeight.Medium)
+ })
+ ListItem(
+ headlineContent = {
+ Text(stringResource(R.string.screen_room_retry_send_menu_send_again_action))
+ },
+ modifier = Modifier.clickable {
+ coroutineScope.launch {
+ sheetState.hide()
+ onRetry()
+ }
+ }
+ )
+ ListItem(
+ headlineContent = {
+ Text(stringResource(R.string.screen_room_retry_send_menu_remove_action))
+ },
+ colors = ListItemDefaults.colors(headlineColor = LocalColors.current.textActionCritical),
+ modifier = Modifier.clickable {
+ coroutineScope.launch {
+ sheetState.hide()
+ onRemoveFailed()
+ }
+ }
+ )
+}
+
+@Preview
+@Composable
+internal fun RetrySendMessageMenuPreviewLight(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) {
+ ElementPreviewLight {
+ ContentToPreview(state)
+ }
+}
+
+@Preview
+@Composable
+internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) {
+ ElementPreviewDark {
+ ContentToPreview(state)
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ContentToPreview(state: RetrySendMenuState) {
+ // TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
+ Column {
+ RetrySendMenuContents(
+ onRetry = {},
+ onRemoveFailed = {},
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
index f4e771a32d..c08c8d65a3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
@@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,7 +46,7 @@ class TimelineItemsFactory @Inject constructor(
private val timelineItemGrouper: TimelineItemGrouper,
) {
- private val timelineItems = MutableStateFlow(emptyList().toImmutableList())
+ private val timelineItems = MutableStateFlow(persistentListOf())
private val timelineItemsCache = arrayListOf()
// Items from rust sdk, used for diffing
@@ -95,7 +96,7 @@ class TimelineItemsFactory @Inject constructor(
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
}
- private suspend fun buildAndCacheItem(
+ private fun buildAndCacheItem(
timelineItems: List,
index: Int
): TimelineItem? {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index f1a29ebcf9..c99ab46ab1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -54,6 +54,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemImageContent(
body = messageType.body,
mediaSource = messageType.source,
+ thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(),
@@ -72,7 +73,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
- duration = messageType.info?.duration ?: 0L,
+ duration = messageType.info?.duration?.toMillis() ?: 0L,
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
@@ -101,11 +102,11 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
}
- private fun aspectRatioOf(width: Long?, height: Long?): Float {
+ private fun aspectRatioOf(width: Long?, height: Long?): Float? {
return if (height != null && width != null) {
width.toFloat() / height.toFloat()
} else {
- 0.7f
+ null
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index ce9a558b97..62aca35d6e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -72,6 +72,7 @@ class TimelineItemEventFactory @Inject constructor(
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
+ transactionId = currentTimelineItem.transactionId,
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
index 0328bf6603..08f9df0535 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
@@ -52,6 +52,7 @@ sealed interface TimelineItem {
data class Event(
val id: String,
val eventId: EventId? = null,
+ val transactionId: String? = null,
val senderId: UserId,
val senderDisplayName: String?,
val senderAvatar: AvatarData,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
index a5ef890c82..342e0a336b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
@@ -16,18 +16,26 @@
package io.element.android.features.messages.impl.timeline.model.event
+import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemImageContent(
val body: String,
val mediaSource: MediaSource,
+ val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,
- val aspectRatio: Float
+ val aspectRatio: Float?
) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent"
+
+ val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
+ mediaSource
+ } else {
+ thumbnailSource ?: mediaSource
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
index 519f1e58a4..004bac390a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
@@ -32,6 +32,7 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider"Natočit video"
"Příloha"
"Knihovna fotografií a videí"
+ "Nepodařilo se načíst údaje o uživateli"
+ "Chtěli byste je pozvat zpět?"
+ "V tomto chatu jste sami"
+ "Nemáte oprávnění vkládat příspěvky do této místnosti"
+ "Odeslat znovu"
+ "Vaši zprávu se nepodařilo odeslat"
"Nahrání média se nezdařilo, zkuste to prosím znovu."
-
\ No newline at end of file
+ "Odstranit"
+
diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml
index 78088386ad..b8811cb203 100644
--- a/features/messages/impl/src/main/res/values-de/translations.xml
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -9,4 +9,9 @@
"Video aufnehmen"
"Anhang"
"Foto- & Video-Bibliothek"
-
\ No newline at end of file
+ "Benutzerdetails konnten nicht abgerufen werden"
+ "Erneut senden"
+ "Ihre Nachricht konnte nicht gesendet werden"
+ "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."
+ "Entfernen"
+
diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml
index 7cd4b6e764..5d41b319bd 100644
--- a/features/messages/impl/src/main/res/values-es/translations.xml
+++ b/features/messages/impl/src/main/res/values-es/translations.xml
@@ -4,4 +4,5 @@
- "%1$d cambio en la sala"
- "%1$d cambios en la sala"
-
\ No newline at end of file
+ "Eliminar"
+
diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml
index 2eb6016f2f..8a873e08f9 100644
--- a/features/messages/impl/src/main/res/values-fr/translations.xml
+++ b/features/messages/impl/src/main/res/values-fr/translations.xml
@@ -5,4 +5,5 @@
- "%1$d changements dans la conversation"
"Prendre une photo"
-
\ No newline at end of file
+ "Supprimer"
+
diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml
index 649a91405b..694de002fe 100644
--- a/features/messages/impl/src/main/res/values-it/translations.xml
+++ b/features/messages/impl/src/main/res/values-it/translations.xml
@@ -4,4 +4,5 @@
- "%1$d modifica alla stanza"
- "%1$d modifiche alla stanza"
-
\ No newline at end of file
+ "Rimuovi"
+
diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml
index 0a2aa20456..f8ed777638 100644
--- a/features/messages/impl/src/main/res/values-ro/translations.xml
+++ b/features/messages/impl/src/main/res/values-ro/translations.xml
@@ -10,5 +10,7 @@
"Înregistrați un videoclip"
"Atașament"
"Bibliotecă foto și video"
+ "Nu am putut găsi detaliile utilizatorului"
"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."
-
\ No newline at end of file
+ "Ștergeți"
+
diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml
index d94f32a88f..557b6ccd90 100644
--- a/features/messages/impl/src/main/res/values/localazy.xml
+++ b/features/messages/impl/src/main/res/values/localazy.xml
@@ -10,5 +10,11 @@
"Attachment"
"Photo & Video Library"
"Could not retrieve user details"
+ "Would you like to invite them back?"
+ "You are alone in this chat"
+ "You do not have permission to post to this room"
+ "Send again"
+ "Your message failed to send"
"Failed processing media to upload, please try again."
-
\ No newline at end of file
+ "Remove"
+
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt
new file mode 100644
index 0000000000..8a374e5bcb
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+
+import io.element.android.features.messages.impl.MessagesNavigator
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+
+class FakeMessagesNavigator : MessagesNavigator {
+ var onShowEventDebugInfoClickedCount = 0
+ private set
+
+ var onForwardEventClickedCount = 0
+ private set
+
+ var onReportContentClickedCount = 0
+ private set
+
+ override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
+ onShowEventDebugInfoClickedCount++
+ }
+
+ override fun onForwardEventClicked(eventId: EventId) {
+ onForwardEventClickedCount++
+ }
+
+ override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
+ onReportContentClickedCount++
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
index d517b261e2..40af424406 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
@@ -26,15 +26,19 @@ import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.MessagesEvents
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
+import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
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.TimelineItemVideoContent
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.core.mimetype.MimeTypes
@@ -42,6 +46,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -53,7 +58,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -75,8 +79,9 @@ class MessagesPresenterTest {
@Test
fun `present - handle sending a reaction`() = runTest {
+ val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val room = FakeMatrixRoom()
- val presenter = createMessagePresenter(matrixRoom = room)
+ val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -89,19 +94,22 @@ class MessagesPresenterTest {
room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(2)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - handle action forward`() = runTest {
- val presenter = createMessagePresenter()
+ val navigator = FakeMessagesNavigator()
+ val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
- // Still a TODO in the code
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
}
}
@@ -114,7 +122,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent()))
- // Still a TODO in the code
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -130,6 +138,7 @@ class MessagesPresenterTest {
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -142,7 +151,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
- skipItems(1)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
// Otherwise we would have some extra items here
ensureAllEventsConsumed()
}
@@ -160,6 +169,7 @@ class MessagesPresenterTest {
content = TimelineItemImageContent(
body = "image.jpg",
mediaSource = MediaSource(AN_AVATAR_URL),
+ thumbnailSource = null,
mimeType = MimeTypes.Jpeg,
blurhash = null,
width = 20,
@@ -175,6 +185,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -207,6 +218,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -234,6 +246,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -249,13 +262,15 @@ class MessagesPresenterTest {
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - handle action redact`() = runTest {
+ val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val matrixRoom = FakeMatrixRoom()
- val presenter = createMessagePresenter(matrixRoom)
+ val presenter = createMessagePresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -263,37 +278,83 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - handle action report content`() = runTest {
- val presenter = createMessagePresenter()
+ val navigator = FakeMessagesNavigator()
+ val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
- // Still a TODO in the code
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
}
}
@Test
- fun `present - handle action show developer info`() = runTest {
+ fun `present - handle dismiss action`() = runTest {
val presenter = createMessagePresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(MessagesEvents.Dismiss)
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - handle action show developer info`() = runTest {
+ val navigator = FakeMessagesNavigator()
+ val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
- // Still a TODO in the code
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - permission to post`() = runTest {
+ val matrixRoom = FakeMatrixRoom()
+ matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(true))
+ val presenter = createMessagePresenter(matrixRoom = matrixRoom)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - no permission to post`() = runTest {
+ val matrixRoom = FakeMatrixRoom()
+ matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(false))
+ val presenter = createMessagePresenter(matrixRoom = matrixRoom)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ assertThat(awaitItem().userHasPermissionToSendMessage).isFalse()
}
}
private fun TestScope.createMessagePresenter(
- matrixRoom: MatrixRoom = FakeMatrixRoom()
+ coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
+ matrixRoom: MatrixRoom = FakeMatrixRoom(),
+ navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@@ -322,15 +383,20 @@ class MessagesPresenterTest {
flavorShortDescription = "",
)
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
+ val customReactionPresenter = CustomReactionPresenter()
+ val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
+ customReactionPresenter = customReactionPresenter,
+ retrySendMenuPresenter = retrySendMenuPresenter,
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
- dispatchers = testCoroutineDispatchers(testScheduler),
+ navigator = navigator,
+ dispatchers = coroutineDispatchers,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
index 3789c36146..18f5ae8da3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
@@ -19,7 +19,6 @@
package io.element.android.features.messages.attachments
import android.net.Uri
-import androidx.media3.common.MimeTypes
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@@ -31,7 +30,6 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -58,7 +56,6 @@ class AttachmentsPreviewPresenterTest {
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading())
- testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(Async.Success(Unit))
assertThat(room.sendMediaCount).isEqualTo(1)
@@ -79,7 +76,6 @@ class AttachmentsPreviewPresenterTest {
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading())
- testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(Async.Failure(failure))
assertThat(room.sendMediaCount).isEqualTo(0)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
index a5b1780589..beb37f8e51 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
@@ -37,8 +37,9 @@ import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.test.TestScope
-internal fun aTimelineItemsFactory(): TimelineItemsFactory {
+internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
new file mode 100644
index 0000000000..b4efaca864
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.forward
+
+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.messages.impl.forward.ForwardMessagesEvents
+import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.RoomSummary
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
+import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ForwardMessagesPresenterTests {
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.selectedRooms).isEmpty()
+ assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
+ assertThat(initialState.isSearchActive).isFalse()
+ assertThat(initialState.isForwarding).isFalse()
+ assertThat(initialState.error).isNull()
+ assertThat(initialState.forwardingSucceeded).isNull()
+
+ // Search is run automatically
+ val searchState = awaitItem()
+ assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
+ }
+ }
+
+ @Test
+ fun `present - toggle search active`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ skipItems(1)
+ val summary = aRoomSummaryDetail()
+
+ initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
+ assertThat(awaitItem().isSearchActive).isTrue()
+
+ initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
+ assertThat(awaitItem().isSearchActive).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - update query`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().apply {
+ postRoomSummary(listOf(RoomSummary.Filled(aRoomSummaryDetail())))
+ }
+ val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ val presenter = aPresenter(client = client)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail())))
+
+ initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained"))
+ assertThat(awaitItem().query).isEqualTo("string not contained")
+ assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
+ }
+ }
+
+ @Test
+ fun `present - select a room and forward successful`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ skipItems(1)
+ val summary = aRoomSummaryDetail()
+
+ initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
+ awaitItem()
+
+ // Test successful forwarding
+ initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
+
+ val forwardingState = awaitItem()
+ assertThat(forwardingState.isSearchActive).isFalse()
+ assertThat(forwardingState.isForwarding).isTrue()
+
+ val successfulForwardState = awaitItem()
+ assertThat(successfulForwardState.isForwarding).isFalse()
+ assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
+ }
+ }
+
+ @Test
+ fun `present - select a room and forward failed, then clear`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = aPresenter(fakeMatrixRoom = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ skipItems(1)
+ val summary = aRoomSummaryDetail()
+
+ initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
+ awaitItem()
+
+ // Test failed forwarding
+ room.givenForwardEventResult(Result.failure(Throwable("error")))
+ initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
+ skipItems(1)
+
+ val failedForwardState = awaitItem()
+ assertThat(failedForwardState.isForwarding).isFalse()
+ assertThat(failedForwardState.error).isNotNull()
+
+ // Then clear error
+ initialState.eventSink(ForwardMessagesEvents.ClearError)
+ assertThat(awaitItem().error).isNull()
+ }
+ }
+
+ @Test
+ fun `present - select and remove a room`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ skipItems(1)
+ val summary = aRoomSummaryDetail()
+
+ initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
+ assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
+
+ initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
+ assertThat(awaitItem().selectedRooms).isEmpty()
+ }
+ }
+
+ private fun CoroutineScope.aPresenter(
+ eventId: EventId = AN_EVENT_ID,
+ fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
+ coroutineScope: CoroutineScope = this,
+ client: FakeMatrixClient = FakeMatrixClient(),
+ ) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client)
+
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt
index 25a62e439a..5bdef5f9b1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt
@@ -19,10 +19,9 @@ package io.element.android.features.messages.media
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaActions
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import kotlinx.coroutines.withContext
+import io.element.android.tests.testutils.simulateLongTask
-class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions {
+class FakeLocalMediaActions : LocalMediaActions {
var shouldFail = false
@@ -31,7 +30,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche
//NOOP
}
- override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun saveOnDisk(localMedia: LocalMedia): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
@@ -39,7 +38,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche
}
}
- override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun share(localMedia: LocalMedia): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
@@ -47,7 +46,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche
}
}
- override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun open(localMedia: LocalMedia): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
index ea40cfe791..2b66d2cf9b 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
@@ -23,7 +23,6 @@ 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.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.aFileInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
@@ -34,7 +33,6 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
-import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -49,9 +47,8 @@ class MediaViewerPresenterTest {
@Test
fun `present - download media success scenario`() = runTest {
- val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
- val mediaLoader = FakeMediaLoader(coroutineDispatchers)
- val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
+ val mediaLoader = FakeMediaLoader()
+ val mediaActions = FakeLocalMediaActions()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -70,10 +67,10 @@ class MediaViewerPresenterTest {
@Test
fun `present - check all actions `() = runTest {
- val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
- val mediaLoader = FakeMediaLoader(coroutineDispatchers)
- val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
- val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
+ val mediaLoader = FakeMediaLoader()
+ val mediaActions = FakeLocalMediaActions()
+ val snackbarDispatcher = SnackbarDispatcher()
+ val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -94,34 +91,31 @@ class MediaViewerPresenterTest {
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
- state = awaitItem()
- assertThat(state.snackbarMessage).isNull()
+ snackbarDispatcher.clear()
+ assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
- state = awaitItem()
- assertThat(state.snackbarMessage).isNull()
+ snackbarDispatcher.clear()
+ assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
- state = awaitItem()
- assertThat(state.snackbarMessage).isNull()
+ snackbarDispatcher.clear()
+ assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
- state = awaitItem()
- assertThat(state.snackbarMessage).isNull()
}
}
@Test
fun `present - download media failure then retry with success scenario`() = runTest {
- val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
- val mediaLoader = FakeMediaLoader(coroutineDispatchers)
- val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
+ val mediaLoader = FakeMediaLoader()
+ val mediaActions = FakeLocalMediaActions()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -150,6 +144,7 @@ class MediaViewerPresenterTest {
private fun aMediaViewerPresenter(
mediaLoader: FakeMediaLoader,
localMediaActions: FakeLocalMediaActions,
+ snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
@@ -160,7 +155,7 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader,
localMediaActions = localMediaActions,
- snackbarDispatcher = SnackbarDispatcher()
+ snackbarDispatcher = snackbarDispatcher,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt
new file mode 100644
index 0000000000..090dd36dbe
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.report
+
+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.messages.impl.report.ReportMessageEvents
+import io.element.android.features.messages.impl.report.ReportMessagePresenter
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ReportMessagePresenterTests {
+
+ @Test
+ fun `presenter - initial state`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.reason).isEmpty()
+ assertThat(initialState.blockUser).isFalse()
+ assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ }
+
+ @Test
+ fun `presenter - update reason`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val reason = "This user is making the chat very toxic."
+ initialState.eventSink(ReportMessageEvents.UpdateReason(reason))
+
+ assertThat(awaitItem().reason).isEqualTo(reason)
+ }
+ }
+
+ @Test
+ fun `presenter - toggle block user`() = runTest {
+ val presenter = aPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
+
+ assertThat(awaitItem().blockUser).isTrue()
+
+ initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
+
+ assertThat(awaitItem().blockUser).isFalse()
+ }
+ }
+
+ @Test
+ fun `presenter - handle successful report and block user`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = aPresenter(matrixRoom = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
+ skipItems(1)
+ initialState.eventSink(ReportMessageEvents.Report)
+ assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
+ assertThat(room.reportedContentCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `presenter - handle successful report`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = aPresenter(matrixRoom = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ReportMessageEvents.Report)
+ assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
+ assertThat(room.reportedContentCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `presenter - handle failed report`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenReportContentResult(Result.failure(Exception("Failed to report content")))
+ }
+ val presenter = aPresenter(matrixRoom = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ReportMessageEvents.Report)
+ assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
+ val resultState = awaitItem()
+ assertThat(resultState.result).isInstanceOf(Async.Failure::class.java)
+ assertThat(room.reportedContentCount).isEqualTo(1)
+
+ resultState.eventSink(ReportMessageEvents.ClearError)
+ assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ }
+
+ private fun aPresenter(
+ inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID),
+ matrixRoom: MatrixRoom = FakeMatrixRoom(),
+ snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
+ ) = ReportMessagePresenter(
+ inputs = inputs,
+ room = matrixRoom,
+ snackbarDispatcher = snackbarDispatcher,
+ )
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
index 8fc9b702bc..7e0cbeeba3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
@@ -36,7 +36,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.ImageInfo
-import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
@@ -50,7 +49,6 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
-import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk
@@ -301,16 +299,7 @@ class MessageComposerPresenterTest {
thumbnailSource = null,
blurhash = null,
),
- thumbnailInfo = ThumbnailProcessingInfo(
- file = File("/some/path"),
- info = ThumbnailInfo(
- width = null,
- height = null,
- mimetype = null,
- size = null,
- ),
- blurhash = "",
- )
+ thumbnailFile = File("/some/path")
)
)
)
@@ -344,16 +333,7 @@ class MessageComposerPresenterTest {
thumbnailSource = null,
blurhash = null,
),
- thumbnailInfo = ThumbnailProcessingInfo(
- file = File("/some/path"),
- info = ThumbnailInfo(
- width = null,
- height = null,
- mimetype = null,
- size = null,
- ),
- blurhash = "",
- )
+ thumbnailFile = File("/some/path")
)
)
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt
new file mode 100644
index 0000000000..237cb81d38
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt
@@ -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.messages.timeline.components.customreaction
+
+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.messages.impl.timeline.components.customreaction.CustomReactionEvents
+import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class CustomReactionPresenterTests {
+
+ private val presenter = CustomReactionPresenter()
+
+ @Test
+ fun `present - handle selecting and de-selecting an event`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.selectedEventId).isNull()
+
+ initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(AN_EVENT_ID))
+ assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID)
+
+ initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
+ assertThat(awaitItem().selectedEventId).isNull()
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
new file mode 100644
index 0000000000..1e467b82af
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.timeline.components.retrysendmenu
+
+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.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
+import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
+import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RetrySendMenuPresenterTests {
+
+ private val room = FakeMatrixRoom()
+ private val presenter = RetrySendMenuPresenter(room)
+
+ @Test
+ fun `present - handle event selected`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent()
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+
+ assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
+ }
+ }
+
+ @Test
+ fun `present - handle dismiss`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent()
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.Dismiss)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle resend with transactionId`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ assertThat(room.retrySendMessageCount).isEqualTo(1)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle resend without transactionId`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = null)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ assertThat(room.retrySendMessageCount).isEqualTo(0)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle resend with error`() = runTest {
+ room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ assertThat(room.retrySendMessageCount).isEqualTo(1)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle remove failed message with transactionId`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ assertThat(room.cancelSendCount).isEqualTo(1)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle remove failed message without transactionId`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = null)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ assertThat(room.cancelSendCount).isEqualTo(0)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+
+ @Test
+ fun `present - handle remove failed message with error`() = runTest {
+ room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
+ initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
+ skipItems(1)
+
+ initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ assertThat(room.cancelSendCount).isEqualTo(1)
+ assertThat(awaitItem().selectedEvent).isNull()
+ }
+ }
+}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
index a081c0b7ab..d86623cae2 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
@@ -53,6 +53,7 @@ class OnBoardingNode @AssistedInject constructor(
state = state,
modifier = modifier,
onSignIn = ::onSignIn,
+ onCreateAccount = ::onSignUp,
)
}
}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
index 6f071fd090..643ab2bfd8 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
@@ -120,9 +120,7 @@ private fun OnBoardingButtons(
ButtonColumnMolecule(modifier = modifier) {
if (state.canLoginWithQrCode) {
Button(
- onClick = {
- onSignInWithQrCode()
- },
+ onClick = onSignInWithQrCode,
enabled = true,
modifier = Modifier
.fillMaxWidth()
@@ -136,9 +134,7 @@ private fun OnBoardingButtons(
}
}
Button(
- onClick = {
- onSignIn()
- },
+ onClick = onSignIn,
enabled = true,
modifier = Modifier
.fillMaxWidth()
@@ -148,9 +144,7 @@ private fun OnBoardingButtons(
}
if (state.canCreateAccount) {
OutlinedButton(
- onClick = {
- onCreateAccount()
- },
+ onClick = onCreateAccount,
enabled = true,
modifier = Modifier
.fillMaxWidth()
diff --git a/features/onboarding/impl/src/main/res/values-cs/translations.xml b/features/onboarding/impl/src/main/res/values-cs/translations.xml
index 279037e455..176c446673 100644
--- a/features/onboarding/impl/src/main/res/values-cs/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-cs/translations.xml
@@ -4,6 +4,6 @@
"Přihlásit se pomocí QR kódu"
"Vytvořit účet"
"Komunikujte a spolupracujte bezpečně"
- "Vítejte v %1$s Beta. Vylepšený, pro rychlost a jednoduchost."
+ "Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost."
"Buďte ve svém živlu"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml
index da5178aaf2..c6d934cbf2 100644
--- a/features/onboarding/impl/src/main/res/values-de/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-de/translations.xml
@@ -1,7 +1,9 @@
+ "Manuell anmelden"
"Mit QR-Code anmelden"
"Konto erstellen"
- "Willkommen zur %1$s Beta. Verbessert, für Geschwindigkeit und Einfachheit."
+ "Sicher kommunizieren und zusammenarbeiten"
+ "Willkommen zur %1$s. Verbessert, für Geschwindigkeit und Einfachheit."
"Sei in deinem Element"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values-es/translations.xml b/features/onboarding/impl/src/main/res/values-es/translations.xml
index 235fb4558a..2489344438 100644
--- a/features/onboarding/impl/src/main/res/values-es/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-es/translations.xml
@@ -1,5 +1,5 @@
- "Bienvenido a la beta de %1$s. Vitaminado, para mayor rapidez y sencillez."
+ "Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez."
"Siéntente en tu Elemento"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml
index 502b464fa7..018bf21379 100644
--- a/features/onboarding/impl/src/main/res/values-fr/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml
@@ -1,5 +1,5 @@
- "Bienvenue dans la version %1$s Beta. Affiné pour plus de rapidité et de simplicité."
+ "Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité."
"Soyez dans votre Element"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values-it/translations.xml b/features/onboarding/impl/src/main/res/values-it/translations.xml
index cd3c6a696c..652d9e6c22 100644
--- a/features/onboarding/impl/src/main/res/values-it/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-it/translations.xml
@@ -1,5 +1,5 @@
- "Benvenuto nella beta di %1$s. Potenziato in velocità e semplicità."
+ "Benvenuto su %1$s. Potenziato in velocità e semplicità."
"Sii nel tuo elemento"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values-ro/translations.xml b/features/onboarding/impl/src/main/res/values-ro/translations.xml
index 03d967ab75..13e5eb505b 100644
--- a/features/onboarding/impl/src/main/res/values-ro/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-ro/translations.xml
@@ -1,5 +1,9 @@
- "Bun venit la versiunea beta a %1$s. Supraalimentat, pentru viteză și simplitate."
+ "Conectați-vă manual"
+ "Conectați-vă cu un cod QR"
+ "Creați un cont"
+ "Comunicați și colaborați în siguranță"
+ "Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."
"Fii în Elementul tău"
-
\ No newline at end of file
+
diff --git a/features/onboarding/impl/src/main/res/values/localazy.xml b/features/onboarding/impl/src/main/res/values/localazy.xml
index 54d86ba247..bad5b524da 100644
--- a/features/onboarding/impl/src/main/res/values/localazy.xml
+++ b/features/onboarding/impl/src/main/res/values/localazy.xml
@@ -4,6 +4,6 @@
"Sign in with QR code"
"Create account"
"Communicate and collaborate securely"
- "Welcome to the %1$s Beta. Supercharged, for speed and simplicity."
+ "Welcome to %1$s. Supercharged, for speed and simplicity."
"Be in your Element"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-cs/translations.xml b/features/rageshake/api/src/main/res/values-cs/translations.xml
index 7ae9126f29..20d6f31ed0 100644
--- a/features/rageshake/api/src/main/res/values-cs/translations.xml
+++ b/features/rageshake/api/src/main/res/values-cs/translations.xml
@@ -2,4 +2,4 @@
"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"
"Zdá se, že jste frustrovaně třásli telefonem. Chcete otevřít obrazovku pro nahlášení chyby?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-de/translations.xml b/features/rageshake/api/src/main/res/values-de/translations.xml
index 1633cd340e..f2446a4028 100644
--- a/features/rageshake/api/src/main/res/values-de/translations.xml
+++ b/features/rageshake/api/src/main/res/values-de/translations.xml
@@ -2,4 +2,4 @@
"%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"
"Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-es/translations.xml b/features/rageshake/api/src/main/res/values-es/translations.xml
index 26ff483b91..597ec74260 100644
--- a/features/rageshake/api/src/main/res/values-es/translations.xml
+++ b/features/rageshake/api/src/main/res/values-es/translations.xml
@@ -2,4 +2,4 @@
"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"
"Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-fr/translations.xml b/features/rageshake/api/src/main/res/values-fr/translations.xml
index 32bdaf4685..455ab1daef 100644
--- a/features/rageshake/api/src/main/res/values-fr/translations.xml
+++ b/features/rageshake/api/src/main/res/values-fr/translations.xml
@@ -2,4 +2,4 @@
"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"
"Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-it/translations.xml b/features/rageshake/api/src/main/res/values-it/translations.xml
index e6ef37d287..6d5e7a74c0 100644
--- a/features/rageshake/api/src/main/res/values-it/translations.xml
+++ b/features/rageshake/api/src/main/res/values-it/translations.xml
@@ -2,4 +2,4 @@
"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"
"Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values-ro/translations.xml b/features/rageshake/api/src/main/res/values-ro/translations.xml
index 17180d5145..2c89703deb 100644
--- a/features/rageshake/api/src/main/res/values-ro/translations.xml
+++ b/features/rageshake/api/src/main/res/values-ro/translations.xml
@@ -2,4 +2,4 @@
"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"
"Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/api/src/main/res/values/localazy.xml b/features/rageshake/api/src/main/res/values/localazy.xml
index 112cc427ba..bb694f2d00 100644
--- a/features/rageshake/api/src/main/res/values/localazy.xml
+++ b/features/rageshake/api/src/main/res/values/localazy.xml
@@ -2,4 +2,4 @@
"%1$s crashed the last time it was used. Would you like to share a crash report with us?"
"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt
index 05f3232aac..0e0ebaaab2 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt
@@ -81,12 +81,9 @@ fun BugReportView(
.systemBarsPadding()
.imePadding()
) {
- val scrollState = rememberScrollState()
Column(
modifier = Modifier
- .verticalScroll(
- state = scrollState,
- )
+ .verticalScroll(state = rememberScrollState())
.padding(horizontal = 16.dp),
) {
val isError = state.sending is Async.Failure
diff --git a/features/rageshake/impl/src/main/res/values-cs/translations.xml b/features/rageshake/impl/src/main/res/values-cs/translations.xml
index 5a037a7596..5863cba70e 100644
--- a/features/rageshake/impl/src/main/res/values-cs/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml
@@ -11,4 +11,4 @@
"Odeslat snímek obrazovky"
"Aby bylo možné zkontrolovat, zda věci fungují podle očekávání, budou s vaší zprávou odeslány protokoly. Tyto budou soukromé. Chcete-li pouze odeslat zprávu, vypněte toto nastavení."
"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml
index 8712bba1a0..437f5fff6f 100644
--- a/features/rageshake/impl/src/main/res/values-de/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-de/translations.xml
@@ -11,4 +11,4 @@
"Bildschirmfoto senden"
"Um zu überprüfen, ob alles wie vorgesehen funktioniert, werden Protokolle mit deiner Nachricht gesendet. Diese werden privat sein. Um nur Ihre Nachricht zu senden, schalte diese Einstellung aus."
"%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml
index 0b1a374b97..4191f67596 100644
--- a/features/rageshake/impl/src/main/res/values-es/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-es/translations.xml
@@ -11,4 +11,4 @@
"Enviar captura de pantalla"
"Para comprobar que todo funciona correctamente, se enviarán registros de fallos con su mensaje. Serán privados. Para enviar sólo tu mensaje, desactiva esta opción."
"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml
index e5e88975a6..84b3ad3386 100644
--- a/features/rageshake/impl/src/main/res/values-fr/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml
@@ -11,4 +11,4 @@
"Envoyer une capture d’écran"
"Pour vérifier que les choses fonctionnent comme prévu, les journaux seront envoyés avec votre message. Ceux-ci seront privées. Pour simplement envoyer votre message, désactivez ce paramètre."
"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values-it/translations.xml b/features/rageshake/impl/src/main/res/values-it/translations.xml
index c8a15eeedf..2c95849db0 100644
--- a/features/rageshake/impl/src/main/res/values-it/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-it/translations.xml
@@ -11,4 +11,4 @@
"Invia istantanea schermo"
"Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Questi saranno privati. Per inviare solo il tuo messaggio, disattiva questa impostazione."
"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml
index 6d2657bc0c..db0398c0db 100644
--- a/features/rageshake/impl/src/main/res/values-ro/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml
@@ -11,4 +11,4 @@
"Trimiteți captură de ecran"
"Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare."
"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"
-
\ No newline at end of file
+
diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml
index 192666fc1c..75db33350f 100644
--- a/features/rageshake/impl/src/main/res/values/localazy.xml
+++ b/features/rageshake/impl/src/main/res/values/localazy.xml
@@ -11,4 +11,4 @@
"Send screenshot"
"To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting."
"%1$s crashed the last time it was used. Would you like to share a crash report with us?"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
index ced5e79bbe..867d2f433d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
@@ -147,7 +147,7 @@ private fun RoomInviteMembersSearchBar(
selectedUsers: ImmutableList,
active: Boolean,
modifier: Modifier = Modifier,
- placeHolderTitle: String = stringResource(io.element.android.libraries.ui.strings.R.string.common_search_for_someone),
+ placeHolderTitle: String = stringResource(StringR.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserToggled: (MatrixUser) -> Unit = {},
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
index 62b3d07a85..f995cb14b6 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
@@ -89,9 +89,10 @@ fun RoomMemberDetailsView(
Spacer(modifier = Modifier.height(26.dp))
- SendMessageSection(onSendMessage = {
- // TODO implement send DM
- })
+ // TODO implement send DM
+ // SendMessageSection(onSendMessage = {
+ // ...
+ // })
if (!state.isCurrentUser) {
BlockUserSection(state)
diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
index fad77eb62f..745764cee9 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -13,6 +13,8 @@
"Nelze aktualizovat místnost"
"Zprávy jsou zabezpečeny zámky. Pouze vy a příjemci máte jedinečné klíče k jejich odemčení."
"Šifrování zpráv povoleno"
+ "Pozvat lidi"
+ "Oznámení"
"Název místnosti"
"Sdílet místnost"
"Aktualizace místnosti…"
@@ -28,4 +30,4 @@
"Lidé"
"Zabezpečení"
"Téma"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml
index feb3c47866..0211825920 100644
--- a/features/roomdetails/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml
@@ -9,9 +9,14 @@
"Bereits eingeladen"
"Raum bearbeiten"
"Wir konnten nicht alle Informationen für diesen Raum aktualisieren."
+ "Raum konnte nicht aktualisiert werden"
"Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."
"Nachrichtenverschlüsselung aktiviert"
+ "Personen einladen"
+ "Raumname"
"Raum teilen"
+ "Aktualisiere Raum…"
+ "Ausstehend"
"Raummitglieder"
"Blockieren"
"Blockierte Benutzer können dir keine Nachrichten senden und alle Nachrichten von ihnen werden ausgeblendet. Du kannst diese Aktion jederzeit rückgängig machen."
@@ -23,4 +28,4 @@
"Personen"
"Sicherheit"
"Thema"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml
index 58c486d6c3..42bce4b756 100644
--- a/features/roomdetails/impl/src/main/res/values-es/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml
@@ -18,4 +18,4 @@
"Personas"
"Seguridad"
"Tema"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index 4c2296fd97..6037e57a7a 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -6,6 +6,7 @@
"Les messages sont sécurisés par des verrous. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."
"Chiffrement des messages activé"
+ "Inviter des personnes"
"Partager la salle"
"Bloquer"
"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment."
@@ -17,4 +18,4 @@
"Personnes"
"Sécurité"
"Sujet"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml
index a2e61a329c..190eda82ee 100644
--- a/features/roomdetails/impl/src/main/res/values-it/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml
@@ -18,4 +18,4 @@
"Persone"
"Sicurezza"
"Oggetto"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index 93feec60c4..61599d1ceb 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -7,10 +7,13 @@
"Adăugare subiect"
"Deja membru"
"Deja invitat"
+ "Editați camera"
"A apărut o eroare la actualizarea detaliilor camerei"
+ "Nu s-a putut actualiza camera"
"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."
"Criptarea mesajelor este activată"
"Invitați persoane"
+ "Numele camerei"
"Partajați camera"
"Se actualizează camera…"
"În așteptare"
@@ -25,4 +28,4 @@
"Persoane"
"Securitate"
"Subiect"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index 5fffafb51d..17e5a56825 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -13,6 +13,7 @@
"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."
"Message encryption enabled"
"Invite people"
+ "Notification"
"Room name"
"Share room"
"Updating room…"
@@ -28,4 +29,4 @@
"People"
"Security"
"Topic"
-
\ No newline at end of file
+
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
index cf1d9a49ac..f85942c3ee 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
@@ -27,7 +27,6 @@ import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -48,7 +47,7 @@ class RoomDetailsPresenterTests {
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
- return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
+ return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId)
}
}
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake())
@@ -250,10 +249,6 @@ class RoomDetailsPresenterTests {
}
}
-fun aMatrixClient(
- sessionId: SessionId = A_SESSION_ID,
-) = FakeMatrixClient()
-
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
index c1d098dab5..df80f40e9b 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
@@ -105,7 +105,8 @@ class RoomDetailsEditPresenterTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) }
+ givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
+ }
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
@@ -381,7 +382,7 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
initialState.eventSink(RoomDetailsEditEvents.Save)
-
+ skipItems(5)
assertThat(room.newName).isEqualTo("New name")
assertThat(room.newTopic).isEqualTo("New topic")
assertThat(room.newAvatarData).isNull()
@@ -476,7 +477,7 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(2)
+ skipItems(3)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
@@ -501,7 +502,7 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(1)
+ skipItems(2)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
@@ -567,7 +568,7 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(1)
+ skipItems(2)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
@@ -588,6 +589,7 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1)
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
}
}
@@ -599,14 +601,17 @@ class RoomDetailsEditPresenterTest {
}
fakePickerProvider.givenResult(anotherAvatarUri)
- fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile(
- file = processedFile,
- info = mockk(),
- )))
+ fakeMediaPreProcessor.givenResult(
+ Result.success(
+ MediaUploadInfo.AnyFile(
+ file = processedFile,
+ info = mockk(),
+ )
+ )
+ )
}
companion object {
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
}
-
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
index 3baea96990..8600cefeac 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
@@ -38,6 +38,7 @@ import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -92,9 +93,8 @@ internal class RoomInviteMembersPresenterTest {
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
-
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -119,9 +119,8 @@ internal class RoomInviteMembersPresenterTest {
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
-
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -156,17 +155,24 @@ internal class RoomInviteMembersPresenterTest {
val invitedUser = userList[1]
val repository = FakeUserRepository()
+ val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
- roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
- givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
- aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
- aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
- )))
- }),
- coroutineDispatchers = testCoroutineDispatchers()
+ roomMemberListDataSource = createDataSource(
+ matrixRoom = FakeMatrixRoom().apply {
+ givenRoomMembersState(
+ MatrixRoomMembersState.Ready(
+ listOf(
+ aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
+ aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
+ )
+ )
+ )
+ },
+ coroutineDispatchers = coroutineDispatchers,
+ ),
+ coroutineDispatchers = coroutineDispatchers
)
-
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -214,12 +220,16 @@ internal class RoomInviteMembersPresenterTest {
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
- givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
- aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
- aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
- )))
+ givenRoomMembersState(
+ MatrixRoomMembersState.Ready(
+ listOf(
+ aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
+ aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
+ )
+ )
+ )
}),
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
moleculeFlow(RecompositionClock.Immediate) {
@@ -286,9 +296,8 @@ internal class RoomInviteMembersPresenterTest {
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
-
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -322,16 +331,14 @@ internal class RoomInviteMembersPresenterTest {
}
}
-
@Test
fun `present - toggling a user updates existing search results`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
-
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -368,7 +375,7 @@ internal class RoomInviteMembersPresenterTest {
}
}
- private fun createDataSource(
+ private fun TestScope.createDataSource(
matrixRoom: MatrixRoom = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
},
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
index 9625a167f7..01f60847f8 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
@@ -20,7 +20,6 @@ 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.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
@@ -35,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -165,16 +165,16 @@ class RoomMemberListPresenterTests {
}
@ExperimentalCoroutinesApi
-private fun createDataSource(
- matrixRoom: MatrixRoom = aMatrixRoom().apply {
+private fun TestScope.createDataSource(
+ matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
},
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
@ExperimentalCoroutinesApi
-private fun createPresenter(
+private fun TestScope.createPresenter(
+ coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
- roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
- coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
+ roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
index 294b689ea9..912f354c89 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
@@ -20,13 +20,13 @@ 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.roomdetails.aMatrixClient
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -34,8 +34,6 @@ import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberDetailsPresenterTests {
- private val matrixClient = aMatrixClient()
-
@Test
fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
@@ -44,7 +42,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success("A custom avatar"))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
- val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -53,7 +51,7 @@ class RoomMemberDetailsPresenterTests {
Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName)
Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored)
-
+ skipItems(1)
val loadedState = awaitItem()
Truth.assertThat(loadedState.userName).isEqualTo("A custom name")
Truth.assertThat(loadedState.avatarUrl).isEqualTo("A custom avatar")
@@ -68,7 +66,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
- val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -88,7 +86,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success(null))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
- val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -104,7 +102,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
- val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -125,7 +123,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
- val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -142,7 +140,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
- val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
+ val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
index 50a7a7bfbe..3eb2ab848d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: RoomListPresenter,
+ private val presenter: RoomListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onRoomClicked(roomId: RoomId) {
diff --git a/features/roomlist/impl/src/main/res/values-cs/translations.xml b/features/roomlist/impl/src/main/res/values-cs/translations.xml
index a222367f09..d355d2c70c 100644
--- a/features/roomlist/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-cs/translations.xml
@@ -4,4 +4,4 @@
"Všechny chaty"
"Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."
"Přístup k historii zpráv"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml
index ab61ed4c4e..2ed1cd0263 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -4,4 +4,4 @@
"Alle Chats"
"Es sieht so aus, als ob du ein neues Gerät verwendest. Verifiziere, dass du es bist, um auf deine verschlüsselten Nachrichten zuzugreifen."
"Greife auf deine Nachrichten-Historie zu"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml
index 7edd6192a1..899b1e2cac 100644
--- a/features/roomlist/impl/src/main/res/values-es/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-es/translations.xml
@@ -4,4 +4,4 @@
"Todos los chats"
"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."
"Accede a tu historial de mensajes"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml
index 6a049a4e72..3f22122014 100644
--- a/features/roomlist/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml
@@ -4,4 +4,4 @@
"Toutes les conversations"
"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez que vous êtes bien autorisé à accéder à vos messages cryptés."
"Accédez à l\'historique de vos messages"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml
index 6bfb8baa0c..cbe93e52d9 100644
--- a/features/roomlist/impl/src/main/res/values-it/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-it/translations.xml
@@ -4,4 +4,4 @@
"Tutte le conversazioni"
"Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati."
"Accedi alla cronologia dei messaggi"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml
index 7401b30b82..b8ffc57090 100644
--- a/features/roomlist/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml
@@ -4,4 +4,4 @@
"Toate conversatiile"
"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."
"Accesați istoricul mesajelor"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml
index 613e6681ae..e18d4c9017 100644
--- a/features/roomlist/impl/src/main/res/values/localazy.xml
+++ b/features/roomlist/impl/src/main/res/values/localazy.xml
@@ -4,4 +4,4 @@
"All Chats"
"Looks like you’re using a new device. Verify it’s you to access your encrypted messages."
"Access your message history"
-
\ No newline at end of file
+
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
index 040a5b5b50..389aee1e92 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
@@ -69,7 +69,7 @@ internal class DefaultInviteStateDataSourceTest {
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
seenStore.publishRoomIds(setOf(A_ROOM_ID))
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
+ val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
@@ -86,7 +86,7 @@ internal class DefaultInviteStateDataSourceTest {
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
seenStore.publishRoomIds(setOf(A_ROOM_ID))
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
+ val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
index fe76d9837b..37623b4f28 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
-import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -51,7 +50,7 @@ class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
@@ -77,7 +76,6 @@ class RoomListPresenterTests {
fun `present - should start with no user and then load user with error`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(
- A_SESSION_ID,
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION),
),
@@ -102,7 +100,7 @@ class RoomListPresenterTests {
@Test
fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
@@ -130,7 +128,6 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
- sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
@@ -163,7 +160,6 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
- sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
@@ -202,7 +198,6 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
- sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
@@ -251,7 +246,6 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
- sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
@@ -280,9 +274,7 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val presenter = RoomListPresenter(
- FakeMatrixClient(
- sessionId = A_SESSION_ID,
- ),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
@@ -312,7 +304,7 @@ class RoomListPresenterTests {
@Test
fun `present - show context menu`() = runTest {
val presenter = RoomListPresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
@@ -339,7 +331,7 @@ class RoomListPresenterTests {
@Test
fun `present - hide context menu`() = runTest {
val presenter = RoomListPresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
@@ -371,7 +363,7 @@ class RoomListPresenterTests {
fun `present - leave room calls into leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val presenter = RoomListPresenter(
- FakeMatrixClient(A_SESSION_ID),
+ FakeMatrixClient(),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml
index 41f95d41fe..6bf8db5ac0 100644
--- a/features/verifysession/impl/src/main/res/values-cs/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml
@@ -16,4 +16,4 @@
"Čekání na přijetí žádosti"
"Ověření zrušeno"
"Začít"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml
index 53479844b4..f5f149cfd9 100644
--- a/features/verifysession/impl/src/main/res/values-de/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-de/translations.xml
@@ -16,4 +16,4 @@
"Warten auf die Annahme der Anfrage"
"Verifizierung abgebrochen"
"Starten"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml
index ccc656e845..386ecfc37c 100644
--- a/features/verifysession/impl/src/main/res/values-es/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-es/translations.xml
@@ -16,4 +16,4 @@
"A la espera de aceptar la solicitud"
"Verificación cancelada"
"Comenzar"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml
index cd06c5db8b..71e8015827 100644
--- a/features/verifysession/impl/src/main/res/values-fr/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml
@@ -16,4 +16,4 @@
"En attente d\'acceptation de la demande"
"Vérification annulée"
"Démarrer"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml
index 1bf0e87ea9..7a6765adbf 100644
--- a/features/verifysession/impl/src/main/res/values-it/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-it/translations.xml
@@ -16,4 +16,4 @@
"In attesa di accettare la richiesta"
"Verifica annullata"
"Inizia"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml
index 3ad0de6e56..e392438bcd 100644
--- a/features/verifysession/impl/src/main/res/values-ro/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml
@@ -16,4 +16,4 @@
"Se așteptă acceptarea cererii"
"Verificare anulată"
"Începeți"
-
\ No newline at end of file
+
diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml
index c217f0d2a4..67dc975128 100644
--- a/features/verifysession/impl/src/main/res/values/localazy.xml
+++ b/features/verifysession/impl/src/main/res/values/localazy.xml
@@ -16,4 +16,4 @@
"Waiting to accept request"
"Verification cancelled"
"Start"
-
\ No newline at end of file
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 11d6e102ff..86a18b013b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,7 @@ media3 = "1.0.2"
browser = "1.5.0"
# Compose
-compose_bom = "2023.06.00"
+compose_bom = "2023.06.01"
composecompiler = "1.4.7"
# Coroutines
@@ -50,6 +50,9 @@ telephoto = "0.4.0"
dagger = "2.46.1"
anvil = "2.4.6"
+# Auto service
+autoservice = "1.1.1"
+
# quality
detekt = "1.23.0"
dependencygraph = "0.12"
@@ -61,7 +64,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:32.1.0"
+google_firebase_bom = "com.google.firebase:firebase-bom:32.1.1"
# AndroidX
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
@@ -121,8 +124,8 @@ test_mockk = "io.mockk:mockk:1.13.5"
test_barista = "com.adevinta.android:barista:4.3.0"
test_hamcrest = "org.hamcrest:hamcrest:2.2"
test_orchestrator = "androidx.test:orchestrator:1.4.2"
-test_turbine = "app.cash.turbine:turbine:0.13.0"
-test_truth = "com.google.truth:truth:1.1.4"
+test_turbine = "app.cash.turbine:turbine:1.0.0"
+test_truth = "com.google.truth:truth:1.1.5"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.12"
test_robolectric = "org.robolectric:robolectric:4.10.3"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
@@ -139,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.16"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.22"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@@ -155,7 +158,7 @@ statemachine = "com.freeletics.flowredux:compose:1.1.0"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
-sentry_android = "io.sentry:sentry-android:6.22.0"
+sentry_android = "io.sentry:sentry-android:6.23.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
# Di
@@ -165,11 +168,16 @@ dagger_compiler = { module = "com.google.dagger:dagger-compiler", version.ref =
anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref = "anvil" }
anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" }
+# Auto services
+google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
+google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" }
+
+
# Miscellaneous
# Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the
# value of `composecompiler` (which is used to set composeOptions.kotlinCompilerExtensionVersion.
# See https://github.com/renovatebot/renovate/issues/18354
-android_composeCompiler = {module="androidx.compose.compiler:compiler", version.ref ="composecompiler"}
+android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" }
[bundles]
@@ -182,11 +190,11 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
-ktlint = "org.jlleitschuh.gradle.ktlint:11.4.0"
+ktlint = "org.jlleitschuh.gradle.ktlint:11.4.2"
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }
paparazzi = "app.cash.paparazzi:1.2.0"
-sonarqube = "org.sonarqube:4.2.0.3129"
+sonarqube = "org.sonarqube:4.2.1.3168"
kover = "org.jetbrains.kotlinx.kover:0.6.1"
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts
index e9a8feaa05..f98914e08a 100644
--- a/libraries/androidutils/build.gradle.kts
+++ b/libraries/androidutils/build.gradle.kts
@@ -28,5 +28,6 @@ dependencies {
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
+ implementation(libs.androidx.browser)
implementation(projects.libraries.core)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
similarity index 97%
rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt
rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
index be98566e7c..ec0d9662c7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.features.login.impl.oidc.customtab
+package io.element.android.libraries.androidutils.browser
import android.app.Activity
import android.content.ActivityNotFoundException
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
index 581d45a2b0..269407d3b5 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
@@ -35,6 +35,19 @@ fun File.safeDelete() {
)
}
+fun File.safeRenameTo(dest: File) {
+ tryOrNull(
+ onError = {
+ Timber.e(it, "Error, unable to rename file $path to ${dest.path}")
+ },
+ operation = {
+ if (renameTo(dest).not()) {
+ Timber.w("Warning, unable to rename file $path to ${dest.path}")
+ }
+ }
+ )
+}
+
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
val suffix = extension?.let { ".$extension" }
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
index 3b29787285..8f942957e0 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
@@ -20,5 +20,9 @@ import android.media.MediaMetadataRetriever
/** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */
inline fun MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T {
- return block().also { release() }
+ return try {
+ block()
+ } finally {
+ release()
+ }
}
diff --git a/libraries/androidutils/src/main/res/values-cs/translations.xml b/libraries/androidutils/src/main/res/values-cs/translations.xml
index ab592fee1d..345812c6ff 100644
--- a/libraries/androidutils/src/main/res/values-cs/translations.xml
+++ b/libraries/androidutils/src/main/res/values-cs/translations.xml
@@ -1,4 +1,4 @@
"Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values-de/translations.xml b/libraries/androidutils/src/main/res/values-de/translations.xml
index a34a5b393b..d30d83f831 100644
--- a/libraries/androidutils/src/main/res/values-de/translations.xml
+++ b/libraries/androidutils/src/main/res/values-de/translations.xml
@@ -1,4 +1,4 @@
"Keine kompatible App für diese Aktion gefunden."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values-es/translations.xml b/libraries/androidutils/src/main/res/values-es/translations.xml
index 80b2b88347..d95373265c 100644
--- a/libraries/androidutils/src/main/res/values-es/translations.xml
+++ b/libraries/androidutils/src/main/res/values-es/translations.xml
@@ -1,4 +1,4 @@
"No se encontró ninguna aplicación compatible con esta acción."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values-fr/translations.xml b/libraries/androidutils/src/main/res/values-fr/translations.xml
index d564c18817..b974766fce 100644
--- a/libraries/androidutils/src/main/res/values-fr/translations.xml
+++ b/libraries/androidutils/src/main/res/values-fr/translations.xml
@@ -1,4 +1,4 @@
"Aucune application compatible n\'a été trouvée pour gérer cette action."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values-it/translations.xml b/libraries/androidutils/src/main/res/values-it/translations.xml
index 03aaf3ffd1..fcafd9fe3f 100644
--- a/libraries/androidutils/src/main/res/values-it/translations.xml
+++ b/libraries/androidutils/src/main/res/values-it/translations.xml
@@ -1,4 +1,4 @@
"Non è stata trovata alcuna app compatibile per gestire questa azione."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values-ro/translations.xml b/libraries/androidutils/src/main/res/values-ro/translations.xml
index d2149227c5..eac7dd0285 100644
--- a/libraries/androidutils/src/main/res/values-ro/translations.xml
+++ b/libraries/androidutils/src/main/res/values-ro/translations.xml
@@ -1,4 +1,4 @@
"Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune."
-
\ No newline at end of file
+
diff --git a/libraries/androidutils/src/main/res/values/localazy.xml b/libraries/androidutils/src/main/res/values/localazy.xml
index 0599c8922b..741c1b20ec 100644
--- a/libraries/androidutils/src/main/res/values/localazy.xml
+++ b/libraries/androidutils/src/main/res/values/localazy.xml
@@ -1,4 +1,4 @@
"No compatible app was found to handle this action."
-
\ No newline at end of file
+
diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt
index 3be961598d..301dcfd936 100644
--- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt
+++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt
@@ -36,17 +36,20 @@ sealed interface Async {
}
}
-suspend fun (suspend () -> T).execute(state: MutableState>, errorMapping: ((Throwable) -> Throwable)? = null) {
+suspend inline fun (suspend () -> T).execute(
+ state: MutableState>,
+ errorMapping: ((Throwable) -> Throwable) = { it },
+) {
try {
state.value = Async.Loading()
val result = this()
state.value = Async.Success(result)
} catch (error: Throwable) {
- state.value = Async.Failure(errorMapping?.invoke(error) ?: error)
+ state.value = Async.Failure(errorMapping.invoke(error))
}
}
-suspend fun (suspend () -> Result).executeResult(state: MutableState>) {
+suspend inline fun (suspend () -> Result).executeResult(state: MutableState>) {
if (state.value !is Async.Success) {
state.value = Async.Loading()
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt
new file mode 100644
index 0000000000..752c80175d
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.atoms
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.LocalColors
+import io.element.android.libraries.designsystem.theme.components.Icon
+
+/**
+ * RoundedIconAtom is an atom which displays an icon inside a rounded container.
+ *
+ * @param modifier the modifier to apply to this layout
+ * @param size the size of the icon
+ * @param resourceId the resource id of the icon to display, exclusive with [imageVector]
+ * @param imageVector the image vector of the icon to display, exclusive with [resourceId]
+ * @param tint the tint to apply to the icon
+ */
+@Composable
+fun RoundedIconAtom(
+ modifier: Modifier = Modifier,
+ size: RoundedIconAtomSize = RoundedIconAtomSize.Large,
+ resourceId: Int? = null,
+ imageVector: ImageVector? = null,
+ tint: Color = MaterialTheme.colorScheme.secondary
+) {
+ Box(
+ modifier = modifier
+ .size(size.toContainerSize())
+ .background(
+ color = LocalColors.current.quinary,
+ shape = RoundedCornerShape(size.toCornerSize())
+ )
+ ) {
+ Icon(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(size.toIconSize()),
+ tint = tint,
+ resourceId = resourceId,
+ imageVector = imageVector,
+ contentDescription = "",
+ )
+ }
+}
+
+private fun RoundedIconAtomSize.toContainerSize(): Dp {
+ return when (this) {
+ RoundedIconAtomSize.Medium -> 30.dp
+ RoundedIconAtomSize.Large -> 70.dp
+ }
+}
+
+private fun RoundedIconAtomSize.toCornerSize(): Dp {
+ return when (this) {
+ RoundedIconAtomSize.Medium -> 8.dp
+ RoundedIconAtomSize.Large -> 14.dp
+ }
+}
+
+private fun RoundedIconAtomSize.toIconSize(): Dp {
+ return when (this) {
+ RoundedIconAtomSize.Medium -> 16.dp
+ RoundedIconAtomSize.Large -> 48.dp
+ }
+}
+
+@Preview
+@Composable
+internal fun RoundedIconAtomLightPreview() =
+ ElementPreviewLight { ContentToPreview() }
+
+@Preview
+@Composable
+internal fun RoundedIconAtomDarkPreview() =
+ ElementPreviewDark { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ RoundedIconAtom(
+ size = RoundedIconAtomSize.Medium,
+ imageVector = Icons.Filled.Home,
+ )
+ RoundedIconAtom(
+ size = RoundedIconAtomSize.Large,
+ imageVector = Icons.Filled.Home,
+ )
+ }
+}
+
+enum class RoundedIconAtomSize {
+ Medium,
+ Large
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
index bda10b8ba2..24adf90156 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
@@ -16,55 +16,55 @@
package io.element.android.libraries.designsystem.atomic.molecules
-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.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.R
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
-import io.element.android.libraries.designsystem.theme.LocalColors
-import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
+/**
+ * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle.
+ *
+ * @param title the title to display
+ * @param subTitle the subtitle to display
+ * @param modifier the modifier to apply to this layout
+ * @param iconResourceId the resource id of the icon to display, exclusive with [iconImageVector]
+ * @param iconImageVector the image vector of the icon to display, exclusive with [iconResourceId]
+ * @param iconTint the tint to apply to the icon
+ */
@Composable
fun IconTitleSubtitleMolecule(
- iconResourceId: Int,
title: String,
subTitle: String,
modifier: Modifier = Modifier,
+ iconResourceId: Int? = null,
+ iconImageVector: ImageVector? = null,
+ iconTint: Color = MaterialTheme.colorScheme.primary,
) {
Column(modifier) {
- Box(
+ RoundedIconAtom(
modifier = Modifier
- .size(width = 70.dp, height = 70.dp)
- .align(Alignment.CenterHorizontally)
- .background(
- color = LocalColors.current.quinary,
- shape = RoundedCornerShape(14.dp)
- )
- ) {
- Icon(
- modifier = Modifier
- .align(Alignment.Center)
- .size(width = 48.dp, height = 48.dp),
- tint = MaterialTheme.colorScheme.secondary,
- resourceId = iconResourceId,
- contentDescription = "",
- )
- }
+ .align(Alignment.CenterHorizontally),
+ size = RoundedIconAtomSize.Large,
+ resourceId = iconResourceId,
+ imageVector = iconImageVector,
+ tint = iconTint,
+ )
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt
index 81a2ca36fc..71a41c2460 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt
@@ -37,8 +37,8 @@ fun LabelledTextField(
value: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
- maxLines: Int = Int.MAX_VALUE,
singleLine: Boolean = false,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
onValueChange: (String) -> Unit = {},
) {
Column(
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt
index 5863f4c80c..e81bc140bf 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt
@@ -24,12 +24,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.Text
+import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun AsyncFailure(
@@ -43,11 +45,11 @@ fun AsyncFailure(
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- Text(text = throwable.message ?: "An error occurred")
+ Text(text = throwable.message ?: stringResource(id = StringR.string.error_unknown))
if (onRetry != null) {
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
- Text(text = "Retry")
+ Text(text = stringResource(id = StringR.string.action_retry))
}
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt
index c7366dcfb7..0de4dbba78 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt
@@ -22,5 +22,5 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
-public fun textFieldState(stateValue: String): MutableState =
+fun textFieldState(stateValue: String): MutableState =
remember(stateValue) { mutableStateOf(stateValue) }
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt
index 10578ebcca..3826e6ebde 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt
@@ -69,14 +69,11 @@ fun PreferenceView(
)
},
content = {
- val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it)
- .verticalScroll(
- state = scrollState,
- )
+ .verticalScroll(state = rememberScrollState())
) {
content()
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
new file mode 100644
index 0000000000..7d4de84301
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.text
+
+import android.graphics.Typeface
+import android.text.SpannableString
+import android.text.style.ForegroundColorSpan
+import android.text.style.StyleSpan
+import android.text.style.UnderlineSpan
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import io.element.android.libraries.designsystem.LinkColor
+
+fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
+ append(this@toAnnotatedString)
+ val spannable = SpannableString(this@toAnnotatedString)
+ spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
+ val start = spannable.getSpanStart(span)
+ val end = spannable.getSpanEnd(span)
+ when (span) {
+ is StyleSpan -> when (span.style) {
+ Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
+ Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
+ Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
+ }
+ is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
+ is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
+ }
+ }
+}
+
+/**
+ * Convert a string to an [AnnotatedString] with styles applied.
+ *
+ * @param fullTextRes the string resource to use as the full text. Must contain a single %s
+ * @param coloredTextRes the string resource to use as the colored part of the string
+ * @param color the color to apply to the string
+ * @param underline whether to underline the string
+ * @param bold whether to bold the string
+ */
+@Composable
+fun buildAnnotatedStringWithStyledPart(
+ @StringRes fullTextRes: Int,
+ @StringRes coloredTextRes: Int,
+ color: Color = LinkColor,
+ underline: Boolean = true,
+ bold: Boolean = false,
+) = buildAnnotatedString {
+ val coloredPart = stringResource(coloredTextRes)
+ val fullText = stringResource(fullTextRes, coloredPart)
+ val startIndex = fullText.indexOf(coloredPart)
+ append(fullText)
+ addStyle(
+ style = SpanStyle(
+ color = color,
+ textDecoration = if (underline) TextDecoration.Underline else null,
+ fontWeight = if (bold) FontWeight.Bold else null,
+ ),
+ start = startIndex,
+ end = startIndex + coloredPart.length,
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt
index dedf2af060..24de433058 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt
@@ -30,6 +30,54 @@ import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
+/**
+ * Icon is a wrapper around [androidx.compose.material3.Icon] which allows to use
+ * [ImageVector], [ImageBitmap] or [DrawableRes] as icon source.
+ *
+ * @param contentDescription the content description to be used for accessibility
+ * @param modifier the modifier to apply to this layout
+ * @param tint the tint to apply to the icon
+ * @param imageVector the image vector of the icon to display, exclusive with [bitmap] and [resourceId]
+ * @param bitmap the bitmap of the icon to display, exclusive with [imageVector] and [resourceId]
+ * @param resourceId the resource id of the icon to display, exclusive with [imageVector] and [bitmap]
+ */
+@Composable
+fun Icon(
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ tint: Color = LocalContentColor.current,
+ imageVector: ImageVector? = null,
+ bitmap: ImageBitmap? = null,
+ @DrawableRes resourceId: Int? = null,
+) {
+ when {
+ imageVector != null -> {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint
+ )
+ }
+ bitmap != null -> {
+ Icon(
+ bitmap = bitmap,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint
+ )
+ }
+ resourceId != null -> {
+ Icon(
+ resourceId = resourceId,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint
+ )
+ }
+ }
+}
+
@Composable
fun Icon(
imageVector: ImageVector,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
index 0c98caaa7d..27d9f101f7 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
@@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -50,6 +53,7 @@ fun ModalBottomSheet(
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
content: @Composable ColumnScope.() -> Unit,
) {
androidx.compose.material3.ModalBottomSheet(
@@ -62,10 +66,19 @@ fun ModalBottomSheet(
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
+ windowInsets = windowInsets,
content = content,
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+fun SheetState.hide(coroutineScope: CoroutineScope, then: suspend () -> Unit) {
+ coroutineScope.launch {
+ hide()
+ then()
+ }
+}
+
// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
@Preview(group = PreviewGroup.BottomSheets)
@Composable
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt
index 423d1e46d1..358ca2abab 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt
@@ -67,7 +67,7 @@ fun OutlinedTextField(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
- maxLines: Int = Int.MAX_VALUE,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt
index 8376369fba..2ad41887aa 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt
@@ -68,7 +68,7 @@ fun TextField(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
- maxLines: Int = Int.MAX_VALUE,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
index 35b81ff324..1de46c78e0 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
@@ -22,13 +22,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -52,19 +53,16 @@ class SnackbarDispatcher {
}
}
+/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */
+val LocalSnackbarDispatcher = compositionLocalOf {
+ error("No SnackbarDispatcher provided")
+}
+
@Composable
fun handleSnackbarMessage(
snackbarDispatcher: SnackbarDispatcher
): SnackbarMessage? {
- val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
- LaunchedEffect(snackbarMessage) {
- if (snackbarMessage != null) {
- launch {
- snackbarDispatcher.clear()
- }
- }
- }
- return snackbarMessage
+ return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value
}
@Composable
@@ -74,6 +72,7 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId)
}
+ val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect
coroutineScope.launch {
@@ -81,6 +80,9 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
+ if (isActive) {
+ dispatcher.clear()
+ }
}
}
return snackbarHostState
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
index 169ec55a9d..ccf9c2976a 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
+import io.element.android.libraries.ui.strings.R as StringR
class StateContentFormatter @Inject constructor(
private val sp: StringProvider,
@@ -49,7 +50,7 @@ class StateContentFormatter @Inject constructor(
sp.getString(R.string.state_event_room_created, senderDisplayName)
}
}
- is OtherState.RoomEncryption -> sp.getString(io.element.android.libraries.ui.strings.R.string.common_encryption_enabled)
+ is OtherState.RoomEncryption -> sp.getString(StringR.string.common_encryption_enabled)
is OtherState.RoomName -> {
val hasRoomName = content.name != null
when {
diff --git a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
index ebb2826b86..69179b1276 100644
--- a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
@@ -54,4 +54,4 @@
"%1$s zrušil(a) vykázání %2$s"
"Zrušili jste vykázání pro %1$s"
"%1$s provedl(a) neznámou změnu svého členství"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
index 62854b7d46..0ca17bc4fe 100644
--- a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
@@ -54,4 +54,4 @@
"%1$s hat %2$s entbannt"
"Du hast %1$s entbannt"
"%1$s hat eine unbekannte Änderung an seiner Mitgliedschaft vorgenommen"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml
index 701f56f41c..dc732d9e97 100644
--- a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml
@@ -54,4 +54,4 @@
"%1$s readmitió a %2$s"
"Readmitiste a %1$s"
"%1$s realizó un cambio desconocido en su membresía"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
index bdf693e976..f69ea03050 100644
--- a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
@@ -54,4 +54,4 @@
"%1$s a débanni %2$s"
"Vous avez débanni %1$s"
"%1$s a apporté une modification inconnue à son adhésion"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
index 0380d802f4..2e2e914dfe 100644
--- a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
@@ -54,4 +54,4 @@
"%1$s ha sbloccato %2$s"
"Hai sbloccato %1$s"
"%1$s ha apportato una modifica sconosciuta alla propria iscrizione"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml
index 2e3abf93d0..2586ad3cd2 100644
--- a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml
@@ -54,4 +54,4 @@
"%1$s a anulat interdicția pentru %2$s"
"Ați anulat interdicția pentru %1$s"
"%1$s a făcut o modificare necunoscută asupra calității sale de membru"
-
\ No newline at end of file
+
diff --git a/libraries/eventformatter/impl/src/main/res/values/localazy.xml b/libraries/eventformatter/impl/src/main/res/values/localazy.xml
index 2fd4217cd4..03a13bd29b 100644
--- a/libraries/eventformatter/impl/src/main/res/values/localazy.xml
+++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml
@@ -54,4 +54,4 @@
"%1$s unbanned %2$s"
"You unbanned %1$s"
"%1$s made an unknown change to their membership"
-
\ No newline at end of file
+
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index f0dec42855..4a018e18da 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@@ -52,7 +53,7 @@ interface MatrixClient : Closeable {
suspend fun logout()
suspend fun loadUserDisplayName(): Result
suspend fun loadUserAvatarURLString(): Result
- suspend fun uploadMedia(mimeType: String, data: ByteArray): Result
+ suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result
fun onSlidingSyncUpdate()
fun roomMembershipObserver(): RoomMembershipObserver
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
index 4e6d468c54..bc0f0c04bc 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
@@ -34,7 +34,7 @@ object MatrixPatterns {
val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
// regex pattern to find room ids in a string.
- private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9-]+$DOMAIN_REGEX"
+ private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9.-]+$DOMAIN_REGEX"
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
// regex pattern to find room aliases in a string.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt
new file mode 100644
index 0000000000..2b41907eec
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt
@@ -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.libraries.matrix.api.core
+
+interface ProgressCallback {
+ fun onProgress(current: Long, total: Long)
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
index 6ed6e474b6..e9708a6926 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
@@ -16,8 +16,10 @@
package io.element.android.libraries.matrix.api.media
+import java.time.Duration
+
data class AudioInfo(
- val duration: Long?,
+ val duration: Duration?,
val size: Long?,
val mimeType: String?,
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt
index aa291bd653..b7af54c6b2 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt
@@ -16,8 +16,10 @@
package io.element.android.libraries.matrix.api.media
+import java.time.Duration
+
data class VideoInfo(
- val duration: Long?,
+ val duration: Duration?,
val height: Long?,
val width: Long?,
val mimetype: String?,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt
new file mode 100644
index 0000000000..6b2813feb8
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+class ForwardEventException(
+ val roomIds: List
+) : Exception() {
+
+ override val message: String? = "Failed to deliver event to $roomIds rooms"
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 92374b5b00..afd0e8ea25 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
@@ -73,16 +75,22 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result
- suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result
+ suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result
- suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result
+ suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result
- suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result
+ suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result
- suspend fun sendFile(file: File, fileInfo: FileInfo): Result
+ suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result
suspend fun sendReaction(emoji: String, eventId: EventId): Result
+ suspend fun forwardEvent(eventId: EventId, rooms: List): Result
+
+ suspend fun retrySendMessage(transactionId: String): Result
+
+ suspend fun cancelSend(transactionId: String): Result
+
suspend fun leave(): Result
suspend fun acceptInvitation(): Result
@@ -95,6 +103,8 @@ interface MatrixRoom : Closeable {
suspend fun canSendStateEvent(type: StateEventType): Result
+ suspend fun canSendEvent(type: MessageEventType): Result
+
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result
suspend fun removeAvatar(): Result
@@ -102,4 +112,6 @@ interface MatrixRoom : Closeable {
suspend fun setName(name: String): Result
suspend fun setTopic(topic: String): Result
+
+ suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt
new file mode 100644
index 0000000000..109e50e602
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+enum class MessageEventType {
+ CALL_ANSWER,
+ CALL_INVITE,
+ CALL_HANGUP,
+ CALL_CANDIDATES,
+ KEY_VERIFICATION_READY,
+ KEY_VERIFICATION_START,
+ KEY_VERIFICATION_CANCEL,
+ KEY_VERIFICATION_ACCEPT,
+ KEY_VERIFICATION_KEY,
+ KEY_VERIFICATION_MAC,
+ KEY_VERIFICATION_DONE,
+ REACTION_SENT,
+ ROOM_ENCRYPTED,
+ ROOM_MESSAGE,
+ ROOM_REDACTION,
+ STICKER
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt
index 091691b0ec..4eaf04d775 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt
@@ -42,4 +42,6 @@ interface MatrixTimeline {
suspend fun editMessage(originalEventId: EventId, message: String): Result
suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result
+
+ suspend fun fetchDetailsForEvent(eventId: EventId): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt
index 547e593a42..f84f1875e4 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt
@@ -24,6 +24,7 @@ sealed interface MatrixTimelineItem {
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
val uniqueId: String = event.uniqueIdentifier
val eventId: EventId? = event.eventId
+ val transactionId: String? = event.transactionId
}
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
index 05f440c413..2a5c068519 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
data class EventTimelineItem(
val uniqueIdentifier: String,
val eventId: EventId?,
+ val transactionId: String?,
val isEditable: Boolean,
val isLocal: Boolean,
val isOwn: Boolean,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index d8cd8c480f..268e09c764 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -20,6 +20,7 @@ package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
@@ -28,6 +29,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService
+import io.element.android.libraries.matrix.api.room.ForwardEventException
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
@@ -35,9 +37,11 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
+import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
@@ -50,6 +54,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@@ -60,6 +65,7 @@ import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.RequiredState
+import org.matrix.rustcomponents.sdk.RoomMessageEventContent
import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
@@ -197,6 +203,8 @@ class RustMatrixClient constructor(
private val roomMembershipObserver = RoomMembershipObserver()
+ private val roomContentForwarder = RoomContentForwarder(slidingSync)
+
init {
client.setDelegate(clientDelegate)
rustRoomSummaryDataSource.init()
@@ -218,6 +226,7 @@ class RustMatrixClient constructor(
coroutineScope = coroutineScope,
coroutineDispatchers = dispatchers,
clock = clock,
+ roomContentForwarder = roomContentForwarder,
)
}
@@ -351,9 +360,9 @@ class RustMatrixClient constructor(
}
@OptIn(ExperimentalUnsignedTypes::class)
- override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result = withContext(dispatchers.io) {
+ override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result = withContext(dispatchers.io) {
runCatching {
- client.uploadMedia(mimeType, data.toUByteArray().toList())
+ client.uploadMedia(mimeType, data.toUByteArray().toList(), progressCallback?.toProgressWatcher())
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt
new file mode 100644
index 0000000000..f904e13bd6
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.core
+
+import io.element.android.libraries.matrix.api.core.ProgressCallback
+import org.matrix.rustcomponents.sdk.ProgressWatcher
+import org.matrix.rustcomponents.sdk.TransmissionProgress
+
+internal class ProgressWatcherWrapper(private val progressCallback: ProgressCallback) : ProgressWatcher {
+ override fun transmissionProgress(progress: TransmissionProgress) {
+ progressCallback.onProgress(progress.current.toLong(), progress.total.toLong())
+ }
+}
+
+internal fun ProgressCallback.toProgressWatcher(): ProgressWatcher {
+ return ProgressWatcherWrapper(this)
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
index 7c35c14fb7..2f0d6879a4 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
@@ -20,13 +20,13 @@ import io.element.android.libraries.matrix.api.media.AudioInfo
import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
- duration = duration?.toLong(),
+ duration = duration,
size = size?.toLong(),
mimeType = mimetype
)
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
- duration = duration?.toULong(),
+ duration = duration,
size = size?.toULong(),
mimetype = mimeType,
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
index b474c2ab2e..661d1b9b33 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
@@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo
fun RustVideoInfo.map(): VideoInfo = VideoInfo(
- duration = duration?.toLong(),
+ duration = duration,
height = height?.toLong(),
width = width?.toLong(),
mimetype = mimetype,
@@ -31,7 +31,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
)
fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
- duration = duration?.toULong(),
+ duration = duration,
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt
index adb9dcce72..9b70582308 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt
@@ -16,8 +16,6 @@
package io.element.android.libraries.matrix.impl.notification
-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.notification.NotificationEvent
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
@@ -105,5 +103,6 @@ private fun MessageType.toContent(): String {
is MessageType.Notice -> content.body
is MessageType.Text -> content.body
is MessageType.Video -> content.use { it.body }
+ is MessageType.Location -> content.body
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt
new file mode 100644
index 0000000000..a117c1d313
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.room
+
+import io.element.android.libraries.matrix.api.room.MessageEventType
+import org.matrix.rustcomponents.sdk.MessageLikeEventType
+
+fun MessageEventType.map(): MessageLikeEventType = when (this) {
+ MessageEventType.CALL_ANSWER -> MessageLikeEventType.CALL_ANSWER
+ MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE
+ MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP
+ MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES
+ MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY
+ MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START
+ MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL
+ MessageEventType.KEY_VERIFICATION_ACCEPT -> MessageLikeEventType.KEY_VERIFICATION_ACCEPT
+ MessageEventType.KEY_VERIFICATION_KEY -> MessageLikeEventType.KEY_VERIFICATION_KEY
+ MessageEventType.KEY_VERIFICATION_MAC -> MessageLikeEventType.KEY_VERIFICATION_MAC
+ MessageEventType.KEY_VERIFICATION_DONE -> MessageLikeEventType.KEY_VERIFICATION_DONE
+ MessageEventType.REACTION_SENT -> MessageLikeEventType.REACTION_SENT
+ MessageEventType.ROOM_ENCRYPTED -> MessageLikeEventType.ROOM_ENCRYPTED
+ MessageEventType.ROOM_MESSAGE -> MessageLikeEventType.ROOM_MESSAGE
+ MessageEventType.ROOM_REDACTION -> MessageLikeEventType.ROOM_REDACTION
+ MessageEventType.STICKER -> MessageLikeEventType.STICKER
+}
+
+fun MessageLikeEventType.map(): MessageEventType = when (this) {
+ MessageLikeEventType.CALL_ANSWER -> MessageEventType.CALL_ANSWER
+ MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE
+ MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP
+ MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES
+ MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY
+ MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START
+ MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL
+ MessageLikeEventType.KEY_VERIFICATION_ACCEPT -> MessageEventType.KEY_VERIFICATION_ACCEPT
+ MessageLikeEventType.KEY_VERIFICATION_KEY -> MessageEventType.KEY_VERIFICATION_KEY
+ MessageLikeEventType.KEY_VERIFICATION_MAC -> MessageEventType.KEY_VERIFICATION_MAC
+ MessageLikeEventType.KEY_VERIFICATION_DONE -> MessageEventType.KEY_VERIFICATION_DONE
+ MessageLikeEventType.REACTION_SENT -> MessageEventType.REACTION_SENT
+ MessageLikeEventType.ROOM_ENCRYPTED -> MessageEventType.ROOM_ENCRYPTED
+ MessageLikeEventType.ROOM_MESSAGE -> MessageEventType.ROOM_MESSAGE
+ MessageLikeEventType.ROOM_REDACTION -> MessageEventType.ROOM_REDACTION
+ MessageLikeEventType.STICKER -> MessageEventType.STICKER
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt
new file mode 100644
index 0000000000..1f68c14456
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.room
+
+import io.element.android.libraries.core.coroutine.parallelMap
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.ForwardEventException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.withTimeout
+import org.matrix.rustcomponents.sdk.Room
+import org.matrix.rustcomponents.sdk.SlidingSync
+import org.matrix.rustcomponents.sdk.TimelineDiff
+import org.matrix.rustcomponents.sdk.TimelineListener
+import org.matrix.rustcomponents.sdk.genTransactionId
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Helper to forward event contents from a room to a set of other rooms.
+ * @param slidingSync the [SlidingSync] to fetch room instances to forward the event to
+ */
+class RoomContentForwarder(
+ private val slidingSync: SlidingSync,
+) {
+
+ /**
+ * Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds].
+ * @param fromRoom the room to forward the event from
+ * @param eventId the id of the event to forward
+ * @param toRoomIds the ids of the rooms to forward the event to
+ * @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
+ */
+ suspend fun forward(
+ fromRoom: Room,
+ eventId: EventId,
+ toRoomIds: List,
+ timeoutMs: Long = 5000L
+ ) {
+ val content = fromRoom.getTimelineEventContentByEventId(eventId.value)
+ val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> slidingSync.getRoom(roomId.value) }
+ val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } }
+ val failedForwardingTo = mutableSetOf()
+ targetRooms.parallelMap { room ->
+ room.use { targetRoom ->
+ val result = runCatching {
+ // Sending a message requires a registered timeline listener
+ targetRoom.addTimelineListener(NoOpTimelineListener)
+ withTimeout(timeoutMs.milliseconds) {
+ targetRoom.send(content, genTransactionId())
+ }
+ }
+ // After sending, we remove the timeline
+ targetRoom.removeTimeline()
+ result
+ }.onFailure {
+ failedForwardingTo.add(RoomId(room.id()))
+ if (it is CancellationException) {
+ throw it
+ }
+ }
+ }
+
+ if (failedForwardingTo.isNotEmpty()) {
+ throw ForwardEventException(toRoomIds.toList())
+ }
+ }
+
+ private object NoOpTimelineListener: TimelineListener {
+ override fun onUpdate(diff: TimelineDiff) = Unit
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 63d54892f7..bc8525d87b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@@ -27,14 +28,15 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
+import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
+import timber.log.Timber
import java.io.File
class RustMatrixRoom(
@@ -58,6 +61,7 @@ class RustMatrixRoom(
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val clock: SystemClock,
+ private val roomContentForwarder: RoomContentForwarder,
) : MatrixRoom {
override val membersStateFlow: StateFlow
@@ -235,62 +239,103 @@ class RustMatrixRoom(
}
}
- override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun canSendEvent(type: MessageEventType): Result = withContext(coroutineDispatchers.io) {
runCatching {
- innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
+ innerRoom.member(sessionId.value).use { it.canSendMessage(type.map()) }
}
}
- override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result = withContext(
+ coroutineDispatchers.io
+ ) {
runCatching {
- innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
+ innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher())
}
}
- override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result = withContext(
+ coroutineDispatchers.io
+ ) {
runCatching {
- innerRoom.sendAudio(file.path, audioInfo.map())
+ innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher())
}
}
- override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = withContext(coroutineDispatchers.io) {
+ override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = withContext(coroutineDispatchers.io) {
runCatching {
- innerRoom.sendFile(file.path, fileInfo.map())
+ innerRoom.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher())
}
}
- override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(Dispatchers.IO) {
+ override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result = withContext(coroutineDispatchers.io) {
+ runCatching {
+ innerRoom.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher())
+ }
+ }
+
+ override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.sendReaction(key = emoji, eventId = eventId.value)
}
}
+ override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = withContext(coroutineDispatchers.io) {
+ runCatching {
+ roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
+ }.onFailure {
+ Timber.e(it)
+ }
+ }
+
+ override suspend fun retrySendMessage(transactionId: String): Result =
+ withContext(coroutineDispatchers.io) {
+ runCatching {
+ innerRoom.retrySend(transactionId)
+ }
+ }
+
+ override suspend fun cancelSend(transactionId: String): Result =
+ withContext(coroutineDispatchers.io) {
+ runCatching {
+ innerRoom.cancelSend(transactionId)
+ }
+ }
+
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result =
- withContext(Dispatchers.IO) {
+ withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList())
}
}
override suspend fun removeAvatar(): Result =
- withContext(Dispatchers.IO) {
+ withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.removeAvatar()
}
}
override suspend fun setName(name: String): Result =
- withContext(Dispatchers.IO) {
+ withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.setName(name)
}
}
override suspend fun setTopic(topic: String): Result =
- withContext(Dispatchers.IO) {
+ withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.setTopic(topic)
}
}
+
+ override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result = withContext(coroutineDispatchers.io) {
+ runCatching {
+ innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
+ if (blockUserId != null) {
+ innerRoom.ignoreUser(blockUserId.value)
+ }
+ }
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
index ab94298418..41c602f0d2 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
@@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.SlidingSync
import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff
import org.matrix.rustcomponents.sdk.SlidingSyncSelectiveModeBuilder
-import org.matrix.rustcomponents.sdk.SlidingSyncState
+import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState
import org.matrix.rustcomponents.sdk.UpdateSummary
import timber.log.Timber
import java.io.Closeable
@@ -56,7 +56,7 @@ internal class RustRoomSummaryDataSource(
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
private val roomSummaries = MutableStateFlow>(emptyList())
- private val state = MutableStateFlow(SlidingSyncState.NOT_LOADED)
+ private val state = MutableStateFlow(SlidingSyncListLoadingState.NOT_LOADED)
fun init() {
coroutineScope.launch {
@@ -75,9 +75,9 @@ internal class RustRoomSummaryDataSource(
.launchIn(this)
slidingSyncList.state(this)
- .onEach { slidingSyncState ->
- Timber.v("New sliding sync state: $slidingSyncState")
- state.value = slidingSyncState
+ .onEach { SlidingSyncListLoadingState ->
+ Timber.v("New sliding sync state: $SlidingSyncListLoadingState")
+ state.value = SlidingSyncListLoadingState
}.launchIn(this)
}
@@ -107,7 +107,7 @@ internal class RustRoomSummaryDataSource(
private suspend fun didReceiveSyncUpdate(summary: UpdateSummary) {
Timber.v("UpdateRooms with identifiers: ${summary.rooms}")
- if (state.value != SlidingSyncState.FULLY_LOADED) {
+ if (state.value != SlidingSyncListLoadingState.FULLY_LOADED) {
return
}
updateRoomSummaries {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt
index 2aa0c59330..eb8019a79d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt
@@ -21,11 +21,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.SlidingSyncList
+import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState
import org.matrix.rustcomponents.sdk.SlidingSyncListRoomListObserver
import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsCountObserver
import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff
import org.matrix.rustcomponents.sdk.SlidingSyncListStateObserver
-import org.matrix.rustcomponents.sdk.SlidingSyncState
fun SlidingSyncList.roomListDiff(scope: CoroutineScope): Flow =
mxCallbackFlow {
@@ -39,9 +39,9 @@ fun SlidingSyncList.roomListDiff(scope: CoroutineScope): Flow = mxCallbackFlow {
+fun SlidingSyncList.state(scope: CoroutineScope): Flow = mxCallbackFlow {
val observer = object : SlidingSyncListStateObserver {
- override fun didReceiveUpdate(newState: SlidingSyncState) {
+ override fun didReceiveUpdate(newState: SlidingSyncListLoadingState) {
scope.launch {
send(newState)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
index c90e672f28..f7cf728691 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
@@ -22,15 +22,13 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelin
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
-import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.TimelineItem
-import timber.log.Timber
class MatrixTimelineItemMapper(
- private val room: Room,
+ private val fetchDetailsForEvent: suspend (EventId) -> Result,
private val coroutineScope: CoroutineScope,
private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(),
- private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(),
+ private val eventTimelineItemMapper: EventTimelineItemMapper = EventTimelineItemMapper(),
) {
fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use {
@@ -40,7 +38,7 @@ class MatrixTimelineItemMapper(
if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) {
- fetchDetailsForEvent(eventTimelineItem.eventId!!)
+ fetchEventDetails(eventTimelineItem.eventId!!)
}
return MatrixTimelineItem.Event(eventTimelineItem)
@@ -53,12 +51,7 @@ class MatrixTimelineItemMapper(
return MatrixTimelineItem.Other
}
- private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch {
- runCatching {
- room.fetchDetailsForEvent(eventId.value)
- }.onFailure {
- Timber.e(it)
- }
+ private fun fetchEventDetails(eventId: EventId) = coroutineScope.launch {
+ fetchDetailsForEvent(eventId)
}
-
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
index af27aedc13..4b07d59970 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
@@ -63,7 +63,7 @@ class RustMatrixTimeline(
)
private val timelineItemFactory = MatrixTimelineItemMapper(
- room = innerRoom,
+ fetchDetailsForEvent = this::fetchDetailsForEvent,
coroutineScope = coroutineScope,
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
eventTimelineItemMapper = EventTimelineItemMapper(
@@ -130,6 +130,12 @@ class RustMatrixTimeline(
return matrixRoom.replyMessage(inReplyToEventId, message)
}
+ override suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(coroutineDispatchers.io) {
+ runCatching {
+ innerRoom.fetchDetailsForEvent(eventId.value)
+ }
+ }
+
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result = withContext(coroutineDispatchers.io) {
runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
@@ -138,7 +144,8 @@ class RustMatrixTimeline(
}
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
- items = untilNumberOfItems.toUShort()
+ items = untilNumberOfItems.toUShort(),
+ waitForToken = true,
)
innerRoom.paginateBackwards(paginationOptions)
}.onFailure {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
index 8a052d1a3a..d45124bf40 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
@@ -65,9 +65,11 @@ class EventMessageMapper {
is MessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
+ is MessageType.Location,
null -> {
UnknownMessageType
}
+
}
}
val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
@@ -103,7 +105,7 @@ private fun RustFormattedBody.map(): FormattedBody = FormattedBody(
private fun RustMessageFormat.map(): MessageFormat {
return when (this) {
- RustMessageFormat.HTML -> MessageFormat.HTML
- RustMessageFormat.UNKNOWN -> MessageFormat.UNKNOWN
+ RustMessageFormat.Html -> MessageFormat.HTML
+ is RustMessageFormat.Unknown -> MessageFormat.UNKNOWN
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
index a77ddbd80f..bbb9c8fe2a 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
@@ -35,6 +35,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
EventTimelineItem(
uniqueIdentifier = it.uniqueIdentifier(),
eventId = it.eventId()?.let(::EventId),
+ transactionId = it.transactionId(),
isEditable = it.isEditable(),
isLocal = it.isLocal(),
isOwn = it.isOwn(),
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 79a8186e1a..8b38a74457 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -16,8 +16,8 @@
package io.element.android.libraries.matrix.test
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@@ -37,18 +37,15 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
-import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay
-import kotlinx.coroutines.test.StandardTestDispatcher
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
- private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
private val userDisplayName: Result = Result.success(A_USER_NAME),
private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
- override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers),
+ override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(),
@@ -120,7 +117,11 @@ class FakeMatrixClient(
return userAvatarURLString
}
- override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result {
+ override suspend fun uploadMedia(
+ mimeType: String,
+ data: ByteArray,
+ progressCallback: ProgressCallback?
+ ): Result {
return uploadMediaResult
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index 7e54d8e851..4cc9422eb7 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -37,6 +37,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
+const val A_TRANSACTION_ID = "aTransactionId"
const val A_UNIQUE_ID = "aUniqueId"
const val A_ROOM_NAME = "A room name"
@@ -54,8 +55,6 @@ const val AN_AVATAR_URL = "mxc://data"
const val A_FAILURE_REASON = "There has been a failure"
-const val FAKE_DELAY_IN_MS = 100L
-
val A_THROWABLE = Throwable(A_FAILURE_REASON)
val AN_EXCEPTION = Exception(A_FAILURE_REASON)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt
index 2b34a158a4..816bfc572a 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt
@@ -22,8 +22,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_USER_ID
-import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
-import kotlinx.coroutines.delay
+import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -58,27 +57,24 @@ class FakeAuthenticationService : MatrixAuthenticationService {
this.homeserver.value = homeserver
}
- override suspend fun setHomeserver(homeserver: String): Result {
- delay(FAKE_DELAY_IN_MS)
- return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
+ override suspend fun setHomeserver(homeserver: String): Result = simulateLongTask {
+ changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
}
- override suspend fun login(username: String, password: String): Result {
- delay(FAKE_DELAY_IN_MS)
- return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
+ override suspend fun login(username: String, password: String): Result = simulateLongTask {
+ loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
- override suspend fun getOidcUrl(): Result {
- return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
+ override suspend fun getOidcUrl(): Result = simulateLongTask {
+ oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
}
override suspend fun cancelOidcLogin(): Result {
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
}
- override suspend fun loginWithOidc(callbackUrl: String): Result {
- delay(FAKE_DELAY_IN_MS)
- return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
+ override suspend fun loginWithOidc(callbackUrl: String): Result = simulateLongTask {
+ loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
fun givenOidcError(throwable: Throwable?) {
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt
index 4282860c99..9ef0413a3a 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt
@@ -16,20 +16,16 @@
package io.element.android.libraries.matrix.test.media
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-import kotlin.coroutines.coroutineContext
+import io.element.android.tests.testutils.simulateLongTask
-class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader {
+class FakeMediaLoader : MatrixMediaLoader {
var shouldFail = false
- override suspend fun loadMediaContent(source: MediaSource): Result = withContext(coroutineDispatchers.io){
+ override suspend fun loadMediaContent(source: MediaSource): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
@@ -37,7 +33,7 @@ class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) :
}
}
- override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = withContext(coroutineDispatchers.io){
+ override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
@@ -45,7 +41,7 @@ class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) :
}
}
- override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(coroutineDispatchers.io){
+ override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 1199d94ac4..d8187b0a1d 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@@ -26,13 +27,13 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
-import kotlinx.coroutines.delay
+import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
@@ -65,12 +66,17 @@ class FakeMatrixRoom(
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf>()
+ private val canSendEventResults = mutableMapOf>()
private var sendMediaResult = Result.success(Unit)
private var setNameResult = Result.success(Unit)
private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
private var sendReactionResult = Result.success(Unit)
+ private var retrySendMessageResult = Result.success(Unit)
+ private var cancelSendResult = Result.success(Unit)
+ private var forwardEventResult = Result.success(Unit)
+ private var reportContentResult = Result.success(Unit)
var sendMediaCount = 0
private set
@@ -78,6 +84,15 @@ class FakeMatrixRoom(
var sendReactionCount = 0
private set
+ var retrySendMessageCount: Int = 0
+ private set
+
+ var cancelSendCount: Int = 0
+ private set
+
+ var reportedContentCount: Int = 0
+ private set
+
var isInviteAccepted: Boolean = false
private set
@@ -103,8 +118,8 @@ class FakeMatrixRoom(
override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown)
- override suspend fun updateMembers(): Result {
- return updateMembersResult
+ override suspend fun updateMembers(): Result = simulateLongTask {
+ updateMembersResult
}
override fun syncUpdateFlow(): Flow {
@@ -115,17 +130,16 @@ class FakeMatrixRoom(
return matrixTimeline
}
- override suspend fun userDisplayName(userId: UserId): Result {
- return userDisplayNameResult
+ override suspend fun userDisplayName(userId: UserId): Result = simulateLongTask {
+ userDisplayNameResult
}
- override suspend fun userAvatarUrl(userId: UserId): Result {
- return userAvatarUrlResult
+ override suspend fun userAvatarUrl(userId: UserId): Result = simulateLongTask {
+ userAvatarUrlResult
}
- override suspend fun sendMessage(message: String): Result {
- delay(FAKE_DELAY_IN_MS)
- return Result.success(Unit)
+ override suspend fun sendMessage(message: String): Result = simulateLongTask {
+ Result.success(Unit)
}
override suspend fun sendReaction(emoji: String, eventId: EventId): Result {
@@ -133,12 +147,21 @@ class FakeMatrixRoom(
return sendReactionResult
}
+ override suspend fun retrySendMessage(transactionId: String): Result {
+ retrySendMessageCount++
+ return retrySendMessageResult
+ }
+
+ override suspend fun cancelSend(transactionId: String): Result {
+ cancelSendCount++
+ return cancelSendResult
+ }
+
var editMessageParameter: String? = null
private set
override suspend fun editMessage(originalEventId: EventId, message: String): Result {
editMessageParameter = message
- delay(FAKE_DELAY_IN_MS)
return Result.success(Unit)
}
@@ -147,7 +170,6 @@ class FakeMatrixRoom(
override suspend fun replyMessage(eventId: EventId, message: String): Result {
replyMessageParameter = message
- delay(FAKE_DELAY_IN_MS)
return Result.success(Unit)
}
@@ -156,11 +178,11 @@ class FakeMatrixRoom(
override suspend fun redactEvent(eventId: EventId, reason: String?): Result {
redactEventEventIdParam = eventId
- delay(FAKE_DELAY_IN_MS)
return Result.success(Unit)
}
override suspend fun leave(): Result = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
+
override suspend fun acceptInvitation(): Result {
isInviteAccepted = true
return acceptInviteResult
@@ -171,9 +193,9 @@ class FakeMatrixRoom(
return rejectInviteResult
}
- override suspend fun inviteUserById(id: UserId): Result {
+ override suspend fun inviteUserById(id: UserId): Result = simulateLongTask {
invitedUserId = id
- return inviteUserResult
+ inviteUserResult
}
override suspend fun canInvite(): Result {
@@ -184,39 +206,60 @@ class FakeMatrixRoom(
return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
}
- override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = fakeSendMedia()
+ override suspend fun canSendEvent(type: MessageEventType): Result {
+ return canSendEventResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
+ }
- override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = fakeSendMedia()
+ override suspend fun sendImage(
+ file: File,
+ thumbnailFile: File,
+ imageInfo: ImageInfo,
+ progressCallback: ProgressCallback?
+ ): Result = fakeSendMedia()
- override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = fakeSendMedia()
+ override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia()
- override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = fakeSendMedia()
+ override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia()
- private suspend fun fakeSendMedia(): Result {
- delay(FAKE_DELAY_IN_MS)
- return sendMediaResult.onSuccess {
+ override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result