Merge remote-tracking branch 'origin/develop' into
feature/fre/create_room_select_avatar
This commit is contained in:
commit
029eb876df
149 changed files with 1030 additions and 600 deletions
1
.github/renovate.json
vendored
1
.github/renovate.json
vendored
|
|
@ -4,7 +4,6 @@
|
||||||
"config:base"
|
"config:base"
|
||||||
],
|
],
|
||||||
"labels": ["dependencies"],
|
"labels": ["dependencies"],
|
||||||
"reviewers": ["team:element-x-android-reviewers"],
|
|
||||||
"ignoreDeps": ["string:app_name"],
|
"ignoreDeps": ["string:app_name"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
1
.github/workflows/nightly.yml
vendored
1
.github/workflows/nightly.yml
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
name: Build and release nightly APK
|
name: Build and release nightly APK
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# Every nights at 4
|
# Every nights at 4
|
||||||
- cron: "0 4 * * *"
|
- cron: "0 4 * * *"
|
||||||
|
|
|
||||||
43
.github/workflows/nightly_manual.yml
vendored
43
.github/workflows/nightly_manual.yml
vendored
|
|
@ -1,43 +0,0 @@
|
||||||
name: Build and release nightly APK manually
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch
|
|
||||||
|
|
||||||
env:
|
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
nightly:
|
|
||||||
name: Build and publish nightly APK to Firebase
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use JDK 17
|
|
||||||
uses: actions/setup-java@v3
|
|
||||||
with:
|
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
|
||||||
java-version: '17'
|
|
||||||
- name: Install towncrier
|
|
||||||
run: |
|
|
||||||
python3 -m pip install towncrier
|
|
||||||
- name: Prepare changelog file
|
|
||||||
run: |
|
|
||||||
mv towncrier.toml towncrier.toml.bak
|
|
||||||
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
|
|
||||||
rm towncrier.toml.bak
|
|
||||||
yes n | towncrier build --version nightly
|
|
||||||
- name: Build and upload Nightly APK
|
|
||||||
run: |
|
|
||||||
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
|
|
||||||
env:
|
|
||||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
|
||||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
|
||||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
|
||||||
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
|
|
||||||
- name: Additionally upload Nightly APK to browserstack for testing
|
|
||||||
continue-on-error: true # don't block anything by this upload failing (for now)
|
|
||||||
run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
|
|
||||||
env:
|
|
||||||
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
|
|
||||||
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
|
|
||||||
5
.maestro/tests/assertions/assertRoomListSynced.yaml
Normal file
5
.maestro/tests/assertions/assertRoomListSynced.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${ROOM_NAME}
|
||||||
|
timeout: 10_000
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
|
- runFlow: ../assertions/assertRoomListSynced.yaml
|
||||||
- tapOn: "search"
|
- tapOn: "search"
|
||||||
- inputText: ${ROOM_NAME.substring(0, 3)}
|
- inputText: ${ROOM_NAME.substring(0, 3)}
|
||||||
- takeScreenshot: build/maestro/400-SearchRoom
|
- takeScreenshot: build/maestro/400-SearchRoom
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.appnav
|
package io.element.android.appnav
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -30,7 +28,6 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
||||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -29,7 +27,6 @@ import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
import io.element.android.libraries.push.providers.api.Distributor
|
import io.element.android.libraries.push.providers.api.Distributor
|
||||||
import io.element.android.libraries.push.providers.api.PushProvider
|
import io.element.android.libraries.push.providers.api.PushProvider
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
1
changelog.d/385.feature
Normal file
1
changelog.d/385.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Show pending invitations in room members list
|
||||||
|
|
@ -41,7 +41,6 @@ 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.components.TextButton
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddPeopleView(
|
fun AddPeopleView(
|
||||||
state: UserListState,
|
state: UserListState,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -61,6 +59,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConfigureRoomView(
|
fun ConfigureRoomView(
|
||||||
state: ConfigureRoomState,
|
state: ConfigureRoomState,
|
||||||
|
|
@ -150,6 +149,7 @@ fun ConfigureRoomView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConfigureRoomToolbar(
|
fun ConfigureRoomToolbar(
|
||||||
isNextActionEnabled: Boolean,
|
isNextActionEnabled: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,12 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
|
|
||||||
fun startDm(matrixUser: MatrixUser) {
|
fun startDm(matrixUser: MatrixUser) {
|
||||||
startDmAction.value = Async.Uninitialized
|
startDmAction.value = Async.Uninitialized
|
||||||
val existingDM = matrixClient.findDM(matrixUser.userId)
|
matrixClient.findDM(matrixUser.userId).use { existingDM ->
|
||||||
if (existingDM == null) {
|
if (existingDM == null) {
|
||||||
localCoroutineScope.createDM(matrixUser, startDmAction)
|
localCoroutineScope.createDM(matrixUser, startDmAction)
|
||||||
} else {
|
} else {
|
||||||
startDmAction.value = Async.Success(existingDM.roomId)
|
startDmAction.value = Async.Success(existingDM.roomId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.designsystem.R as DrawableR
|
import io.element.android.libraries.designsystem.R as DrawableR
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateRoomRootView(
|
fun CreateRoomRootView(
|
||||||
state: CreateRoomRootState,
|
state: CreateRoomRootState,
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
internal class AllMatrixUsersDataSourceTest {
|
internal class AllMatrixUsersDataSourceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.addpeople
|
package io.element.android.features.createroom.impl.addpeople
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -26,7 +24,6 @@ import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
import io.element.android.features.userlist.api.UserListDataStore
|
||||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||||
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
|
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -44,7 +42,6 @@ import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.root
|
package io.element.android.features.createroom.impl.root
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -35,7 +33,6 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
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.FakeMatrixRoom
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
|
||||||
|
|
@ -131,14 +131,18 @@ class InviteListPresenter @Inject constructor(
|
||||||
|
|
||||||
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<Async<RoomId>>) = launch {
|
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<Async<RoomId>>) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
client.getRoom(roomId)?.acceptInvitation()?.getOrThrow()
|
client.getRoom(roomId)?.use {
|
||||||
|
it.acceptInvitation().getOrThrow()
|
||||||
|
}
|
||||||
roomId
|
roomId
|
||||||
}.execute(acceptedAction)
|
}.execute(acceptedAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
|
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
client.getRoom(roomId)?.rejectInvitation()?.getOrThrow() ?: Unit
|
client.getRoom(roomId)?.use {
|
||||||
|
it.rejectInvitation().getOrThrow()
|
||||||
|
} ?: Unit
|
||||||
}.execute(declinedAction)
|
}.execute(declinedAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,9 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
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.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InviteListPresenterTests {
|
class InviteListPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -509,5 +507,4 @@ class InviteListPresenterTests {
|
||||||
unreadNotificationCount = 0,
|
unreadNotificationCount = 0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.changeserver
|
package io.element.android.features.login.impl.changeserver
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -28,11 +26,9 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
|
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_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class ChangeServerPresenterTest {
|
class ChangeServerPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - should start with default homeserver`() = runTest {
|
fun `present - should start with default homeserver`() = runTest {
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,18 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.login.impl.root.LoggedInState
|
|
||||||
import io.element.android.features.login.impl.root.LoginFormState
|
|
||||||
import io.element.android.features.login.impl.root.LoginRootEvents
|
|
||||||
import io.element.android.features.login.impl.root.LoginRootPresenter
|
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
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_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
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_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.logout.impl
|
package io.element.android.features.logout.impl
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -28,7 +26,6 @@ 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_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -52,6 +54,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
private val timelinePresenter: TimelinePresenter,
|
private val timelinePresenter: TimelinePresenter,
|
||||||
private val actionListPresenter: ActionListPresenter,
|
private val actionListPresenter: ActionListPresenter,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -71,6 +74,8 @@ class MessagesPresenter @Inject constructor(
|
||||||
|
|
||||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
|
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
|
||||||
|
|
||||||
|
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
|
||||||
|
|
||||||
LaunchedEffect(syncUpdateFlow) {
|
LaunchedEffect(syncUpdateFlow) {
|
||||||
roomAvatar.value =
|
roomAvatar.value =
|
||||||
AvatarData(
|
AvatarData(
|
||||||
|
|
@ -97,6 +102,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
|
snackbarMessage = snackbarMessage,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -32,5 +33,6 @@ data class MessagesState(
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState(
|
||||||
),
|
),
|
||||||
actionListState = anActionListState(),
|
actionListState = anActionListState(),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
|
snackbarMessage = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(
|
|
||||||
ExperimentalMaterial3Api::class,
|
|
||||||
ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
|
|
||||||
)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
@ -46,6 +41,7 @@ import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -83,6 +79,7 @@ import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesView(
|
fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
|
@ -94,7 +91,6 @@ fun MessagesView(
|
||||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
)
|
)
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val composerState = state.composerState
|
val composerState = state.composerState
|
||||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
|
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
|
||||||
ModalBottomSheetValue.Expanded
|
ModalBottomSheetValue.Expanded
|
||||||
|
|
@ -110,6 +106,19 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
||||||
|
if (snackbarMessageText != null) {
|
||||||
|
SideEffect {
|
||||||
|
coroutineScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = snackbarMessageText,
|
||||||
|
duration = state.snackbarMessage.duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||||
val localView = LocalView.current
|
val localView = LocalView.current
|
||||||
|
|
||||||
|
|
@ -226,6 +235,7 @@ fun MessagesViewContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesViewTopBar(
|
fun MessagesViewTopBar(
|
||||||
roomTitle: String?,
|
roomTitle: String?,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterialApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.actionlist
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -49,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionListView(
|
fun ActionListView(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
|
|
@ -90,6 +89,7 @@ fun ActionListView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetContent(
|
private fun SheetContent(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
|
|
@ -145,6 +145,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
|
||||||
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: ActionListState) {
|
private fun ContentToPreview(state: ActionListState) {
|
||||||
ActionListView(
|
ActionListView(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import io.element.android.libraries.core.data.toStableCharSequence
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -40,11 +42,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaType
|
import io.element.android.libraries.mediaupload.api.MediaType
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class MessageComposerPresenter @Inject constructor(
|
class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
@ -53,6 +57,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -60,6 +65,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
|
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
|
||||||
|
if (uri == null) return@registerGalleryPicker
|
||||||
Timber.d("Media picked from $uri")
|
Timber.d("Media picked from $uri")
|
||||||
// We don't know which type of media was retrieved, so we need this check
|
// We don't know which type of media was retrieved, so we need this check
|
||||||
val mediaType = when {
|
val mediaType = when {
|
||||||
|
|
@ -67,22 +73,25 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
mimeType.isMimeTypeVideo() -> MediaType.Video
|
mimeType.isMimeTypeVideo() -> MediaType.Video
|
||||||
else -> error("MimeType must be either image/* or video/*")
|
else -> error("MimeType must be either image/* or video/*")
|
||||||
}
|
}
|
||||||
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
|
appCoroutineScope.sendMedia(uri, mediaType)
|
||||||
})
|
})
|
||||||
|
|
||||||
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
|
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
|
||||||
|
if (uri == null) return@registerFilePicker
|
||||||
Timber.d("File picked from $uri")
|
Timber.d("File picked from $uri")
|
||||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
|
appCoroutineScope.sendMedia(uri, MediaType.File)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||||
|
if (uri == null) return@registerCameraPhotoPicker
|
||||||
Timber.d("Photo saved at $uri")
|
Timber.d("Photo saved at $uri")
|
||||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
|
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||||
|
if (uri == null) return@registerCameraVideoPicker
|
||||||
Timber.d("Video saved at $uri")
|
Timber.d("Video saved at $uri")
|
||||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
|
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
|
|
@ -181,14 +190,44 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.handleMediaPreProcessing(
|
private fun CoroutineScope.sendMedia(
|
||||||
uri: Uri?,
|
uri: Uri,
|
||||||
mediaType: MediaType,
|
mediaType: MediaType,
|
||||||
deleteOriginal: Boolean = false
|
deleteOriginal: Boolean = false
|
||||||
) = launch {
|
) = launch {
|
||||||
if (uri == null) return@launch
|
runCatching {
|
||||||
|
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
|
||||||
|
when (info) {
|
||||||
|
is MediaUploadInfo.Image -> {
|
||||||
|
room.sendImage(info.file, info.thumbnailInfo.file, info.info)
|
||||||
|
}
|
||||||
|
|
||||||
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
|
is MediaUploadInfo.Video -> {
|
||||||
|
room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MediaUploadInfo.AnyFile -> {
|
||||||
|
room.sendFile(info.file, info.info)
|
||||||
|
}
|
||||||
|
else -> error("Unexpected MediaUploadInfo format: $info")
|
||||||
|
}.getOrThrow()
|
||||||
|
}.onFailure {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
|
||||||
|
Timber.e(it, "Couldn't upload media")
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.d("Media uploaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMediaPreProcessing(
|
||||||
|
uri: Uri,
|
||||||
|
mediaType: MediaType,
|
||||||
|
deleteOriginal: Boolean,
|
||||||
|
): Result<MediaUploadInfo> {
|
||||||
|
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
|
||||||
Timber.d("Pre-processed media result: $result")
|
Timber.d("Pre-processed media result: $result")
|
||||||
|
return result.onFailure {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages
|
package io.element.android.features.messages
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -31,6 +29,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.AN_EVENT_ID
|
||||||
|
|
@ -39,7 +38,6 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -136,6 +134,7 @@ class MessagesPresenterTest {
|
||||||
mediaPickerProvider = FakePickerProvider(),
|
mediaPickerProvider = FakePickerProvider(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
mediaPreProcessor = FakeMediaPreProcessor(),
|
mediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
)
|
)
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
|
|
@ -148,6 +147,7 @@ class MessagesPresenterTest {
|
||||||
timelinePresenter = timelinePresenter,
|
timelinePresenter = timelinePresenter,
|
||||||
actionListPresenter = actionListPresenter,
|
actionListPresenter = actionListPresenter,
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.actionlist
|
package io.element.android.features.messages.actionlist
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -23,9 +21,9 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
|
@ -37,7 +35,6 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_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.A_USER_NAME
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.fixtures
|
package io.element.android.features.messages.fixtures
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
|
|
@ -34,7 +32,6 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
|
|
||||||
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
|
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.textcomposer
|
package io.element.android.features.messages.textcomposer
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -29,9 +27,13 @@ import io.element.android.features.messages.impl.textcomposer.MessageComposerPre
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.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.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -42,14 +44,17 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MessageComposerPresenterTest {
|
class MessageComposerPresenterTest {
|
||||||
|
|
||||||
|
|
@ -62,6 +67,7 @@ class MessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
private val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
|
|
@ -285,16 +291,87 @@ class MessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Pick media from gallery`() = runTest {
|
fun `present - Pick image from gallery`() = runTest {
|
||||||
val presenter = createPresenter(this)
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||||
|
mediaPreProcessor.givenResult(
|
||||||
|
Result.success(
|
||||||
|
MediaUploadInfo.Image(
|
||||||
|
file = File("/some/path"),
|
||||||
|
info = ImageInfo(
|
||||||
|
width = null,
|
||||||
|
height = null,
|
||||||
|
mimetype = null,
|
||||||
|
size = null,
|
||||||
|
thumbnailInfo = null,
|
||||||
|
thumbnailUrl = null,
|
||||||
|
blurhash = null,
|
||||||
|
),
|
||||||
|
thumbnailInfo = ThumbnailProcessingInfo(
|
||||||
|
file = File("/some/path"),
|
||||||
|
info = ThumbnailInfo(
|
||||||
|
width = null,
|
||||||
|
height = null,
|
||||||
|
mimetype = null,
|
||||||
|
size = null,
|
||||||
|
),
|
||||||
|
blurhash = "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||||
|
// Wait for the launched upload coroutine to run
|
||||||
|
runCurrent()
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO verify some post processing of the selected media is done
|
@Test
|
||||||
|
fun `present - Pick video from gallery`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
|
pickerProvider.givenMimeType(MimeTypes.Videos)
|
||||||
|
mediaPreProcessor.givenResult(
|
||||||
|
Result.success(
|
||||||
|
MediaUploadInfo.Video(
|
||||||
|
file = File("/some/path"),
|
||||||
|
info = VideoInfo(
|
||||||
|
width = null,
|
||||||
|
height = null,
|
||||||
|
mimetype = null,
|
||||||
|
duration = null,
|
||||||
|
size = null,
|
||||||
|
thumbnailInfo = null,
|
||||||
|
thumbnailUrl = null,
|
||||||
|
blurhash = null,
|
||||||
|
),
|
||||||
|
thumbnailInfo = ThumbnailProcessingInfo(
|
||||||
|
file = File("/some/path"),
|
||||||
|
info = ThumbnailInfo(
|
||||||
|
width = null,
|
||||||
|
height = null,
|
||||||
|
mimetype = null,
|
||||||
|
size = null,
|
||||||
|
),
|
||||||
|
blurhash = "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||||
|
// Wait for the launched upload coroutine to run
|
||||||
|
runCurrent()
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,7 +391,7 @@ class MessageComposerPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
|
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
|
||||||
val presenter = createPresenter(this)
|
val presenter = createPresenter(this)
|
||||||
with(pickerProvider){
|
with(pickerProvider) {
|
||||||
givenResult(null) // Simulate a user canceling the flow
|
givenResult(null) // Simulate a user canceling the flow
|
||||||
givenMimeType(MimeTypes.Images)
|
givenMimeType(MimeTypes.Images)
|
||||||
}
|
}
|
||||||
|
|
@ -329,40 +406,67 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Pick file from storage`() = runTest {
|
fun `present - Pick file from storage`() = runTest {
|
||||||
val presenter = createPresenter(this)
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||||
|
// Wait for the launched upload coroutine to run
|
||||||
|
runCurrent()
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Take photo`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
|
||||||
|
// Wait for the launched upload coroutine to run
|
||||||
|
runCurrent()
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Record video`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
|
||||||
|
// Wait for the launched upload coroutine to run
|
||||||
|
runCurrent()
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Uploading media failure can be recovered from`() = runTest {
|
||||||
|
val room = FakeMatrixRoom().apply {
|
||||||
|
givenSendMediaResult(Result.failure(Exception()))
|
||||||
|
}
|
||||||
|
val presenter = createPresenter(this, room = room)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||||
|
|
||||||
// TODO verify some post processing of the selected media is done
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
}
|
// Initial value is always null
|
||||||
}
|
skipItems(1)
|
||||||
|
// Assert error message received
|
||||||
@Test
|
assertThat(awaitItem()).isNotNull()
|
||||||
fun `present - Take photo`() = runTest {
|
}
|
||||||
val presenter = createPresenter(this)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
|
|
||||||
|
|
||||||
// TODO verify some post processing of the captured image is done
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Record video`() = runTest {
|
|
||||||
val presenter = createPresenter(this)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
|
|
||||||
|
|
||||||
// TODO verify some post processing of the captured video is done
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,8 +485,9 @@ class MessageComposerPresenterTest {
|
||||||
pickerProvider: PickerProvider = this.pickerProvider,
|
pickerProvider: PickerProvider = this.pickerProvider,
|
||||||
featureFlagService: FeatureFlagService = this.featureFlagService,
|
featureFlagService: FeatureFlagService = this.featureFlagService,
|
||||||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||||
|
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor
|
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.messages.timeline
|
package io.element.android.features.messages.timeline
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -27,8 +25,6 @@ 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.TimelinePresenter
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
import com.google.accompanist.pager.HorizontalPager
|
||||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
import com.google.accompanist.pager.rememberPagerState
|
||||||
|
|
@ -53,9 +52,7 @@ import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnBoardingScreen(
|
fun OnBoardingScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.developer
|
package io.element.android.features.preferences.impl.developer
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -24,7 +22,6 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -29,7 +27,6 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.rageshake.impl.bugreport
|
package io.element.android.features.rageshake.impl.bugreport
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -28,7 +26,6 @@ import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
|
||||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.rageshake.impl.crash.ui
|
package io.element.android.features.rageshake.impl.crash.ui
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -26,7 +24,6 @@ import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
|
||||||
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
|
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
|
||||||
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
|
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
|
||||||
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.rageshake.impl.detection
|
package io.element.android.features.rageshake.impl.detection
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
|
@ -31,10 +29,10 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
|
||||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class RageshakeDetectionPresenterTest {
|
class RageshakeDetectionPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -101,7 +99,7 @@ class RageshakeDetectionPresenterTest {
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test(timeout = 30.seconds) {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isStarted).isFalse()
|
assertThat(initialState.isStarted).isFalse()
|
||||||
|
|
@ -155,7 +153,7 @@ class RageshakeDetectionPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - screenshot then disable`() = runTest {
|
fun `present - screenshot then disable`() = runTest(timeout = 1.seconds) {
|
||||||
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
|
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
|
||||||
val rageshake = FakeRageShake(isAvailableValue = true)
|
val rageshake = FakeRageShake(isAvailableValue = true)
|
||||||
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
|
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.features.rageshake.impl.preferences
|
package io.element.android.features.rageshake.impl.preferences
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
|
|
@ -26,7 +24,6 @@ import io.element.android.features.rageshake.api.preferences.RageshakePreference
|
||||||
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
|
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.features.userlist.api)
|
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
api(projects.features.roomdetails.api)
|
api(projects.features.roomdetails.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
@ -51,7 +50,6 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.features.userlist.impl)
|
|
||||||
testImplementation(projects.features.userlist.test)
|
testImplementation(projects.features.userlist.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ 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.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -124,7 +125,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
MatrixRoomMembersState.Unknown -> Async.Uninitialized
|
MatrixRoomMembersState.Unknown -> Async.Uninitialized
|
||||||
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
|
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
|
||||||
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
|
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
|
||||||
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.size)
|
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,31 +17,17 @@
|
||||||
package io.element.android.features.roomdetails.impl.di
|
package io.element.android.features.roomdetails.impl.di
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
|
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import javax.inject.Named
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ContributesTo(RoomScope::class)
|
@ContributesTo(RoomScope::class)
|
||||||
interface RoomMemberBindsModule {
|
object RoomMemberModule {
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Named("RoomMembers")
|
|
||||||
fun bindRoomMemberUserListDataSource(dataSource: RoomUserListDataSource): UserListDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@ContributesTo(RoomScope::class)
|
|
||||||
object RoomMemberProvidesModule {
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideRoomMemberDetailsPresenterFactory(
|
fun provideRoomMemberDetailsPresenterFactory(
|
||||||
|
|
@ -16,27 +16,23 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
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.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import kotlinx.coroutines.flow.dropWhile
|
import kotlinx.coroutines.flow.dropWhile
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RoomUserListDataSource @Inject constructor(
|
class RoomMemberListDataSource @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : UserListDataSource {
|
) {
|
||||||
|
|
||||||
override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
|
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
|
||||||
val roomMembers = room.membersStateFlow
|
val roomMembers = room.membersStateFlow
|
||||||
.dropWhile { it !is MatrixRoomMembersState.Ready }
|
.dropWhile { it !is MatrixRoomMembersState.Ready }
|
||||||
.first()
|
.first()
|
||||||
|
|
@ -50,11 +46,7 @@ class RoomUserListDataSource @Inject constructor(
|
||||||
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filteredMembers.map(RoomMember::toMatrixUser)
|
filteredMembers
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -16,8 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
|
|
||||||
sealed interface RoomMemberListEvents {
|
sealed interface RoomMemberListEvents {
|
||||||
data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
|
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
||||||
|
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,54 +18,73 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import io.element.android.features.userlist.api.SelectionMode
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
|
||||||
import io.element.android.features.userlist.api.UserListPresenter
|
|
||||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Named
|
|
||||||
|
|
||||||
class RoomMemberListPresenter @Inject constructor(
|
class RoomMemberListPresenter @Inject constructor(
|
||||||
private val userListPresenterFactory: UserListPresenter.Factory,
|
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||||
@Named("RoomMembers") private val userListDataSource: UserListDataSource,
|
|
||||||
private val userListDataStore: UserListDataStore,
|
|
||||||
private val room: MatrixRoom,
|
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : Presenter<RoomMemberListState> {
|
) : Presenter<RoomMemberListState> {
|
||||||
|
|
||||||
private val userListPresenter by lazy {
|
|
||||||
userListPresenterFactory.create(
|
|
||||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
|
||||||
userListDataSource,
|
|
||||||
userListDataStore,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomMemberListState {
|
override fun present(): RoomMemberListState {
|
||||||
val userListState = userListPresenter.present()
|
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
|
||||||
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var searchResults by remember {
|
||||||
|
mutableStateOf<RoomMemberSearchResultState>(RoomMemberSearchResultState.NotSearching)
|
||||||
|
}
|
||||||
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
|
val members = roomMemberListDataSource.search("").groupBy { it.membership }
|
||||||
|
roomMembers = Async.Success(
|
||||||
|
RoomMembers(
|
||||||
|
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||||
|
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(searchQuery) {
|
||||||
|
withContext(coroutineDispatchers.io) {
|
||||||
|
searchResults = if (searchQuery.isEmpty()) {
|
||||||
|
RoomMemberSearchResultState.NotSearching
|
||||||
|
} else {
|
||||||
|
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
|
||||||
|
if (results.isEmpty()) RoomMemberSearchResultState.NoResults
|
||||||
|
else RoomMemberSearchResultState.Results(
|
||||||
|
RoomMembers(
|
||||||
|
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||||
|
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return RoomMemberListState(
|
return RoomMemberListState(
|
||||||
allUsers = allUsers.value,
|
roomMembers = roomMembers,
|
||||||
userListState = userListState,
|
searchQuery = searchQuery,
|
||||||
|
searchResults = searchResults,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
eventSink = { event ->
|
||||||
|
when (event) {
|
||||||
|
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||||
|
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,30 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import io.element.android.features.userlist.api.UserListState
|
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class RoomMemberListState(
|
data class RoomMemberListState(
|
||||||
val allUsers: Async<ImmutableList<MatrixUser>>,
|
val roomMembers: Async<RoomMembers>,
|
||||||
val userListState: UserListState,
|
val searchQuery: String,
|
||||||
|
val searchResults: RoomMemberSearchResultState,
|
||||||
|
val isSearchActive: Boolean,
|
||||||
|
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RoomMembers(
|
||||||
|
val invited: ImmutableList<RoomMember>,
|
||||||
|
val joined: ImmutableList<RoomMember>
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface RoomMemberSearchResultState {
|
||||||
|
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
|
||||||
|
object NotSearching : RoomMemberSearchResultState
|
||||||
|
|
||||||
|
/** The search has completed, but no results were found. */
|
||||||
|
object NoResults : RoomMemberSearchResultState
|
||||||
|
|
||||||
|
/** The search has completed, and some matching users were found. */
|
||||||
|
data class Results(val results: RoomMembers) : RoomMemberSearchResultState
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,27 +17,93 @@
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.userlist.api.UserSearchResultState
|
|
||||||
import io.element.android.features.userlist.api.aUserListState
|
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
|
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
|
||||||
override val values: Sequence<RoomMemberListState>
|
override val values: Sequence<RoomMemberListState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))),
|
aRoomMemberListState(
|
||||||
aRoomMemberListState(allUsers = Async.Loading())
|
roomMembers = Async.Success(
|
||||||
|
RoomMembers(
|
||||||
|
invited = persistentListOf(aVictor(), aWalter()),
|
||||||
|
joined = persistentListOf(anAlice(), aBob()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
aRoomMemberListState(roomMembers = Async.Loading()),
|
||||||
|
aRoomMemberListState().copy(isSearchActive = false),
|
||||||
|
aRoomMemberListState().copy(isSearchActive = true),
|
||||||
|
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||||
|
aRoomMemberListState().copy(
|
||||||
|
isSearchActive = true,
|
||||||
|
searchQuery = "@someone:matrix.org",
|
||||||
|
searchResults = RoomMemberSearchResultState.Results(
|
||||||
|
RoomMembers(
|
||||||
|
invited = persistentListOf(aVictor()),
|
||||||
|
joined = persistentListOf(anAlice()),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
aRoomMemberListState().copy(
|
||||||
|
isSearchActive = true,
|
||||||
|
searchQuery = "something-with-no-results",
|
||||||
|
searchResults = RoomMemberSearchResultState.NoResults
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aRoomMemberListState(
|
internal fun aRoomMemberListState(
|
||||||
searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
|
roomMembers: Async<RoomMembers> = Async.Uninitialized,
|
||||||
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
|
searchResults: RoomMemberSearchResultState = RoomMemberSearchResultState.NotSearching,
|
||||||
) =
|
) = RoomMemberListState(
|
||||||
RoomMemberListState(
|
roomMembers = roomMembers,
|
||||||
userListState = aUserListState().copy(searchResults = searchResults),
|
searchQuery = "",
|
||||||
allUsers = allUsers,
|
searchResults = searchResults,
|
||||||
)
|
isSearchActive = false,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aRoomMember(
|
||||||
|
userId: UserId = UserId("@alice:server.org"),
|
||||||
|
displayName: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
|
membership: RoomMembershipState = RoomMembershipState.JOIN,
|
||||||
|
isNameAmbiguous: Boolean = false,
|
||||||
|
powerLevel: Long = 0L,
|
||||||
|
normalizedPowerLevel: Long = 0L,
|
||||||
|
isIgnored: Boolean = false,
|
||||||
|
) = RoomMember(
|
||||||
|
userId = userId,
|
||||||
|
displayName = displayName,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
membership = membership,
|
||||||
|
isNameAmbiguous = isNameAmbiguous,
|
||||||
|
powerLevel = powerLevel,
|
||||||
|
normalizedPowerLevel = normalizedPowerLevel,
|
||||||
|
isIgnored = isIgnored,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aRoomMemberList() = listOf(
|
||||||
|
anAlice(),
|
||||||
|
aBob(),
|
||||||
|
aRoomMember(UserId("@carol:server.org"), "Carol"),
|
||||||
|
aRoomMember(UserId("@david:server.org"), "David"),
|
||||||
|
aRoomMember(UserId("@eve:server.org"), "Eve"),
|
||||||
|
aRoomMember(UserId("@justin:server.org"), "Justin"),
|
||||||
|
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
|
||||||
|
aRoomMember(UserId("@susie:server.org"), "Susie"),
|
||||||
|
aVictor(),
|
||||||
|
aWalter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice")
|
||||||
|
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob")
|
||||||
|
|
||||||
|
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
||||||
|
|
||||||
|
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,31 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -39,37 +50,42 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.roomdetails.impl.R
|
import io.element.android.features.roomdetails.impl.R
|
||||||
import io.element.android.features.userlist.api.components.SearchSingleUserResultItem
|
|
||||||
import io.element.android.features.userlist.api.components.UserListView
|
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.isLoading
|
import io.element.android.libraries.architecture.isLoading
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
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.Scaffold
|
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.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberListView(
|
fun RoomMemberListView(
|
||||||
state: RoomMemberListState,
|
state: RoomMemberListState,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
|
onMemberSelected: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackPressed: () -> Unit = {},
|
|
||||||
onMemberSelected: (UserId) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun onUserSelected(user: MatrixUser) {
|
fun onUserSelected(roomMember: RoomMember) {
|
||||||
onMemberSelected(user.userId)
|
onMemberSelected(roomMember.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!state.userListState.isSearchActive) {
|
if (!state.isSearchActive) {
|
||||||
RoomMemberListTopBar(onBackPressed = onBackPressed)
|
RoomMemberListTopBar(onBackPressed = onBackPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,33 +96,26 @@ fun RoomMemberListView(
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
UserListView(
|
Column {
|
||||||
state = state.userListState,
|
RoomMemberSearchBar(
|
||||||
onUserSelected = ::onUserSelected,
|
query = state.searchQuery,
|
||||||
)
|
state = state.searchResults,
|
||||||
|
active = state.isSearchActive,
|
||||||
|
placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
|
||||||
|
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
|
||||||
|
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
|
||||||
|
onUserSelected = ::onUserSelected,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.userListState.isSearchActive) {
|
if (!state.isSearchActive) {
|
||||||
if (state.allUsers is Async.Success) {
|
if (state.roomMembers is Async.Success) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
RoomMemberList(
|
||||||
item {
|
roomMembers = state.roomMembers.state,
|
||||||
val memberCount = state.allUsers.state.count()
|
onUserSelected = ::onUserSelected,
|
||||||
Text(
|
)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
} else if (state.roomMembers.isLoading()) {
|
||||||
text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount),
|
|
||||||
style = ElementTextStyles.Regular.callout,
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(state.allUsers.state) { matrixUser ->
|
|
||||||
SearchSingleUserResultItem(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
matrixUser = matrixUser,
|
|
||||||
onClick = { onUserSelected(matrixUser) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (state.allUsers.isLoading()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
|
|
@ -116,9 +125,73 @@ fun RoomMemberListView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoomMemberList(
|
||||||
|
roomMembers: RoomMembers,
|
||||||
|
onUserSelected: (RoomMember) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
||||||
|
if (roomMembers.invited.isNotEmpty()) {
|
||||||
|
roomMemberListSection(
|
||||||
|
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
|
||||||
|
members = roomMembers.invited,
|
||||||
|
onMemberSelected = { onUserSelected(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (roomMembers.joined.isNotEmpty()) {
|
||||||
|
val memberCount = roomMembers.joined.count()
|
||||||
|
roomMemberListSection(
|
||||||
|
headerText = { pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) },
|
||||||
|
members = roomMembers.joined,
|
||||||
|
onMemberSelected = { onUserSelected(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.roomMemberListSection(
|
||||||
|
headerText: @Composable () -> String,
|
||||||
|
members: ImmutableList<RoomMember>,
|
||||||
|
onMemberSelected: (RoomMember) -> Unit,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
text = headerText(),
|
||||||
|
style = ElementTextStyles.Regular.callout,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(members) { matrixUser ->
|
||||||
|
RoomMemberListItem(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
roomMember = matrixUser,
|
||||||
|
onClick = { onMemberSelected(matrixUser) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoomMemberListItem(
|
||||||
|
roomMember: RoomMember,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
MatrixUserRow(
|
||||||
|
modifier = modifier.clickable(onClick = onClick),
|
||||||
|
matrixUser = MatrixUser(
|
||||||
|
userId = roomMember.userId,
|
||||||
|
displayName = roomMember.displayName,
|
||||||
|
avatarUrl = roomMember.avatarUrl
|
||||||
|
),
|
||||||
|
avatarSize = AvatarSize.Custom(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberListTopBar(
|
private fun RoomMemberListTopBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackPressed: () -> Unit = {},
|
onBackPressed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -135,6 +208,86 @@ fun RoomMemberListTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RoomMemberSearchBar(
|
||||||
|
query: String,
|
||||||
|
state: RoomMemberSearchResultState,
|
||||||
|
active: Boolean,
|
||||||
|
placeHolderTitle: String,
|
||||||
|
onActiveChanged: (Boolean) -> Unit,
|
||||||
|
onTextChanged: (String) -> Unit,
|
||||||
|
onUserSelected: (RoomMember) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
onTextChanged("")
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchBar(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onTextChanged,
|
||||||
|
onSearch = { focusManager.clearFocus() },
|
||||||
|
active = active,
|
||||||
|
onActiveChange = onActiveChanged,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = if (!active) 16.dp else 0.dp),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = placeHolderTitle,
|
||||||
|
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = if (active) {
|
||||||
|
{ BackButton(onClick = { onActiveChanged(false) }) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
trailingIcon = when {
|
||||||
|
active && query.isNotEmpty() -> {
|
||||||
|
{
|
||||||
|
IconButton(onClick = { onTextChanged("") }) {
|
||||||
|
Icon(Icons.Default.Close, stringResource(StringR.string.action_clear))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!active -> {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(StringR.string.action_search),
|
||||||
|
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
content = {
|
||||||
|
if (state is RoomMemberSearchResultState.Results) {
|
||||||
|
RoomMemberList(
|
||||||
|
roomMembers = state.results,
|
||||||
|
onUserSelected = { onUserSelected(it) }
|
||||||
|
)
|
||||||
|
} else if (state is RoomMemberSearchResultState.NoResults) {
|
||||||
|
Spacer(Modifier.size(80.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(StringR.string.common_no_results),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
|
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
|
||||||
|
|
@ -147,5 +300,9 @@ fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::cla
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: RoomMemberListState) {
|
private fun ContentToPreview(state: RoomMemberListState) {
|
||||||
RoomMemberListView(state)
|
RoomMemberListView(
|
||||||
|
state = state,
|
||||||
|
onBackPressed = {},
|
||||||
|
onMemberSelected = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import io.element.android.features.roomdetails.impl.LeaveRoomWarning
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||||
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
|
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -90,7 +92,7 @@ class RoomDetailsPresenterTests {
|
||||||
val room = aMatrixRoom()
|
val room = aMatrixRoom()
|
||||||
val roomMembers = listOf(
|
val roomMembers = listOf(
|
||||||
aRoomMember(A_USER_ID),
|
aRoomMember(A_USER_ID),
|
||||||
aRoomMember(A_USER_ID_2),
|
aRoomMember(A_USER_ID_2, membership = RoomMembershipState.INVITE),
|
||||||
)
|
)
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
val presenter = aRoomDetailsPresenter(room)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -112,7 +114,7 @@ class RoomDetailsPresenterTests {
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||||
//skipItems(1)
|
//skipItems(1)
|
||||||
val successState = awaitItem()
|
val successState = awaitItem()
|
||||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(roomMembers.size))
|
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
|
|
@ -266,22 +268,3 @@ fun aMatrixRoom(
|
||||||
isDirect = isDirect,
|
isDirect = isDirect,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aRoomMember(
|
|
||||||
userId: UserId = A_USER_ID,
|
|
||||||
displayName: String? = null,
|
|
||||||
avatarUrl: String? = null,
|
|
||||||
membership: RoomMembershipState = RoomMembershipState.JOIN,
|
|
||||||
isNameAmbiguous: Boolean = false,
|
|
||||||
powerLevel: Long = 0L,
|
|
||||||
normalizedPowerLevel: Long = 0L,
|
|
||||||
isIgnored: Boolean = false,
|
|
||||||
) = RoomMember(
|
|
||||||
userId = userId,
|
|
||||||
displayName = displayName,
|
|
||||||
avatarUrl = avatarUrl,
|
|
||||||
membership = membership,
|
|
||||||
isNameAmbiguous = isNameAmbiguous,
|
|
||||||
powerLevel = powerLevel,
|
|
||||||
normalizedPowerLevel = normalizedPowerLevel,
|
|
||||||
isIgnored = isIgnored,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -20,62 +20,111 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
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
|
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
|
||||||
import io.element.android.features.userlist.api.SelectionMode
|
import io.element.android.features.roomdetails.impl.members.RoomMemberSearchResultState
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
import io.element.android.features.roomdetails.impl.members.aVictor
|
||||||
import io.element.android.features.userlist.api.UserListPresenter
|
import io.element.android.features.roomdetails.impl.members.aWalter
|
||||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
|
||||||
import io.element.android.features.userlist.api.UserSearchResultState
|
|
||||||
import io.element.android.features.userlist.impl.DefaultUserListPresenter
|
|
||||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.internal.toImmutableList
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class RoomMemberListPresenterTests {
|
class RoomMemberListPresenterTests {
|
||||||
|
|
||||||
private val testCoroutineDispatchers = testCoroutineDispatchers()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - search is done automatically on start, but is async`() = runTest {
|
fun `search is done automatically on start, but is async`() = runTest {
|
||||||
val searchResult = listOf(aMatrixUser())
|
val presenter = createPresenter()
|
||||||
val userListDataSource = FakeUserListDataSource().apply {
|
|
||||||
givenSearchResult(searchResult)
|
|
||||||
}
|
|
||||||
val userListDataStore = UserListDataStore()
|
|
||||||
val userListFactory = object : UserListPresenter.Factory {
|
|
||||||
override fun create(
|
|
||||||
args: UserListPresenterArgs,
|
|
||||||
userListDataSource: UserListDataSource,
|
|
||||||
userListDataStore: UserListDataStore,
|
|
||||||
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
|
|
||||||
}
|
|
||||||
val fakeRoom = FakeMatrixRoom()
|
|
||||||
val presenter = RoomMemberListPresenter(
|
|
||||||
userListPresenterFactory = userListFactory,
|
|
||||||
userListDataSource = userListDataSource,
|
|
||||||
userListDataStore = userListDataStore,
|
|
||||||
room = fakeRoom,
|
|
||||||
coroutineDispatchers = testCoroutineDispatchers
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
|
Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
|
||||||
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
|
Truth.assertThat(initialState.searchQuery).isEmpty()
|
||||||
Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
Truth.assertThat(initialState.searchResults).isEqualTo(RoomMemberSearchResultState.NotSearching)
|
||||||
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
|
Truth.assertThat(initialState.isSearchActive).isFalse()
|
||||||
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
|
Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java)
|
||||||
|
Truth.assertThat((loadedState.roomMembers as Async.Success).state.invited).isEqualTo(listOf(aVictor(), aWalter()))
|
||||||
|
Truth.assertThat((loadedState.roomMembers as Async.Success).state.joined).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `open search`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||||
|
|
||||||
|
val searchActiveState = awaitItem()
|
||||||
|
Truth.assertThat((searchActiveState.isSearchActive)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search for something which is not found`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||||
|
val searchActiveState = awaitItem()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
||||||
|
val searchQueryUpdatedState = awaitItem()
|
||||||
|
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
|
||||||
|
val searchSearchResultDelivered = awaitItem()
|
||||||
|
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search for something which is found`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||||
|
val searchActiveState = awaitItem()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
|
||||||
|
val searchQueryUpdatedState = awaitItem()
|
||||||
|
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
|
||||||
|
val searchSearchResultDelivered = awaitItem()
|
||||||
|
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java)
|
||||||
|
Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName)
|
||||||
|
.isEqualTo("Alice")
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
private fun createDataSource(
|
||||||
|
matrixRoom: MatrixRoom = aMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||||
|
},
|
||||||
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
private fun createPresenter(
|
||||||
|
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
|
||||||
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
import io.element.android.features.roomdetails.aMatrixClient
|
import io.element.android.features.roomdetails.aMatrixClient
|
||||||
import io.element.android.features.roomdetails.aMatrixRoom
|
import io.element.android.features.roomdetails.aMatrixRoom
|
||||||
import io.element.android.features.roomdetails.aRoomMember
|
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.RoomMemberDetailsEvents
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ fun RoomListView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListContent(
|
fun RoomListContent(
|
||||||
state: RoomListState,
|
state: RoomListState,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.features.roomlist.impl.components
|
package io.element.android.features.roomlist.impl.components
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
@ -30,7 +29,6 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -60,6 +58,7 @@ import io.element.android.libraries.designsystem.modifiers.applyIf
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.copy
|
import io.element.android.libraries.designsystem.utils.copy
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -128,12 +127,14 @@ internal fun RoomListSearchResultContent(
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
value = filter,
|
value = filter,
|
||||||
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||||
colors = TextFieldDefaults.textFieldColors(
|
colors = TextFieldDefaults.colors(
|
||||||
containerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
focusedIndicatorColor = Color.Transparent,
|
focusedIndicatorColor = Color.Transparent,
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
errorIndicatorColor = Color.Transparent,
|
errorIndicatorColor = Color.Transparent,
|
||||||
disabledIndicatorColor = Color.Transparent
|
|
||||||
),
|
),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (filter.isNotEmpty()) {
|
if (filter.isNotEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,9 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
internal class DefaultInviteStateDataSourceTest {
|
internal class DefaultInviteStateDataSourceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -133,5 +131,4 @@ internal class DefaultInviteStateDataSourceTest {
|
||||||
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) class RoomListPresenterTests {
|
class RoomListPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - should start with no user and then load user with success`() = runTest {
|
fun `present - should start with no user and then load user with success`() = runTest {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.userlist.impl
|
package io.element.android.features.userlist.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
|
@ -30,14 +29,10 @@ import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.mockk.coJustRun
|
|
||||||
import io.mockk.mockkConstructor
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class DefaultUserListPresenterTests {
|
class DefaultUserListPresenterTests {
|
||||||
|
|
||||||
private val userListDataSource = FakeUserListDataSource()
|
private val userListDataSource = FakeUserListDataSource()
|
||||||
|
|
@ -136,9 +131,6 @@ class DefaultUserListPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - select a user`() = runTest {
|
fun `present - select a user`() = runTest {
|
||||||
mockkConstructor(LazyListState::class)
|
|
||||||
coJustRun { anyConstructed<LazyListState>().scrollToItem(index = any()) }
|
|
||||||
|
|
||||||
val presenter = DefaultUserListPresenter(
|
val presenter = DefaultUserListPresenter(
|
||||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||||
userListDataSource,
|
userListDataSource,
|
||||||
|
|
@ -158,16 +150,15 @@ class DefaultUserListPresenterTests {
|
||||||
assertThat(awaitItem().selectedUsers).containsExactly(userA)
|
assertThat(awaitItem().selectedUsers).containsExactly(userA)
|
||||||
|
|
||||||
initialState.eventSink(UserListEvents.AddToSelection(userB))
|
initialState.eventSink(UserListEvents.AddToSelection(userB))
|
||||||
// the last added user should be presented first
|
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB)
|
||||||
assertThat(awaitItem().selectedUsers).containsExactly(userB, userA)
|
|
||||||
|
|
||||||
initialState.eventSink(UserListEvents.AddToSelection(userABis))
|
initialState.eventSink(UserListEvents.AddToSelection(userABis))
|
||||||
initialState.eventSink(UserListEvents.AddToSelection(userC))
|
initialState.eventSink(UserListEvents.AddToSelection(userC))
|
||||||
// duplicated users should be ignored
|
// duplicated users should be ignored
|
||||||
assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA)
|
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC)
|
||||||
|
|
||||||
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
|
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
|
||||||
assertThat(awaitItem().selectedUsers).containsExactly(userC, userA)
|
assertThat(awaitItem().selectedUsers).containsExactly(userA, userC)
|
||||||
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
|
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
|
||||||
assertThat(awaitItem().selectedUsers).containsExactly(userC)
|
assertThat(awaitItem().selectedUsers).containsExactly(userC)
|
||||||
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
|
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,11 @@ activity = "1.7.1"
|
||||||
startup = "1.1.1"
|
startup = "1.1.1"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2023.04.01"
|
compose_bom = "2023.05.01"
|
||||||
composecompiler = "1.4.7"
|
composecompiler = "1.4.7"
|
||||||
|
|
||||||
# Coroutines
|
# Coroutines
|
||||||
coroutines = "1.6.4"
|
coroutines = "1.7.1"
|
||||||
|
|
||||||
# Accompanist
|
# Accompanist
|
||||||
accompanist = "0.30.1"
|
accompanist = "0.30.1"
|
||||||
|
|
@ -43,7 +43,7 @@ stem = "2.3.0"
|
||||||
sqldelight = "1.5.5"
|
sqldelight = "1.5.5"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
dagger = "2.46"
|
dagger = "2.46.1"
|
||||||
anvil = "2.4.5"
|
anvil = "2.4.5"
|
||||||
|
|
||||||
# quality
|
# quality
|
||||||
|
|
@ -129,7 +129,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||||
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
||||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.12"
|
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.14"
|
||||||
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
|
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-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
|
|
@ -138,6 +138,7 @@ sqlite = "androidx.sqlite:sqlite:2.3.1"
|
||||||
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
||||||
gujun_span = "me.gujun.android:span:1.7"
|
gujun_span = "me.gujun.android:span:1.7"
|
||||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||||
|
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
||||||
|
|
||||||
# Di
|
# Di
|
||||||
inject = "javax.inject:javax.inject:1"
|
inject = "javax.inject:javax.inject:1"
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ fun File.safeDelete() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Context.createTmpFile(baseDir: File = cacheDir): File = withContext(Dispatchers.IO) {
|
suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) {
|
||||||
File.createTempFile(UUID.randomUUID().toString(), null, baseDir).apply { mkdirs() }
|
val suffix = extension?.let { ".$extension" }
|
||||||
|
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ object MimeTypes {
|
||||||
const val Gif = "image/gif"
|
const val Gif = "image/gif"
|
||||||
|
|
||||||
const val Videos = "video/*"
|
const val Videos = "video/*"
|
||||||
|
const val Mp4 = "video/mp4"
|
||||||
|
|
||||||
const val Audio = "audio/*"
|
const val Audio = "audio/*"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferenceView(
|
fun PreferenceView(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
|
@ -57,6 +55,7 @@ fun MediumTopAppBar(
|
||||||
internal fun MediumTopAppBarPreview() =
|
internal fun MediumTopAppBarPreview() =
|
||||||
ElementThemedPreview { ContentToPreview() }
|
ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
MediumTopAppBar(title = { Text(text = "Title") })
|
MediumTopAppBar(title = { Text(text = "Title") })
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialApi::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -107,6 +105,7 @@ internal fun ModalBottomSheetLayoutLightPreview() =
|
||||||
internal fun ModalBottomSheetLayoutDarkPreview() =
|
internal fun ModalBottomSheetLayoutDarkPreview() =
|
||||||
ElementPreviewDark { ContentToPreview() }
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,9 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.TextFieldColors
|
import androidx.compose.material3.TextFieldColors
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
|
@ -49,7 +48,6 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
import io.element.android.libraries.designsystem.utils.allBooleans
|
import io.element.android.libraries.designsystem.utils.allBooleans
|
||||||
import io.element.android.libraries.designsystem.utils.asInt
|
import io.element.android.libraries.designsystem.utils.asInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OutlinedTextField(
|
fun OutlinedTextField(
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -70,8 +68,8 @@ fun OutlinedTextField(
|
||||||
singleLine: Boolean = false,
|
singleLine: Boolean = false,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
shape: Shape = TextFieldDefaults.outlinedShape,
|
shape: Shape = OutlinedTextFieldDefaults.shape,
|
||||||
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors()
|
colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.OutlinedTextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FabPosition
|
import androidx.compose.material3.FabPosition
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ScaffoldDefaults
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
|
@ -27,7 +26,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Scaffold(
|
fun Scaffold(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -77,6 +75,7 @@ fun SearchBar(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DockedSearchBarPreview() = ElementThemedPreview { ContentToPreview() }
|
internal fun DockedSearchBarPreview() = ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.TextFieldColors
|
import androidx.compose.material3.TextFieldColors
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
|
@ -50,7 +49,6 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
import io.element.android.libraries.designsystem.utils.allBooleans
|
import io.element.android.libraries.designsystem.utils.allBooleans
|
||||||
import io.element.android.libraries.designsystem.utils.asInt
|
import io.element.android.libraries.designsystem.utils.asInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextField(
|
fun TextField(
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -71,8 +69,8 @@ fun TextField(
|
||||||
singleLine: Boolean = false,
|
singleLine: Boolean = false,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
shape: Shape = TextFieldDefaults.filledShape,
|
shape: Shape = TextFieldDefaults.shape,
|
||||||
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
|
colors: TextFieldColors = TextFieldDefaults.colors()
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.TextField(
|
androidx.compose.material3.TextField(
|
||||||
value = value,
|
value = value,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,6 @@ package io.element.android.libraries.matrix.api.media
|
||||||
|
|
||||||
data class AudioInfo(
|
data class AudioInfo(
|
||||||
val duration: Long?,
|
val duration: Long?,
|
||||||
val size: Long?
|
val size: Long?,
|
||||||
|
val mimeType: String?,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,15 @@ 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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
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.MatrixTimeline
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
interface MatrixRoom : Closeable {
|
interface MatrixRoom : Closeable {
|
||||||
val sessionId: SessionId
|
val sessionId: SessionId
|
||||||
|
|
@ -67,6 +72,14 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit>
|
||||||
|
|
||||||
suspend fun leave(): Result<Unit>
|
suspend fun leave(): Result<Unit>
|
||||||
|
|
||||||
suspend fun acceptInvitation(): Result<Unit>
|
suspend fun acceptInvitation(): Result<Unit>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.libraries.matrix.api.room
|
package io.element.android.libraries.matrix.api.room
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
|
|
||||||
data class RoomMember(
|
data class RoomMember(
|
||||||
val userId: UserId,
|
val userId: UserId,
|
||||||
|
|
@ -30,12 +29,6 @@ data class RoomMember(
|
||||||
val isIgnored: Boolean,
|
val isIgnored: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun RoomMember.toMatrixUser() = MatrixUser(
|
|
||||||
userId = userId,
|
|
||||||
displayName = displayName,
|
|
||||||
avatarUrl = avatarUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class RoomMembershipState {
|
enum class RoomMembershipState {
|
||||||
BAN, INVITE, JOIN, KNOCK, LEAVE
|
BAN, INVITE, JOIN, KNOCK, LEAVE
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
@ -307,7 +306,6 @@ class RustMatrixClient constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
slidingSyncUpdateJob?.cancel()
|
slidingSyncUpdateJob?.cancel()
|
||||||
stopSync()
|
stopSync()
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,12 @@ import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
|
||||||
|
|
||||||
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
|
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
|
||||||
duration = duration?.toLong(),
|
duration = duration?.toLong(),
|
||||||
size = size?.toLong()
|
size = size?.toLong(),
|
||||||
|
mimeType = mimetype
|
||||||
|
)
|
||||||
|
|
||||||
|
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
|
||||||
|
duration = duration?.toULong(),
|
||||||
|
size = size?.toULong(),
|
||||||
|
mimetype = mimeType,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,10 @@ fun RustFileInfo.map(): FileInfo = FileInfo(
|
||||||
thumbnailInfo = thumbnailInfo?.map(),
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
thumbnailUrl = thumbnailSource?.useUrl()
|
thumbnailUrl = thumbnailSource?.useUrl()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun FileInfo.map(): RustFileInfo = RustFileInfo(
|
||||||
|
mimetype = mimetype,
|
||||||
|
size = size?.toULong(),
|
||||||
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
|
thumbnailSource = null
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.matrix.impl.media
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaSource
|
||||||
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
|
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
|
||||||
|
|
||||||
fun RustImageInfo.map(): ImageInfo = ImageInfo(
|
fun RustImageInfo.map(): ImageInfo = ImageInfo(
|
||||||
|
|
@ -28,3 +29,13 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
|
||||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
thumbnailUrl = thumbnailSource?.useUrl(),
|
||||||
blurhash = blurhash
|
blurhash = blurhash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun ImageInfo.map(): RustImageInfo = RustImageInfo(
|
||||||
|
height = height?.toULong(),
|
||||||
|
width = width?.toULong(),
|
||||||
|
mimetype = mimetype,
|
||||||
|
size = size?.toULong(),
|
||||||
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
|
thumbnailSource = null,
|
||||||
|
blurhash = blurhash,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,10 @@ fun RustThumbnailInfo.map(): ThumbnailInfo = ThumbnailInfo(
|
||||||
mimetype = mimetype,
|
mimetype = mimetype,
|
||||||
size = size?.toLong()
|
size = size?.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun ThumbnailInfo.map(): RustThumbnailInfo = RustThumbnailInfo(
|
||||||
|
height = height?.toULong(),
|
||||||
|
width = width?.toULong(),
|
||||||
|
mimetype = mimetype,
|
||||||
|
size = size?.toULong()
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,14 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
|
||||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
thumbnailUrl = thumbnailSource?.useUrl(),
|
||||||
blurhash = blurhash
|
blurhash = blurhash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
|
||||||
|
duration = duration?.toULong(),
|
||||||
|
height = height?.toULong(),
|
||||||
|
width = width?.toULong(),
|
||||||
|
mimetype = mimetype,
|
||||||
|
size = size?.toULong(),
|
||||||
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
|
thumbnailSource = null,
|
||||||
|
blurhash = blurhash
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.notification
|
package io.element.android.libraries.matrix.impl.notification
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.core.UserId
|
||||||
|
|
@ -36,7 +37,7 @@ class NotificationMapper @Inject constructor() {
|
||||||
senderDisplayName = it.senderDisplayName,
|
senderDisplayName = it.senderDisplayName,
|
||||||
roomAvatarUrl = it.roomAvatarUrl,
|
roomAvatarUrl = it.roomAvatarUrl,
|
||||||
isDirect = it.isDirect,
|
isDirect = it.isDirect,
|
||||||
isEncrypted = it.isEncrypted,
|
isEncrypted = it.isEncrypted.orFalse(),
|
||||||
isNoisy = it.isNoisy
|
isNoisy = it.isNoisy
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,18 @@ import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
|
||||||
|
|
||||||
object RoomMemberMapper {
|
object RoomMemberMapper {
|
||||||
|
|
||||||
fun map(roomMember: RustRoomMember): RoomMember =
|
fun map(roomMember: RustRoomMember): RoomMember = roomMember.use {
|
||||||
RoomMember(
|
RoomMember(
|
||||||
UserId(roomMember.userId()),
|
UserId(it.userId()),
|
||||||
roomMember.displayName(),
|
it.displayName(),
|
||||||
roomMember.avatarUrl(),
|
it.avatarUrl(),
|
||||||
mapMembership(roomMember.membership()),
|
mapMembership(it.membership()),
|
||||||
roomMember.isNameAmbiguous(),
|
it.isNameAmbiguous(),
|
||||||
roomMember.powerLevel(),
|
it.powerLevel(),
|
||||||
roomMember.normalizedPowerLevel(),
|
it.normalizedPowerLevel(),
|
||||||
roomMember.isIgnored(),
|
it.isIgnored(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
|
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
|
||||||
when (membershipState) {
|
when (membershipState) {
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,15 @@ 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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
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.room.MatrixRoom
|
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.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -39,6 +44,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class RustMatrixRoom(
|
class RustMatrixRoom(
|
||||||
override val sessionId: SessionId,
|
override val sessionId: SessionId,
|
||||||
|
|
@ -155,9 +161,10 @@ class RustMatrixRoom(
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
|
override suspend fun sendMessage(message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
val transactionId = genTransactionId()
|
val transactionId = genTransactionId()
|
||||||
val content = messageEventContentFromMarkdown(message)
|
messageEventContentFromMarkdown(message).use { content ->
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.send(content, transactionId)
|
innerRoom.send(content, transactionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,4 +209,28 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
innerRoom.sendAudio(file.path, audioInfo.map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
innerRoom.sendFile(file.path, fileInfo.map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,6 @@ internal class RustRoomSummaryDataSource(
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
//@OptIn(FlowPreview::class)
|
|
||||||
override fun roomSummaries(): StateFlow<List<RoomSummary>> {
|
override fun roomSummaries(): StateFlow<List<RoomSummary>> {
|
||||||
return roomSummaries
|
return roomSummaries
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,12 @@ class MatrixTimelineItemMapper(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use {
|
fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use {
|
||||||
val asEvent = timelineItem.asEvent()
|
val asEvent = it.asEvent()
|
||||||
if (asEvent != null) {
|
if (asEvent != null) {
|
||||||
val eventTimelineItem = eventTimelineItemMapper.map(asEvent)
|
val eventTimelineItem = eventTimelineItemMapper.map(asEvent)
|
||||||
return MatrixTimelineItem.Event(eventTimelineItem)
|
return MatrixTimelineItem.Event(eventTimelineItem)
|
||||||
}
|
}
|
||||||
val asVirtual = timelineItem.asVirtual()
|
val asVirtual = it.asVirtual()
|
||||||
if (asVirtual != null) {
|
if (asVirtual != null) {
|
||||||
val virtualTimelineItem = virtualTimelineItemMapper.map(asVirtual)
|
val virtualTimelineItem = virtualTimelineItemMapper.map(asVirtual)
|
||||||
return MatrixTimelineItem.Virtual(virtualTimelineItem)
|
return MatrixTimelineItem.Virtual(virtualTimelineItem)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
|
||||||
class EventMessageMapper {
|
class EventMessageMapper {
|
||||||
|
|
||||||
fun map(message: Message): MessageContent = message.use {
|
fun map(message: Message): MessageContent = message.use {
|
||||||
val type = message.msgtype().use { type ->
|
val type = it.msgtype().use { type ->
|
||||||
when (type) {
|
when (type) {
|
||||||
is MessageType.Audio -> {
|
is MessageType.Audio -> {
|
||||||
AudioMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
AudioMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
||||||
|
|
@ -68,9 +68,9 @@ class EventMessageMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent(
|
MessageContent(
|
||||||
body = message.body(),
|
body = it.body(),
|
||||||
inReplyTo = message.inReplyTo()?.eventId?.let(::EventId),
|
inReplyTo = it.inReplyTo()?.eventId?.let(::EventId),
|
||||||
isEdited = message.isEdited(),
|
isEdited = it.isEdited(),
|
||||||
type = type
|
type = type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,18 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
||||||
|
|
||||||
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
|
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
|
||||||
EventTimelineItem(
|
EventTimelineItem(
|
||||||
uniqueIdentifier = eventTimelineItem.uniqueIdentifier(),
|
uniqueIdentifier = it.uniqueIdentifier(),
|
||||||
eventId = eventTimelineItem.eventId()?.let { EventId(it) },
|
eventId = it.eventId()?.let { EventId(it) },
|
||||||
isEditable = eventTimelineItem.isEditable(),
|
isEditable = it.isEditable(),
|
||||||
isLocal = eventTimelineItem.isLocal(),
|
isLocal = it.isLocal(),
|
||||||
isOwn = eventTimelineItem.isOwn(),
|
isOwn = it.isOwn(),
|
||||||
isRemote = eventTimelineItem.isRemote(),
|
isRemote = it.isRemote(),
|
||||||
localSendState = eventTimelineItem.localSendState()?.map(),
|
localSendState = it.localSendState()?.map(),
|
||||||
reactions = eventTimelineItem.reactions().map(),
|
reactions = it.reactions().map(),
|
||||||
sender = UserId(eventTimelineItem.sender()),
|
sender = UserId(it.sender()),
|
||||||
senderProfile = eventTimelineItem.senderProfile().map(),
|
senderProfile = it.senderProfile().map(),
|
||||||
timestamp = eventTimelineItem.timestamp().toLong(),
|
timestamp = it.timestamp().toLong(),
|
||||||
content = contentMapper.map(eventTimelineItem.content())
|
content = contentMapper.map(it.content())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||||
|
|
@ -26,7 +27,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||||
import io.element.android.libraries.matrix.impl.media.map
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
|
|
@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.OtherState as RustOtherState
|
||||||
class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) {
|
class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) {
|
||||||
|
|
||||||
fun map(content: TimelineItemContent): EventContent = content.use {
|
fun map(content: TimelineItemContent): EventContent = content.use {
|
||||||
when (val kind = content.kind()) {
|
when (val kind = it.kind()) {
|
||||||
is TimelineItemContentKind.FailedToParseMessageLike -> {
|
is TimelineItemContentKind.FailedToParseMessageLike -> {
|
||||||
FailedToParseMessageLikeContent(
|
FailedToParseMessageLikeContent(
|
||||||
eventType = kind.eventType,
|
eventType = kind.eventType,
|
||||||
|
|
@ -54,7 +54,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TimelineItemContentKind.Message -> {
|
TimelineItemContentKind.Message -> {
|
||||||
val message = content.asMessage()
|
val message = it.asMessage()
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
UnknownContent
|
UnknownContent
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ 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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
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.room.MatrixRoom
|
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.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
|
@ -30,6 +34,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class FakeMatrixRoom(
|
class FakeMatrixRoom(
|
||||||
override val sessionId: SessionId = A_SESSION_ID,
|
override val sessionId: SessionId = A_SESSION_ID,
|
||||||
|
|
@ -54,6 +59,9 @@ class FakeMatrixRoom(
|
||||||
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
||||||
private var acceptInviteResult = Result.success(Unit)
|
private var acceptInviteResult = Result.success(Unit)
|
||||||
private var rejectInviteResult = Result.success(Unit)
|
private var rejectInviteResult = Result.success(Unit)
|
||||||
|
private var sendMediaResult = Result.success(Unit)
|
||||||
|
var sendMediaCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var isInviteAccepted: Boolean = false
|
var isInviteAccepted: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -128,6 +136,14 @@ class FakeMatrixRoom(
|
||||||
return rejectInviteResult
|
return rejectInviteResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
||||||
|
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
||||||
|
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
||||||
|
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||||
|
|
||||||
override fun close() = Unit
|
override fun close() = Unit
|
||||||
|
|
||||||
fun givenLeaveRoomError(throwable: Throwable?) {
|
fun givenLeaveRoomError(throwable: Throwable?) {
|
||||||
|
|
@ -165,4 +181,8 @@ class FakeMatrixRoom(
|
||||||
fun givenUnIgnoreResult(result: Result<Unit>) {
|
fun givenUnIgnoreResult(result: Result<Unit>) {
|
||||||
unignoreResult = result
|
unignoreResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenSendMediaResult(result: Result<Unit>) {
|
||||||
|
sendMediaResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
sealed interface MediaUploadInfo {
|
sealed interface MediaUploadInfo {
|
||||||
data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo
|
data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
|
||||||
data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo
|
data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
|
||||||
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
|
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
|
||||||
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
|
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
|
||||||
}
|
}
|
||||||
|
|
@ -33,4 +33,5 @@ sealed interface MediaUploadInfo {
|
||||||
data class ThumbnailProcessingInfo(
|
data class ThumbnailProcessingInfo(
|
||||||
val file: File,
|
val file: File,
|
||||||
val info: ThumbnailInfo,
|
val info: ThumbnailInfo,
|
||||||
|
val blurhash: String,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ android {
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(libs.otaliastudios.transcoder)
|
implementation(libs.otaliastudios.transcoder)
|
||||||
|
implementation(libs.vanniktech.blurhash)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.libraries.mediaupload
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import com.vanniktech.blurhash.BlurHash
|
||||||
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
|
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
|
||||||
import io.element.android.libraries.androidutils.bitmap.resizeToMax
|
import io.element.android.libraries.androidutils.bitmap.resizeToMax
|
||||||
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
|
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
|
||||||
|
|
@ -48,14 +49,21 @@ class ImageCompressor @Inject constructor(
|
||||||
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
|
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
|
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
|
||||||
|
val blurhash = BlurHash.encode(compressedBitmap, 3, 3)
|
||||||
|
|
||||||
// Encode bitmap to the destination temporary file
|
// Encode bitmap to the destination temporary file
|
||||||
val tmpFile = context.createTmpFile()
|
val tmpFile = context.createTmpFile(extension = "jpeg")
|
||||||
tmpFile.outputStream().use {
|
tmpFile.outputStream().use {
|
||||||
compressedBitmap.compress(format, desiredQuality, it)
|
compressedBitmap.compress(format, desiredQuality, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageCompressionResult(tmpFile, compressedBitmap.width, compressedBitmap.height, tmpFile.length())
|
ImageCompressionResult(
|
||||||
|
file = tmpFile,
|
||||||
|
width = compressedBitmap.width,
|
||||||
|
height = compressedBitmap.height,
|
||||||
|
size = tmpFile.length(),
|
||||||
|
blurhash = blurhash
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +116,7 @@ data class ImageCompressionResult(
|
||||||
val width: Int,
|
val width: Int,
|
||||||
val height: Int,
|
val height: Int,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
|
val blurhash: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface ResizeMode {
|
sealed interface ResizeMode {
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@ import io.element.android.libraries.androidutils.file.createTmpFile
|
||||||
import io.element.android.libraries.androidutils.media.runAndRelease
|
import io.element.android.libraries.androidutils.media.runAndRelease
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
|
@ -41,7 +39,6 @@ import io.element.android.libraries.mediaupload.api.MediaType
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
|
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -93,19 +90,23 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
deleteOriginal: Boolean,
|
deleteOriginal: Boolean,
|
||||||
): Result<MediaUploadInfo> = runCatching {
|
): Result<MediaUploadInfo> = runCatching {
|
||||||
// Camera returns an 'octet-stream' mimetype, so it needs to be overridden
|
// Camera returns an 'octet-stream' mimetype, so it needs to be overridden
|
||||||
val originalMimeType = contentResolver.getType(uri)
|
val mimeType = contentResolver.getType(uri)
|
||||||
val mimeType = when (mediaType) {
|
val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) {
|
||||||
MediaType.Image -> MimeTypes.Images
|
when(mediaType) {
|
||||||
MediaType.Video -> MimeTypes.Videos
|
MediaType.Image -> MimeTypes.Jpeg
|
||||||
MediaType.Audio -> MimeTypes.Audio
|
MediaType.Video -> MimeTypes.Mp4
|
||||||
else -> originalMimeType
|
MediaType.Audio -> MimeTypes.Ogg
|
||||||
|
else -> mimeType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mimeType
|
||||||
}
|
}
|
||||||
val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
|
val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
|
||||||
val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
|
val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
|
||||||
when {
|
when(mediaType) {
|
||||||
mimeType.isMimeTypeImage() -> processImage(uri)
|
MediaType.Image -> processImage(uri)
|
||||||
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType)
|
MediaType.Video -> processVideo(uri, mimeTypeOrDefault)
|
||||||
mimeType.isMimeTypeAudio() -> processAudio(uri)
|
MediaType.Audio -> processAudio(uri, mimeTypeOrDefault)
|
||||||
else -> error("Cannot compress file of type: $mimeType")
|
else -> error("Cannot compress file of type: $mimeType")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -115,7 +116,7 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
removeSensitiveImageMetadata(file)
|
removeSensitiveImageMetadata(file)
|
||||||
}
|
}
|
||||||
val info = FileInfo(
|
val info = FileInfo(
|
||||||
mimetype = originalMimeType,
|
mimetype = mimeType,
|
||||||
size = file.length(),
|
size = file.length(),
|
||||||
thumbnailInfo = null,
|
thumbnailInfo = null,
|
||||||
thumbnailUrl = null,
|
thumbnailUrl = null,
|
||||||
|
|
@ -141,7 +142,7 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
removeSensitiveImageMetadata(compressedFileResult.file)
|
removeSensitiveImageMetadata(compressedFileResult.file)
|
||||||
|
|
||||||
val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) }
|
val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) }
|
||||||
val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult?.file?.path, thumbnailResult?.info)
|
val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult.file.path, thumbnailResult.info)
|
||||||
return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult)
|
return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,32 +156,33 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
.first()
|
.first()
|
||||||
.file
|
.file
|
||||||
|
|
||||||
val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo?.file?.path, thumbnailInfo?.info)
|
val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo)
|
||||||
return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo)
|
return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processAudio(uri: Uri): MediaUploadInfo {
|
private suspend fun processAudio(uri: Uri, mimeType: String?): MediaUploadInfo {
|
||||||
val file = copyToTmpFile(uri)
|
val file = copyToTmpFile(uri)
|
||||||
return MediaMetadataRetriever().runAndRelease {
|
return MediaMetadataRetriever().runAndRelease {
|
||||||
setDataSource(context, Uri.fromFile(file))
|
setDataSource(context, Uri.fromFile(file))
|
||||||
|
|
||||||
val info = AudioInfo(
|
val info = AudioInfo(
|
||||||
duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
|
duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
|
||||||
size = file.length()
|
size = file.length(),
|
||||||
|
mimeType = mimeType,
|
||||||
)
|
)
|
||||||
|
|
||||||
MediaUploadInfo.Audio(file, info)
|
MediaUploadInfo.Audio(file, info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo? {
|
private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo {
|
||||||
val thumbnailResult = imageCompressor
|
val thumbnailResult = imageCompressor
|
||||||
.compressToTmpFile(
|
.compressToTmpFile(
|
||||||
inputStream = inputStream,
|
inputStream = inputStream,
|
||||||
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
|
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
|
||||||
).getOrNull()
|
).getOrThrow()
|
||||||
|
|
||||||
return thumbnailResult?.toThumbnailProcessingInfo(MimeTypes.Jpeg)
|
return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeSensitiveImageMetadata(file: File) {
|
private fun removeSensitiveImageMetadata(file: File) {
|
||||||
|
|
@ -203,7 +205,7 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?): VideoInfo =
|
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo =
|
||||||
MediaMetadataRetriever().runAndRelease {
|
MediaMetadataRetriever().runAndRelease {
|
||||||
setDataSource(context, Uri.fromFile(file))
|
setDataSource(context, Uri.fromFile(file))
|
||||||
|
|
||||||
|
|
@ -213,16 +215,16 @@ class MediaPreProcessorImpl @Inject constructor(
|
||||||
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
|
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
|
||||||
mimetype = mimeType,
|
mimetype = mimeType,
|
||||||
size = file.length(),
|
size = file.length(),
|
||||||
thumbnailInfo = thumbnailInfo,
|
thumbnailInfo = thumbnailInfo?.info,
|
||||||
thumbnailUrl = thumbnailUrl,
|
thumbnailUrl = thumbnailUrl,
|
||||||
blurhash = null,
|
blurhash = thumbnailInfo?.blurhash,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? =
|
private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo =
|
||||||
MediaMetadataRetriever().runAndRelease {
|
MediaMetadataRetriever().runAndRelease {
|
||||||
setDataSource(context, uri)
|
setDataSource(context, uri)
|
||||||
val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null
|
val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME))
|
||||||
val inputStream = ByteArrayOutputStream().use {
|
val inputStream = ByteArrayOutputStream().use {
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
|
||||||
ByteArrayInputStream(it.toByteArray())
|
ByteArrayInputStream(it.toByteArray())
|
||||||
|
|
@ -249,7 +251,7 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?,
|
||||||
size = size,
|
size = size,
|
||||||
thumbnailInfo = thumbnailInfo,
|
thumbnailInfo = thumbnailInfo,
|
||||||
thumbnailUrl = thumbnailUrl,
|
thumbnailUrl = thumbnailUrl,
|
||||||
blurhash = null,
|
blurhash = blurhash,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo(
|
fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo(
|
||||||
|
|
@ -260,4 +262,5 @@ fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = Thumbna
|
||||||
mimetype = mimeType,
|
mimetype = mimeType,
|
||||||
size = size,
|
size = size,
|
||||||
),
|
),
|
||||||
|
blurhash = blurhash,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class VideoCompressor @Inject constructor(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun compress(uri: Uri) = callbackFlow {
|
fun compress(uri: Uri) = callbackFlow {
|
||||||
val tmpFile = context.createTmpFile()
|
val tmpFile = context.createTmpFile(extension = "mp4")
|
||||||
val future = Transcoder.into(tmpFile.path)
|
val future = Transcoder.into(tmpFile.path)
|
||||||
.addDataSource(context, uri)
|
.addDataSource(context, uri)
|
||||||
.setListener(object : TranscoderListener {
|
.setListener(object : TranscoderListener {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalPermissionsApi::class)
|
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
|
||||||
package io.element.android.libraries.permissions.impl
|
package io.element.android.libraries.permissions.impl
|
||||||
|
|
||||||
|
|
@ -25,7 +25,6 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.PermissionStatus
|
import com.google.accompanist.permissions.PermissionStatus
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.permissions.noop
|
package io.element.android.libraries.permissions.noop
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.libraries.pushstore.impl.clientsecret
|
package io.element.android.libraries.pushstore.impl.clientsecret
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,10 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
|
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
|
||||||
import io.element.android.libraries.matrix.session.SessionData
|
import io.element.android.libraries.matrix.session.SessionData
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class DatabaseSessionStoreTests {
|
class DatabaseSessionStoreTests {
|
||||||
|
|
||||||
private lateinit var database: SessionDatabase
|
private lateinit var database: SessionDatabase
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,7 @@ fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) {
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.material:material")
|
implementation("androidx.compose.material:material")
|
||||||
// Override BOM version, SearchBar is not available in the actual version
|
implementation("androidx.compose.material3:material3")
|
||||||
// do not use latest version because of clashes on androidx lifecycle dependency
|
|
||||||
implementation("androidx.compose.material3:material3:1.1.0-alpha04")
|
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package io.element.android.samples.minimal
|
package io.element.android.samples.minimal
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,16 @@ import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
class RoomListScreen(
|
class RoomListScreen(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers = Singleton.coroutineDispatchers,
|
||||||
) {
|
) {
|
||||||
private val clock = Clock.System
|
private val clock = Clock.System
|
||||||
private val locale = Locale.getDefault()
|
private val locale = Locale.getDefault()
|
||||||
|
|
@ -58,28 +57,24 @@ class RoomListScreen(
|
||||||
sessionVerificationService = sessionVerificationService,
|
sessionVerificationService = sessionVerificationService,
|
||||||
networkMonitor = NetworkMonitorImpl(context),
|
networkMonitor = NetworkMonitorImpl(context),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
inviteStateDataSource = DefaultInviteStateDataSource(
|
inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers)
|
||||||
matrixClient,
|
|
||||||
DefaultSeenInvitesStore(context),
|
|
||||||
CoroutineDispatchers(
|
|
||||||
io = Dispatchers.IO,
|
|
||||||
computation = Dispatchers.Default,
|
|
||||||
main = Dispatchers.Main,
|
|
||||||
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Content(modifier: Modifier = Modifier) {
|
fun Content(modifier: Modifier = Modifier) {
|
||||||
fun onRoomClicked(roomId: RoomId) {
|
fun onRoomClicked(roomId: RoomId) {
|
||||||
val room = matrixClient.getRoom(roomId)!!
|
|
||||||
val timeline = room.timeline()
|
|
||||||
Singleton.appScope.launch {
|
Singleton.appScope.launch {
|
||||||
timeline.apply {
|
withContext(coroutineDispatchers.io) {
|
||||||
initialize()
|
matrixClient.getRoom(roomId)!!.use { room ->
|
||||||
paginateBackwards(20, 50)
|
val timeline = room.timeline()
|
||||||
dispose()
|
|
||||||
|
timeline.apply {
|
||||||
|
// TODO This doesn't work reliably as initialize is asynchronous, and the timeline can't be used until it's finished
|
||||||
|
initialize()
|
||||||
|
paginateBackwards(20, 50)
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
|
|
||||||
package io.element.android.services.appnavstate.impl
|
package io.element.android.services.appnavstate.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
|
@ -28,11 +26,11 @@ import io.element.android.services.appnavstate.test.A_ROOM_OWNER
|
||||||
import io.element.android.services.appnavstate.test.A_SESSION_OWNER
|
import io.element.android.services.appnavstate.test.A_SESSION_OWNER
|
||||||
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
|
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
|
||||||
import io.element.android.services.appnavstate.test.A_THREAD_OWNER
|
import io.element.android.services.appnavstate.test.A_THREAD_OWNER
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DefaultAppNavigationStateServiceTest {
|
class DefaultAppNavigationStateServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue