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