Merge remote-tracking branch 'origin/develop' into

feature/fre/create_room_select_avatar
This commit is contained in:
Florian Renaud 2023-05-16 14:18:29 +02:00
commit 029eb876df
149 changed files with 1030 additions and 600 deletions

View file

@ -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": [
{ {

View file

@ -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 * * *"

View file

@ -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 }}

View file

@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: ${ROOM_NAME}
timeout: 10_000

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -0,0 +1 @@
Show pending invitations in room members list

View file

@ -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,

View file

@ -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,

View file

@ -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)
}
} }
} }

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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,
) )
) )
} }

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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
) )
} }

View file

@ -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
) )

View file

@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState(
), ),
actionListState = anActionListState(), actionListState = anActionListState(),
hasNetworkConnection = true, hasNetworkConnection = true,
snackbarMessage = null,
eventSink = {} eventSink = {}
) )

View file

@ -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?,

View file

@ -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(

View file

@ -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))
}
} }
} }

View file

@ -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(),
) )
} }
} }

View file

@ -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

View file

@ -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(),

View file

@ -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
) )
} }

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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 })
} }
} }
} }

View file

@ -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(

View file

@ -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
} }
} }

View file

@ -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
} }

View file

@ -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
}
},
) )
} }
} }

View file

@ -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
}

View file

@ -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)

View file

@ -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 = {}
)
} }

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -116,7 +116,7 @@ fun RoomListView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomListContent( fun RoomListContent(
state: RoomListState, state: RoomListState,

View file

@ -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

View file

@ -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()) {

View file

@ -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)
} }
} }
} }

View file

@ -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 {

View file

@ -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))

View file

@ -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"

View file

@ -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() }
} }

View file

@ -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/*"

View file

@ -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,

View file

@ -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

View file

@ -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") })

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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

View file

@ -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?,
) )

View file

@ -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>

View file

@ -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
} }

View file

@ -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()

View file

@ -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,
) )

View file

@ -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
)

View file

@ -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,
)

View file

@ -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()
)

View file

@ -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
)

View file

@ -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
) )
} }

View file

@ -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) {

View file

@ -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())
}
}
} }

View file

@ -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
} }

View file

@ -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)

View file

@ -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
) )
} }

View file

@ -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())
) )
} }
} }

View file

@ -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 {

View file

@ -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
}
} }

View file

@ -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,
) )

View file

@ -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)

View file

@ -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 {

View file

@ -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,
) )

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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()
}
}
} }
} }
} }

View file

@ -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