mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/fga/user_status_last_pieces
This commit is contained in:
@@ -51,7 +51,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
upload_reports:
|
||||
name: Project Check Suite
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
|
||||
- Click on OK
|
||||
4. Configure generate-module-from-template plugin :
|
||||
- Navigate to AS/Settings/Tools/Module Template Settings
|
||||
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
|
||||
imported template and will not pick up changes made to `FeatureModule.json` on its own
|
||||
- Click on + / Import From File
|
||||
- Pick the `tools/templates/FeatureModule.json`
|
||||
|
||||
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
|
||||
5. The modules api/impl should be created under `features/roomdetails` directory.
|
||||
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
|
||||
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
|
||||
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
|
||||
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
|
||||
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
|
||||
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
|
||||
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
|
||||
`PreferencesRoot` in the `features/preferences/impl/root` package.
|
||||
|
||||
|
||||
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
|
||||
|
||||
+14
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
|
||||
},
|
||||
onResume = ::onResume,
|
||||
onDestroy = {
|
||||
linkMobileHandlerJob?.cancel()
|
||||
linkDesktopHandlerJob?.cancel()
|
||||
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
private fun onResume() = sessionCoroutineScope.launch {
|
||||
// Application is resumed, if the step is waiting for auth, send the confirmation
|
||||
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
|
||||
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
|
||||
it.continuationMessageSender.confirm()
|
||||
}
|
||||
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
|
||||
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
|
||||
it.continuationMessageSender.confirm()
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToError(errorType: ErrorType) {
|
||||
// Map the error to an error screen
|
||||
val error = when (errorType) {
|
||||
|
||||
+9
-2
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.core.uri.isValidUrl
|
||||
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,8 +36,13 @@ class HomeserverResolver(
|
||||
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
||||
val flowContext = currentCoroutineContext()
|
||||
val trimmedUserInput = userInput.trim()
|
||||
if (trimmedUserInput.length < 4) return@flow
|
||||
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
|
||||
val searchTerm = when {
|
||||
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
|
||||
trimmedUserInput.startsWith("@") -> return@flow
|
||||
else -> trimmedUserInput
|
||||
}
|
||||
if (searchTerm.length < 4) return@flow
|
||||
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
|
||||
val list = getUrlCandidates(candidateBase)
|
||||
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
|
||||
// Run all the requests in parallel
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.resolver
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class HomeserverResolverTest {
|
||||
@Test
|
||||
fun `resolve - a server name is used as is`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - an url is used as is`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("https://example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id resolves the server name of the user id`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:example.org:8448").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org:8448")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice").test {
|
||||
awaitComplete()
|
||||
}
|
||||
sut.resolve("@alice:").test {
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:a.b").test {
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun TestScope.createHomeserverResolver(
|
||||
checkResult: (String) -> Result<Boolean>,
|
||||
) = HomeserverResolver(
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
)
|
||||
}
|
||||
+13
-1
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.MessagesEvent
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesPresenter
|
||||
import io.element.android.features.messages.impl.MessagesState
|
||||
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val threadRootEventId: ThreadId,
|
||||
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
customReactionBottomSheet = {
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onSelectEmoji = { uniqueId, emoji ->
|
||||
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
)
|
||||
},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
|
||||
+5
-5
@@ -801,15 +801,15 @@ private fun MessageEventBubbleContent(
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(shape)
|
||||
|
||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||
val talkbackCompatModifier = when {
|
||||
// Use z-index to make the replied to text being read after the message
|
||||
// Usually, you'd use traversalIndex for that, but it's not working for some reason
|
||||
inReplyToModifier.zIndex(1f)
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
|
||||
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
|
||||
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
}
|
||||
|
||||
val contentHasError = currentContentValidationState.hasError()
|
||||
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
|
||||
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
||||
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||
Box(
|
||||
|
||||
+17
@@ -8,11 +8,16 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
) {
|
||||
ExpandSheetWhenImeIsVisible(sheetState)
|
||||
emojiPickerRenderer.Render(
|
||||
state = state.target.emojiPickerState,
|
||||
onSelectEmoji = ::onEmojiSelectedDismiss,
|
||||
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
|
||||
val isImeVisible = WindowInsets.isImeVisible
|
||||
LaunchedEffect(isImeVisible) {
|
||||
if (isImeVisible) {
|
||||
sheetState.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
|
||||
private val mediaPreviewService: MediaPreviewService,
|
||||
private val room: BaseRoom,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val timelineProtectionStore: TimelineProtectionStore,
|
||||
) : Presenter<TimelineProtectionState> {
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineProtectionState {
|
||||
val mediaPreviewValue = remember {
|
||||
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
|
||||
if (isPreviewEnabled) {
|
||||
ProtectionState.RenderAll
|
||||
} else {
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
||||
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
|
||||
fun handleEvent(event: TimelineProtectionEvent) {
|
||||
when (event) {
|
||||
is TimelineProtectionEvent.ShowContent -> {
|
||||
allowedEvents += setOfNotNull(event.eventId)
|
||||
event.eventId?.let(timelineProtectionStore::allowEvent)
|
||||
}
|
||||
is TimelineProtectionEvent.ValidateContent -> {
|
||||
contentScannerService.scan(event.mediaSources, event.validationState)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
interface TimelineProtectionStore {
|
||||
val allowedEventIds: SnapshotStateSet<EventId>
|
||||
fun allowEvent(eventId: EventId)
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultTimelineProtectionStore : TimelineProtectionStore {
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
|
||||
|
||||
override fun allowEvent(eventId: EventId) {
|
||||
allowedEvents += eventId
|
||||
}
|
||||
}
|
||||
+65
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToIndex
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
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.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = InReplyToDetails.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetailsReady(),
|
||||
eventContent = MessageContent(
|
||||
body = "Replied message",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("Replied message", null),
|
||||
),
|
||||
textContent = "Replied message",
|
||||
),
|
||||
),
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
onAllNodesWithText("Replied message").onFirst().performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
|
||||
),
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onMessageClick = {},
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
|
||||
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
|
||||
eventsRecorder.assertEmpty()
|
||||
}
|
||||
|
||||
@Ignore(
|
||||
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
|
||||
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
|
||||
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
val sheetState = renderWithIme(isImeVisible = false)
|
||||
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
val sheetState = renderWithIme(isImeVisible = true)
|
||||
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
|
||||
lateinit var sheetState: SheetState
|
||||
lateinit var view: View
|
||||
setContent {
|
||||
view = LocalView.current
|
||||
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
|
||||
ExpandSheetWhenImeIsVisible(sheetState)
|
||||
}
|
||||
runOnIdle {
|
||||
ViewCompat.dispatchApplyWindowInsets(
|
||||
view,
|
||||
WindowInsetsCompat.Builder()
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
waitForIdle()
|
||||
return sheetState
|
||||
}
|
||||
+20
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - shown content is restored when the presenter is recreated`() = runTest {
|
||||
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
|
||||
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||
val timelineProtectionStore = DefaultTimelineProtectionStore()
|
||||
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||
}
|
||||
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - validate media scans the media source`() = runTest {
|
||||
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
|
||||
room: BaseRoom = FakeBaseRoom(),
|
||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
|
||||
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
|
||||
) = TimelineProtectionPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
timelineProtectionStore = timelineProtectionStore,
|
||||
)
|
||||
}
|
||||
|
||||
+35
-11
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -58,6 +59,7 @@ class RoomMemberListPresenter(
|
||||
override fun present(): RoomMemberListState {
|
||||
val searchQuery = rememberTextFieldState()
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
|
||||
val roomModerationState = roomMembersModerationPresenter.present()
|
||||
|
||||
@@ -72,13 +74,18 @@ class RoomMemberListPresenter(
|
||||
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
|
||||
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||
val activeCallParticipants = roomInfo.activeRoomCallParticipants
|
||||
|
||||
val comparator = remember(activeCallParticipants) {
|
||||
roomMemberListComparator(activeCallParticipants)
|
||||
}
|
||||
|
||||
// Update the room members when the screen is loaded
|
||||
LaunchedEffect(Unit) {
|
||||
room.updateMembers()
|
||||
}
|
||||
|
||||
LaunchedEffect(membersState, roomMemberIdentityStates) {
|
||||
LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) {
|
||||
if (membersState is RoomMembersState.Unknown) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -90,23 +97,23 @@ class RoomMemberListPresenter(
|
||||
}
|
||||
withContext(coroutineDispatchers.io) {
|
||||
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
||||
val info = room.info()
|
||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
|
||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) {
|
||||
// Don't display initial room member list if we have less than half of the joined members:
|
||||
// This result will come from the timeline loading membership events and it'll be wrong.
|
||||
// This result will come from the timeline loading membership events, and it'll be wrong.
|
||||
return@withContext
|
||||
}
|
||||
val result = RoomMembers(
|
||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||
.sortedWith(powerLevelRoomMemberComparator)
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.sortedWith(comparator)
|
||||
// .sortedWith(powerLevelRoomMemberComparator)
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
|
||||
.sortedBy { it.userId.value }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
)
|
||||
roomMembers = if (membersState is RoomMembersState.Pending) {
|
||||
@@ -150,12 +157,29 @@ class RoomMemberListPresenter(
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
||||
private suspend fun RoomMember.withIdentityState(
|
||||
identityStates: ImmutableMap<UserId, IdentityState>,
|
||||
activeCallParticipants: ImmutableList<UserId>
|
||||
): RoomMemberWithIdentityState {
|
||||
return if (room.info().isEncrypted != true) {
|
||||
RoomMemberWithIdentityState(this, null)
|
||||
RoomMemberWithIdentityState(this, null, userId in activeCallParticipants)
|
||||
} else {
|
||||
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull()
|
||||
RoomMemberWithIdentityState(this, identityState)
|
||||
RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roomMemberListComparator(
|
||||
activeCallParticipants: List<UserId>,
|
||||
): Comparator<RoomMember> =
|
||||
Comparator { a, b ->
|
||||
val aInCall = a.userId in activeCallParticipants
|
||||
val bInCall = b.userId in activeCallParticipants
|
||||
|
||||
when {
|
||||
aInCall && !bInCall -> -1
|
||||
!aInCall && bInCall -> 1
|
||||
else -> powerLevelRoomMemberComparator.compare(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -66,4 +66,5 @@ data class RoomMembers(
|
||||
data class RoomMemberWithIdentityState(
|
||||
val roomMember: RoomMember,
|
||||
val identityState: IdentityState?,
|
||||
val isInCall: Boolean
|
||||
)
|
||||
|
||||
+6
-2
@@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success(
|
||||
joined = persistentListOf(
|
||||
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||
aCarol().withIdentity(),
|
||||
aCarol().withIdentity(isInCall = true),
|
||||
aDavid().withIdentity(),
|
||||
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||
),
|
||||
@@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N
|
||||
|
||||
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN)
|
||||
|
||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState(
|
||||
this,
|
||||
identityState,
|
||||
isInCall
|
||||
)
|
||||
|
||||
+9
@@ -338,6 +338,15 @@ private fun RoomMemberListItem(
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
if (roomMemberWithIdentity.isInCall) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.VideoCallSolid(),
|
||||
contentDescription = stringResource(CommonStrings.action_video_call),
|
||||
tint = ElementTheme.colors.iconSuccessPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+32
@@ -191,6 +191,38 @@ class RoomMemberListPresenterTest {
|
||||
awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `call participants are shown first in joined members`() = runTest {
|
||||
val room = createFakeJoinedRoom().apply {
|
||||
givenRoomInfo(
|
||||
aRoomInfo(
|
||||
joinedMembersCount = 5,
|
||||
activeRoomCallParticipants = listOf(
|
||||
aCarol().userId
|
||||
),
|
||||
)
|
||||
)
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
|
||||
val presenter = createPresenter(joinedRoom = room)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
|
||||
val loadedState = awaitItem()
|
||||
val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined
|
||||
|
||||
assertThat(joined.first().roomMember.userId)
|
||||
.isEqualTo(aCarol().userId)
|
||||
|
||||
assertThat(joined.first().isInCall).isTrue()
|
||||
|
||||
assertThat(joined.count { it.isInCall }).isEqualTo(1)
|
||||
assertThat(joined.drop(1).all { !it.isInCall }).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFakeJoinedRoom(
|
||||
|
||||
@@ -14,7 +14,7 @@ firebaseAppDistribution = "5.3.0"
|
||||
core = "1.18.0"
|
||||
datastore = "1.2.1"
|
||||
constraintlayout = "2.2.2"
|
||||
constraintlayout_compose = "1.1.1"
|
||||
constraintlayout_compose = "1.1.2"
|
||||
lifecycle = "2.11.0"
|
||||
activity = "1.13.0"
|
||||
media3 = "1.10.1"
|
||||
@@ -199,7 +199,7 @@ serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-jso
|
||||
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1"
|
||||
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
|
||||
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
|
||||
jsoup = "org.jsoup:jsoup:1.22.2"
|
||||
jsoup = "org.jsoup:jsoup:1.23.1"
|
||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0"
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
|
||||
@@ -36,5 +36,5 @@ dependencies {
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
|
||||
queryState = state.searchQuery,
|
||||
resultState = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEqualTo
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.hasScrollToIndexAction
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
private val PICKER_HEIGHT = 600.dp
|
||||
|
||||
class EmojiPickerViewTest : RobolectricTest() {
|
||||
@Config(qualifiers = "w400dp-h800dp")
|
||||
@Test
|
||||
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setEmojiPickerView()
|
||||
onNode(hasScrollToIndexAction())
|
||||
.getBoundsInRoot()
|
||||
.bottom
|
||||
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
|
||||
setContent {
|
||||
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
|
||||
EmojiPickerView(
|
||||
state = aDefaultEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
hexcode = "0x00",
|
||||
label = "grinning face",
|
||||
tags = persistentListOf("grinning"),
|
||||
shortcodes = persistentListOf("smile"),
|
||||
unicode = "😀",
|
||||
skins = null,
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
onSelectEmoji = {},
|
||||
selectedEmojis = persistentSetOf(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
|
||||
import io.element.android.libraries.matrix.api.BuildConfig
|
||||
|
||||
object OAuthConfig {
|
||||
// This *can* be null if the build time config field is not set
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
|
||||
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
|
||||
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.linknewdevice
|
||||
|
||||
interface ContinuationMessageSender {
|
||||
suspend fun cancel(): Result<Unit>
|
||||
suspend fun confirm(): Result<Unit>
|
||||
}
|
||||
+2
@@ -18,8 +18,10 @@ interface LinkDesktopHandler {
|
||||
sealed interface LinkDesktopStep {
|
||||
data object Uninitialized : LinkDesktopStep
|
||||
data object Starting : LinkDesktopStep
|
||||
|
||||
data class WaitingForAuth(
|
||||
val verificationUri: String,
|
||||
val continuationMessageSender: ContinuationMessageSender,
|
||||
) : LinkDesktopStep
|
||||
|
||||
data class EstablishingSecureChannel(
|
||||
|
||||
+6
-1
@@ -22,7 +22,12 @@ sealed interface LinkMobileStep {
|
||||
data object Starting : LinkMobileStep
|
||||
data class QrReady(val data: String) : LinkMobileStep
|
||||
data object QrRotating : LinkMobileStep
|
||||
data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
|
||||
|
||||
data class WaitingForAuth(
|
||||
val verificationUri: String,
|
||||
val continuationMessageSender: ContinuationMessageSender,
|
||||
) : LinkMobileStep
|
||||
|
||||
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
|
||||
data class Error(val errorType: ErrorType) : LinkMobileStep
|
||||
data object SyncingSecrets : LinkMobileStep
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.linknewdevice
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender
|
||||
|
||||
class RustContinuationMessageSender(
|
||||
private val inner: FfiContinuationMessageSender,
|
||||
private val sessionDispatcher: CoroutineDispatcher,
|
||||
) : ContinuationMessageSender {
|
||||
override suspend fun cancel(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
inner.cancel()
|
||||
}.onFailure {
|
||||
Timber.e(it, "Error cancelling continuation message")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun confirm(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
inner.confirm()
|
||||
}.onFailure {
|
||||
Timber.e(it, "Error confirming continuation message")
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -75,6 +75,10 @@ class RustLinkDesktopHandler(
|
||||
GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
|
||||
is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
|
||||
verificationUri = verificationUri,
|
||||
continuationMessageSender = RustContinuationMessageSender(
|
||||
inner = continuationSender,
|
||||
sessionDispatcher = sessionDispatcher,
|
||||
)
|
||||
)
|
||||
is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
|
||||
checkCode = checkCode,
|
||||
|
||||
+7
-1
@@ -84,7 +84,13 @@ class RustLinkMobileHandler(
|
||||
)
|
||||
GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
|
||||
GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
|
||||
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri)
|
||||
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(
|
||||
verificationUri = verificationUri,
|
||||
continuationMessageSender = RustContinuationMessageSender(
|
||||
inner = continuationSender,
|
||||
sessionDispatcher = sessionDispatcher,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.linknewdevice
|
||||
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
class FakeContinuationMessageSender(
|
||||
private val cancelResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val confirmResult: () -> Result<Unit> = { lambdaError() },
|
||||
) : ContinuationMessageSender {
|
||||
override suspend fun cancel(): Result<Unit> = simulateLongTask {
|
||||
cancelResult()
|
||||
}
|
||||
|
||||
override suspend fun confirm(): Result<Unit> = simulateLongTask {
|
||||
confirmResult()
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -16,6 +16,8 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA
|
||||
import io.element.android.tests.testutils.ExpectedResult
|
||||
import io.element.android.tests.testutils.match
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest {
|
||||
runCurrent()
|
||||
// progress from the handler is mapped and emitted
|
||||
listOf(
|
||||
GrantQrLoginProgress.Starting to LinkDesktopStep.Starting,
|
||||
GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets,
|
||||
GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting),
|
||||
GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets),
|
||||
GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
||||
to LinkDesktopStep.WaitingForAuth("aVerificationUri"),
|
||||
to ExpectedResult(
|
||||
resultClass = LinkDesktopStep.WaitingForAuth::class.java,
|
||||
),
|
||||
GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
|
||||
to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"),
|
||||
GrantQrLoginProgress.Done to LinkDesktopStep.Done,
|
||||
).forEach { (progress, expectedStep) ->
|
||||
to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")),
|
||||
GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done),
|
||||
).forEach { (progress, expectedResult) ->
|
||||
handler.emitScanProgress(progress)
|
||||
assertThat(awaitItem()).isEqualTo(expectedStep)
|
||||
assertThat(awaitItem()).match(expectedResult)
|
||||
}
|
||||
// scan returns, no new event is emitted
|
||||
completable.complete(Unit)
|
||||
|
||||
+10
-8
@@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
|
||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
|
||||
import io.element.android.tests.testutils.ExpectedResult
|
||||
import io.element.android.tests.testutils.match
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest {
|
||||
runCurrent()
|
||||
// progress from the handler is mapped and emitted
|
||||
listOf(
|
||||
GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java,
|
||||
GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java,
|
||||
GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting),
|
||||
GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets),
|
||||
GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
||||
to LinkMobileStep.WaitingForAuth::class.java,
|
||||
to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java),
|
||||
GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
|
||||
to LinkMobileStep.QrScanned::class.java,
|
||||
to ExpectedResult(LinkMobileStep.QrScanned::class.java),
|
||||
GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))
|
||||
to LinkMobileStep.QrReady::class.java,
|
||||
GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java,
|
||||
).forEach { (progress, expectedStepClass) ->
|
||||
to ExpectedResult(LinkMobileStep.QrReady::class.java),
|
||||
GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done),
|
||||
).forEach { (progress, expectedResult) ->
|
||||
handler.emitGenerateProgress(progress)
|
||||
assertThat(awaitItem()).isInstanceOf(expectedStepClass)
|
||||
assertThat(awaitItem()).match(expectedResult)
|
||||
}
|
||||
// generate returns, no new event is emitted
|
||||
completable.complete(Unit)
|
||||
|
||||
+4
-4
@@ -8,12 +8,12 @@
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -40,8 +40,8 @@ fun CheckableUserRow(
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Checkbox, enabled = enabled) {
|
||||
onCheckedChange(!checked)
|
||||
.toggleable(value = checked, role = Role.Checkbox, enabled = enabled) {
|
||||
onCheckedChange(it)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -66,7 +66,7 @@ fun CheckableUserRow(
|
||||
}
|
||||
}
|
||||
Checkbox(
|
||||
onCheckedChange = onCheckedChange,
|
||||
onCheckedChange = null,
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
+9
-7
@@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
@Composable
|
||||
internal fun ContentErrorView(
|
||||
title: String,
|
||||
message: String,
|
||||
message: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
@@ -51,12 +51,14 @@ internal fun ContentErrorView(
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-18
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
@@ -77,7 +77,7 @@ fun InReplyToView(
|
||||
else -> ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
|
||||
ReplyToErrorContent(modifier = modifier)
|
||||
is InReplyToDetails.Loading ->
|
||||
ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
@@ -150,24 +150,15 @@ private fun ReplyToLoadingContent(
|
||||
|
||||
@Composable
|
||||
private fun ReplyToErrorContent(
|
||||
data: InReplyToDetails.Error,
|
||||
maxLines: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Text(
|
||||
text = data.message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ContentErrorView(
|
||||
title = stringResource(CommonStrings.error_message_not_found),
|
||||
message = null,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -22,7 +22,7 @@ object BuildTimeConfig {
|
||||
val URL_ACCEPTABLE_USE: String? = null
|
||||
val URL_PRIVACY: String? = null
|
||||
val URL_POLICY: String? = null
|
||||
val OAUTH_CLIENT_URL_PATH: String? = null
|
||||
val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
|
||||
val SERVICES_MAPTILER_BASE_URL: String? = null
|
||||
val SERVICES_MAPTILER_APIKEY: String? = null
|
||||
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.tests.testutils
|
||||
|
||||
import com.google.common.truth.Subject
|
||||
|
||||
/**
|
||||
* A helper class to assert expected results in tests. It can be used to assert either the type of result or its value.
|
||||
*/
|
||||
data class ExpectedResult<T : Any>(
|
||||
val resultClass: Class<out T>,
|
||||
val result: T? = null,
|
||||
) {
|
||||
constructor(result: T) : this(result::class.java, result)
|
||||
}
|
||||
|
||||
fun <T : Any> Subject.match(expectedResult: ExpectedResult<T>) {
|
||||
if (expectedResult.result != null) {
|
||||
isEqualTo(expectedResult.result)
|
||||
} else {
|
||||
isInstanceOf(expectedResult.resultClass)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +1 @@
|
||||
{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":"","BUILD_GRADLE_API":"build.gradle.kts","BUILD_GRADLE_IMPL":"build.gradle.kts"},"fileTemplates":{"${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","${BUILD_GRADLE_API}":"Template Module Feature Build Gradle API","${BUILD_GRADLE_IMPL}":"Template Module Feature Build Gradle Impl","${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_API}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_IMPL}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false}
|
||||
{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":""},"fileTemplates":{"api/${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","impl/Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","api/build.gradle.kts":"Template Module Feature Build Gradle API","impl/build.gradle.kts":"Template Module Feature Build Gradle Impl","impl/${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false}
|
||||
Reference in New Issue
Block a user