diff --git a/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt similarity index 64% rename from anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt rename to anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt index 0695e93263..1367dc0dc7 100644 --- a/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt +++ b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,15 +19,21 @@ package io.element.android.x.anvilannotations import kotlin.reflect.KClass /** - * Adds view model to the specified component graph. - * Equivalent to the following declaration in a dagger module: + * Adds Node to the specified component graph. + * Equivalent to the following declaration: * - * @Binds - * @IntoMap - * @ViewModelKey(YourViewModel::class) - * public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *> + * @Module + * @ContributesTo(Scope::class) + * abstract class YourNodeModule { + + * @Binds + * @IntoMap + * @NodeKey(YourNode::class) + * abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*> + *} + */ @Target(AnnotationTarget.CLASS) -annotation class ContributesViewModel( +annotation class ContributesNode( val scope: KClass<*>, ) diff --git a/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesViewModelCodeGenerator.kt b/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt similarity index 57% rename from anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesViewModelCodeGenerator.kt rename to anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt index cce2d5ca4b..0ca9da53af 100644 --- a/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesViewModelCodeGenerator.kt +++ b/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,32 +46,32 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.multibindings.IntoMap -import io.element.android.x.anvilannotations.ContributesViewModel -import java.io.File +import io.element.android.x.anvilannotations.ContributesNode import org.jetbrains.kotlin.descriptors.ModuleDescriptor import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtFile +import java.io.File /** - * This is an anvil plugin that allows ViewModels to use [ContributesViewModel] alone and let this plugin automatically + * This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically * handle the rest of the Dagger wiring required for constructor injection. */ @AutoService(CodeGenerator::class) -class ContributesViewModelCodeGenerator : CodeGenerator { +class ContributesNodeCodeGenerator : CodeGenerator { override fun isApplicable(context: AnvilContext): Boolean = true override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection): Collection { return projectFiles.classAndInnerClassReferences(module) - .filter { it.isAnnotatedWith(ContributesViewModel::class.fqName) } + .filter { it.isAnnotatedWith(ContributesNode::class.fqName) } .flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) } .toList() } - private fun generateModule(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { - val generatedPackage = vmClass.packageFqName.toString() - val moduleClassName = "${vmClass.shortName}_Module" - val scope = vmClass.annotations.single { it.fqName == ContributesViewModel::class.fqName }.scope() + private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val moduleClassName = "${nodeClass.shortName}_Module" + val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope() val content = FileSpec.buildFile(generatedPackage, moduleClassName) { addType( TypeSpec.classBuilder(moduleClassName) @@ -79,17 +79,17 @@ class ContributesViewModelCodeGenerator : CodeGenerator { .addAnnotation(Module::class) .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build()) .addFunction( - FunSpec.builder("bind${vmClass.shortName}Factory") + FunSpec.builder("bind${nodeClass.shortName}Factory") .addModifiers(KModifier.ABSTRACT) - .addParameter("factory", ClassName(generatedPackage, "${vmClass.shortName}_AssistedFactory")) - .returns(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(STAR, STAR)) + .addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory")) + .returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR)) .addAnnotation(Binds::class) .addAnnotation(IntoMap::class) .addAnnotation( - AnnotationSpec.Companion - .builder(viewModelKeyFqName.asClassName(module)) - .addMember("%T::class", vmClass.asClassName()) - .build() + AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember( + "%T::class", + nodeClass.asClassName() + ).build() ) .build(), ) @@ -99,35 +99,46 @@ class ContributesViewModelCodeGenerator : CodeGenerator { return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content) } - private fun generateAssistedFactory(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { - val generatedPackage = vmClass.packageFqName.toString() - val assistedFactoryClassName = "${vmClass.shortName}_AssistedFactory" - val constructor = vmClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) } - val assistedParameter = constructor?.parameters?.singleOrNull { it.isAnnotatedWith(Assisted::class.fqName) } - if (constructor == null || assistedParameter == null) { + private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory" + val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) } + val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty() + if (constructor == null || assistedParameters.size != 2) { throw AnvilCompilationException( - "${vmClass.fqName} must have an @AssistedInject constructor with @Assisted initialState: S parameter", - element = vmClass.clazz, + "${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters", + element = nodeClass.clazz, ) } - if (assistedParameter.name != "initialState") { + val contextAssistedParam = assistedParameters[0] + if (contextAssistedParam.name != "buildContext") { throw AnvilCompilationException( - "${vmClass.fqName} @Assisted parameter must be named initialState", - element = assistedParameter.parameter, + "${nodeClass.fqName} @Assisted parameter must be named buildContext", + element = contextAssistedParam.parameter, ) } - val vmClassName = vmClass.asClassName() - val stateClassName = assistedParameter.type().asTypeName() + val pluginsAssistedParam = assistedParameters[1] + if (pluginsAssistedParam.name != "plugins") { + throw AnvilCompilationException( + "${nodeClass.fqName} @Assisted parameter must be named plugins", + element = pluginsAssistedParam.parameter, + ) + } + + val nodeClassName = nodeClass.asClassName() + val buildContextClassName = contextAssistedParam.type().asTypeName() + val pluginsClassName = pluginsAssistedParam.type().asTypeName() val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) { addType( TypeSpec.interfaceBuilder(assistedFactoryClassName) - .addSuperinterface(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(vmClassName, stateClassName)) + .addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName)) .addAnnotation(AssistedFactory::class) .addFunction( FunSpec.builder("create") .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT) - .addParameter("initialState", stateClassName) - .returns(vmClassName) + .addParameter("buildContext", buildContextClassName) + .addParameter("plugins", pluginsClassName) + .returns(nodeClassName) .build(), ) .build(), @@ -137,7 +148,7 @@ class ContributesViewModelCodeGenerator : CodeGenerator { } companion object { - private val assistedViewModelFactoryFqName = FqName("io.element.android.x.core.di.AssistedViewModelFactory") - private val viewModelKeyFqName = FqName("io.element.android.x.core.di.ViewModelKey") + private val assistedNodeFactoryFqName = FqName("io.element.android.x.architecture.AssistedNodeFactory") + private val nodeKeyFqName = FqName("io.element.android.x.architecture.NodeKey") } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01b36f2bd4..220835ffa4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ + + /* * Copyright (c) 2022 New Vector Ltd * @@ -24,6 +26,7 @@ plugins { alias(libs.plugins.kapt) id("com.google.firebase.appdistribution") version "3.0.2" id("org.jetbrains.kotlinx.knit") version "0.4.0" + id("kotlin-parcelize") } android { @@ -156,6 +159,7 @@ dependencies { implementation(project(":libraries:matrix")) implementation(project(":libraries:matrixui")) implementation(project(":libraries:core")) + implementation(project(":libraries:architecture")) implementation(project(":features:onboarding")) implementation(project(":features:login")) implementation(project(":features:logout")) @@ -167,17 +171,17 @@ dependencies { implementation(project(":anvilannotations")) anvil(project(":anvilcodegen")) - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.0") + // https://developer.android.com/studio/write/java8-support#library-desugaring-versions + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2") implementation(libs.compose.destinations) ksp(libs.compose.destinations.processor) + implementation(libs.appyx.core) implementation(libs.androidx.corektx) implementation(libs.androidx.lifecycle.runtime) - implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.coil) - implementation(libs.mavericks.compose) implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b60d5b9112..61a03241a1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,11 +29,10 @@ android:supportsRtl="true" android:theme="@style/Theme.ElementX" tools:targetApi="33"> - @@ -47,7 +46,7 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" android:exported="false" - tools:node="remove"/> + tools:node="remove" /> diff --git a/app/src/main/java/io/element/android/x/ElementXApplication.kt b/app/src/main/java/io/element/android/x/ElementXApplication.kt index 8f079b9a20..285c56d5d8 100644 --- a/app/src/main/java/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/java/io/element/android/x/ElementXApplication.kt @@ -19,35 +19,26 @@ package io.element.android.x import android.app.Application import androidx.startup.AppInitializer import io.element.android.x.core.di.DaggerComponentOwner -import io.element.android.x.core.di.bindings -import io.element.android.x.di.AppBindings import io.element.android.x.di.AppComponent import io.element.android.x.di.DaggerAppComponent -import io.element.android.x.di.SessionComponentsOwner -import io.element.android.x.initializer.CoilInitializer import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.MatrixInitializer -import io.element.android.x.initializer.MavericksInitializer import io.element.android.x.initializer.TimberInitializer class ElementXApplication : Application(), DaggerComponentOwner { private lateinit var appComponent: AppComponent - private var sessionComponentsOwner: SessionComponentsOwner? = null override val daggerComponent: Any - get() = listOfNotNull(sessionComponentsOwner?.activeSessionComponent, appComponent) + get() = appComponent override fun onCreate() { super.onCreate() appComponent = DaggerAppComponent.factory().create(applicationContext) - sessionComponentsOwner = bindings().sessionComponentsOwner() AppInitializer.getInstance(this).apply { initializeComponent(CrashInitializer::class.java) initializeComponent(TimberInitializer::class.java) initializeComponent(MatrixInitializer::class.java) - initializeComponent(CoilInitializer::class.java) - initializeComponent(MavericksInitializer::class.java) } } } diff --git a/app/src/main/java/io/element/android/x/MainActivity.kt b/app/src/main/java/io/element/android/x/MainActivity.kt index b1df7ed3fe..3f9ff827cc 100644 --- a/app/src/main/java/io/element/android/x/MainActivity.kt +++ b/app/src/main/java/io/element/android/x/MainActivity.kt @@ -14,199 +14,45 @@ * limitations under the License. */ -@file:OptIn( - ExperimentalAnimationApi::class, - ExperimentalMaterialNavigationApi::class -) - package io.element.android.x import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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 androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat -import androidx.navigation.NavHostController -import com.airbnb.android.showkase.models.Showkase -import com.airbnb.mvrx.compose.mavericksActivityViewModel -import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi -import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations -import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine -import com.ramcosta.composedestinations.spec.Route -import io.element.android.x.core.compose.OnLifecycleEvent +import com.bumble.appyx.core.integration.NodeHost +import com.bumble.appyx.core.integrationpoint.NodeComponentActivity +import io.element.android.x.architecture.bindings +import io.element.android.x.core.di.DaggerComponentOwner import io.element.android.x.designsystem.ElementXTheme -import io.element.android.x.destinations.OnBoardingScreenNavigationDestination -import io.element.android.x.features.rageshake.bugreport.BugReportScreen -import io.element.android.x.features.rageshake.crash.ui.CrashDetectionScreen -import io.element.android.x.features.rageshake.detection.RageshakeDetectionScreen -import kotlinx.coroutines.runBlocking -import timber.log.Timber +import io.element.android.x.di.AppBindings +import io.element.android.x.node.RootFlowNode -private const val transitionAnimationDuration = 500 - -class MainActivity : ComponentActivity() { +class MainActivity : NodeComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val appBindings = bindings() WindowCompat.setDecorFitsSystemWindows(window, false) setContent { ElementXTheme { - MainScreen(viewModel = mavericksActivityViewModel()) - } - } - } - - @Composable - private fun ShowkaseButton( - isVisible: Boolean, - onClick: () -> Unit, - onCloseClicked: () -> Unit - ) { - if (isVisible) { - Button( - modifier = Modifier - .padding(top = 32.dp, start = 16.dp), - onClick = onClick - ) { - Text(text = "Showkase Browser") - IconButton( - modifier = Modifier - .padding(start = 8.dp) - .size(16.dp), - onClick = onCloseClicked, + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background ) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "") + NodeHost(integrationPoint = appyxIntegrationPoint) { + RootFlowNode( + buildContext = it, + appComponentOwner = applicationContext as DaggerComponentOwner, + matrix = appBindings.matrix(), + rootPresenter = appBindings.rootPresenter() + ) + } } } } } - - @Composable - private fun MainScreen(viewModel: MainViewModel) { - val startRoute = runBlocking { - if (!viewModel.isLoggedIn()) { - OnBoardingScreenNavigationDestination - } else { - viewModel.restoreSession() - NavGraphs.root.startRoute - } - } - - var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) } - var isBugReportVisible by remember { mutableStateOf(false) } - - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - MainContent( - startRoute = startRoute - ) - ShowkaseButton( - isVisible = isShowkaseButtonVisible, - onCloseClicked = { isShowkaseButtonVisible = false }, - onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) } - ) - RageshakeDetectionScreen( - onOpenBugReport = { - isBugReportVisible = true - } - ) - CrashDetectionScreen( - onOpenBugReport = { - isBugReportVisible = true - } - ) - if (isBugReportVisible) { - // TODO Improve the navigation, when pressing back here, it closes the app. - BugReportScreen( - onDone = { isBugReportVisible = false } - ) - } - } - OnLifecycleEvent { _, event -> - Timber.v("OnLifecycleEvent: $event") - } - } - - @Composable - private fun MainContent(startRoute: Route) { - val engine = rememberAnimatedNavHostEngine( - rootDefaultAnimations = RootNavGraphDefaultAnimations( - enterTransition = { - slideIntoContainer( - AnimatedContentScope.SlideDirection.Left, - animationSpec = tween(transitionAnimationDuration) - ) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentScope.SlideDirection.Left, - animationSpec = tween(transitionAnimationDuration) - ) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentScope.SlideDirection.Right, - animationSpec = tween(transitionAnimationDuration) - ) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentScope.SlideDirection.Right, - animationSpec = tween(transitionAnimationDuration) - ) - } - ) - ) - val navController = engine.rememberNavController() - LogNavigation(navController) - - DestinationsNavHost( - modifier = Modifier.background(MaterialTheme.colorScheme.background), - engine = engine, - navController = navController, - navGraph = NavGraphs.root, - startRoute = startRoute, - dependenciesContainerBuilder = { - } - ) - } - - @Composable - private fun LogNavigation(navController: NavHostController) { - LaunchedEffect(key1 = navController) { - navController.appCurrentDestinationFlow.collect { - Timber.d("Navigating to ${it.route}") - } - } - } - - @Composable - @Preview - fun MainContentPreview() { - MainContent(startRoute = OnBoardingScreenNavigationDestination) - } } diff --git a/app/src/main/java/io/element/android/x/MainViewModel.kt b/app/src/main/java/io/element/android/x/MainViewModel.kt deleted file mode 100644 index 94d7596ba1..0000000000 --- a/app/src/main/java/io/element/android/x/MainViewModel.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x - -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.AppScope -import io.element.android.x.di.SessionComponentsOwner -import io.element.android.x.matrix.Matrix -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -data class MainState(val fake: Boolean = false) : MavericksState - -@ContributesViewModel(AppScope::class) -class MainViewModel @AssistedInject constructor( - private val matrix: Matrix, - private val sessionComponentsOwner: SessionComponentsOwner, - @Assisted initialState: MainState -) : MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - suspend fun isLoggedIn(): Boolean { - return matrix.isLoggedIn().first() - } - - fun startSyncIfLogged() { - viewModelScope.launch { - if (!isLoggedIn()) return@launch - } - } - - fun stopSyncIfLogged() { - viewModelScope.launch { - if (!isLoggedIn()) return@launch - } - } - - suspend fun restoreSession() { - val matrixClient = matrix.restoreSession() - if (matrixClient == null) { - throw IllegalStateException("Couldn't restore session...") - } else { - sessionComponentsOwner.create(matrixClient) - matrixClient.startSync() - } - } -} diff --git a/app/src/main/java/io/element/android/x/Navigation.kt b/app/src/main/java/io/element/android/x/Navigation.kt deleted file mode 100644 index 701b1830c7..0000000000 --- a/app/src/main/java/io/element/android/x/Navigation.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootNavGraph -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.navigation.popUpTo -import io.element.android.x.core.di.bindings -import io.element.android.x.destinations.BugReportScreenNavigationDestination -import io.element.android.x.destinations.ChangeServerScreenNavigationDestination -import io.element.android.x.destinations.LoginScreenNavigationDestination -import io.element.android.x.destinations.MessagesScreenNavigationDestination -import io.element.android.x.destinations.OnBoardingScreenNavigationDestination -import io.element.android.x.destinations.PreferencesScreenNavigationDestination -import io.element.android.x.destinations.RoomListScreenNavigationDestination -import io.element.android.x.di.AppBindings -import io.element.android.x.features.login.LoginScreen -import io.element.android.x.features.login.changeserver.ChangeServerScreen -import io.element.android.x.features.messages.MessagesScreen -import io.element.android.x.features.onboarding.OnBoardingScreen -import io.element.android.x.features.preferences.PreferencesScreen -import io.element.android.x.features.rageshake.bugreport.BugReportScreen -import io.element.android.x.features.roomlist.RoomListScreen -import io.element.android.x.matrix.core.RoomId - -@Destination -@Composable -fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) { - OnBoardingScreen( - onSignUp = { - // TODO - }, - onSignIn = { - navigator.navigate(LoginScreenNavigationDestination) - } - ) -} - -@Destination -@Composable -fun LoginScreenNavigation(navigator: DestinationsNavigator) { - val sessionComponentsOwner = LocalContext.current.bindings().sessionComponentsOwner() - LoginScreen( - onChangeServer = { - navigator.navigate(ChangeServerScreenNavigationDestination) - }, - onLoginWithSuccess = { - sessionComponentsOwner.create(it) - navigator.navigate(RoomListScreenNavigationDestination) { - popUpTo(OnBoardingScreenNavigationDestination) { - inclusive = true - } - } - } - ) -} - -// TODO Create a subgraph in Login module -@Destination -@Composable -fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) { - ChangeServerScreen( - onChangeServerSuccess = { - navigator.popBackStack() - } - ) -} - -@RootNavGraph(start = true) -@Destination -@Composable -fun RoomListScreenNavigation(navigator: DestinationsNavigator) { - RoomListScreen( - onRoomClicked = { roomId: RoomId -> - navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value)) - }, - onOpenSettings = { - navigator.navigate(PreferencesScreenNavigationDestination()) - }, - ) -} - -@Destination -@Composable -fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) { - MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp) -} - -@Destination -@Composable -fun BugReportScreenNavigation(navigator: DestinationsNavigator) { - BugReportScreen( - onDone = navigator::popBackStack - ) -} - -@Destination -@Composable -fun PreferencesScreenNavigation(navigator: DestinationsNavigator) { - val sessionComponentsOwner = LocalContext.current.bindings().sessionComponentsOwner() - PreferencesScreen( - onBackPressed = navigator::navigateUp, - onOpenRageShake = { - navigator.navigate(BugReportScreenNavigationDestination) - }, - onSuccessLogout = { - sessionComponentsOwner.releaseActiveSession() - navigator.navigate(OnBoardingScreenNavigationDestination) { - popUpTo(RoomListScreenNavigationDestination) { - inclusive = true - } - } - }, - ) -} diff --git a/app/src/main/java/io/element/android/x/component/ShowkaseButton.kt b/app/src/main/java/io/element/android/x/component/ShowkaseButton.kt new file mode 100644 index 0000000000..bd8906c2a7 --- /dev/null +++ b/app/src/main/java/io/element/android/x/component/ShowkaseButton.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun ShowkaseButton( + isVisible: Boolean, + onClick: () -> Unit, + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isVisible) { + Button( + modifier = modifier + .padding(top = 32.dp, start = 16.dp), + onClick = onClick + ) { + Text(text = "Showkase Browser") + IconButton( + modifier = Modifier + .padding(start = 8.dp) + .size(16.dp), + onClick = onCloseClicked, + ) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + } + } +} diff --git a/app/src/main/java/io/element/android/x/di/AppBindings.kt b/app/src/main/java/io/element/android/x/di/AppBindings.kt index f330346e80..8cebc3e838 100644 --- a/app/src/main/java/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/java/io/element/android/x/di/AppBindings.kt @@ -18,13 +18,12 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo import io.element.android.x.matrix.Matrix -import io.element.android.x.matrix.ui.MatrixUi +import io.element.android.x.root.RootPresenter import kotlinx.coroutines.CoroutineScope @ContributesTo(AppScope::class) interface AppBindings { fun coroutineScope(): CoroutineScope + fun rootPresenter(): RootPresenter fun matrix(): Matrix - fun matrixUi(): MatrixUi - fun sessionComponentsOwner(): SessionComponentsOwner } diff --git a/app/src/main/java/io/element/android/x/di/AppComponent.kt b/app/src/main/java/io/element/android/x/di/AppComponent.kt index 728c3041cc..342c18e3a4 100644 --- a/app/src/main/java/io/element/android/x/di/AppComponent.kt +++ b/app/src/main/java/io/element/android/x/di/AppComponent.kt @@ -20,11 +20,11 @@ import android.content.Context import com.squareup.anvil.annotations.MergeComponent import dagger.BindsInstance import dagger.Component -import io.element.android.x.core.di.DaggerMavericksBindings +import io.element.android.x.architecture.NodeFactoriesBindings @SingleIn(AppScope::class) @MergeComponent(AppScope::class) -interface AppComponent : DaggerMavericksBindings { +interface AppComponent : NodeFactoriesBindings { @Component.Factory interface Factory { diff --git a/app/src/main/java/io/element/android/x/di/AppModule.kt b/app/src/main/java/io/element/android/x/di/AppModule.kt index b8e703c775..55c331680c 100644 --- a/app/src/main/java/io/element/android/x/di/AppModule.kt +++ b/app/src/main/java/io/element/android/x/di/AppModule.kt @@ -19,10 +19,14 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides +import io.element.android.x.core.coroutine.CoroutineDispatchers import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus +import java.util.concurrent.Executors @Module @ContributesTo(AppScope::class) @@ -33,4 +37,15 @@ object AppModule { fun providesAppCoroutineScope(): CoroutineScope { return MainScope() + CoroutineName("ElementX Scope") } + + @Provides + @SingleIn(AppScope::class) + fun providesCoroutineDispatchers(): CoroutineDispatchers { + return CoroutineDispatchers( + io = Dispatchers.IO, + computation = Dispatchers.Default, + main = Dispatchers.Main, + diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + ) + } } diff --git a/app/src/main/java/io/element/android/x/di/RoomComponent.kt b/app/src/main/java/io/element/android/x/di/RoomComponent.kt new file mode 100644 index 0000000000..cfd7eee471 --- /dev/null +++ b/app/src/main/java/io/element/android/x/di/RoomComponent.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.di + +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.BindsInstance +import dagger.Subcomponent +import io.element.android.x.architecture.NodeFactoriesBindings +import io.element.android.x.matrix.room.MatrixRoom + +@SingleIn(RoomScope::class) +@MergeSubcomponent(RoomScope::class) +interface RoomComponent : NodeFactoriesBindings { + + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun room(room: MatrixRoom): Builder + fun build(): RoomComponent + } + + @ContributesTo(SessionScope::class) + interface ParentBindings { + fun roomComponentBuilder(): Builder + } +} diff --git a/app/src/main/java/io/element/android/x/di/SessionComponent.kt b/app/src/main/java/io/element/android/x/di/SessionComponent.kt index c481268ce8..8da31df8eb 100644 --- a/app/src/main/java/io/element/android/x/di/SessionComponent.kt +++ b/app/src/main/java/io/element/android/x/di/SessionComponent.kt @@ -20,14 +20,12 @@ import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.MergeSubcomponent import dagger.BindsInstance import dagger.Subcomponent -import io.element.android.x.core.di.DaggerMavericksBindings +import io.element.android.x.architecture.NodeFactoriesBindings import io.element.android.x.matrix.MatrixClient @SingleIn(SessionScope::class) @MergeSubcomponent(SessionScope::class) -interface SessionComponent : DaggerMavericksBindings { - - fun matrixClient(): MatrixClient +interface SessionComponent : NodeFactoriesBindings, RoomComponent.ParentBindings { @Subcomponent.Builder interface Builder { diff --git a/app/src/main/java/io/element/android/x/di/SessionComponentsOwner.kt b/app/src/main/java/io/element/android/x/di/SessionComponentsOwner.kt deleted file mode 100644 index ce1009354d..0000000000 --- a/app/src/main/java/io/element/android/x/di/SessionComponentsOwner.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.di - -import android.content.Context -import io.element.android.x.core.di.bindings -import io.element.android.x.matrix.MatrixClient -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -@SingleIn(AppScope::class) -class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) { - - private val sessionComponents = ConcurrentHashMap() - var activeSessionComponent: SessionComponent? = null - private set - - fun setActive(sessionId: String) { - val sessionComponent = sessionComponents[sessionId] - if (activeSessionComponent != sessionComponent) { - activeSessionComponent = sessionComponent - } - } - - fun create(matrixClient: MatrixClient) { - val sessionId = matrixClient.sessionId - val sessionComponent = - context.bindings().sessionComponentBuilder() - .client(matrixClient).build() - sessionComponents[sessionId] = sessionComponent - setActive(sessionId) - } - - fun releaseActiveSession() { - activeSessionComponent?.also { - release(it.matrixClient().sessionId) - } - } - - fun release(sessionId: String) { - val sessionComponent = sessionComponents.remove(sessionId) - if (activeSessionComponent == sessionComponent) { - activeSessionComponent = null - } - } -} diff --git a/app/src/main/java/io/element/android/x/initializer/CoilInitializer.kt b/app/src/main/java/io/element/android/x/initializer/CoilInitializer.kt deleted file mode 100644 index 686e656e56..0000000000 --- a/app/src/main/java/io/element/android/x/initializer/CoilInitializer.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.initializer - -import android.content.Context -import androidx.startup.Initializer -import coil.Coil -import coil.ImageLoader -import coil.ImageLoaderFactory -import io.element.android.x.core.di.bindings -import io.element.android.x.di.AppBindings - -class CoilInitializer : Initializer { - - override fun create(context: Context) { - Coil.setImageLoader(ElementImageLoaderFactory(context)) - } - - override fun dependencies(): List>> = emptyList() -} - -private class ElementImageLoaderFactory( - private val context: Context -) : ImageLoaderFactory { - override fun newImageLoader(): ImageLoader { - return ImageLoader - .Builder(context) - .components { - val appBindings = context.bindings() - val matrixUi = appBindings.matrixUi() - val matrixClientProvider = { - appBindings - .sessionComponentsOwner().activeSessionComponent?.matrixClient() - } - matrixUi.registerCoilComponents(this, matrixClientProvider) - } - .build() - } -} diff --git a/app/src/main/java/io/element/android/x/initializer/MatrixInitializer.kt b/app/src/main/java/io/element/android/x/initializer/MatrixInitializer.kt index fdccf51078..bfcdaea612 100644 --- a/app/src/main/java/io/element/android/x/initializer/MatrixInitializer.kt +++ b/app/src/main/java/io/element/android/x/initializer/MatrixInitializer.kt @@ -32,5 +32,5 @@ class MatrixInitializer : Initializer { } } - override fun dependencies(): List>> = emptyList() + override fun dependencies(): List>> = listOf(TimberInitializer::class.java) } diff --git a/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt new file mode 100644 index 0000000000..f3b407953a --- /dev/null +++ b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.node + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import coil.Coil +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import io.element.android.x.architecture.bindings +import io.element.android.x.architecture.createNode +import io.element.android.x.core.di.DaggerComponentOwner +import io.element.android.x.di.SessionComponent +import io.element.android.x.features.preferences.PreferencesFlowNode +import io.element.android.x.features.roomlist.RoomListNode +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.core.RoomId +import io.element.android.x.matrix.core.SessionId +import io.element.android.x.matrix.ui.di.MatrixUIBindings +import kotlinx.parcelize.Parcelize + +class LoggedInFlowNode( + buildContext: BuildContext, + val sessionId: SessionId, + private val matrixClient: MatrixClient, + private val onOpenBugReport: () -> Unit, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.RoomList, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + navModel = backstack, + buildContext = buildContext +), DaggerComponentOwner { + + override val daggerComponent: Any by lazy { + parent!!.bindings().sessionComponentBuilder().client(matrixClient).build() + } + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onCreate = { + val imageLoaderFactory = bindings().loggedInImageLoaderFactory() + Coil.setImageLoader(imageLoaderFactory) + matrixClient.startSync() + }, + onDestroy = { + val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() + Coil.setImageLoader(imageLoaderFactory) + } + ) + } + + private val roomListCallback = object : RoomListNode.Callback { + override fun onRoomClicked(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId)) + } + + override fun onSettingsClicked() { + backstack.push(NavTarget.Settings) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object RoomList : NavTarget + + @Parcelize + data class Room(val roomId: RoomId) : NavTarget + + @Parcelize + object Settings : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.RoomList -> { + createNode(buildContext, plugins = listOf(roomListCallback)) + } + is NavTarget.Room -> { + val room = matrixClient.getRoom(roomId = navTarget.roomId) + if (room == null) { + // TODO CREATE UNKNOWN ROOM NODE + node(buildContext) { + Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "Unknown room with id = ${navTarget.roomId}") + } + } + } else { + RoomFlowNode(buildContext, room) + } + } + NavTarget.Settings -> { + PreferencesFlowNode(buildContext, onOpenBugReport) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt new file mode 100644 index 0000000000..3c619c5e38 --- /dev/null +++ b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.node + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.replace +import io.element.android.x.features.login.LoginFlowNode +import io.element.android.x.features.onboarding.OnBoardingScreen +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +class NotLoggedInFlowNode( + buildContext: BuildContext, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.OnBoarding, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + navModel = backstack, + buildContext = buildContext +) { + + init { + lifecycle.subscribe( + onCreate = { Timber.v("OnCreate") }, + onDestroy = { Timber.v("OnDestroy") } + ) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object OnBoarding : NavTarget + + @Parcelize + object LoginFlow : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.OnBoarding -> node(buildContext) { + OnBoardingScreen( + onSignIn = { backstack.replace(NavTarget.LoginFlow) } + ) + } + NavTarget.LoginFlow -> LoginFlowNode(buildContext) + } + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt b/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt new file mode 100644 index 0000000000..21ef4ad9cc --- /dev/null +++ b/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.node + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.navmodel.backstack.BackStack +import io.element.android.x.architecture.bindings +import io.element.android.x.architecture.createNode +import io.element.android.x.core.di.DaggerComponentOwner +import io.element.android.x.di.RoomComponent +import io.element.android.x.features.messages.MessagesNode +import io.element.android.x.matrix.room.MatrixRoom +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +class RoomFlowNode( + buildContext: BuildContext, + private val room: MatrixRoom, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.Messages, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + navModel = backstack, + buildContext = buildContext +), DaggerComponentOwner { + + override val daggerComponent: Any by lazy { + parent!!.bindings().roomComponentBuilder().room(room).build() + } + + init { + lifecycle.subscribe( + onCreate = { Timber.v("OnCreate") }, + onDestroy = { Timber.v("OnDestroy") } + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Messages -> createNode(buildContext) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Messages : NavTarget + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/app/src/main/java/io/element/android/x/node/RootFlowNode.kt b/app/src/main/java/io/element/android/x/node/RootFlowNode.kt new file mode 100644 index 0000000000..40569bd845 --- /dev/null +++ b/app/src/main/java/io/element/android/x/node/RootFlowNode.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.node + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import io.element.android.x.architecture.createNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.core.di.DaggerComponentOwner +import io.element.android.x.features.rageshake.bugreport.BugReportNode +import io.element.android.x.matrix.Matrix +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.core.SessionId +import io.element.android.x.root.RootPresenter +import io.element.android.x.root.RootView +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +class RootFlowNode( + buildContext: BuildContext, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.SplashScreen, + savedStateMap = buildContext.savedStateMap, + ), + private val appComponentOwner: DaggerComponentOwner, + private val matrix: Matrix, + rootPresenter: RootPresenter +) : + ParentNode( + navModel = backstack, + buildContext = buildContext, + ), + + DaggerComponentOwner by appComponentOwner { + + private val matrixClientsHolder = ConcurrentHashMap() + private val presenterConnector = presenterConnector(rootPresenter) + + override fun onBuilt() { + super.onBuilt() + whenChildAttached(LoggedInFlowNode::class) { _, child -> + child.lifecycle.subscribe( + onDestroy = { matrixClientsHolder.remove(child.sessionId) } + ) + } + matrix.isLoggedIn() + .distinctUntilChanged() + .onEach { isLoggedIn -> + Timber.v("isLoggedIn=$isLoggedIn") + if (isLoggedIn) { + val matrixClient = matrix.restoreSession() + if (matrixClient == null) { + backstack.newRoot(NavTarget.NotLoggedInFlow) + } else { + matrixClientsHolder[matrixClient.sessionId] = matrixClient + backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId)) + } + } else { + backstack.newRoot(NavTarget.NotLoggedInFlow) + } + } + .launchIn(lifecycleScope) + } + + private fun onOpenBugReport() { + backstack.push(NavTarget.BugReport) + } + + @Composable + override fun View(modifier: Modifier) { + val state by presenterConnector.stateFlow.collectAsState() + RootView( + state = state, + onOpenBugReport = this::onOpenBugReport, + ) { + Children(navModel = backstack) + } + } + + private val bugReportNodeCallback = object : BugReportNode.Callback { + override fun onBugReportSent() { + backstack.pop() + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object SplashScreen : NavTarget + + @Parcelize + object NotLoggedInFlow : NavTarget + + @Parcelize + data class LoggedInFlow(val sessionId: SessionId) : NavTarget + + @Parcelize + object BugReport : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.LoggedInFlow -> { + val matrixClient = + matrixClientsHolder[navTarget.sessionId] ?: throw IllegalStateException("Makes sure to give a matrixClient with the given sessionId") + LoggedInFlowNode( + buildContext = buildContext, + sessionId = navTarget.sessionId, + matrixClient = matrixClient, + onOpenBugReport = this::onOpenBugReport + ) + } + NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext) + NavTarget.SplashScreen -> node(buildContext) { + Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + NavTarget.BugReport -> createNode(buildContext, plugins = listOf(bugReportNodeCallback)) + } + } +} diff --git a/app/src/main/java/io/element/android/x/root/RootEvents.kt b/app/src/main/java/io/element/android/x/root/RootEvents.kt new file mode 100644 index 0000000000..a37e9c3c5b --- /dev/null +++ b/app/src/main/java/io/element/android/x/root/RootEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.root + +sealed interface RootEvents { + object HideShowkaseButton : RootEvents +} diff --git a/app/src/main/java/io/element/android/x/root/RootPresenter.kt b/app/src/main/java/io/element/android/x/root/RootPresenter.kt new file mode 100644 index 0000000000..2bcf49505a --- /dev/null +++ b/app/src/main/java/io/element/android/x/root/RootPresenter.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.rageshake.bugreport.BugReportPresenter +import io.element.android.x.features.rageshake.crash.ui.CrashDetectionPresenter +import io.element.android.x.features.rageshake.detection.RageshakeDetectionPresenter +import javax.inject.Inject + +class RootPresenter @Inject constructor( + private val bugReportPresenter: BugReportPresenter, + private val crashDetectionPresenter: CrashDetectionPresenter, + private val rageshakeDetectionPresenter: RageshakeDetectionPresenter, +) : Presenter { + + @Composable + override fun present(): RootState { + val isBugReportVisible = rememberSaveable { + mutableStateOf(false) + } + val isShowkaseButtonVisible = rememberSaveable { + mutableStateOf(true) + } + val rageshakeDetectionState = rageshakeDetectionPresenter.present() + val crashDetectionState = crashDetectionPresenter.present() + val bugReportState = bugReportPresenter.present() + + fun handleEvent(event: RootEvents) { + when (event) { + RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false + } + } + + return RootState( + isBugReportVisible = isBugReportVisible.value, + isShowkaseButtonVisible = isShowkaseButtonVisible.value, + rageshakeDetectionState = rageshakeDetectionState, + crashDetectionState = crashDetectionState, + bugReportState = bugReportState, + eventSink = ::handleEvent + ) + } +} diff --git a/app/src/main/java/io/element/android/x/root/RootState.kt b/app/src/main/java/io/element/android/x/root/RootState.kt new file mode 100644 index 0000000000..6a062d2925 --- /dev/null +++ b/app/src/main/java/io/element/android/x/root/RootState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.root + +import androidx.compose.runtime.Stable +import io.element.android.x.features.rageshake.bugreport.BugReportState +import io.element.android.x.features.rageshake.crash.ui.CrashDetectionState +import io.element.android.x.features.rageshake.detection.RageshakeDetectionState + +@Stable +data class RootState( + val isBugReportVisible: Boolean, + val isShowkaseButtonVisible: Boolean, + val rageshakeDetectionState: RageshakeDetectionState, + val crashDetectionState: CrashDetectionState, + val bugReportState: BugReportState, + val eventSink: (RootEvents) -> Unit +) diff --git a/app/src/main/java/io/element/android/x/root/RootView.kt b/app/src/main/java/io/element/android/x/root/RootView.kt new file mode 100644 index 0000000000..7aa3046719 --- /dev/null +++ b/app/src/main/java/io/element/android/x/root/RootView.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.root + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.airbnb.android.showkase.models.Showkase +import io.element.android.x.component.ShowkaseButton +import io.element.android.x.features.rageshake.crash.ui.CrashDetectionEvents +import io.element.android.x.features.rageshake.crash.ui.CrashDetectionView +import io.element.android.x.features.rageshake.detection.RageshakeDetectionEvents +import io.element.android.x.features.rageshake.detection.RageshakeDetectionView +import io.element.android.x.getBrowserIntent + +@Composable +fun RootView( + state: RootState, + modifier: Modifier = Modifier, + onOpenBugReport: () -> Unit = {}, + children: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + children() + val eventSink = state.eventSink + val context = LocalContext.current + + fun onOpenBugReport() { + state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed) + state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss) + onOpenBugReport.invoke() + } + + ShowkaseButton( + isVisible = state.isShowkaseButtonVisible, + onCloseClicked = { eventSink(RootEvents.HideShowkaseButton) }, + onClick = { ContextCompat.startActivity(context, Showkase.getBrowserIntent(context), null) } + ) + RageshakeDetectionView( + state = state.rageshakeDetectionState, + onOpenBugReport = ::onOpenBugReport, + ) + CrashDetectionView( + state = state.crashDetectionState, + onOpenBugReport = ::onOpenBugReport, + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index fa9822913a..eca2828846 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.anvil) apply false + alias(libs.plugins.molecule) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kapt) apply false alias(libs.plugins.dependencycheck) apply false diff --git a/features/login/build.gradle.kts b/features/login/build.gradle.kts index 2eb24bb6bc..29987189e9 100644 --- a/features/login/build.gradle.kts +++ b/features/login/build.gradle.kts @@ -20,6 +20,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) alias(libs.plugins.anvil) + id("kotlin-parcelize") } android { @@ -35,10 +36,11 @@ dependencies { anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) implementation(project(":libraries:core")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:matrix")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:elementresources")) - implementation(libs.mavericks.compose) + implementation(libs.appyx.core) ksp(libs.showkase.processor) testImplementation(libs.test.junit) androidTestImplementation(libs.test.junitext) diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt new file mode 100644 index 0000000000..12aa0887f2 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/LoginFlowNode.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import io.element.android.x.architecture.createNode +import io.element.android.x.features.login.changeserver.ChangeServerNode +import io.element.android.x.features.login.root.LoginRootNode +import kotlinx.parcelize.Parcelize + +class LoginFlowNode( + buildContext: BuildContext, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + navModel = backstack, + buildContext = buildContext +) { + + private val loginRootCallback = object : LoginRootNode.Callback { + override fun onChangeHomeServer() { + backstack.push(NavTarget.ChangeServer) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + + @Parcelize + object ChangeServer : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> createNode(buildContext, plugins = listOf(loginRootCallback)) + NavTarget.ChangeServer -> createNode(buildContext) + } + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt deleted file mode 100644 index 539d5be671..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.login - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshotFlow -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Uninitialized -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.AppScope -import io.element.android.x.matrix.Matrix -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -@ContributesViewModel(AppScope::class) -class LoginViewModel @AssistedInject constructor( - private val matrix: Matrix, - @Assisted initialState: LoginViewState) : - MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - var formState = mutableStateOf(LoginFormState.Default) - private set - - init { - snapshotFlow { formState.value } - .onEach { - setState { copy(formState = it) } - }.launchIn(viewModelScope) - } - - fun onResume() { - val currentHomeserver = matrix.getHomeserverOrDefault() - setState { - copy( - homeserver = currentHomeserver - ) - } - } - - fun onSubmit() { - viewModelScope.launch { - suspend { - val state = awaitState() - // Ensure the server is provided to the Rust SDK - matrix.setHomeserver(state.homeserver) - matrix.login(state.formState.login.trim(), state.formState.password.trim()).also { - it.startSync() - } - }.execute { - copy(loggedInClient = it) - } - } - } - - fun onSetPassword(password: String) { - formState.value = formState.value.copy(password = password) - setState { copy(loggedInClient = Uninitialized) } - } - - fun onSetName(name: String) { - formState.value = formState.value.copy(login = name) - setState { copy(loggedInClient = Uninitialized) } - } -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt new file mode 100644 index 0000000000..78e4e64ffd --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.changeserver + +sealed interface ChangeServerEvents { + data class SetServer(val server: String) : ChangeServerEvents + object Submit : ChangeServerEvents +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt new file mode 100644 index 0000000000..2e20380182 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerNode.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.changeserver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.AppScope + +@ContributesNode(AppScope::class) +class ChangeServerNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ChangeServerPresenter, +) : Node(buildContext, plugins = plugins) { + + private val presenterConnector = presenterConnector(presenter) + + private fun onSuccess() { + navigateUp() + } + + @Composable + override fun View(modifier: Modifier) { + val state by presenterConnector.stateFlow.collectAsState() + ChangeServerView( + state = state, + onChangeServerSuccess = this::onSuccess, + ) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt new file mode 100644 index 0000000000..ec6f60f2df --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerPresenter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.changeserver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Async +import io.element.android.x.architecture.Presenter +import io.element.android.x.architecture.execute +import io.element.android.x.matrix.Matrix +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ChangeServerPresenter @Inject constructor(private val matrix: Matrix) : Presenter { + + @Composable + override fun present(): ChangeServerState { + val localCoroutineScope = rememberCoroutineScope() + val homeserver = rememberSaveable { + mutableStateOf(matrix.getHomeserverOrDefault()) + } + val changeServerAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: ChangeServerEvents) { + when (event) { + is ChangeServerEvents.SetServer -> homeserver.value = event.server + ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction) + } + } + + return ChangeServerState( + homeserver = homeserver.value, + changeServerAction = changeServerAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState>) = launch { + suspend { + matrix.setHomeserver(homeserver) + }.execute(changeServerAction) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt similarity index 70% rename from features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt rename to features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt index 6061fd86a9..90dcb8ce64 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,14 +16,12 @@ package io.element.android.x.features.login.changeserver -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized +import io.element.android.x.architecture.Async -data class ChangeServerViewState( +data class ChangeServerState( val homeserver: String = "", - val changeServerAction: Async = Uninitialized, -) : MavericksState { - val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading + val changeServerAction: Async = Async.Uninitialized, + val eventSink: (ChangeServerEvents) -> Unit = {}, +) { + val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading } diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt similarity index 83% rename from features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt rename to features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt index 776aa941c3..80dc4f2e0b 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerView.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -51,42 +52,24 @@ 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.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel +import io.element.android.x.architecture.Async +import io.element.android.x.core.compose.textFieldState import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.VectorIcon import io.element.android.x.features.login.R import io.element.android.x.features.login.error.changeServerError @Composable -fun ChangeServerScreen( - viewModel: ChangeServerViewModel = mavericksViewModel(), - onChangeServerSuccess: () -> Unit = { } -) { - val state: ChangeServerViewState by viewModel.collectAsState() - ChangeServerContent( - state = state, - onChangeServer = viewModel::setServer, - onChangeServerSubmit = viewModel::setServerSubmit, - onChangeServerSuccess = onChangeServerSuccess - ) -} - -@Composable -fun ChangeServerContent( - state: ChangeServerViewState, +fun ChangeServerView( + state: ChangeServerState, modifier: Modifier = Modifier, - onChangeServer: (String) -> Unit = {}, - onChangeServerSubmit: () -> Unit = {}, onChangeServerSuccess: () -> Unit = {}, ) { Surface( modifier = modifier, color = MaterialTheme.colorScheme.background, ) { + val eventSink = state.eventSink val scrollState = rememberScrollState() Box( modifier = Modifier @@ -101,7 +84,7 @@ fun ChangeServerContent( ) .padding(horizontal = 16.dp) ) { - val isError = state.changeServerAction is Fail + val isError = state.changeServerAction is Async.Failure Box( modifier = Modifier .padding(top = 99.dp) @@ -142,12 +125,16 @@ fun ChangeServerContent( fontSize = 16.sp, color = MaterialTheme.colorScheme.secondary ) + var homeserverFieldState by textFieldState(stateValue = state.homeserver) OutlinedTextField( - value = state.homeserver, + value = homeserverFieldState, modifier = Modifier .fillMaxWidth() .padding(top = 200.dp), - onValueChange = onChangeServer, + onValueChange = { + homeserverFieldState = it + eventSink(ChangeServerEvents.SetServer(it)) + }, label = { Text(text = "Server") }, @@ -157,10 +144,10 @@ fun ChangeServerContent( imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions( - onDone = { onChangeServerSubmit() } + onDone = { eventSink(ChangeServerEvents.Submit) } ) ) - if (state.changeServerAction is Fail) { + if (state.changeServerAction is Async.Failure) { Text( text = changeServerError( state.homeserver, @@ -172,7 +159,7 @@ fun ChangeServerContent( ) } Button( - onClick = onChangeServerSubmit, + onClick = { eventSink(ChangeServerEvents.Submit) }, enabled = state.submitEnabled, modifier = Modifier .fillMaxWidth() @@ -180,11 +167,11 @@ fun ChangeServerContent( ) { Text(text = "Continue") } - if (state.changeServerAction is Success) { + if (state.changeServerAction is Async.Success) { onChangeServerSuccess() } } - if (state.changeServerAction is Loading) { + if (state.changeServerAction is Async.Loading) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center) ) @@ -197,8 +184,8 @@ fun ChangeServerContent( @Preview fun ChangeServerContentPreview() { ElementXTheme { - ChangeServerContent( - state = ChangeServerViewState(homeserver = "matrix.org"), + ChangeServerView( + state = ChangeServerState(homeserver = "matrix.org"), ) } } diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt deleted file mode 100644 index 098f67acd9..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.login.changeserver - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Uninitialized -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.AppScope -import io.element.android.x.matrix.Matrix -import kotlinx.coroutines.launch - -@ContributesViewModel(AppScope::class) -class ChangeServerViewModel @AssistedInject constructor( - private val matrix: Matrix, - @Assisted initialState: ChangeServerViewState -) : - MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - init { - setState { - copy( - homeserver = matrix.getHomeserverOrDefault() - ) - } - } - - fun setServer(server: String) { - setState { - copy( - homeserver = server, - changeServerAction = Uninitialized, - ) - } - } - - fun setServerSubmit() { - viewModelScope.launch { - suspend { - val state = awaitState() - matrix.setHomeserver(state.homeserver) - }.execute { - copy(changeServerAction = it) - } - } - } -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt b/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt index 93942dd535..3afc697572 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/error/ErrorFormatter.kt @@ -19,8 +19,8 @@ package io.element.android.x.features.login.error import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import io.element.android.x.core.uri.isValidUrl +import io.element.android.x.features.login.root.LoginFormState import io.element.android.x.element.resources.R as ElementR -import io.element.android.x.features.login.LoginFormState @Composable fun loginError( diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt new file mode 100644 index 0000000000..de72602775 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.root + +sealed interface LoginRootEvents { + object RefreshHomeServer : LoginRootEvents + data class SetLogin(val login: String) : LoginRootEvents + data class SetPassword(val password: String) : LoginRootEvents + object Submit : LoginRootEvents +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt new file mode 100644 index 0000000000..eb1f1d304b --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootNode.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.core.compose.OnLifecycleEvent +import io.element.android.x.di.AppScope + +@ContributesNode(AppScope::class) +class LoginRootNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LoginRootPresenter, +) : Node(buildContext, plugins = plugins) { + + private val presenterConnector = presenterConnector(presenter) + + interface Callback : Plugin { + fun onChangeHomeServer() + } + + private fun onChangeHomeServer() { + plugins().forEach { it.onChangeHomeServer() } + } + + @Composable + override fun View(modifier: Modifier) { + val state by presenterConnector.stateFlow.collectAsState() + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer) + else -> Unit + } + } + LoginRootScreen( + state = state, + onChangeServer = this::onChangeHomeServer, + ) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt new file mode 100644 index 0000000000..ef6b2eab7b --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.login.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.matrix.Matrix +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoginRootPresenter @Inject constructor(private val matrix: Matrix) : Presenter { + + @Composable + override fun present(): LoginRootState { + val localCoroutineScope = rememberCoroutineScope() + val homeserver = rememberSaveable { + mutableStateOf(matrix.getHomeserverOrDefault()) + } + val loggedInState: MutableState = remember { + mutableStateOf(LoggedInState.NotLoggedIn) + } + val formState = rememberSaveable { + mutableStateOf(LoginFormState.Default) + } + + fun handleEvents(event: LoginRootEvents) { + when (event) { + LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver) + is LoginRootEvents.SetLogin -> updateFormState(formState) { + copy(login = event.login) + } + is LoginRootEvents.SetPassword -> updateFormState(formState) { + copy(password = event.password) + } + LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState) + } + } + + return LoginRootState( + homeserver = homeserver.value, + loggedInState = loggedInState.value, + formState = formState.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState) = launch { + loggedInState.value = LoggedInState.LoggingIn + try { + matrix.setHomeserver(homeserver) + val sessionId = matrix.login(formState.login.trim(), formState.password.trim()) + loggedInState.value = LoggedInState.LoggedIn(sessionId) + } catch (failure: Throwable) { + loggedInState.value = LoggedInState.ErrorLoggingIn(failure) + } + } + + private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) { + formState.value = updateLambda(formState.value) + } + + private fun refreshHomeServer(homeserver: MutableState) { + homeserver.value = matrix.getHomeserverOrDefault() + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt similarity index 79% rename from features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt rename to features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt index 36538bc83c..d71cb909c3 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootScreen.kt @@ -16,7 +16,7 @@ @file:OptIn(ExperimentalMaterial3Api::class) -package io.element.android.x.features.login +package io.element.android.x.features.login.root import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,7 +42,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text 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 @@ -58,51 +57,20 @@ 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.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel +import io.element.android.x.core.compose.textFieldState import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.features.login.error.loginError -import io.element.android.x.matrix.MatrixClient -import timber.log.Timber - -@Composable -fun LoginScreen( - viewModel: LoginViewModel = mavericksViewModel(), - onChangeServer: () -> Unit = { }, - onLoginWithSuccess: (MatrixClient) -> Unit = { }, -) { - val state: LoginViewState by viewModel.collectAsState() - val formState: LoginFormState by viewModel.formState - LaunchedEffect(key1 = Unit) { - Timber.d("resume") - viewModel.onResume() - } - LoginContent( - state = state, - formState = formState, - onChangeServer = onChangeServer, - onLoginChanged = viewModel::onSetName, - onPasswordChanged = viewModel::onSetPassword, - onSubmitClicked = viewModel::onSubmit, - onLoginWithSuccess = onLoginWithSuccess - ) -} +import io.element.android.x.matrix.core.SessionId @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LoginContent( - state: LoginViewState, - formState: LoginFormState, +fun LoginRootScreen( + state: LoginRootState, modifier: Modifier = Modifier, onChangeServer: () -> Unit = {}, - onLoginChanged: (String) -> Unit = {}, - onPasswordChanged: (String) -> Unit = {}, - onSubmitClicked: () -> Unit = {}, - onLoginWithSuccess: (MatrixClient) -> Unit = {}, + onLoginWithSuccess: (SessionId) -> Unit = {}, ) { + val eventSink = state.eventSink Surface( modifier = modifier, color = MaterialTheme.colorScheme.background, @@ -114,6 +82,9 @@ fun LoginContent( .imePadding() ) { val scrollState = rememberScrollState() + var loginFieldState by textFieldState(stateValue = state.formState.login) + var passwordFieldState by textFieldState(stateValue = state.formState.password) + Column( modifier = Modifier .verticalScroll( @@ -121,7 +92,7 @@ fun LoginContent( ) .padding(horizontal = 16.dp), ) { - val isError = state.loggedInClient is Fail + val isError = state.loggedInState is LoggedInState.ErrorLoggingIn // Title Text( text = "Welcome back", @@ -162,30 +133,36 @@ fun LoginContent( ) } OutlinedTextField( - value = formState.login, + value = loginFieldState, modifier = Modifier .fillMaxWidth() .padding(top = 60.dp), label = { Text(text = "Email or username") }, - onValueChange = onLoginChanged, + onValueChange = { + loginFieldState = it + eventSink(LoginRootEvents.SetLogin(it)) + }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Next ), ) var passwordVisible by remember { mutableStateOf(false) } - if (state.loggedInClient is Loading) { + if (state.loggedInState is LoggedInState.LoggingIn) { // Ensure password is hidden when user submits the form passwordVisible = false } OutlinedTextField( - value = formState.password, + value = passwordFieldState, modifier = Modifier .fillMaxWidth() .padding(top = 24.dp), - onValueChange = onPasswordChanged, + onValueChange = { + passwordFieldState = it + eventSink(LoginRootEvents.SetPassword(it)) + }, label = { Text(text = "Password") }, @@ -206,12 +183,12 @@ fun LoginContent( imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions( - onDone = { onSubmitClicked() } + onDone = { eventSink(LoginRootEvents.Submit) } ), ) - if (state.loggedInClient is Fail) { + if (state.loggedInState is LoggedInState.ErrorLoggingIn) { Text( - text = loginError(state.formState, state.loggedInClient.error), + text = loginError(state.formState, state.loggedInState.failure), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 16.dp) @@ -220,7 +197,7 @@ fun LoginContent( } // Submit Button( - onClick = onSubmitClicked, + onClick = { eventSink(LoginRootEvents.Submit) }, enabled = state.submitEnabled, modifier = Modifier .fillMaxWidth() @@ -228,12 +205,12 @@ fun LoginContent( ) { Text(text = "Continue") } - when (val loggedInClient = state.loggedInClient) { - is Success -> onLoginWithSuccess(loggedInClient()) + when (val loggedInState = state.loggedInState) { + is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId) else -> Unit } } - if (state.loggedInClient is Loading) { + if (state.loggedInState is LoggedInState.LoggingIn) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center) ) @@ -246,11 +223,10 @@ fun LoginContent( @Preview fun LoginContentPreview() { ElementXTheme(darkTheme = false) { - LoginContent( - state = LoginViewState( + LoginRootScreen( + state = LoginRootState( homeserver = "matrix.org", ), - formState = LoginFormState("", "") ) } } diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt similarity index 57% rename from features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt rename to features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt index 8933cedf55..4481b3afff 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/root/LoginRootState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,27 +14,34 @@ * limitations under the License. */ -package io.element.android.x.features.login +package io.element.android.x.features.login.root -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized -import io.element.android.x.matrix.MatrixClient +import android.os.Parcelable +import io.element.android.x.matrix.core.SessionId +import kotlinx.parcelize.Parcelize -data class LoginViewState( +data class LoginRootState( val homeserver: String = "", - val loggedInClient: Async = Uninitialized, + val loggedInState: LoggedInState = LoggedInState.NotLoggedIn, val formState: LoginFormState = LoginFormState.Default, -) : MavericksState { + val eventSink: (LoginRootEvents) -> Unit = {} +) { val submitEnabled = - formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInClient !is Loading + formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn } +sealed interface LoggedInState { + object NotLoggedIn : LoggedInState + object LoggingIn : LoggedInState + data class ErrorLoggingIn(val failure: Throwable) : LoggedInState + data class LoggedIn(val sessionId: SessionId) : LoggedInState +} + +@Parcelize data class LoginFormState( val login: String, val password: String -) { +) : Parcelable { companion object { val Default = LoginFormState("", "") diff --git a/features/logout/build.gradle.kts b/features/logout/build.gradle.kts index ad678224e5..c7a28ef2d7 100644 --- a/features/logout/build.gradle.kts +++ b/features/logout/build.gradle.kts @@ -34,11 +34,11 @@ dependencies { implementation(project(":anvilannotations")) anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:core")) implementation(project(":libraries:matrix")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:elementresources")) - implementation(libs.mavericks.compose) ksp(libs.showkase.processor) testImplementation(libs.test.junit) androidTestImplementation(libs.test.junitext) diff --git a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceEvents.kt b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceEvents.kt new file mode 100644 index 0000000000..83aa2fe14f --- /dev/null +++ b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.logout + +sealed interface LogoutPreferenceEvents { + object Logout : LogoutPreferenceEvents +} diff --git a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferencePresenter.kt b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferencePresenter.kt new file mode 100644 index 0000000000..578de59787 --- /dev/null +++ b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferencePresenter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.logout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.x.architecture.Async +import io.element.android.x.architecture.Presenter +import io.element.android.x.architecture.execute +import io.element.android.x.matrix.MatrixClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : Presenter { + + @Composable + override fun present(): LogoutPreferenceState { + val localCoroutineScope = rememberCoroutineScope() + val logoutAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: LogoutPreferenceEvents) { + when (event) { + LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction) + } + } + + return LogoutPreferenceState( + logoutAction = logoutAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.logout(logoutAction: MutableState>) = launch { + suspend { + matrixClient.logout() + }.execute(logoutAction) + } +} diff --git a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutScreen.kt b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceScreen.kt similarity index 83% rename from features/logout/src/main/java/io/element/android/x/features/logout/LogoutScreen.kt rename to features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceScreen.kt index 029b2dd1d8..75f29319dd 100644 --- a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutScreen.kt +++ b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceScreen.kt @@ -19,15 +19,12 @@ package io.element.android.x.features.logout import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Logout import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel +import io.element.android.x.architecture.Async import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.ProgressDialog import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog @@ -36,16 +33,17 @@ import io.element.android.x.designsystem.components.preferences.PreferenceText import io.element.android.x.element.resources.R as ElementR @Composable -fun LogoutPreference( - viewModel: LogoutViewModel = mavericksViewModel(), - onSuccessLogout: () -> Unit = { }, +fun LogoutPreferenceView( + state: LogoutPreferenceState, + onSuccessLogout: () -> Unit = {} ) { - val state: LogoutViewState by viewModel.collectAsState() - if (state.logoutAction is Success) { - onSuccessLogout() + val eventSink = state.eventSink + if (state.logoutAction is Async.Success) { + LaunchedEffect(state.logoutAction) { + onSuccessLogout() + } return } - val openDialog = remember { mutableStateOf(false) } LogoutPreferenceContent( @@ -65,7 +63,7 @@ fun LogoutPreference( }, onSubmitClicked = { openDialog.value = false - viewModel.logout() + eventSink(LogoutPreferenceEvents.Logout) }, onDismiss = { openDialog.value = false @@ -73,7 +71,7 @@ fun LogoutPreference( ) } - if (state.logoutAction is Loading) { + if (state.logoutAction is Async.Loading) { ProgressDialog(text = "Login out...") } } @@ -95,6 +93,6 @@ fun LogoutPreferenceContent( @Preview fun LogoutContentPreview() { ElementXTheme(darkTheme = false) { - LogoutPreference() + LogoutPreferenceView(LogoutPreferenceState()) } } diff --git a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewState.kt b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceState.kt similarity index 76% rename from features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewState.kt rename to features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceState.kt index e72442af05..70e637899b 100644 --- a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewState.kt +++ b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutPreferenceState.kt @@ -16,10 +16,9 @@ package io.element.android.x.features.logout -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized +import io.element.android.x.architecture.Async -data class LogoutViewState( - val logoutAction: Async = Uninitialized, -) : MavericksState +data class LogoutPreferenceState( + val logoutAction: Async = Async.Uninitialized, + val eventSink: (LogoutPreferenceEvents) -> Unit = {}, +) diff --git a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewModel.kt b/features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewModel.kt deleted file mode 100644 index 08bcdd69cc..0000000000 --- a/features/logout/src/main/java/io/element/android/x/features/logout/LogoutViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.logout - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.SessionScope -import io.element.android.x.matrix.MatrixClient -import kotlinx.coroutines.launch - -@ContributesViewModel(SessionScope::class) -class LogoutViewModel @AssistedInject constructor( - private val client: MatrixClient, - @Assisted initialState: LogoutViewState -) : MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - fun logout() { - viewModelScope.launch { - suspend { - client.logout() - }.execute { - copy(logoutAction = it) - } - } - } -} diff --git a/features/messages/build.gradle.kts b/features/messages/build.gradle.kts index b24ddb94d4..861cb183c4 100644 --- a/features/messages/build.gradle.kts +++ b/features/messages/build.gradle.kts @@ -35,11 +35,12 @@ dependencies { anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) implementation(project(":libraries:core")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:matrix")) implementation(project(":libraries:matrixui")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:textcomposer")) - implementation(libs.mavericks.compose) + implementation(libs.appyx.core) implementation(libs.coil.compose) implementation(libs.datetime) implementation(libs.accompanist.flowlayout) diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt similarity index 59% rename from features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt index a4a170515c..88e8c52221 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewModel.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.onboarding +package io.element.android.x.features.messages -import com.airbnb.mvrx.MavericksViewModel +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.timeline.model.TimelineItem -class OnBoardingViewModel(initialState: OnBoardingViewState) : - MavericksViewModel(initialState) { - - fun onPageChanged(page: Int) { - setState { - copy( - currentPage = page, - ) - } - } +sealed interface MessagesEvents { + data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt new file mode 100644 index 0000000000..dd7b435ef0 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.RoomScope + +@ContributesNode(RoomScope::class) +class MessagesNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenter: MessagesPresenter, +) : Node(buildContext, plugins = plugins) { + + private val connector = presenterConnector(presenter) + + @Composable + override fun View(modifier: Modifier) { + val state by connector.stateFlow.collectAsState() + MessagesView( + state = state, + onBackPressed = this::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt new file mode 100644 index 0000000000..8324dc806b --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.designsystem.components.avatar.AvatarSize +import io.element.android.x.features.messages.actionlist.ActionListPresenter +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.textcomposer.MessageComposerEvents +import io.element.android.x.features.messages.textcomposer.MessageComposerPresenter +import io.element.android.x.features.messages.textcomposer.MessageComposerState +import io.element.android.x.features.messages.timeline.TimelineEvents +import io.element.android.x.features.messages.timeline.TimelinePresenter +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.room.MatrixRoom +import io.element.android.x.matrix.ui.MatrixItemHelper +import io.element.android.x.textcomposer.MessageComposerMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class MessagesPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val room: MatrixRoom, + private val composerPresenter: MessageComposerPresenter, + private val timelinePresenter: TimelinePresenter, + private val actionListPresenter: ActionListPresenter, +) : Presenter { + + private val matrixItemHelper = MatrixItemHelper(matrixClient) + + @Composable + override fun present(): MessagesState { + val localCoroutineScope = rememberCoroutineScope() + val composerState = composerPresenter.present() + val timelineState = timelinePresenter.present() + val actionListState = actionListPresenter.present() + + val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L) + val roomName: MutableState = rememberSaveable { + mutableStateOf(null) + } + val roomAvatar: MutableState = remember { + mutableStateOf(null) + } + LaunchedEffect(syncUpdateFlow) { + roomAvatar.value = + matrixItemHelper.loadAvatarData( + room = room, + size = AvatarSize.SMALL + ) + roomName.value = room.name + } + LaunchedEffect(composerState.mode.relatedEventId) { + timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId)) + } + fun handleEvents(event: MessagesEvents) { + when (event) { + is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState) + } + } + return MessagesState( + roomId = room.roomId, + roomName = roomName.value, + roomAvatar = roomAvatar.value, + composerState = composerState, + timelineState = timelineState, + actionListState = actionListState, + eventSink = ::handleEvents + ) + } + + fun CoroutineScope.handleTimelineAction( + action: TimelineItemAction, + targetEvent: TimelineItem.MessageEvent, + composerState: MessageComposerState, + ) = launch { + when (action) { + TimelineItemAction.Copy -> notImplementedYet() + TimelineItemAction.Forward -> notImplementedYet() + TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState) + TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState) + } + } + + private fun notImplementedYet() { + Timber.v("NotImplementedYet") + } + + private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) { + room.redactEvent(event.id) + } + + private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { + val composerMode = MessageComposerMode.Edit( + targetEvent.id, + (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty() + ) + composerState.eventSink( + MessageComposerEvents.SetMode(composerMode) + ) + } + + private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) { + val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "") + composerState.eventSink( + MessageComposerEvents.SetMode(composerMode) + ) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt deleted file mode 100644 index 7d9b8146ae..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ /dev/null @@ -1,689 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn( - ExperimentalMaterial3Api::class, - ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class -) - -package io.element.android.x.features.messages - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.End -import androidx.compose.ui.Alignment.Companion.Start -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.LastBaseline -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel -import io.element.android.x.core.compose.LogCompositions -import io.element.android.x.core.compose.PairCombinedPreviewParameter -import io.element.android.x.core.data.StableCharSequence -import io.element.android.x.designsystem.components.avatar.Avatar -import io.element.android.x.designsystem.components.avatar.AvatarData -import io.element.android.x.features.messages.components.MessageEventBubble -import io.element.android.x.features.messages.components.MessagesReactionsView -import io.element.android.x.features.messages.components.MessagesTimelineItemEncryptedView -import io.element.android.x.features.messages.components.MessagesTimelineItemImageView -import io.element.android.x.features.messages.components.MessagesTimelineItemRedactedView -import io.element.android.x.features.messages.components.MessagesTimelineItemTextView -import io.element.android.x.features.messages.components.MessagesTimelineItemUnknownView -import io.element.android.x.features.messages.components.TimelineItemActionsScreen -import io.element.android.x.features.messages.model.AggregatedReaction -import io.element.android.x.features.messages.model.MessagesItemGroupPosition -import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider -import io.element.android.x.features.messages.model.MessagesItemReactionState -import io.element.android.x.features.messages.model.MessagesTimelineItemState -import io.element.android.x.features.messages.model.MessagesViewState -import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider -import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent -import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel -import io.element.android.x.features.messages.textcomposer.MessageComposerViewState -import io.element.android.x.textcomposer.MessageComposerMode -import io.element.android.x.textcomposer.TextComposer -import java.lang.Math.random -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import timber.log.Timber - -@Composable -fun MessagesScreen( - roomId: String, - onBackPressed: () -> Unit, - viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }), - composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId }) -) { - fun onSendMessage(textMessage: String) { - viewModel.sendMessage(textMessage) - composerViewModel.updateText("") - } - - LogCompositions(tag = "MessagesScreen", msg = "Root") - val itemActionsBottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - ) - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val roomTitle by viewModel.collectAsState(MessagesViewState::roomName) - val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar) - val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) - val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad) - val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent) - val composerMode by viewModel.collectAsState(MessagesViewState::composerMode) - val highlightedEventId by viewModel.collectAsState(MessagesViewState::highlightedEventId) - val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen) - val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible) - val composerText by composerViewModel.collectAsState(MessageComposerViewState::text) - - MessagesScreenContent( - roomTitle = roomTitle, - roomAvatar = roomAvatar, - timelineItems = timelineItems().orEmpty().toImmutableList(), - hasMoreToLoad = hasMoreToLoad, - onReachedLoadMore = viewModel::loadMore, - onBackPressed = onBackPressed, - onSendMessage = ::onSendMessage, - composerFullScreen = composerFullScreen, - onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange, - onComposerTextChange = composerViewModel::updateText, - composerMode = composerMode, - highlightedEventId = highlightedEventId, - onCloseSpecialMode = viewModel::setNormalMode, - composerCanSendMessage = composerCanSendMessage, - composerText = composerText, - onClick = { - Timber.v("onClick on timeline item: ${it.id}") - }, - onLongClick = { - focusManager.clearFocus(force = true) - viewModel.computeActionsSheetState(it) - coroutineScope.launch { - itemActionsBottomSheetState.show() - } - }, - snackbarHostState = snackbarHostState, - ) - TimelineItemActionsScreen( - viewModel = viewModel, - composerViewModel = composerViewModel, - modalBottomSheetState = itemActionsBottomSheetState, - ) - snackBarContent?.let { - coroutineScope.launch { - snackbarHostState.showSnackbar(it) - } - viewModel.onSnackbarShown() - } -} - -@Composable -fun MessagesScreenContent( - roomTitle: String?, - roomAvatar: AvatarData?, - timelineItems: ImmutableList, - hasMoreToLoad: Boolean, - onReachedLoadMore: () -> Unit, - onBackPressed: () -> Unit, - onSendMessage: (String) -> Unit, - onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, - onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, - composerFullScreen: Boolean, - onComposerFullScreenChange: () -> Unit, - onComposerTextChange: (CharSequence) -> Unit, - composerMode: MessageComposerMode, - highlightedEventId: String?, - onCloseSpecialMode: () -> Unit, - composerCanSendMessage: Boolean, - composerText: StableCharSequence?, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - LogCompositions(tag = "MessagesScreen", msg = "Content") - Scaffold( - modifier = modifier, - contentWindowInsets = WindowInsets.statusBars, - topBar = { - MessagesTopAppBar( - roomTitle = roomTitle, - roomAvatar = roomAvatar, - onBackPressed = onBackPressed - ) - }, - content = { padding -> - MessagesContent( - modifier = Modifier.padding(padding), - timelineItems = timelineItems, - hasMoreToLoad = hasMoreToLoad, - onReachedLoadMore = onReachedLoadMore, - onSendMessage = onSendMessage, - onClick = onClick, - onLongClick = onLongClick, - highlightedEventId = highlightedEventId, - composerMode = composerMode, - onCloseSpecialMode = onCloseSpecialMode, - composerFullScreen = composerFullScreen, - onComposerFullScreenChange = onComposerFullScreenChange, - onComposerTextChange = onComposerTextChange, - composerCanSendMessage = composerCanSendMessage, - composerText = composerText - ) - }, - snackbarHost = { - SnackbarHost( - snackbarHostState, - modifier = Modifier.navigationBarsPadding() - ) - }, - ) -} - -@Composable -fun MessagesContent( - timelineItems: ImmutableList, - hasMoreToLoad: Boolean, - onReachedLoadMore: () -> Unit, - onSendMessage: (String) -> Unit, - onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, - onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, - composerMode: MessageComposerMode, - highlightedEventId: String?, - onCloseSpecialMode: () -> Unit, - composerFullScreen: Boolean, - onComposerFullScreenChange: () -> Unit, - onComposerTextChange: (CharSequence) -> Unit, - composerCanSendMessage: Boolean, - composerText: StableCharSequence?, - modifier: Modifier = Modifier -) { - val lazyListState = rememberLazyListState() - Column( - modifier = modifier - .fillMaxSize() - .navigationBarsPadding() - .imePadding() - ) { - if (!composerFullScreen) { - TimelineItems( - lazyListState = lazyListState, - timelineItems = timelineItems, - highlightedEventId = highlightedEventId, - hasMoreToLoad = hasMoreToLoad, - onReachedLoadMore = onReachedLoadMore, - modifier = Modifier.weight(1f), - onClick = onClick, - onLongClick = onLongClick - ) - } - TextComposer( - onSendMessage = onSendMessage, - fullscreen = composerFullScreen, - onFullscreenToggle = onComposerFullScreenChange, - composerMode = composerMode, - onCloseSpecialMode = onCloseSpecialMode, - onComposerTextChange = onComposerTextChange, - composerCanSendMessage = composerCanSendMessage, - composerText = composerText?.charSequence?.toString(), - modifier = Modifier - .fillMaxWidth() - .let { - if (composerFullScreen) { - it.weight(1f, fill = false) - } else { - it.wrapContentHeight(Alignment.Bottom) - } - }, - ) - } -} - -@Composable -fun MessagesTopAppBar( - roomTitle: String?, - roomAvatar: AvatarData?, - modifier: Modifier = Modifier, - onBackPressed: () -> Unit = {}, -) { - TopAppBar( - modifier = modifier, - navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (roomAvatar != null) { - Avatar(roomAvatar) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - text = roomTitle ?: "Unknown room", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - ) -} - -@Composable -fun TimelineItems( - lazyListState: LazyListState, - timelineItems: ImmutableList, - highlightedEventId: String?, - modifier: Modifier = Modifier, - hasMoreToLoad: Boolean = false, - onClick: (MessagesTimelineItemState.MessageEvent) -> Unit = {}, - onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit = {}, - onReachedLoadMore: () -> Unit = {}, -) { - Box(modifier = modifier.fillMaxWidth()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Bottom, - reverseLayout = true - ) { - items( - items = timelineItems, - contentType = { timelineItem -> timelineItem.contentType() }, - key = { timelineItem -> timelineItem.key() }, - ) { timelineItem -> - TimelineItemRow( - timelineItem = timelineItem, - isHighlighted = timelineItem.key() == highlightedEventId, - onClick = onClick, - onLongClick = onLongClick - ) - } - if (hasMoreToLoad) { - item { - MessagesLoadingMoreIndicator() - } - } - } - MessagesScrollHelper( - lazyListState = lazyListState, - timelineItems = timelineItems, - onLoadMore = onReachedLoadMore - ) - } -} - -private fun MessagesTimelineItemState.key(): String { - return when (this) { - is MessagesTimelineItemState.MessageEvent -> id - is MessagesTimelineItemState.Virtual -> id - } -} - -private fun MessagesTimelineItemState.contentType(): Int { - return when (this) { - is MessagesTimelineItemState.MessageEvent -> 0 - is MessagesTimelineItemState.Virtual -> 1 - } -} - -@Composable -fun TimelineItemRow( - timelineItem: MessagesTimelineItemState, - isHighlighted: Boolean, - onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, - onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, -) { - when (timelineItem) { - is MessagesTimelineItemState.Virtual -> return - is MessagesTimelineItemState.MessageEvent -> MessageEventRow( - messageEvent = timelineItem, - isHighlighted = isHighlighted, - onClick = { onClick(timelineItem) }, - onLongClick = { onLongClick(timelineItem) } - ) - } -} - -@Composable -fun MessageEventRow( - messageEvent: MessagesTimelineItemState.MessageEvent, - isHighlighted: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { - Pair(Alignment.CenterEnd, End) - } else { - Pair(Alignment.CenterStart, Start) - } - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - contentAlignment = parentAlignment - ) { - Row { - if (!messageEvent.isMine) { - Spacer(modifier = Modifier.width(16.dp)) - } - Column(horizontalAlignment = contentAlignment) { - if (messageEvent.showSenderInformation) { - MessageSenderInformation( - messageEvent.safeSenderName, - messageEvent.senderAvatar, - Modifier.zIndex(1f) - ) - } - MessageEventBubble( - groupPosition = messageEvent.groupPosition, - isMine = messageEvent.isMine, - interactionSource = interactionSource, - isHighlighted = isHighlighted, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .zIndex(-1f) - .widthIn(max = 320.dp) - ) { - val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - when (messageEvent.content) { - is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView( - content = messageEvent.content, - modifier = contentModifier - ) - is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView( - content = messageEvent.content, - modifier = contentModifier - ) - is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView( - content = messageEvent.content, - interactionSource = interactionSource, - modifier = contentModifier, - onTextClicked = onClick, - onTextLongClicked = onLongClick - ) - is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView( - content = messageEvent.content, - modifier = contentModifier - ) - is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView( - content = messageEvent.content, - modifier = contentModifier - ) - } - } - MessagesReactionsView( - reactionsState = messageEvent.reactionsState, - modifier = Modifier - .zIndex(1f) - .offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp)) - ) - } - if (messageEvent.isMine) { - Spacer(modifier = Modifier.width(16.dp)) - } - } - } - if (messageEvent.groupPosition.isNew()) { - Spacer(modifier = modifier.height(8.dp)) - } else { - Spacer(modifier = modifier.height(2.dp)) - } -} - -@Composable -private fun MessageSenderInformation( - sender: String, - senderAvatar: AvatarData?, - modifier: Modifier = Modifier -) { - Row(modifier = modifier) { - if (senderAvatar != null) { - Avatar(senderAvatar) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = sender, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .alignBy(LastBaseline) - ) - } -} - -@Composable -internal fun BoxScope.MessagesScrollHelper( - lazyListState: LazyListState, - timelineItems: ImmutableList, - onLoadMore: () -> Unit = {}, -) { - val coroutineScope = rememberCoroutineScope() - val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } - - // Auto-scroll when new timeline items appear - LaunchedEffect(timelineItems, firstVisibleItemIndex) { - if (!lazyListState.isScrollInProgress && - firstVisibleItemIndex < 2 - ) coroutineScope.launch { - lazyListState.animateScrollToItem(0) - } - } - - // Handle load more preloading - val loadMore by remember { - derivedStateOf { - val layoutInfo = lazyListState.layoutInfo - val totalItemsNumber = layoutInfo.totalItemsCount - val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - lastVisibleItemIndex > (totalItemsNumber - 30) - } - } - - LaunchedEffect(loadMore) { - snapshotFlow { loadMore } - .distinctUntilChanged() - .collect { - onLoadMore() - } - } - - // Jump to bottom button - if (firstVisibleItemIndex > 2) { - FloatingActionButton( - onClick = { - coroutineScope.launch { - if (firstVisibleItemIndex > 10) { - lazyListState.scrollToItem(0) - } else { - lazyListState.animateScrollToItem(0) - } - } - }, - shape = CircleShape, - modifier = Modifier - .align(Alignment.BottomCenter) - .size(40.dp), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) { - Icon(Icons.Default.ArrowDownward, "") - } - } -} - -@Composable -internal fun MessagesLoadingMoreIndicator() { - Box( - Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } -} - -class MessagesItemGroupPositionToMessagesTimelineItemContentProvider : - PairCombinedPreviewParameter( - MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider() - ) - -@Suppress("PreviewPublic") -@Preview(showBackground = true) -@Composable -fun TimelineItemsPreview( - @PreviewParameter(MessagesTimelineItemContentProvider::class) - content: MessagesTimelineItemContent -) { - TimelineItems( - lazyListState = LazyListState(), - timelineItems = persistentListOf( - // 3 items (First Middle Last) with isMine = false - createMessageEvent( - isMine = false, - content = content, - groupPosition = MessagesItemGroupPosition.First - ), - createMessageEvent( - isMine = false, - content = content, - groupPosition = MessagesItemGroupPosition.Middle - ), - createMessageEvent( - isMine = false, - content = content, - groupPosition = MessagesItemGroupPosition.Last - ), - // 3 items (First Middle Last) with isMine = true - createMessageEvent( - isMine = true, - content = content, - groupPosition = MessagesItemGroupPosition.First - ), - createMessageEvent( - isMine = true, - content = content, - groupPosition = MessagesItemGroupPosition.Middle - ), - createMessageEvent( - isMine = true, - content = content, - groupPosition = MessagesItemGroupPosition.Last - ), - ), - highlightedEventId = null, - hasMoreToLoad = true, - ) -} - -private fun createMessageEvent( - isMine: Boolean, - content: MessagesTimelineItemContent, - groupPosition: MessagesItemGroupPosition -): MessagesTimelineItemState { - return MessagesTimelineItemState.MessageEvent( - id = random().toString(), - senderId = "senderId", - senderAvatar = AvatarData("sender"), - content = content, - reactionsState = MessagesItemReactionState( - listOf( - AggregatedReaction("👍", "1") - ) - ), - isMine = isMine, - senderDisplayName = "sender", - groupPosition = groupPosition, - ) -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt new file mode 100644 index 0000000000..af3b0793b5 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages + +import androidx.compose.runtime.Immutable +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.features.messages.actionlist.ActionListState +import io.element.android.x.features.messages.textcomposer.MessageComposerState +import io.element.android.x.features.messages.timeline.TimelineState +import io.element.android.x.matrix.core.RoomId + +@Immutable +data class MessagesState( + val roomId: RoomId, + val roomName: String?, + val roomAvatar: AvatarData?, + val composerState: MessageComposerState, + val timelineState: TimelineState, + val actionListState: ActionListState, + val eventSink: (MessagesEvents) -> Unit +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt new file mode 100644 index 0000000000..7711233699 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, +) + +package io.element.android.x.features.messages + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.x.core.compose.LogCompositions +import io.element.android.x.designsystem.components.avatar.Avatar +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.features.messages.actionlist.ActionListEvents +import io.element.android.x.features.messages.actionlist.ActionListView +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.textcomposer.MessageComposerView +import io.element.android.x.features.messages.timeline.TimelineView +import kotlinx.coroutines.launch +import timber.log.Timber + +@Composable +fun MessagesView( + state: MessagesState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit, +) { + LogCompositions(tag = "MessagesScreen", msg = "Root") + val itemActionsBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + val snackbarHostState = remember { SnackbarHostState() } + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + LogCompositions(tag = "MessagesScreen", msg = "Content") + + fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) { + Timber.v("OnMessageClicked= ${messageEvent.id}") + } + + fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) { + Timber.v("OnMessageLongClicked= ${messageEvent.id}") + focusManager.clearFocus(force = true) + state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent)) + coroutineScope.launch { + itemActionsBottomSheetState.show() + } + } + + fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) { + state.eventSink(MessagesEvents.HandleAction(action, messageEvent)) + } + + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets.statusBars, + topBar = { + MessagesViewTopBar( + roomTitle = state.roomName, + roomAvatar = state.roomAvatar, + onBackPressed = onBackPressed + ) + }, + content = { padding -> + MessagesViewContent( + state = state, + modifier = Modifier.padding(padding), + onMessageClicked = ::onMessageClicked, + onMessageLongClicked = ::onMessageLongClicked + ) + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + }, + ) + + ActionListView( + state = state.actionListState, + modalBottomSheetState = itemActionsBottomSheetState, + onActionSelected = ::onActionSelected + ) +} + +@Composable +fun MessagesViewContent( + state: MessagesState, + modifier: Modifier = Modifier, + onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {}, + onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + .imePadding() + ) { + // Hide timeline if composer is full screen + if (!state.composerState.isFullScreen) { + TimelineView( + state = state.timelineState, + modifier = Modifier.weight(1f), + onMessageClicked = onMessageClicked, + onMessageLongClicked = onMessageLongClicked + ) + } + MessageComposerView( + state = state.composerState, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(Alignment.Bottom) + ) + } +} + +@Composable +fun MessagesViewTopBar( + roomTitle: String?, + roomAvatar: AvatarData?, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (roomAvatar != null) { + Avatar(roomAvatar) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + text = roomTitle ?: "Unknown room", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt deleted file mode 100644 index 5083a9fd7b..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.messages - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Uninitialized -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.designsystem.components.avatar.AvatarSize -import io.element.android.x.di.SessionScope -import io.element.android.x.features.messages.model.MessagesItemAction -import io.element.android.x.features.messages.model.MessagesItemActionsSheetState -import io.element.android.x.features.messages.model.MessagesTimelineItemState -import io.element.android.x.features.messages.model.MessagesViewState -import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent -import io.element.android.x.matrix.MatrixClient -import io.element.android.x.matrix.timeline.MatrixTimeline -import io.element.android.x.matrix.timeline.MatrixTimelineItem -import io.element.android.x.matrix.ui.MatrixItemHelper -import io.element.android.x.textcomposer.MessageComposerMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -private const val PAGINATION_COUNT = 50 - -@ContributesViewModel(SessionScope::class) -class MessagesViewModel @AssistedInject constructor( - private val client: MatrixClient, - @Assisted private val initialState: MessagesViewState -) : - MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - private val matrixItemHelper = MatrixItemHelper(client) - private val room = client.getRoom(initialState.roomId)!! - private val messageTimelineItemStateFactory = - MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default) - private val timeline = room.timeline() - - private val timelineCallback = object : MatrixTimeline.Callback { - override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) { - viewModelScope.launch { - messageTimelineItemStateFactory.pushItem(timelineItem) - } - } - } - - init { - handleInit() - } - - fun loadMore() { - viewModelScope.launch { - timeline.paginateBackwards(PAGINATION_COUNT) - setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) } - } - } - - fun sendMessage(text: String) { - viewModelScope.launch { - val state = awaitState() - // Reset composer right away - setNormalMode() - when (state.composerMode) { - is MessageComposerMode.Normal -> timeline.sendMessage(text) - is MessageComposerMode.Edit -> timeline.editMessage( - state.composerMode.eventId, - text - ) - is MessageComposerMode.Quote -> TODO() - is MessageComposerMode.Reply -> timeline.replyMessage( - state.composerMode.eventId, - text - ) - } - } - } - - suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? { - val currentState = awaitState() - return currentState.itemActionsSheetState.invoke()?.targetItem - } - - fun handleItemAction( - action: MessagesItemAction, - targetEvent: MessagesTimelineItemState.MessageEvent - ) { - viewModelScope.launch(Dispatchers.Default) { - when (action) { - MessagesItemAction.Copy -> notImplementedYet() - MessagesItemAction.Forward -> notImplementedYet() - MessagesItemAction.Redact -> handleActionRedact(targetEvent) - MessagesItemAction.Edit -> handleActionEdit(targetEvent) - MessagesItemAction.Reply -> handleActionReply(targetEvent) - } - } - } - - fun setNormalMode() { - setComposerMode(MessageComposerMode.Normal("")) - } - - fun onSnackbarShown() { - setSnackbarContent(null) - } - - fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent?) { - if (messagesTimelineItemState == null) { - setState { copy(itemActionsSheetState = Uninitialized) } - return - } - suspend { - val actions = - if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) { - emptyList() - } else { - mutableListOf( - MessagesItemAction.Reply, - MessagesItemAction.Forward, - MessagesItemAction.Copy, - ).also { - if (messagesTimelineItemState.isMine) { - it.add(MessagesItemAction.Edit) - it.add(MessagesItemAction.Redact) - } - } - } - MessagesItemActionsSheetState( - targetItem = messagesTimelineItemState, - actions = actions - ) - }.execute(Dispatchers.Default) { - copy(itemActionsSheetState = it) - } - } - - private fun handleInit() { - timeline.initialize() - timeline.callback = timelineCallback - room.syncUpdateFlow() - .onEach { - val avatarData = - matrixItemHelper.loadAvatarData( - room = room, - size = AvatarSize.SMALL - ) - setState { - copy( - roomName = room.name, roomAvatar = avatarData, - ) - } - }.launchIn(viewModelScope) - - timeline - .timelineItems() - .onEach(messageTimelineItemStateFactory::replaceWith) - .launchIn(viewModelScope) - - messageTimelineItemStateFactory - .flow() - .execute { - copy(timelineItems = it) - } - } - - private fun setSnackbarContent(message: String?) { - setState { copy(snackbarContent = message) } - } - - private fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) { - viewModelScope.launch { - room.redactEvent(event.id) - } - } - - private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) { - setComposerMode( - MessageComposerMode.Edit( - targetEvent.id, - (targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty() - ) - ) - } - - private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) { - setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")) - } - - private fun setComposerMode(mode: MessageComposerMode) { - setState { - copy( - composerMode = mode, - highlightedEventId = mode.relatedEventId - ) - } - } - - private fun notImplementedYet() { - setSnackbarContent("Not implemented yet!") - } - - override fun onCleared() { - super.onCleared() - timeline.callback = null - timeline.dispose() - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt new file mode 100644 index 0000000000..65b26cf84a --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.actionlist + +import io.element.android.x.features.messages.timeline.model.TimelineItem + +sealed interface ActionListEvents { + object Clear : ActionListEvents + data class ComputeForMessage(val messageEvent: TimelineItem.MessageEvent) : ActionListEvents +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt new file mode 100644 index 0000000000..cc894931c6 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.actionlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ActionListPresenter @Inject constructor() : Presenter { + + @Composable + override fun present(): ActionListState { + val localCoroutineScope = rememberCoroutineScope() + + val target: MutableState = remember { + mutableStateOf(ActionListState.Target.None) + } + + fun handleEvents(event: ActionListEvents) { + when (event) { + ActionListEvents.Clear -> target.value = ActionListState.Target.None + is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.messageEvent, target) + } + } + + return ActionListState( + target = target.value, + eventSink = ::handleEvents + ) + } + + fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.MessageEvent, target: MutableState) = launch { + target.value = ActionListState.Target.Loading(timelineItem) + val actions = + if (timelineItem.content is TimelineItemRedactedContent) { + emptyList() + } else { + mutableListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + ).also { + if (timelineItem.isMine) { + it.add(TimelineItemAction.Edit) + it.add(TimelineItemAction.Redact) + } + } + } + target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList()) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt new file mode 100644 index 0000000000..00c3ec59ab --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.actionlist + +import androidx.compose.runtime.Immutable +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.timeline.model.TimelineItem +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class ActionListState( + val target: Target, + val eventSink: (ActionListEvents) -> Unit, +) { + + sealed interface Target { + object None : Target + data class Loading(val messageEvent: TimelineItem.MessageEvent) : Target + data class Success( + val messageEvent: TimelineItem.MessageEvent, + val actions: ImmutableList, + ) : Target + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt new file mode 100644 index 0000000000..7984577de4 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterialApi::class) + +package io.element.android.x.features.messages.actionlist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ListItem +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.x.designsystem.components.VectorIcon +import io.element.android.x.features.messages.actionlist.model.TimelineItemAction +import io.element.android.x.features.messages.timeline.model.TimelineItem +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +@Composable +fun ActionListView( + state: ActionListState, + modalBottomSheetState: ModalBottomSheetState, + onActionSelected: (action: TimelineItemAction, TimelineItem.MessageEvent) -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(modalBottomSheetState) { + snapshotFlow { modalBottomSheetState.currentValue } + .filter { it == ModalBottomSheetValue.Hidden } + .collect { + state.eventSink(ActionListEvents.Clear) + } + } + + fun onItemActionClicked( + itemAction: TimelineItemAction, + targetItem: TimelineItem.MessageEvent + ) { + onActionSelected(itemAction, targetItem) + coroutineScope.launch { + modalBottomSheetState.hide() + } + } + + ModalBottomSheetLayout( + modifier = modifier, + sheetState = modalBottomSheetState, + sheetContent = { + SheetContent( + state = state, + onActionClicked = ::onItemActionClicked, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) + } + ) {} +} + +@Composable +private fun SheetContent( + state: ActionListState, + modifier: Modifier = Modifier, + onActionClicked: (TimelineItemAction, TimelineItem.MessageEvent) -> Unit = { _, _ -> }, +) { + when (val target = state.target) { + is ActionListState.Target.Loading, + ActionListState.Target.None -> { + // Crashes if sheetContent size is zero + Box(modifier = modifier.size(1.dp)) + } + is ActionListState.Target.Success -> { + val actions = target.actions + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { + onActionClicked(action, target.messageEvent) + }, + text = { + Text( + text = action.title, + color = if (action.destructive) MaterialTheme.colors.error else Color.Unspecified, + ) + }, + icon = { + VectorIcon( + resourceId = action.icon, + tint = if (action.destructive) MaterialTheme.colors.error else LocalContentColor.current, + ) + } + ) + } + } + } + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/model/TimelineItemAction.kt similarity index 61% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/actionlist/model/TimelineItemAction.kt index 151f6be67a..4e23eb34f0 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/model/TimelineItemAction.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,21 +14,21 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model +package io.element.android.x.features.messages.actionlist.model import androidx.annotation.DrawableRes -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable import io.element.android.x.designsystem.VectorIcons -@Stable -sealed class MessagesItemAction( +@Immutable +sealed class TimelineItemAction( val title: String, @DrawableRes val icon: Int, val destructive: Boolean = false ) { - object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward) - object Copy : MessagesItemAction("Copy", VectorIcons.Copy) - object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true) - object Reply : MessagesItemAction("Reply", VectorIcons.Reply) - object Edit : MessagesItemAction("Edit", VectorIcons.Edit) + object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward) + object Copy : TimelineItemAction("Copy", VectorIcons.Copy) + object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true) + object Reply : TimelineItemAction("Reply", VectorIcons.Reply) + object Edit : TimelineItemAction("Edit", VectorIcons.Edit) } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt deleted file mode 100644 index f5bdb70471..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalMaterialApi::class) - -package io.element.android.x.features.messages.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ListItem -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.airbnb.mvrx.compose.collectAsState -import io.element.android.x.designsystem.components.VectorIcon -import io.element.android.x.features.messages.MessagesViewModel -import io.element.android.x.features.messages.model.MessagesItemAction -import io.element.android.x.features.messages.model.MessagesItemActionsSheetState -import io.element.android.x.features.messages.model.MessagesTimelineItemState -import io.element.android.x.features.messages.model.MessagesViewState -import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent -import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch - -@Composable -fun TimelineItemActionsScreen( - viewModel: MessagesViewModel, - composerViewModel: MessageComposerViewModel, - modalBottomSheetState: ModalBottomSheetState, - modifier: Modifier = Modifier -) { - val coroutineScope = rememberCoroutineScope() - LaunchedEffect(modalBottomSheetState) { - snapshotFlow { modalBottomSheetState.currentValue } - .filter { it == ModalBottomSheetValue.Hidden } - .collect { - viewModel.computeActionsSheetState(null) - } - } - - val itemActionsSheetState by viewModel.collectAsState(MessagesViewState::itemActionsSheetState) - - fun onItemActionClicked( - itemAction: MessagesItemAction, - targetItem: MessagesTimelineItemState.MessageEvent - ) { - viewModel.handleItemAction(itemAction, targetItem) - coroutineScope.launch { - val targetEvent = viewModel.getTargetEvent() - when (itemAction) { - is MessagesItemAction.Edit -> { - // Entering Edit mode, update the text in the composer. - val newComposerText = - (targetEvent?.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty() - composerViewModel.updateText(newComposerText) - } - else -> Unit - } - modalBottomSheetState.hide() - } - } - - ModalBottomSheetLayout( - modifier = modifier, - sheetState = modalBottomSheetState, - sheetContent = { - SheetContent( - actionsSheetState = itemActionsSheetState(), - onActionClicked = ::onItemActionClicked, - modifier = Modifier - .navigationBarsPadding() - .imePadding() - ) - } - ) {} -} - -@Composable -private fun SheetContent( - actionsSheetState: MessagesItemActionsSheetState?, - modifier: Modifier = Modifier, - onActionClicked: (MessagesItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> }, -) { - if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) { - // Crashes if sheetContent size is zero - Box(modifier = modifier.size(1.dp)) - } else { - LazyColumn( - modifier = modifier - .fillMaxWidth() - ) { - items(actionsSheetState.actions) { - ListItem( - modifier = Modifier.clickable { - onActionClicked(it, actionsSheetState.targetItem) - }, - text = { - Text( - text = it.title, - color = if (it.destructive) MaterialTheme.colors.error else Color.Unspecified, - ) - }, - icon = { - VectorIcon( - resourceId = it.icon, - tint = if (it.destructive) MaterialTheme.colors.error else LocalContentColor.current, - ) - } - ) - } - } - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt deleted file mode 100644 index 4441180ce0..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.messages.model - -import androidx.compose.runtime.Stable -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized -import io.element.android.x.designsystem.components.avatar.AvatarData -import io.element.android.x.textcomposer.MessageComposerMode - -@Stable -data class MessagesViewState( - val roomId: String, - val roomName: String? = null, - val roomAvatar: AvatarData? = null, - val timelineItems: Async> = Uninitialized, - val hasMoreToLoad: Boolean = true, - val itemActionsSheetState: Async = Uninitialized, - val snackbarContent: String? = null, - val highlightedEventId: String? = null, - val composerMode: MessageComposerMode = MessageComposerMode.Normal(""), -) : MavericksState { - - @Suppress("unused") - constructor(roomId: String) : this( - roomId = roomId, - roomName = null, - roomAvatar = null - ) -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt new file mode 100644 index 0000000000..8e4e3ffddd --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.textcomposer + +import io.element.android.x.textcomposer.MessageComposerMode + +sealed interface MessageComposerEvents { + object ToggleFullScreenState : MessageComposerEvents + data class SendMessage(val message: String) : MessageComposerEvents + object CloseSpecialMode : MessageComposerEvents + data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents + data class UpdateText(val text: CharSequence) : MessageComposerEvents +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt new file mode 100644 index 0000000000..aba47ec90b --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.textcomposer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.core.data.StableCharSequence +import io.element.android.x.core.data.toStableCharSequence +import io.element.android.x.matrix.room.MatrixRoom +import io.element.android.x.textcomposer.MessageComposerMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class MessageComposerPresenter @Inject constructor( + private val appCoroutineScope: CoroutineScope, + private val room: MatrixRoom +) : Presenter { + + @Composable + override fun present(): MessageComposerState { + val isFullScreen = rememberSaveable { + mutableStateOf(false) + } + val text: MutableState = rememberSaveable { + mutableStateOf(StableCharSequence("")) + } + val composerMode: MutableState = rememberSaveable { + mutableStateOf(MessageComposerMode.Normal("")) + } + + LaunchedEffect(composerMode.value) { + when (val modeValue = composerMode.value) { + is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence() + else -> Unit + } + } + + fun handleEvents(event: MessageComposerEvents) { + when (event) { + MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value + is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence() + MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal() + is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) + is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode + } + } + + return MessageComposerState( + text = text.value, + isFullScreen = isFullScreen.value, + mode = composerMode.value, + eventSink = ::handleEvents + ) + } + + private fun MutableState.setToNormal() { + value = MessageComposerMode.Normal("") + } + + private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState, textState: MutableState) = + launch { + val capturedMode = composerMode.value + // Reset composer right away + textState.value = "".toStableCharSequence() + composerMode.setToNormal() + when (capturedMode) { + is MessageComposerMode.Normal -> room.sendMessage(text) + is MessageComposerMode.Edit -> room.editMessage( + capturedMode.eventId, + text + ) + is MessageComposerMode.Quote -> TODO() + is MessageComposerMode.Reply -> room.replyMessage( + capturedMode.eventId, + text + ) + } + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt new file mode 100644 index 0000000000..44c8e73d7e --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.textcomposer + +import androidx.compose.runtime.Immutable +import io.element.android.x.core.data.StableCharSequence +import io.element.android.x.textcomposer.MessageComposerMode + +@Immutable +data class MessageComposerState( + val text: StableCharSequence?, + val isFullScreen: Boolean, + val mode: MessageComposerMode, + val eventSink: (MessageComposerEvents) -> Unit +) { + val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not() +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt new file mode 100644 index 0000000000..b5592ecceb --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.textcomposer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.x.textcomposer.TextComposer + +@Composable +fun MessageComposerView( + state: MessageComposerState, + modifier: Modifier = Modifier, +) { + fun onFullscreenToggle() { + state.eventSink(MessageComposerEvents.ToggleFullScreenState) + } + + fun sendMessage(message: String) { + state.eventSink(MessageComposerEvents.SendMessage(message)) + } + + fun onCloseSpecialMode() { + state.eventSink(MessageComposerEvents.CloseSpecialMode) + } + + fun onComposerTextChange(text: CharSequence) { + state.eventSink(MessageComposerEvents.UpdateText(text)) + } + + TextComposer( + onSendMessage = ::sendMessage, + fullscreen = state.isFullScreen, + onFullscreenToggle = ::onFullscreenToggle, + composerMode = state.mode, + onCloseSpecialMode = ::onCloseSpecialMode, + onComposerTextChange = ::onComposerTextChange, + composerCanSendMessage = state.isSendButtonVisible, + composerText = state.text?.charSequence?.toString(), + modifier = modifier + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt deleted file mode 100644 index e63eae96a8..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.messages.textcomposer - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.data.StableCharSequence -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.SessionScope -import io.element.android.x.matrix.MatrixClient - -@ContributesViewModel(SessionScope::class) -class MessageComposerViewModel @AssistedInject constructor( - private val client: MatrixClient, - @Assisted private val initialState: MessageComposerViewState -) : MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - fun onComposerFullScreenChange() { - setState { - copy( - isFullScreen = !isFullScreen - ) - } - } - - fun updateText(newText: CharSequence) { - setState { - copy( - text = StableCharSequence(newText), - isSendButtonVisible = newText.isNotEmpty(), - ) - } - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt deleted file mode 100644 index b85ce0da0c..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.messages.textcomposer - -import androidx.compose.runtime.Stable -import com.airbnb.mvrx.MavericksState -import io.element.android.x.core.data.StableCharSequence - -@Stable -data class MessageComposerViewState( - // val roomId: String, - // val canSendMessage: CanSendStatus = CanSendStatus.Allowed, - val isSendButtonVisible: Boolean = false, - val rootThreadEventId: String? = null, - val startsThread: Boolean = false, - // val sendMode: SendMode = SendMode.Regular("", false), - // val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, - // val voiceBroadcastState: VoiceBroadcastState? = null, - val text: StableCharSequence? = null, - val isFullScreen: Boolean = false, -) : MavericksState diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt similarity index 66% rename from libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt index 5a83801252..733a082d73 100644 --- a/libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.x.core.di +package io.element.android.x.features.messages.timeline -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.MavericksViewModel +import io.element.android.x.matrix.core.EventId -interface AssistedViewModelFactory, S : MavericksState> { - fun create(initialState: S): VM +sealed interface TimelineEvents { + object LoadMore : TimelineEvents + data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateFactory.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineItemsFactory.kt similarity index 70% rename from features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateFactory.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineItemsFactory.kt index 68bf073de7..0b8a3bfc48 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateFactory.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineItemsFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,29 +14,31 @@ * limitations under the License. */ -package io.element.android.x.features.messages +package io.element.android.x.features.messages.timeline import androidx.recyclerview.widget.DiffUtil import io.element.android.x.designsystem.components.avatar.AvatarSize -import io.element.android.x.features.messages.diff.CacheInvalidator -import io.element.android.x.features.messages.diff.MatrixTimelineItemsDiffCallback -import io.element.android.x.features.messages.model.AggregatedReaction -import io.element.android.x.features.messages.model.MessagesItemGroupPosition -import io.element.android.x.features.messages.model.MessagesItemReactionState -import io.element.android.x.features.messages.model.MessagesTimelineItemState -import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemEmoteContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemNoticeContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextContent -import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent -import io.element.android.x.features.messages.util.invalidateLast +import io.element.android.x.features.messages.timeline.diff.CacheInvalidator +import io.element.android.x.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback +import io.element.android.x.features.messages.timeline.model.AggregatedReaction +import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.timeline.model.TimelineItemReactions +import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemEmoteContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemNoticeContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent +import io.element.android.x.features.messages.timeline.util.invalidateLast +import io.element.android.x.matrix.core.EventId import io.element.android.x.matrix.media.MediaResolver import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.timeline.MatrixTimelineItem import io.element.android.x.matrix.ui.MatrixItemHelper +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,24 +52,25 @@ import org.matrix.rustcomponents.sdk.FormattedBody import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.MessageType import timber.log.Timber +import javax.inject.Inject import kotlin.system.measureTimeMillis -class MessageTimelineItemStateFactory( +class TimelineItemsFactory @Inject constructor( private val matrixItemHelper: MatrixItemHelper, private val room: MatrixRoom, private val dispatcher: CoroutineDispatcher, ) { - private val timelineItemStates = MutableStateFlow>(emptyList()) - private val timelineItemStatesCache = arrayListOf() + private val timelineItems = MutableStateFlow>(emptyList()) + private val timelineItemsCache = arrayListOf() // Items from rust sdk, used for diffing - private var timelineItems: List = emptyList() + private var matrixTimelineItems: List = emptyList() private val lock = Mutex() - private val cacheInvalidator = CacheInvalidator(timelineItemStatesCache) + private val cacheInvalidator = CacheInvalidator(timelineItemsCache) - fun flow(): StateFlow> = timelineItemStates.asStateFlow() + fun flow(): StateFlow> = timelineItems.asStateFlow() suspend fun replaceWith( timelineItems: List, @@ -83,17 +86,17 @@ class MessageTimelineItemStateFactory( ) = withContext(dispatcher) { lock.withLock { // Makes sure to invalidate last as we need to recompute some data (like groupPosition) - timelineItemStatesCache.invalidateLast() - timelineItemStatesCache.add(null) - timelineItems = timelineItems + timelineItem - buildAndEmitTimelineItemStates(timelineItems) + timelineItemsCache.invalidateLast() + timelineItemsCache.add(null) + matrixTimelineItems = matrixTimelineItems + timelineItem + buildAndEmitTimelineItemStates(matrixTimelineItems) } } private suspend fun buildAndEmitTimelineItemStates(timelineItems: List) { - val newTimelineItemStates = ArrayList() - for (index in timelineItemStatesCache.indices.reversed()) { - val cacheItem = timelineItemStatesCache[index] + val newTimelineItemStates = ArrayList() + for (index in timelineItemsCache.indices.reversed()) { + val cacheItem = timelineItemsCache[index] if (cacheItem == null) { buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> newTimelineItemStates.add(timelineItemState) @@ -102,18 +105,18 @@ class MessageTimelineItemStateFactory( newTimelineItemStates.add(cacheItem) } } - timelineItemStates.emit(newTimelineItemStates) + this.timelineItems.emit(newTimelineItemStates) } private fun calculateAndApplyDiff(newTimelineItems: List) { val timeToDiff = measureTimeMillis { val diffCallback = MatrixTimelineItemsDiffCallback( - oldList = timelineItems, + oldList = matrixTimelineItems, newList = newTimelineItems ) val diffResult = DiffUtil.calculateDiff(diffCallback, false) - timelineItems = newTimelineItems + matrixTimelineItems = newTimelineItems diffResult.dispatchUpdatesTo(cacheInvalidator) } Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms") @@ -122,7 +125,7 @@ class MessageTimelineItemStateFactory( private suspend fun buildAndCacheItem( timelineItems: List, index: Int - ): MessagesTimelineItemState? { + ): TimelineItem? { val timelineItemState = when (val currentTimelineItem = timelineItems[index]) { is MatrixTimelineItem.Event -> { @@ -132,12 +135,12 @@ class MessageTimelineItemStateFactory( timelineItems, ) } - is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual( + is MatrixTimelineItem.Virtual -> TimelineItem.Virtual( "virtual_item_$index" ) MatrixTimelineItem.Other -> null } - timelineItemStatesCache[index] = timelineItemState + timelineItemsCache[index] = timelineItemState return timelineItemState } @@ -145,7 +148,7 @@ class MessageTimelineItemStateFactory( currentTimelineItem: MatrixTimelineItem.Event, index: Int, timelineItems: List, - ): MessagesTimelineItemState.MessageEvent { + ): TimelineItem.MessageEvent { val currentSender = currentTimelineItem.event.sender() val groupPosition = computeGroupPosition(currentTimelineItem, timelineItems, index) @@ -157,8 +160,8 @@ class MessageTimelineItemStateFactory( url = senderAvatarUrl, size = AvatarSize.SMALL ) - return MessagesTimelineItemState.MessageEvent( - id = currentTimelineItem.uniqueId, + return TimelineItem.MessageEvent( + id = EventId(currentTimelineItem.uniqueId), senderId = currentSender, senderDisplayName = senderDisplayName, senderAvatar = senderAvatarData, @@ -169,24 +172,24 @@ class MessageTimelineItemStateFactory( ) } - private fun MatrixTimelineItem.Event.computeReactionsState(): MessagesItemReactionState { + private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { val aggregatedReactions = event.reactions().map { AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false) } - return MessagesItemReactionState(aggregatedReactions) + return TimelineItemReactions(aggregatedReactions.toImmutableList()) } - private fun MatrixTimelineItem.Event.computeContent(): MessagesTimelineItemContent { + private fun MatrixTimelineItem.Event.computeContent(): TimelineItemContent { val content = event.content() content.asUnableToDecrypt()?.let { encryptedMessage -> - return MessagesTimelineItemEncryptedContent(encryptedMessage) + return TimelineItemEncryptedContent(encryptedMessage) } if (content.isRedactedMessage()) { - return MessagesTimelineItemRedactedContent + return TimelineItemRedactedContent } val contentAsMessage = content.asMessage() return when (val messageType = contentAsMessage?.msgtype()) { - is MessageType.Emote -> MessagesTimelineItemEmoteContent( + is MessageType.Emote -> TimelineItemEmoteContent( body = messageType.content.body, htmlDocument = messageType.content.formatted?.toHtmlDocument() ) @@ -198,7 +201,7 @@ class MessageTimelineItemStateFactory( } else { 0.7f } - MessagesTimelineItemImageContent( + TimelineItemImageContent( body = messageType.content.body, imageMeta = MediaResolver.Meta( source = messageType.content.source, @@ -208,15 +211,15 @@ class MessageTimelineItemStateFactory( aspectRatio = aspectRatio ) } - is MessageType.Notice -> MessagesTimelineItemNoticeContent( + is MessageType.Notice -> TimelineItemNoticeContent( body = messageType.content.body, htmlDocument = messageType.content.formatted?.toHtmlDocument() ) - is MessageType.Text -> MessagesTimelineItemTextContent( + is MessageType.Text -> TimelineItemTextContent( body = messageType.content.body, htmlDocument = messageType.content.formatted?.toHtmlDocument() ) - else -> MessagesTimelineItemUnknownContent + else -> TimelineItemUnknownContent } } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt new file mode 100644 index 0000000000..9cbd2a4acf --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.timeline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.core.EventId +import io.element.android.x.matrix.room.MatrixRoom +import io.element.android.x.matrix.timeline.MatrixTimeline +import io.element.android.x.matrix.timeline.MatrixTimelineItem +import io.element.android.x.matrix.ui.MatrixItemHelper +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val PAGINATION_COUNT = 50 + +class TimelinePresenter @Inject constructor( + coroutineDispatchers: CoroutineDispatchers, + client: MatrixClient, + room: MatrixRoom, +) : Presenter { + + private val timeline = room.timeline() + private val matrixItemHelper = MatrixItemHelper(client) + private val timelineItemsFactory = + TimelineItemsFactory(matrixItemHelper, room, coroutineDispatchers.computation) + + private class TimelineCallback( + private val coroutineScope: CoroutineScope, + private val timelineItemsFactory: TimelineItemsFactory, + ) : MatrixTimeline.Callback { + override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) { + coroutineScope.launch { + timelineItemsFactory.pushItem(timelineItem) + } + } + } + + @Composable + override fun present(): TimelineState { + val localCoroutineScope = rememberCoroutineScope() + val hasMoreToLoad = rememberSaveable { + mutableStateOf(timeline.hasMoreToLoad) + } + val highlightedEventId: MutableState = rememberSaveable { + mutableStateOf(null) + } + val timelineItems = timelineItemsFactory + .flow() + .collectAsState(emptyList()) + + fun handleEvents(event: TimelineEvents) { + when (event) { + TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad) + is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId + } + } + + LaunchedEffect(Unit) { + timeline + .timelineItems() + .onEach(timelineItemsFactory::replaceWith) + .launchIn(this) + } + + DisposableEffect(Unit) { + timeline.callback = TimelineCallback(localCoroutineScope, timelineItemsFactory) + timeline.initialize() + onDispose { + timeline.callback = null + timeline.dispose() + } + } + + return TimelineState( + highlightedEventId = highlightedEventId.value, + timelineItems = timelineItems.value.toImmutableList(), + hasMoreToLoad = hasMoreToLoad.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.loadMore(hasMoreToLoad: MutableState) = launch { + timeline.paginateBackwards(PAGINATION_COUNT) + hasMoreToLoad.value = timeline.hasMoreToLoad + } +} diff --git a/app/src/main/java/io/element/android/x/initializer/MavericksInitializer.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt similarity index 55% rename from app/src/main/java/io/element/android/x/initializer/MavericksInitializer.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt index f7d48b66c1..c4ea612334 100644 --- a/app/src/main/java/io/element/android/x/initializer/MavericksInitializer.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt @@ -14,17 +14,17 @@ * limitations under the License. */ -package io.element.android.x.initializer +package io.element.android.x.features.messages.timeline -import android.content.Context -import androidx.startup.Initializer -import com.airbnb.mvrx.Mavericks +import androidx.compose.runtime.Immutable +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.matrix.core.EventId +import kotlinx.collections.immutable.ImmutableList -class MavericksInitializer : Initializer { - - override fun create(context: Context) { - Mavericks.initialize(context) - } - - override fun dependencies(): List>> = listOf() -} +@Immutable +data class TimelineState( + val timelineItems: ImmutableList, + val hasMoreToLoad: Boolean, + val highlightedEventId: EventId?, + val eventSink: (TimelineEvents) -> Unit +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt new file mode 100644 index 0000000000..677487f5a4 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt @@ -0,0 +1,428 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.messages.timeline + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.x.core.compose.PairCombinedPreviewParameter +import io.element.android.x.designsystem.components.avatar.Avatar +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.features.messages.timeline.model.AggregatedReaction +import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition +import io.element.android.x.features.messages.timeline.model.TimelineItemGroupPositionProvider +import io.element.android.x.features.messages.timeline.model.TimelineItemReactions +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.x.features.messages.timeline.model.content.MessagesTimelineItemContentProvider +import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent +import io.element.android.x.features.messages.timeline.components.MessageEventBubble +import io.element.android.x.features.messages.timeline.components.TimelineItemReactionsView +import io.element.android.x.features.messages.timeline.components.TimelineItemEncryptedView +import io.element.android.x.features.messages.timeline.components.TimelineItemImageView +import io.element.android.x.features.messages.timeline.components.TimelineItemRedactedView +import io.element.android.x.features.messages.timeline.components.TimelineItemTextView +import io.element.android.x.features.messages.timeline.components.TimelineItemUnknownView +import io.element.android.x.matrix.core.EventId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun TimelineView( + state: TimelineState, + modifier: Modifier = Modifier, + onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {}, + onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {}, +) { + val lazyListState = rememberLazyListState() + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Bottom, + reverseLayout = true + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.key() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + isHighlighted = timelineItem.key() == state.highlightedEventId?.value, + onClick = onMessageClicked, + onLongClick = onMessageLongClicked + ) + } + if (state.hasMoreToLoad) { + item { + TimelineLoadingMoreIndicator() + } + } + } + + fun onReachedLoadMore() { + state.eventSink(TimelineEvents.LoadMore) + } + + TimelineScrollHelper( + lazyListState = lazyListState, + timelineItems = state.timelineItems, + onLoadMore = ::onReachedLoadMore + ) + } +} + +private fun TimelineItem.key(): String { + return when (this) { + is TimelineItem.MessageEvent -> id.value + is TimelineItem.Virtual -> id + } +} + +private fun TimelineItem.contentType(): Int { + return when (this) { + is TimelineItem.MessageEvent -> 0 + is TimelineItem.Virtual -> 1 + } +} + +@Composable +fun TimelineItemRow( + timelineItem: TimelineItem, + isHighlighted: Boolean, + onClick: (TimelineItem.MessageEvent) -> Unit, + onLongClick: (TimelineItem.MessageEvent) -> Unit, +) { + when (timelineItem) { + is TimelineItem.Virtual -> return + is TimelineItem.MessageEvent -> MessageEventRow( + messageEvent = timelineItem, + isHighlighted = isHighlighted, + onClick = { onClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) } + ) + } +} + +@Composable +fun MessageEventRow( + messageEvent: TimelineItem.MessageEvent, + isHighlighted: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { + Pair(Alignment.CenterEnd, Alignment.End) + } else { + Pair(Alignment.CenterStart, Alignment.Start) + } + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = parentAlignment + ) { + Row { + if (!messageEvent.isMine) { + Spacer(modifier = Modifier.width(16.dp)) + } + Column(horizontalAlignment = contentAlignment) { + if (messageEvent.showSenderInformation) { + MessageSenderInformation( + messageEvent.safeSenderName, + messageEvent.senderAvatar, + Modifier.zIndex(1f) + ) + } + MessageEventBubble( + groupPosition = messageEvent.groupPosition, + isMine = messageEvent.isMine, + interactionSource = interactionSource, + isHighlighted = isHighlighted, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + when (messageEvent.content) { + is TimelineItemEncryptedContent -> TimelineItemEncryptedView( + content = messageEvent.content, + modifier = contentModifier + ) + is TimelineItemRedactedContent -> TimelineItemRedactedView( + content = messageEvent.content, + modifier = contentModifier + ) + is TimelineItemTextBasedContent -> TimelineItemTextView( + content = messageEvent.content, + interactionSource = interactionSource, + modifier = contentModifier, + onTextClicked = onClick, + onTextLongClicked = onLongClick + ) + is TimelineItemUnknownContent -> TimelineItemUnknownView( + content = messageEvent.content, + modifier = contentModifier + ) + is TimelineItemImageContent -> TimelineItemImageView( + content = messageEvent.content, + modifier = contentModifier + ) + } + } + TimelineItemReactionsView( + reactionsState = messageEvent.reactionsState, + modifier = Modifier + .zIndex(1f) + .offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp)) + ) + } + if (messageEvent.isMine) { + Spacer(modifier = Modifier.width(16.dp)) + } + } + } + if (messageEvent.groupPosition.isNew()) { + Spacer(modifier = modifier.height(8.dp)) + } else { + Spacer(modifier = modifier.height(2.dp)) + } +} + +@Composable +private fun MessageSenderInformation( + sender: String, + senderAvatar: AvatarData?, + modifier: Modifier = Modifier +) { + Row(modifier = modifier) { + if (senderAvatar != null) { + Avatar(senderAvatar) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = sender, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .alignBy(LastBaseline) + ) + } +} + +@Composable +internal fun BoxScope.TimelineScrollHelper( + lazyListState: LazyListState, + timelineItems: ImmutableList, + onLoadMore: () -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } + + // Auto-scroll when new timeline items appear + LaunchedEffect(timelineItems, firstVisibleItemIndex) { + if (!lazyListState.isScrollInProgress && + firstVisibleItemIndex < 2 + ) coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } + + // Handle load more preloading + val loadMore by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + lastVisibleItemIndex > (totalItemsNumber - 30) + } + } + + LaunchedEffect(loadMore) { + snapshotFlow { loadMore } + .distinctUntilChanged() + .collect { + onLoadMore() + } + } + + // Jump to bottom button + if (firstVisibleItemIndex > 2) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + if (firstVisibleItemIndex > 10) { + lazyListState.scrollToItem(0) + } else { + lazyListState.animateScrollToItem(0) + } + } + }, + shape = CircleShape, + modifier = Modifier + .align(Alignment.BottomCenter) + .size(40.dp), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Icon(Icons.Default.ArrowDownward, "") + } + } +} + +@Composable +internal fun TimelineLoadingMoreIndicator() { + Box( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } +} + +class MessagesItemGroupPositionToMessagesTimelineItemContentProvider : + PairCombinedPreviewParameter( + TimelineItemGroupPositionProvider() to MessagesTimelineItemContentProvider() + ) + +@Suppress("PreviewPublic") +@Preview(showBackground = true) +@Composable +fun TimelineItemsPreview( + @PreviewParameter(MessagesTimelineItemContentProvider::class) + content: TimelineItemContent +) { + val timelineItems = persistentListOf( + // 3 items (First Middle Last) with isMine = false + createMessageEvent( + isMine = false, + content = content, + groupPosition = MessagesItemGroupPosition.First + ), + createMessageEvent( + isMine = false, + content = content, + groupPosition = MessagesItemGroupPosition.Middle + ), + createMessageEvent( + isMine = false, + content = content, + groupPosition = MessagesItemGroupPosition.Last + ), + // 3 items (First Middle Last) with isMine = true + createMessageEvent( + isMine = true, + content = content, + groupPosition = MessagesItemGroupPosition.First + ), + createMessageEvent( + isMine = true, + content = content, + groupPosition = MessagesItemGroupPosition.Middle + ), + createMessageEvent( + isMine = true, + content = content, + groupPosition = MessagesItemGroupPosition.Last + ), + ) + TimelineView( + state = TimelineState( + timelineItems = timelineItems, + hasMoreToLoad = true, + highlightedEventId = null, + eventSink = {} + ) + ) +} + +private fun createMessageEvent( + isMine: Boolean, + content: TimelineItemContent, + groupPosition: MessagesItemGroupPosition +): TimelineItem { + return TimelineItem.MessageEvent( + id = EventId(Math.random().toString()), + senderId = "senderId", + senderAvatar = AvatarData("sender"), + content = content, + reactionsState = TimelineItemReactions( + persistentListOf( + AggregatedReaction("👍", "1") + ) + ), + isMine = isMine, + senderDisplayName = "sender", + groupPosition = groupPosition, + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt similarity index 96% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt index 60f0c147a7..7466d38e1a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable @@ -36,7 +36,7 @@ import io.element.android.x.designsystem.SystemGrey5Dark import io.element.android.x.designsystem.SystemGrey5Light import io.element.android.x.designsystem.SystemGrey6Dark import io.element.android.x.designsystem.SystemGrey6Light -import io.element.android.x.features.messages.model.MessagesItemGroupPosition +import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition private val BUBBLE_RADIUS = 16.dp diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemEncryptedView.kt similarity index 77% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemEncryptedView.kt index b5a2a7753b..0b037c6f1f 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemEncryptedView.kt @@ -14,20 +14,20 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent @Composable -fun MessagesTimelineItemEncryptedView( - content: MessagesTimelineItemEncryptedContent, +fun TimelineItemEncryptedView( + content: TimelineItemEncryptedContent, modifier: Modifier = Modifier ) { - MessagesTimelineItemInformativeView( + TimelineItemInformativeView( text = "Decryption error", iconDescription = "Warning", icon = Icons.Default.Warning, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemImageView.kt similarity index 90% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemImageView.kt index 9b0a270637..a630959bcb 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemImageView.kt @@ -16,7 +16,7 @@ @file:OptIn(ExperimentalFoundationApi::class) -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box @@ -33,11 +33,11 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil.compose.AsyncImage import coil.request.ImageRequest -import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent @Composable -fun MessagesTimelineItemImageView( - content: MessagesTimelineItemImageContent, +fun TimelineItemImageView( + content: TimelineItemImageContent, modifier: Modifier = Modifier ) { val widthPercent = if (content.aspectRatio > 1f) { diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemInformativeView.kt similarity index 94% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemInformativeView.kt index e2c775198f..6c876f9103 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemInformativeView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun MessagesTimelineItemInformativeView( +fun TimelineItemInformativeView( text: String, iconDescription: String, icon: ImageVector, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemReactionsView.kt similarity index 88% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemReactionsView.kt index 34d80f0a6e..8cc75befea 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemReactionsView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Row @@ -32,12 +32,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.accompanist.flowlayout.FlowRow -import io.element.android.x.features.messages.model.AggregatedReaction -import io.element.android.x.features.messages.model.MessagesItemReactionState +import io.element.android.x.features.messages.timeline.model.AggregatedReaction +import io.element.android.x.features.messages.timeline.model.TimelineItemReactions @Composable -fun MessagesReactionsView( - reactionsState: MessagesItemReactionState, +fun TimelineItemReactionsView( + reactionsState: TimelineItemReactions, modifier: Modifier = Modifier, ) { if (reactionsState.reactions.isEmpty()) return diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemRedactedView.kt similarity index 77% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemRedactedView.kt index 6ad7bb0772..183b44de74 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemRedactedView.kt @@ -14,20 +14,20 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent @Composable -fun MessagesTimelineItemRedactedView( - content: MessagesTimelineItemRedactedContent, +fun TimelineItemRedactedView( + content: TimelineItemRedactedContent, modifier: Modifier = Modifier ) { - MessagesTimelineItemInformativeView( + TimelineItemInformativeView( text = "This message has been deleted", iconDescription = "Delete", icon = Icons.Default.Delete, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemTextView.kt similarity index 90% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemTextView.kt index 94f450e5c5..4751f2ae0d 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemTextView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import android.text.SpannableString import android.text.style.URLSpan @@ -30,12 +30,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.core.text.util.LinkifyCompat import io.element.android.x.designsystem.LinkColor import io.element.android.x.designsystem.components.ClickableLinkText -import io.element.android.x.features.messages.components.html.HtmlDocument -import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent +import io.element.android.x.features.messages.timeline.components.html.HtmlDocument +import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent @Composable -fun MessagesTimelineItemTextView( - content: MessagesTimelineItemTextBasedContent, +fun TimelineItemTextView( + content: TimelineItemTextBasedContent, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, onTextClicked: () -> Unit = {}, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemUnknownView.kt similarity index 77% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemUnknownView.kt index 51021ab678..88355abd90 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/TimelineItemUnknownView.kt @@ -14,20 +14,20 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components +package io.element.android.x.features.messages.timeline.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent @Composable -fun MessagesTimelineItemUnknownView( - content: MessagesTimelineItemUnknownContent, +fun TimelineItemUnknownView( + content: TimelineItemUnknownContent, modifier: Modifier = Modifier ) { - MessagesTimelineItemInformativeView( + TimelineItemInformativeView( text = "Event not handled by EAX", iconDescription = "Info", icon = Icons.Default.Info, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt similarity index 99% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt index 56a7684a47..e5dcfc1112 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.components.html +package io.element.android.x.features.messages.timeline.components.html import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/diff/CacheInvalidator.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/CacheInvalidator.kt similarity index 86% rename from features/messages/src/main/java/io/element/android/x/features/messages/diff/CacheInvalidator.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/CacheInvalidator.kt index a904951239..031d871033 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/diff/CacheInvalidator.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/CacheInvalidator.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,14 +14,14 @@ * limitations under the License. */ -package io.element.android.x.features.messages.diff +package io.element.android.x.features.messages.timeline.diff import androidx.recyclerview.widget.ListUpdateCallback -import io.element.android.x.features.messages.model.MessagesTimelineItemState -import io.element.android.x.features.messages.util.invalidateLast +import io.element.android.x.features.messages.timeline.model.TimelineItem +import io.element.android.x.features.messages.timeline.util.invalidateLast import timber.log.Timber -internal class CacheInvalidator(private val itemStatesCache: MutableList) : +internal class CacheInvalidator(private val itemStatesCache: MutableList) : ListUpdateCallback { override fun onChanged(position: Int, count: Int, payload: Any?) { diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/diff/MatrixTimelineItemsDiffCallback.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/MatrixTimelineItemsDiffCallback.kt similarity index 96% rename from features/messages/src/main/java/io/element/android/x/features/messages/diff/MatrixTimelineItemsDiffCallback.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/MatrixTimelineItemsDiffCallback.kt index e0198d626a..45b754043a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/diff/MatrixTimelineItemsDiffCallback.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/diff/MatrixTimelineItemsDiffCallback.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.diff +package io.element.android.x.features.messages.timeline.diff import androidx.recyclerview.widget.DiffUtil import io.element.android.x.matrix.timeline.MatrixTimelineItem diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesTimelineItemState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItem.kt similarity index 70% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesTimelineItemState.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItem.kt index 6dab9e37ae..ef93d38aa2 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesTimelineItemState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItem.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,27 +14,30 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model +package io.element.android.x.features.messages.timeline.model +import androidx.compose.runtime.Immutable import io.element.android.x.designsystem.components.avatar.AvatarData -import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent +import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.x.matrix.core.EventId -sealed interface MessagesTimelineItemState { +@Immutable +sealed interface TimelineItem { data class Virtual( val id: String - ) : MessagesTimelineItemState + ) : TimelineItem data class MessageEvent( - val id: String, + val id: EventId, val senderId: String, val senderDisplayName: String?, val senderAvatar: AvatarData, - val content: MessagesTimelineItemContent, + val content: TimelineItemContent, val sentTime: String = "", val isMine: Boolean = false, val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None, - val reactionsState: MessagesItemReactionState - ) : MessagesTimelineItemState { + val reactionsState: TimelineItemReactions + ) : TimelineItem { val showSenderInformation = groupPosition.isNew() && !isMine diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemGroupPosition.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemGroupPosition.kt similarity index 84% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemGroupPosition.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemGroupPosition.kt index b5385538a4..977c498bfc 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemGroupPosition.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemGroupPosition.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,12 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model +package io.element.android.x.features.messages.timeline.model +import androidx.compose.runtime.Immutable import androidx.compose.ui.tooling.preview.PreviewParameterProvider +@Immutable sealed interface MessagesItemGroupPosition { object First : MessagesItemGroupPosition object Middle : MessagesItemGroupPosition @@ -30,7 +32,7 @@ sealed interface MessagesItemGroupPosition { } } -internal class MessagesItemGroupPositionProvider : PreviewParameterProvider { +internal class TimelineItemGroupPositionProvider : PreviewParameterProvider { override val values = sequenceOf( MessagesItemGroupPosition.First, MessagesItemGroupPosition.Middle, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemReactions.kt similarity index 74% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemReactions.kt index b7cf57cf0a..ff0fd549bc 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/TimelineItemReactions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,16 +14,14 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model +package io.element.android.x.features.messages.timeline.model -import androidx.compose.runtime.Stable +import kotlinx.collections.immutable.ImmutableList -@Stable -data class MessagesItemReactionState( - val reactions: List +data class TimelineItemReactions( + val reactions: ImmutableList ) -@Stable data class AggregatedReaction( val key: String, val count: String, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemContent.kt similarity index 74% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemContent.kt index fdfb2448d4..9d7b8c3868 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemContent.kt @@ -14,32 +14,32 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import androidx.compose.ui.tooling.preview.PreviewParameterProvider import org.matrix.rustcomponents.sdk.EncryptedMessage -sealed interface MessagesTimelineItemContent +sealed interface TimelineItemContent -class MessagesTimelineItemContentProvider : PreviewParameterProvider { +class MessagesTimelineItemContentProvider : PreviewParameterProvider { override val values = sequenceOf( - MessagesTimelineItemEmoteContent( + TimelineItemEmoteContent( body = "Emote", htmlDocument = null ), - MessagesTimelineItemEncryptedContent( + TimelineItemEncryptedContent( encryptedMessage = EncryptedMessage.Unknown ), // TODO MessagesTimelineItemImageContent(), - MessagesTimelineItemNoticeContent( + TimelineItemNoticeContent( body = "Notice", htmlDocument = null ), - MessagesTimelineItemRedactedContent, - MessagesTimelineItemTextContent( + TimelineItemRedactedContent, + TimelineItemTextContent( body = "Text", htmlDocument = null ), - MessagesTimelineItemUnknownContent, + TimelineItemUnknownContent, ) } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEmoteContent.kt similarity index 82% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEmoteContent.kt index 6c4820dc51..c5c0a8a330 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEmoteContent.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import org.jsoup.nodes.Document -data class MessagesTimelineItemTextContent( +data class TimelineItemEmoteContent( override val body: String, override val htmlDocument: Document? -) : MessagesTimelineItemTextBasedContent +) : TimelineItemTextBasedContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEncryptedContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEncryptedContent.kt similarity index 83% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEncryptedContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEncryptedContent.kt index 0e23c0ab87..62fd231191 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEncryptedContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemEncryptedContent.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import org.matrix.rustcomponents.sdk.EncryptedMessage -data class MessagesTimelineItemEncryptedContent( +data class TimelineItemEncryptedContent( val encryptedMessage: EncryptedMessage -) : MessagesTimelineItemContent +) : TimelineItemContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemImageContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemImageContent.kt similarity index 84% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemImageContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemImageContent.kt index 682c9838d2..25e983d6a4 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemImageContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemImageContent.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import io.element.android.x.matrix.media.MediaResolver -data class MessagesTimelineItemImageContent( +data class TimelineItemImageContent( val body: String, val imageMeta: MediaResolver.Meta, val blurhash: String?, val aspectRatio: Float -) : MessagesTimelineItemContent +) : TimelineItemContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemNoticeContent.kt similarity index 82% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemNoticeContent.kt index 2d13b93471..1bb8df5673 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemNoticeContent.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import org.jsoup.nodes.Document -data class MessagesTimelineItemEmoteContent( +data class TimelineItemNoticeContent( override val body: String, override val htmlDocument: Document? -) : MessagesTimelineItemTextBasedContent +) : TimelineItemTextBasedContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemUnknownContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemRedactedContent.kt similarity index 81% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemUnknownContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemRedactedContent.kt index e46710c9fd..8de2088052 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemUnknownContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemRedactedContent.kt @@ -14,6 +14,6 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content -object MessagesTimelineItemUnknownContent : MessagesTimelineItemContent +object TimelineItemRedactedContent : TimelineItemContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextBasedContent.kt similarity index 82% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextBasedContent.kt index 14395a91fa..15f757bf6e 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextBasedContent.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import org.jsoup.nodes.Document -sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent { +sealed interface TimelineItemTextBasedContent : TimelineItemContent { val body: String val htmlDocument: Document? } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextContent.kt similarity index 82% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextContent.kt index 8d85ba0c70..a3a887df66 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemTextContent.kt @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content import org.jsoup.nodes.Document -data class MessagesTimelineItemNoticeContent( +data class TimelineItemTextContent( override val body: String, override val htmlDocument: Document? -) : MessagesTimelineItemTextBasedContent +) : TimelineItemTextBasedContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemRedactedContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemUnknownContent.kt similarity index 81% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemRedactedContent.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemUnknownContent.kt index 36f7a29d55..44f6cb9af4 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemRedactedContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/model/content/TimelineItemUnknownContent.kt @@ -14,6 +14,6 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model.content +package io.element.android.x.features.messages.timeline.model.content -object MessagesTimelineItemRedactedContent : MessagesTimelineItemContent +object TimelineItemUnknownContent : TimelineItemContent diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/util/MutableListExt.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/util/MutableListExt.kt similarity index 92% rename from features/messages/src/main/java/io/element/android/x/features/messages/util/MutableListExt.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/util/MutableListExt.kt index 383cb04b95..d487af0cdc 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/util/MutableListExt.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/util/MutableListExt.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.x.features.messages.util +package io.element.android.x.features.messages.timeline.util internal inline fun MutableList.invalidateLast() { val indexOfLast = size diff --git a/features/onboarding/build.gradle.kts b/features/onboarding/build.gradle.kts index 497ce03f8a..4c25a25ea3 100644 --- a/features/onboarding/build.gradle.kts +++ b/features/onboarding/build.gradle.kts @@ -29,9 +29,10 @@ dependencies { implementation(project(":libraries:core")) implementation(project(":libraries:elementresources")) implementation(project(":libraries:designsystem")) - implementation(libs.mavericks.compose) + implementation(project(":libraries:architecture")) implementation(libs.accompanist.pager) implementation(libs.accompanist.pagerindicator) + implementation(libs.appyx.core) testImplementation(libs.test.junit) androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt index c5a36d55e4..77f798fb4e 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt +++ b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingScreen.kt @@ -45,8 +45,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPagerIndicator @@ -55,25 +53,9 @@ import io.element.android.x.designsystem.components.VectorButton import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@Composable -fun OnBoardingScreen( - viewModel: OnBoardingViewModel = mavericksViewModel(), - onSignUp: () -> Unit = { }, - onSignIn: () -> Unit = { }, -) { - val state: OnBoardingViewState by viewModel.collectAsState() - OnBoardingContent( - state, - onPageChanged = viewModel::onPageChanged, - onSignUp = onSignUp, - onSignIn = onSignIn, - ) -} - @OptIn(ExperimentalPagerApi::class) @Composable -fun OnBoardingContent( - state: OnBoardingViewState, +fun OnBoardingScreen( modifier: Modifier = Modifier, onPageChanged: (Int) -> Unit = {}, onSignUp: () -> Unit = {}, diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt index 8ecb18edf6..d12f83cfbe 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt +++ b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/SplashCarouselStateFactory.kt @@ -22,11 +22,13 @@ import io.element.android.x.element.resources.R as ElementR class SplashCarouselStateFactory { fun create(): SplashCarouselState { val lightTheme = true + fun background(@DrawableRes lightDrawable: Int) = if (lightTheme) lightDrawable else R.drawable.bg_color_background fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) = if (lightTheme) lightDrawable else darkDrawable + return SplashCarouselState( listOf( SplashCarouselState.Item( diff --git a/features/preferences/build.gradle.kts b/features/preferences/build.gradle.kts index eee2eecabd..32ad183d13 100644 --- a/features/preferences/build.gradle.kts +++ b/features/preferences/build.gradle.kts @@ -20,6 +20,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) alias(libs.plugins.anvil) + id("kotlin-parcelize") } android { @@ -34,13 +35,13 @@ dependencies { implementation(project(":anvilannotations")) anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:core")) implementation(project(":libraries:matrixui")) implementation(project(":features:rageshake")) implementation(project(":features:logout")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:elementresources")) - implementation(libs.mavericks.compose) implementation(libs.datetime) implementation(libs.accompanist.placeholder) testImplementation(libs.test.junit) diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesFlowNode.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesFlowNode.kt new file mode 100644 index 0000000000..6a5397074f --- /dev/null +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesFlowNode.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.preferences + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.navmodel.backstack.BackStack +import io.element.android.x.architecture.createNode +import io.element.android.x.features.preferences.root.PreferencesRootNode +import kotlinx.parcelize.Parcelize + +class PreferencesFlowNode( + buildContext: BuildContext, + private val onOpenBugReport: () -> Unit, + private val backstack: BackStack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + navModel = backstack, + buildContext = buildContext +) { + + private val preferencesRootNodeCallback = object : PreferencesRootNode.Callback { + override fun onOpenBugReport() { + onOpenBugReport.invoke() + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> createNode(buildContext, plugins = listOf(preferencesRootNodeCallback)) + } + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootNode.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootNode.kt new file mode 100644 index 0000000000..429609e207 --- /dev/null +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.preferences.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.SessionScope + +@ContributesNode(SessionScope::class) +class PreferencesRootNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: PreferencesRootPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onOpenBugReport() + } + + private val presenterConnector = presenterConnector(presenter) + + private fun onOpenBugReport() { + plugins().forEach { it.onOpenBugReport() } + } + + @Composable + override fun View(modifier: Modifier) { + val state by presenterConnector.stateFlow.collectAsState() + PreferencesRootView( + state = state, + onBackPressed = this::navigateUp, + onOpenRageShake = this::onOpenBugReport + ) + } +} diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootPresenter.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootPresenter.kt new file mode 100644 index 0000000000..dbc233b805 --- /dev/null +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootPresenter.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.preferences.root + +import androidx.compose.runtime.Composable +import io.element.android.x.architecture.Async +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.logout.LogoutPreferencePresenter +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesPresenter +import javax.inject.Inject + +class PreferencesRootPresenter @Inject constructor( + private val logoutPresenter: LogoutPreferencePresenter, + private val rageshakePresenter: RageshakePreferencesPresenter, +) : Presenter { + + @Composable + override fun present(): PreferencesRootState { + val logoutState = logoutPresenter.present() + val rageshakeState = rageshakePresenter.present() + + return PreferencesRootState( + logoutState = logoutState, + rageshakeState = rageshakeState, + myUser = Async.Uninitialized, + ) + } +} diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootState.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootState.kt new file mode 100644 index 0000000000..9339f00464 --- /dev/null +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.preferences.root + +import io.element.android.x.architecture.Async +import io.element.android.x.features.logout.LogoutPreferenceState +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesState +import io.element.android.x.matrix.ui.model.MatrixUser + +data class PreferencesRootState( + val logoutState: LogoutPreferenceState, + val rageshakeState: RageshakePreferencesState, + val myUser: Async, +) diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootView.kt similarity index 55% rename from features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt rename to features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootView.kt index 61f38f4a14..95049803a3 100644 --- a/features/preferences/src/main/java/io/element/android/x/features/preferences/PreferencesScreen.kt +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/root/PreferencesRootView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,53 +14,53 @@ * limitations under the License. */ -package io.element.android.x.features.preferences +package io.element.android.x.features.preferences.root import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import io.element.android.x.designsystem.components.preferences.PreferenceScreen -import io.element.android.x.element.resources.R as ElementR -import io.element.android.x.features.logout.LogoutPreference +import io.element.android.x.architecture.Async +import io.element.android.x.designsystem.components.preferences.PreferenceView +import io.element.android.x.element.resources.R +import io.element.android.x.features.logout.LogoutPreferenceState +import io.element.android.x.features.logout.LogoutPreferenceView import io.element.android.x.features.preferences.user.UserPreferences -import io.element.android.x.features.rageshake.preferences.RageshakePreferences +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesState +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesView @Composable -fun PreferencesScreen( - onBackPressed: () -> Unit = {}, - onOpenRageShake: () -> Unit = {}, - onSuccessLogout: () -> Unit = {}, -) { - // TODO Hierarchy! - // Include pref from other modules - PreferencesContent( - onBackPressed = onBackPressed, - onOpenRageShake = onOpenRageShake, - onSuccessLogout = onSuccessLogout, - ) -} - -@Composable -fun PreferencesContent( +fun PreferencesRootView( + state: PreferencesRootState, modifier: Modifier = Modifier, onBackPressed: () -> Unit = {}, onOpenRageShake: () -> Unit = {}, - onSuccessLogout: () -> Unit = {}, ) { - PreferenceScreen( + // TODO Hierarchy! + // Include pref from other modules + PreferenceView( modifier = modifier, onBackPressed = onBackPressed, - title = stringResource(id = ElementR.string.settings) + title = stringResource(id = R.string.settings) ) { - UserPreferences() - RageshakePreferences(onOpenRageShake = onOpenRageShake) - LogoutPreference(onSuccessLogout = onSuccessLogout) + UserPreferences(state.myUser) + RageshakePreferencesView( + state = state.rageshakeState, + onOpenRageshake = onOpenRageShake, + ) + LogoutPreferenceView( + state = state.logoutState, + ) } } @Preview @Composable fun PreferencesContentPreview() { - PreferencesContent() + val state = PreferencesRootState( + logoutState = LogoutPreferenceState(), + rageshakeState = RageshakePreferencesState(), + myUser = Async.Uninitialized + ) + PreferencesRootView(state) } diff --git a/features/preferences/src/main/java/io/element/android/x/features/preferences/user/UserPreferences.kt b/features/preferences/src/main/java/io/element/android/x/features/preferences/user/UserPreferences.kt index e3a58c8c46..5422a3f3f5 100644 --- a/features/preferences/src/main/java/io/element/android/x/features/preferences/user/UserPreferences.kt +++ b/features/preferences/src/main/java/io/element/android/x/features/preferences/user/UserPreferences.kt @@ -19,26 +19,22 @@ package io.element.android.x.features.preferences.user import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel +import io.element.android.x.architecture.Async import io.element.android.x.matrix.ui.components.MatrixUserHeader -import io.element.android.x.matrix.ui.viewmodels.user.UserViewModel -import io.element.android.x.matrix.ui.viewmodels.user.UserViewState +import io.element.android.x.matrix.ui.model.MatrixUser @Composable fun UserPreferences( + user: Async, modifier: Modifier = Modifier, - viewModel: UserViewModel = mavericksViewModel(), ) { - val user by viewModel.collectAsState(UserViewState::user) - when (user()) { + when (val userData = user.dataOrNull()) { null -> Spacer(modifier = modifier.height(1.dp)) else -> MatrixUserHeader( modifier = modifier, - matrixUser = user.invoke()!! + matrixUser = userData ) } } diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts index 63046985e1..92f9b75aaa 100644 --- a/features/rageshake/build.gradle.kts +++ b/features/rageshake/build.gradle.kts @@ -20,6 +20,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) alias(libs.plugins.anvil) + id("kotlin-parcelize") } android { @@ -34,10 +35,10 @@ dependencies { implementation(project(":libraries:core")) anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) + implementation(project(":libraries:architecture")) implementation(project(":anvilannotations")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:elementresources")) - implementation(libs.mavericks.compose) implementation(libs.squareup.seismic) implementation(libs.androidx.datastore.preferences) implementation(libs.coil) diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportEvents.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportEvents.kt new file mode 100644 index 0000000000..c8bbfe2741 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.bugreport + +sealed interface BugReportEvents { + object SendBugReport : BugReportEvents + object ResetAll : BugReportEvents + data class SetDescription(val description: String) : BugReportEvents + data class SetSendLog(val sendLog: Boolean) : BugReportEvents + data class SetSendCrashLog(val sendCrashlog: Boolean) : BugReportEvents + data class SetCanContact(val canContact: Boolean) : BugReportEvents + data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportNode.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportNode.kt new file mode 100644 index 0000000000..c01f153ddd --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.bugreport + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.AppScope + +@ContributesNode(AppScope::class) +class BugReportNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenter: BugReportPresenter, +) : Node(buildContext, plugins = plugins) { + + private val presenterConnector = presenterConnector(presenter) + + interface Callback : Plugin { + fun onBugReportSent() + } + + @Composable + override fun View(modifier: Modifier) { + val state by presenterConnector.stateFlow.collectAsState() + BugReportView( + state = state, + modifier = modifier, + onDone = this::onDone + ) + } + + private fun onDone() { + plugins().forEach { it.onBugReportSent() } + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportPresenter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportPresenter.kt new file mode 100644 index 0000000000..c6f2f473e3 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportPresenter.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.bugreport + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.core.net.toUri +import io.element.android.x.architecture.Async +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.rageshake.crash.CrashDataStore +import io.element.android.x.features.rageshake.logs.VectorFileLogger +import io.element.android.x.features.rageshake.reporter.BugReporter +import io.element.android.x.features.rageshake.reporter.ReportType +import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class BugReportPresenter @Inject constructor( + private val bugReporter: BugReporter, + private val crashDataStore: CrashDataStore, + private val screenshotHolder: ScreenshotHolder, + private val appCoroutineScope: CoroutineScope, +) : Presenter { + + private class BugReporterUploadListener( + private val sendingProgress: MutableState, + private val sendingAction: MutableState> + ) : BugReporter.IMXBugReportListener { + + override fun onUploadCancelled() { + sendingProgress.value = 0f + sendingAction.value = Async.Uninitialized + } + + override fun onUploadFailed(reason: String?) { + sendingProgress.value = 0f + sendingAction.value = Async.Failure(Exception(reason)) + } + + override fun onProgress(progress: Int) { + sendingProgress.value = progress.toFloat() / 100 + sendingAction.value = Async.Loading() + } + + override fun onUploadSucceed(reportUrl: String?) { + sendingProgress.value = 0f + sendingAction.value = Async.Success(Unit) + } + } + + @Composable + override fun present(): BugReportState { + val screenshotUri = rememberSaveable { + mutableStateOf( + screenshotHolder.getFile()?.toUri()?.toString() + ) + } + val crashInfo: String by crashDataStore + .crashInfo() + .collectAsState(initial = "") + + val sendingProgress = remember { + mutableStateOf(0f) + } + val sendingAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + val formState: MutableState = remember { + mutableStateOf(BugReportFormState.Default) + } + val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction) + + fun handleEvents(event: BugReportEvents) { + when (event) { + BugReportEvents.SendBugReport -> appCoroutineScope.sendBugReport(formState.value, crashInfo.isNotEmpty(), uploadListener) + BugReportEvents.ResetAll -> appCoroutineScope.resetAll() + is BugReportEvents.SetDescription -> updateFormState(formState) { + copy(description = event.description) + } + is BugReportEvents.SetCanContact -> updateFormState(formState) { + copy(canContact = event.canContact) + } + is BugReportEvents.SetSendCrashLog -> updateFormState(formState) { + copy(sendCrashLogs = event.sendCrashlog) + } + is BugReportEvents.SetSendLog -> updateFormState(formState) { + copy(sendLogs = event.sendLog) + } + is BugReportEvents.SetSendScreenshot -> updateFormState(formState) { + copy(sendScreenshot = event.sendScreenshot) + } + } + } + + return BugReportState( + hasCrashLogs = crashInfo.isNotEmpty(), + sendingProgress = sendingProgress.value, + sending = sendingAction.value, + formState = formState.value, + screenshotUri = screenshotUri.value, + eventSink = ::handleEvents + ) + } + + private fun updateFormState(formState: MutableState, operation: BugReportFormState.() -> BugReportFormState) { + formState.value = operation(formState.value) + } + + private fun CoroutineScope.sendBugReport(formState: BugReportFormState, hasCrashLogs: Boolean, listener: BugReporter.IMXBugReportListener) = launch { + bugReporter.sendBugReport( + coroutineScope = this, + reportType = ReportType.BUG_REPORT, + withDevicesLogs = formState.sendLogs, + withCrashLogs = hasCrashLogs && formState.sendCrashLogs, + withKeyRequestHistory = false, + withScreenshot = formState.sendScreenshot, + theBugDescription = formState.description, + serverVersion = "", + canContact = formState.canContact, + customFields = emptyMap(), + listener = listener + ) + } + + private fun CoroutineScope.resetAll() = launch { + screenshotHolder.reset() + crashDataStore.reset() + VectorFileLogger.getFromTimber().reset() + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportState.kt similarity index 59% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportState.kt index 813a25dec4..80912a7923 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewState.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportState.kt @@ -16,30 +16,37 @@ package io.element.android.x.features.rageshake.bugreport -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized +import android.os.Parcelable +import io.element.android.x.architecture.Async +import kotlinx.parcelize.Parcelize -data class BugReportViewState( +data class BugReportState( val formState: BugReportFormState = BugReportFormState.Default, - val sendLogs: Boolean = true, val hasCrashLogs: Boolean = false, - val sendCrashLogs: Boolean = true, - val canContact: Boolean = false, - val sendScreenshot: Boolean = false, val screenshotUri: String? = null, val sendingProgress: Float = 0F, - val sending: Async = Uninitialized, -) : MavericksState { + val sending: Async = Async.Uninitialized, + val eventSink: (BugReportEvents) -> Unit = {} +) { val submitEnabled = - formState.description.length > 10 && sending !is Loading + formState.description.length > 10 && sending !is Async.Loading } +@Parcelize data class BugReportFormState( val description: String, -) { + val sendLogs: Boolean, + val sendCrashLogs: Boolean, + val canContact: Boolean, + val sendScreenshot: Boolean +) : Parcelable { companion object { - val Default = BugReportFormState("") + val Default = BugReportFormState( + description = "", + sendLogs = true, + sendCrashLogs = true, + canContact = false, + sendScreenshot = false + ) } } diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportView.kt similarity index 74% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportView.kt index cbd9ae8be4..394cf69a5b 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportScreen.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportView.kt @@ -14,8 +14,6 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.x.features.rageshake.bugreport import androidx.compose.foundation.layout.Box @@ -36,7 +34,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -50,58 +50,30 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel +import io.element.android.x.architecture.Async import io.element.android.x.core.compose.LogCompositions +import io.element.android.x.core.compose.textFieldState import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.LabelledCheckbox import io.element.android.x.designsystem.components.dialogs.ErrorDialog import io.element.android.x.element.resources.R as ElementR -@Composable -fun BugReportScreen( - viewModel: BugReportViewModel = mavericksViewModel(), - onDone: () -> Unit = { }, -) { - val state: BugReportViewState by viewModel.collectAsState() - val formState: BugReportFormState by viewModel.formState - LogCompositions(tag = "Rageshake", msg = "Root") - if (state.sending is Success) { - onDone() - } - BugReportContent( - state = state, - formState = formState, - onDescriptionChanged = viewModel::onSetDescription, - onSetSendLog = viewModel::onSetSendLog, - onSetSendCrashLog = viewModel::onSetSendCrashLog, - onSetCanContact = viewModel::onSetCanContact, - onSetSendScreenshot = viewModel::onSetSendScreenshot, - onSubmit = viewModel::onSubmit, - onFailureDialogClosed = viewModel::onFailureDialogClosed, - onDone = onDone, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BugReportContent( - state: BugReportViewState, - formState: BugReportFormState, +fun BugReportView( + state: BugReportState, modifier: Modifier = Modifier, - onDescriptionChanged: (String) -> Unit = {}, - onSetSendLog: (Boolean) -> Unit = {}, - onSetSendCrashLog: (Boolean) -> Unit = {}, - onSetCanContact: (Boolean) -> Unit = {}, - onSetSendScreenshot: (Boolean) -> Unit = {}, - onSubmit: () -> Unit = {}, - onFailureDialogClosed: () -> Unit = { }, onDone: () -> Unit = { }, ) { + LogCompositions(tag = "Rageshake", msg = "Root") + val eventSink = state.eventSink + if (state.sending is Async.Success) { + LaunchedEffect(state.sending) { + eventSink(BugReportEvents.ResetAll) + onDone() + } + return + } Surface( modifier = modifier, color = MaterialTheme.colorScheme.background, @@ -120,8 +92,8 @@ fun BugReportContent( ) .padding(horizontal = 16.dp), ) { - val isError = state.sending is Fail - val isFormEnabled = state.sending !is Loading + val isError = state.sending is Async.Failure + val isFormEnabled = state.sending !is Async.Loading // Title Text( text = stringResource(id = ElementR.string.send_bug_report), @@ -140,11 +112,12 @@ fun BugReportContent( .padding(horizontal = 16.dp, vertical = 16.dp), fontSize = 16.sp, ) + var descriptionFieldState by textFieldState(stateValue = state.formState.description) Column( // modifier = Modifier.weight(1f), ) { OutlinedTextField( - value = formState.description, + value = descriptionFieldState, modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), @@ -155,7 +128,10 @@ fun BugReportContent( supportingText = { Text(text = stringResource(id = ElementR.string.send_bug_report_description_in_english)) }, - onValueChange = onDescriptionChanged, + onValueChange = { + descriptionFieldState = it + eventSink(BugReportEvents.SetDescription(it)) + }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Next @@ -164,33 +140,33 @@ fun BugReportContent( ) } LabelledCheckbox( - checked = state.sendLogs, - onCheckedChange = onSetSendLog, + checked = state.formState.sendLogs, + onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) }, enabled = isFormEnabled, text = stringResource(id = ElementR.string.send_bug_report_include_logs) ) if (state.hasCrashLogs) { LabelledCheckbox( - checked = state.sendCrashLogs, - onCheckedChange = onSetSendCrashLog, + checked = state.formState.sendCrashLogs, + onCheckedChange = { eventSink(BugReportEvents.SetSendCrashLog(it)) }, enabled = isFormEnabled, text = stringResource(id = ElementR.string.send_bug_report_include_crash_logs) ) } LabelledCheckbox( - checked = state.canContact, - onCheckedChange = onSetCanContact, + checked = state.formState.canContact, + onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) }, enabled = isFormEnabled, text = stringResource(id = ElementR.string.you_may_contact_me) ) if (state.screenshotUri != null) { LabelledCheckbox( - checked = state.sendScreenshot, - onCheckedChange = onSetSendScreenshot, + checked = state.formState.sendScreenshot, + onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) }, enabled = isFormEnabled, text = stringResource(id = ElementR.string.send_bug_report_include_screenshot) ) - if (state.sendScreenshot) { + if (state.formState.sendScreenshot) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center @@ -209,7 +185,7 @@ fun BugReportContent( } // Submit Button( - onClick = onSubmit, + onClick = { eventSink(BugReportEvents.SendBugReport) }, enabled = state.submitEnabled, modifier = Modifier .fillMaxWidth() @@ -219,18 +195,16 @@ fun BugReportContent( } } when (state.sending) { - Uninitialized -> Unit - is Loading -> { + is Async.Loading -> { CircularProgressIndicator( progress = state.sendingProgress, modifier = Modifier.align(Alignment.Center) ) } - is Fail -> ErrorDialog( + is Async.Failure -> ErrorDialog( content = state.sending.error.toString(), - onDismiss = onFailureDialogClosed, ) - is Success -> onDone() + else -> Unit } } } @@ -240,9 +214,8 @@ fun BugReportContent( @Preview fun BugReportContentPreview() { ElementXTheme(darkTheme = false) { - BugReportContent( - state = BugReportViewState(), - formState = BugReportFormState.Default + BugReportView( + state = BugReportState(), ) } } diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt deleted file mode 100644 index ad3226542a..0000000000 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/bugreport/BugReportViewModel.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.rageshake.bugreport - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshotFlow -import androidx.core.net.toUri -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.AppScope -import io.element.android.x.features.rageshake.crash.CrashDataStore -import io.element.android.x.features.rageshake.logs.VectorFileLogger -import io.element.android.x.features.rageshake.reporter.BugReporter -import io.element.android.x.features.rageshake.reporter.ReportType -import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -@ContributesViewModel(AppScope::class) -class BugReportViewModel @AssistedInject constructor( - @Assisted initialState: BugReportViewState, - private val bugReporter: BugReporter, - private val crashDataStore: CrashDataStore, - private val screenshotHolder: ScreenshotHolder, - private val appCoroutineScope: CoroutineScope -) : - MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - var formState = mutableStateOf(BugReportFormState.Default) - private set - - init { - snapshotFlow { formState.value } - .onEach { - setState { copy(formState = it) } - }.launchIn(viewModelScope) - observerCrashDataStore() - setState { - copy( - screenshotUri = screenshotHolder.getFile()?.toUri()?.toString() - ) - } - } - - private fun observerCrashDataStore() { - viewModelScope.launch { - crashDataStore.crashInfo().collect { - setState { - copy( - hasCrashLogs = it.isNotEmpty() - ) - } - } - } - } - - private val listener: BugReporter.IMXBugReportListener = object : BugReporter.IMXBugReportListener { - override fun onUploadCancelled() { - setState { - copy( - sendingProgress = 0F, - sending = Uninitialized - ) - } - } - - override fun onUploadFailed(reason: String?) { - setState { - copy( - sendingProgress = 0F, - sending = Fail(Exception(reason)) - ) - } - } - - override fun onProgress(progress: Int) { - setState { - copy( - sendingProgress = progress.toFloat() / 100, - sending = Loading() - ) - } - } - - override fun onUploadSucceed(reportUrl: String?) { - setState { - copy( - sendingProgress = 1F, - sending = Success(Unit) - ) - } - } - } - - override fun onCleared() { - // Use appCoroutineScope because we don't want this coroutine to be cancelled - appCoroutineScope.launch(Dispatchers.IO) { - screenshotHolder.reset() - crashDataStore.reset() - VectorFileLogger.getFromTimber().reset() - } - super.onCleared() - } - - fun onSubmit() { - setState { - copy( - sendingProgress = 0F, - sending = Loading() - ) - } - withState { state -> - bugReporter.sendBugReport( - coroutineScope = viewModelScope, - reportType = ReportType.BUG_REPORT, - withDevicesLogs = state.sendLogs, - withCrashLogs = state.hasCrashLogs && state.sendCrashLogs, - withKeyRequestHistory = false, - withScreenshot = state.sendScreenshot, - theBugDescription = state.formState.description, - serverVersion = "", - canContact = state.canContact, - customFields = emptyMap(), - listener = listener - ) - } - } - - fun onFailureDialogClosed() { - setState { - copy( - sendingProgress = 0F, - sending = Uninitialized - ) - } - } - - fun onSetDescription(str: String) { - formState.value = formState.value.copy(description = str) - setState { copy(sending = Uninitialized) } - } - - fun onSetSendLog(value: Boolean) = setState { copy(sendLogs = value) } - fun onSetSendCrashLog(value: Boolean) = setState { copy(sendCrashLogs = value) } - fun onSetCanContact(value: Boolean) = setState { copy(canContact = value) } - fun onSetSendScreenshot(value: Boolean) = setState { copy(sendScreenshot = value) } -} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionEvents.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionEvents.kt new file mode 100644 index 0000000000..93330b4513 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.crash.ui + +sealed interface CrashDetectionEvents { + object ResetAllCrashData : CrashDetectionEvents + object ResetAppHasCrashed : CrashDetectionEvents +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionPresenter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionPresenter.kt new file mode 100644 index 0000000000..3549bf3cb7 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionPresenter.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.crash.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.rageshake.crash.CrashDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CrashDetectionPresenter @Inject constructor(private val crashDataStore: CrashDataStore) : Presenter { + + @Composable + override fun present(): CrashDetectionState { + val localCoroutineScope = rememberCoroutineScope() + val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false) + + fun handleEvents(event: CrashDetectionEvents) { + when (event) { + CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll() + CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed() + } + } + + return CrashDetectionState( + crashDetected = crashDetected.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.resetAppHasCrashed() = launch { + crashDataStore.resetAppHasCrashed() + } + + private fun CoroutineScope.resetAll() = launch { + crashDataStore.reset() + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt index 7c5cc36acb..7c1e855455 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionScreen.kt @@ -17,40 +17,37 @@ package io.element.android.x.features.rageshake.crash.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.compose.LogCompositions import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog import io.element.android.x.element.resources.R as ElementR @Composable -fun CrashDetectionScreen( - viewModel: CrashDetectionViewModel = mavericksViewModel(), +fun CrashDetectionView( + state: CrashDetectionState, onOpenBugReport: () -> Unit = { }, ) { - val state: CrashDetectionViewState by viewModel.collectAsState() LogCompositions(tag = "Crash", msg = "CrashDetectionScreen") + fun onPopupDismissed() { + state.eventSink(CrashDetectionEvents.ResetAllCrashData) + } + if (state.crashDetected) { CrashDetectionContent( state, - onYesClicked = { - viewModel.onYes() - onOpenBugReport() - }, - onNoClicked = viewModel::onPopupDismissed, - onDismiss = viewModel::onPopupDismissed, + onYesClicked = onOpenBugReport, + onNoClicked = ::onPopupDismissed, + onDismiss = ::onPopupDismissed, ) } } @Composable fun CrashDetectionContent( - state: CrashDetectionViewState, + state: CrashDetectionState, onNoClicked: () -> Unit = { }, onYesClicked: () -> Unit = { }, onDismiss: () -> Unit = { }, @@ -71,7 +68,7 @@ fun CrashDetectionContent( fun CrashDetectionContentPreview() { ElementXTheme { CrashDetectionContent( - state = CrashDetectionViewState() + state = CrashDetectionState() ) } } diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionState.kt similarity index 88% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionState.kt index e7a50645f9..52774c4cc9 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewState.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionState.kt @@ -16,8 +16,7 @@ package io.element.android.x.features.rageshake.crash.ui -import com.airbnb.mvrx.MavericksState - -data class CrashDetectionViewState( +data class CrashDetectionState( val crashDetected: Boolean = false, -) : MavericksState + val eventSink: (CrashDetectionEvents) -> Unit = {} +) diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt deleted file mode 100644 index 359390c7e7..0000000000 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/crash/ui/CrashDetectionViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.rageshake.crash.ui - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.di.AppScope -import io.element.android.x.features.rageshake.crash.CrashDataStore -import kotlinx.coroutines.launch - -@ContributesViewModel(AppScope::class) -class CrashDetectionViewModel @AssistedInject constructor( - @Assisted initialState: CrashDetectionViewState, - private val crashDataStore: CrashDataStore, -) : MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - init { - observeDataStore() - } - - private fun observeDataStore() { - viewModelScope.launch { - crashDataStore.appHasCrashed().collect { appHasCrashed -> - setState { - copy( - crashDetected = appHasCrashed - ) - } - } - } - } - - fun onYes() { - viewModelScope.launch { - crashDataStore.resetAppHasCrashed() - } - } - - fun onPopupDismissed() { - viewModelScope.launch { - crashDataStore.reset() - } - } -} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionEvents.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionEvents.kt new file mode 100644 index 0000000000..0ba2a26bae --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.detection + +import io.element.android.x.core.screenshot.ImageResult + +sealed interface RageshakeDetectionEvents { + object Dismiss : RageshakeDetectionEvents + object Disable : RageshakeDetectionEvents + object StartDetection : RageshakeDetectionEvents + object StopDetection : RageshakeDetectionEvents + data class ProcessScreenshot(val imageResult: ImageResult) : RageshakeDetectionEvents +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionPresenter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionPresenter.kt new file mode 100644 index 0000000000..62e974703d --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionPresenter.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.detection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.core.screenshot.ImageResult +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesEvents +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesPresenter +import io.element.android.x.features.rageshake.rageshake.RageShake +import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class RageshakeDetectionPresenter @Inject constructor( + private val screenshotHolder: ScreenshotHolder, + private val rageShake: RageShake, + private val preferencesPresenter: RageshakePreferencesPresenter, +) : Presenter { + + @Composable + override fun present(): RageshakeDetectionState { + val localCoroutineScope = rememberCoroutineScope() + val preferencesState = preferencesPresenter.present() + val isStarted = rememberSaveable { + mutableStateOf(false) + } + val takeScreenshot = rememberSaveable { + mutableStateOf(false) + } + val showDialog = rememberSaveable { + mutableStateOf(false) + } + + fun handleEvents(event: RageshakeDetectionEvents) { + when (event) { + RageshakeDetectionEvents.Disable -> { + preferencesState.eventSink(RageshakePreferencesEvents.SetIsEnabled(false)) + showDialog.value = false + } + RageshakeDetectionEvents.StartDetection -> isStarted.value = true + RageshakeDetectionEvents.StopDetection -> isStarted.value = false + is RageshakeDetectionEvents.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult) + RageshakeDetectionEvents.Dismiss -> showDialog.value = false + } + } + + val state = remember(preferencesState, isStarted.value, takeScreenshot.value, showDialog.value) { + RageshakeDetectionState( + isStarted = isStarted.value, + takeScreenshot = takeScreenshot.value, + showDialog = showDialog.value, + preferenceState = preferencesState, + eventSink = ::handleEvents + ) + } + + LaunchedEffect(preferencesState.sensitivity) { + rageShake.setSensitivity(preferencesState.sensitivity) + } + val shouldStart = preferencesState.isEnabled && + preferencesState.isSupported && + isStarted.value && + !takeScreenshot.value && + !showDialog.value + + LaunchedEffect(shouldStart) { + handleRageShake(shouldStart, state, takeScreenshot) + } + return state + } + + private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState) { + if (start) { + rageShake.start(state.preferenceState.sensitivity) + rageShake.interceptor = { + takeScreenshot.value = true + } + } else { + rageShake.stop() + rageShake.interceptor = null + } + } + + private fun CoroutineScope.processScreenshot(takeScreenshot: MutableState, showDialog: MutableState, imageResult: ImageResult) = launch { + screenshotHolder.reset() + when (imageResult) { + is ImageResult.Error -> { + Timber.e(imageResult.exception, "Unable to write screenshot") + } + is ImageResult.Success -> { + screenshotHolder.writeBitmap(imageResult.data) + } + } + takeScreenshot.value = false + showDialog.value = true + } +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionState.kt similarity index 71% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionState.kt index 4e426a079e..d32b326dd3 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewState.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionState.kt @@ -16,13 +16,14 @@ package io.element.android.x.features.rageshake.detection -import com.airbnb.mvrx.MavericksState +import androidx.compose.runtime.Stable +import io.element.android.x.features.rageshake.preferences.RageshakePreferencesState -data class RageshakeDetectionViewState( +@Stable +data class RageshakeDetectionState( val takeScreenshot: Boolean = false, val showDialog: Boolean = false, - val isEnabled: Boolean = true, val isStarted: Boolean = false, - val isSupported: Boolean = false, - val sensitivity: Float = 0.5f, -) : MavericksState + val preferenceState: RageshakePreferencesState = RageshakePreferencesState(), + val eventSink: (RageshakeDetectionEvents) -> Unit = {} +) diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionView.kt similarity index 73% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionView.kt index 576f4e7a92..61e9f32150 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionScreen.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionView.kt @@ -18,14 +18,11 @@ package io.element.android.x.features.rageshake.detection import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.Lifecycle -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.compose.LogCompositions import io.element.android.x.core.compose.OnLifecycleEvent import io.element.android.x.core.hardware.vibrate @@ -36,39 +33,32 @@ import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog import io.element.android.x.element.resources.R as ElementR @Composable -fun RageshakeDetectionScreen( - viewModel: RageshakeDetectionViewModel = mavericksViewModel(), +fun RageshakeDetectionView( + state: RageshakeDetectionState, onOpenBugReport: () -> Unit = { }, ) { - val state: RageshakeDetectionViewState by viewModel.collectAsState() LogCompositions(tag = "Rageshake", msg = "RageshakeDetectionScreen") + val eventSink = state.eventSink val context = LocalContext.current OnLifecycleEvent { _, event -> when (event) { - Lifecycle.Event.ON_RESUME -> viewModel.start() - Lifecycle.Event.ON_PAUSE -> viewModel.stop() + Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvents.StartDetection) + Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvents.StopDetection) else -> Unit } } - when { state.takeScreenshot -> TakeScreenshot( - onScreenshotTaken = viewModel::onScreenshotTaken + onScreenshotTaken = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } ) state.showDialog -> { - LaunchedEffect(key1 = "RS_diag") { + LaunchedEffect(Unit) { context.vibrate() } RageshakeDialogContent( - state, - onNoClicked = viewModel::onNo, - onDisableClicked = { - viewModel.onEnableClicked(false) - }, - onYesClicked = { - onOpenBugReport() - viewModel.onYes() - } + onNoClicked = { eventSink(RageshakeDetectionEvents.Dismiss) }, + onDisableClicked = { eventSink(RageshakeDetectionEvents.Disable) }, + onYesClicked = onOpenBugReport ) } } @@ -79,14 +69,15 @@ private fun TakeScreenshot( onScreenshotTaken: (ImageResult) -> Unit = {} ) { val view = LocalView.current - view.screenshot { - onScreenshotTaken(it) + LaunchedEffect(Unit) { + view.screenshot { + onScreenshotTaken(it) + } } } @Composable fun RageshakeDialogContent( - state: RageshakeDetectionViewState, onNoClicked: () -> Unit = { }, onDisableClicked: () -> Unit = { }, onYesClicked: () -> Unit = { }, @@ -97,6 +88,7 @@ fun RageshakeDialogContent( thirdButtonText = stringResource(id = ElementR.string.action_disable), submitText = stringResource(id = ElementR.string.yes), cancelText = stringResource(id = ElementR.string.no), + onCancelClicked = onNoClicked, onThirdButtonClicked = onDisableClicked, onSubmitClicked = onYesClicked, onDismiss = onNoClicked, @@ -107,8 +99,6 @@ fun RageshakeDialogContent( @Composable fun RageshakeDialogContentPreview() { ElementXTheme { - RageshakeDialogContent( - state = RageshakeDetectionViewState() - ) + RageshakeDialogContent() } } diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt deleted file mode 100644 index 97ec4d6217..0000000000 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/detection/RageshakeDetectionViewModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.rageshake.detection - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.core.screenshot.ImageResult -import io.element.android.x.di.AppScope -import io.element.android.x.features.rageshake.rageshake.RageShake -import io.element.android.x.features.rageshake.rageshake.RageshakeDataStore -import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import timber.log.Timber - -@ContributesViewModel(AppScope::class) -class RageshakeDetectionViewModel @AssistedInject constructor( - @Assisted initialState: RageshakeDetectionViewState, - private val rageshakeDataStore: RageshakeDataStore, - private val screenshotHolder: ScreenshotHolder, - private val rageShake: RageShake, -) : MavericksViewModel(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - init { - setState { - copy( - isSupported = rageShake.isAvailable() - ) - } - observeDataStore() - observeState() - } - - private fun observeDataStore() { - viewModelScope.launch { - rageshakeDataStore.isEnabled().collect { isEnabled -> - setState { - copy( - isEnabled = isEnabled - ) - } - } - } - viewModelScope.launch { - rageshakeDataStore.sensitivity().collect { sensitivity -> - setState { - copy( - sensitivity = sensitivity - ) - } - } - } - } - - private fun observeState() { - viewModelScope.launch { - stateFlow - .map { - it.isSupported && - it.isEnabled && - it.isStarted && - !it.takeScreenshot && - !it.showDialog - } - .distinctUntilChanged() - .collect(::handleRageShake) - } - viewModelScope.launch { - stateFlow - .map { - it.sensitivity - } - .distinctUntilChanged() - .collect { - rageShake.setSensitivity(it) - } - } - } - - private fun handleRageShake(shouldStart: Boolean) { - if (shouldStart) { - withState { - rageShake.start(it.sensitivity) - } - rageShake.interceptor = { - setState { - copy( - takeScreenshot = true - ) - } - } - } else { - rageShake.stop() - rageShake.interceptor = null - } - } - - fun onScreenshotTaken(imageResult: ImageResult) { - viewModelScope.launch(Dispatchers.IO) { - screenshotHolder.reset() - when (imageResult) { - is ImageResult.Error -> { - Timber.e(imageResult.exception, "Unable to write screenshot") - } - is ImageResult.Success -> { - screenshotHolder.writeBitmap(imageResult.data) - } - } - setState { - copy( - takeScreenshot = false, - showDialog = true, - ) - } - } - } - - fun start() { - setState { - copy(isStarted = true) - } - } - - private fun onPopupDismissed() { - setState { - copy( - showDialog = false - ) - } - } - - fun onNo() { - onPopupDismissed() - } - - fun onYes() { - onPopupDismissed() - } - - fun onEnableClicked(enabled: Boolean) { - viewModelScope.launch { - rageshakeDataStore.setIsEnabled(enabled) - } - if (!enabled) { - onPopupDismissed() - } - } - - fun onSensitivityChange(sensitivity: Float) { - viewModelScope.launch { - rageshakeDataStore.setSensitivity(sensitivity) - } - rageShake.setSensitivity(sensitivity) - } - - fun stop() { - setState { - copy(isStarted = false) - } - } - - override fun onCleared() { - super.onCleared() - stop() - handleRageShake(false) - } -} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesEvents.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesEvents.kt new file mode 100644 index 0000000000..ae178e9f2f --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.preferences + +sealed interface RageshakePreferencesEvents { + data class SetSensitivity(val sensitivity: Float) : RageshakePreferencesEvents + data class SetIsEnabled(val isEnabled: Boolean) : RageshakePreferencesEvents +} diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesPresenter.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesPresenter.kt new file mode 100644 index 0000000000..e435a1a756 --- /dev/null +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesPresenter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.rageshake.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.x.architecture.Presenter +import io.element.android.x.features.rageshake.rageshake.RageShake +import io.element.android.x.features.rageshake.rageshake.RageshakeDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class RageshakePreferencesPresenter @Inject constructor( + private val rageshake: RageShake, + private val rageshakeDataStore: RageshakeDataStore, +) : Presenter { + + @Composable + override fun present(): RageshakePreferencesState { + val localCoroutineScope = rememberCoroutineScope() + val isSupported: MutableState = rememberSaveable { + mutableStateOf(rageshake.isAvailable()) + } + val isEnabled = rageshakeDataStore + .isEnabled() + .collectAsState(initial = false) + + val sensitivity = rageshakeDataStore + .sensitivity() + .collectAsState(initial = 0f) + + fun handleEvents(event: RageshakePreferencesEvents) { + when (event) { + is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled) + is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity) + } + } + + return RageshakePreferencesState( + isEnabled = isEnabled.value, + isSupported = isSupported.value, + sensitivity = sensitivity.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.setSensitivity(sensitivity: Float) = launch { + rageshakeDataStore.setSensitivity(sensitivity) + } + + private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch { + rageshakeDataStore.setIsEnabled(enabled) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesState.kt similarity index 65% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesState.kt index 264fa85085..5a71b2a42f 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,11 @@ * limitations under the License. */ -package io.element.android.x.features.messages.model +package io.element.android.x.features.rageshake.preferences -import androidx.compose.runtime.Stable - -@Stable -data class MessagesItemActionsSheetState( - val targetItem: MessagesTimelineItemState.MessageEvent, - val actions: List +data class RageshakePreferencesState( + val isEnabled: Boolean = false, + val isSupported: Boolean = true, + val sensitivity: Float = 0.3f, + val eventSink: (RageshakePreferencesEvents) -> Unit = {}, ) diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesView.kt similarity index 74% rename from features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt rename to features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesView.kt index 050689ad44..e9142a2c2e 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferenceCategory.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/preferences/RageshakePreferencesView.kt @@ -20,50 +20,44 @@ import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.designsystem.components.preferences.PreferenceCategory import io.element.android.x.designsystem.components.preferences.PreferenceSlide import io.element.android.x.designsystem.components.preferences.PreferenceSwitch import io.element.android.x.designsystem.components.preferences.PreferenceText import io.element.android.x.element.resources.R as ElementR -import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewModel -import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewState @Composable -fun RageshakePreferences( - onOpenRageShake: () -> Unit = {}, -) { - RageshakePreferencesContent( - onOpenRageShake = onOpenRageShake, - ) -} - -@Composable -fun RageshakePreferencesContent( +fun RageshakePreferencesView( + state: RageshakePreferencesState, modifier: Modifier = Modifier, - viewModel: RageshakeDetectionViewModel = mavericksViewModel(), - onOpenRageShake: () -> Unit = {}, + onOpenRageshake: () -> Unit = {}, ) { - val state: RageshakeDetectionViewState by viewModel.collectAsState() + fun onSensitivityChanged(sensitivity: Float) { + state.eventSink(RageshakePreferencesEvents.SetSensitivity(sensitivity = sensitivity)) + } + + fun onEnabledChanged(isEnabled: Boolean) { + state.eventSink(RageshakePreferencesEvents.SetIsEnabled(isEnabled = isEnabled)) + } + Column(modifier = modifier) { PreferenceCategory(title = stringResource(id = ElementR.string.send_bug_report)) { PreferenceText( title = stringResource(id = ElementR.string.send_bug_report), icon = Icons.Default.BugReport, - onClick = onOpenRageShake + onClick = onOpenRageshake ) } + PreferenceCategory(title = stringResource(id = ElementR.string.settings_rageshake)) { if (state.isSupported) { PreferenceSwitch( title = stringResource(id = ElementR.string.send_bug_report_rage_shake), isChecked = state.isEnabled, - onCheckedChange = viewModel::onEnableClicked + onCheckedChange = ::onEnabledChanged ) PreferenceSlide( title = stringResource(id = ElementR.string.settings_rageshake_detection_threshold), @@ -71,7 +65,7 @@ fun RageshakePreferencesContent( value = state.sensitivity, enabled = state.isEnabled, steps = 3 /* 5 possible values - steps are in ]0, 1[ */, - onValueChange = viewModel::onSensitivityChange + onValueChange = ::onSensitivityChanged ) } else { PreferenceText(title = "Rageshaking is not supported by your device") @@ -82,6 +76,6 @@ fun RageshakePreferencesContent( @Composable @Preview -fun RageshakePreferencePreview() { - RageshakePreferences() +fun RageshakePreferencesPreview() { + RageshakePreferencesView(RageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f)) } diff --git a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt index 1b028ffb2b..00f9d74e96 100644 --- a/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt +++ b/features/rageshake/src/main/java/io/element/android/x/features/rageshake/screenshot/ScreenshotHolder.kt @@ -19,10 +19,13 @@ package io.element.android.x.features.rageshake.screenshot import android.content.Context import android.graphics.Bitmap import io.element.android.x.core.bitmap.writeBitmap +import io.element.android.x.di.AppScope import io.element.android.x.di.ApplicationContext +import io.element.android.x.di.SingleIn import java.io.File import javax.inject.Inject +@SingleIn(AppScope::class) class ScreenshotHolder @Inject constructor( @ApplicationContext private val context: Context, ) { diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index 20b267af56..674ec25d88 100644 --- a/features/roomlist/build.gradle.kts +++ b/features/roomlist/build.gradle.kts @@ -35,11 +35,12 @@ dependencies { anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) implementation(project(":libraries:core")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:matrix")) implementation(project(":libraries:matrixui")) implementation(project(":libraries:designsystem")) + implementation(libs.appyx.core) implementation(project(":libraries:elementresources")) - implementation(libs.mavericks.compose) implementation(libs.datetime) implementation(libs.accompanist.placeholder) testImplementation(libs.test.junit) diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt index 5c7a7bfdb8..5e90a52898 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt @@ -18,10 +18,6 @@ package io.element.android.x.features.roomlist import android.text.format.DateFormat import android.text.format.DateUtils -import java.time.Period -import java.time.format.DateTimeFormatter -import java.util.Locale -import kotlin.math.absoluteValue import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime @@ -30,11 +26,16 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toLocalDateTime +import java.time.Period +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlin.math.absoluteValue -class LastMessageFormatter( - private val clock: Clock = Clock.System, +class LastMessageFormatter @Inject constructor() { + + private val clock: Clock = Clock.System private val locale: Locale = Locale.getDefault() -) { private val onlyTimeFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt new file mode 100644 index 0000000000..246545588f --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.roomlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.SessionScope +import io.element.android.x.matrix.core.RoomId + +@ContributesNode(SessionScope::class) +class RoomListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenter: RoomListPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onRoomClicked(roomId: RoomId) + fun onSettingsClicked() + } + + private val connector = presenterConnector(presenter) + + private fun onRoomClicked(roomId: RoomId) { + plugins().forEach { it.onRoomClicked(roomId) } + } + + private fun onOpenSettings() { + plugins().forEach { it.onSettingsClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + val state by connector.stateFlow.collectAsState() + RoomListView( + state = state, + onRoomClicked = this::onRoomClicked, + onOpenSettings = this::onOpenSettings + ) + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt new file mode 100644 index 0000000000..991239ba59 --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.roomlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.x.architecture.Presenter +import io.element.android.x.core.coroutine.parallelMap +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.designsystem.components.avatar.AvatarSize +import io.element.android.x.features.roomlist.model.RoomListEvents +import io.element.android.x.features.roomlist.model.RoomListRoomSummary +import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders +import io.element.android.x.features.roomlist.model.RoomListState +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.media.MediaResolver +import io.element.android.x.matrix.room.RoomSummary +import io.element.android.x.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val extendedRangeSize = 40 + +class RoomListPresenter @Inject constructor( + private val client: MatrixClient, + private val lastMessageFormatter: LastMessageFormatter, +) : Presenter { + + @Composable + override fun present(): RoomListState { + val matrixUser: MutableState = remember { + mutableStateOf(null) + } + var filter by rememberSaveable { mutableStateOf("") } + val isLoginOut = rememberSaveable { mutableStateOf(false) } + val roomSummaries by client + .roomSummaryDataSource() + .roomSummaries() + .collectAsState(initial = null) + + val filteredRoomSummaries: MutableState> = remember { + mutableStateOf(persistentListOf()) + } + LaunchedEffect(Unit) { + initialLoad(matrixUser) + } + + fun handleEvents(event: RoomListEvents) { + when (event) { + is RoomListEvents.UpdateFilter -> filter = event.newFilter + is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) + } + } + + LaunchedEffect(roomSummaries, filter) { + filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) + } + return RoomListState( + matrixUser = matrixUser.value, + roomList = filteredRoomSummaries.value, + filter = filter, + isLoginOut = isLoginOut.value, + eventSink = ::handleEvents + ) + } + + private suspend fun updateFilteredRoomSummaries(roomSummaries: List?, filter: String): ImmutableList { + if (roomSummaries.isNullOrEmpty()) { + return RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList() + } + val mappedRoomSummaries = mapRoomSummaries(roomSummaries) + return if (filter.isEmpty()) { + mappedRoomSummaries + } else { + mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) } + }.toImmutableList() + } + + private fun CoroutineScope.initialLoad(matrixUser: MutableState) = launch { + val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() + val userDisplayName = client.loadUserDisplayName().getOrNull() + val avatarData = + loadAvatarData( + userDisplayName ?: client.userId().value, + userAvatarUrl, + AvatarSize.SMALL + ) + matrixUser.value = MatrixUser( + id = client.userId(), + username = userDisplayName ?: client.userId().value, + avatarUrl = userAvatarUrl, + avatarData = avatarData, + ) + } + + private fun updateVisibleRange(range: IntRange) { + if (range.isEmpty()) return + val midExtendedRangeSize = extendedRangeSize / 2 + val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0) + // Safe to give bigger size than room list + val extendedRangeEnd = range.last + midExtendedRangeSize + val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) + client.roomSummaryDataSource().setSlidingSyncRange(extendedRange) + } + + private suspend fun mapRoomSummaries( + roomSummaries: List + ): List { + return roomSummaries.parallelMap { roomSummary -> + when (roomSummary) { + is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) + is RoomSummary.Filled -> { + val avatarData = loadAvatarData( + roomSummary.details.name, + roomSummary.details.avatarURLString + ) + RoomListRoomSummary( + id = roomSummary.identifier(), + name = roomSummary.details.name, + hasUnread = roomSummary.details.unreadNotificationCount > 0, + timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage, + avatarData = avatarData, + ) + } + } + } + } + + private suspend fun loadAvatarData( + name: String, + url: String?, + size: AvatarSize = AvatarSize.MEDIUM + ): AvatarData { + val model = client.mediaResolver() + .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value)) + return AvatarData(name, model, size) + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt similarity index 79% rename from features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt rename to features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt index 3695a80c48..97d849415c 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt @@ -21,7 +21,6 @@ package io.element.android.x.features.roomlist import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,48 +36,49 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Velocity -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.compose.LogCompositions import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.features.roomlist.components.RoomListTopBar import io.element.android.x.features.roomlist.components.RoomSummaryRow +import io.element.android.x.features.roomlist.model.RoomListEvents import io.element.android.x.features.roomlist.model.RoomListRoomSummary -import io.element.android.x.features.roomlist.model.RoomListViewState +import io.element.android.x.features.roomlist.model.RoomListState import io.element.android.x.features.roomlist.model.stubbedRoomSummaries import io.element.android.x.matrix.core.RoomId import io.element.android.x.matrix.core.UserId import io.element.android.x.matrix.ui.model.MatrixUser -import io.element.android.x.matrix.ui.viewmodels.user.UserViewModel -import io.element.android.x.matrix.ui.viewmodels.user.UserViewState import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList @Composable -fun RoomListScreen( - viewModel: RoomListViewModel = mavericksViewModel(), - userViewModel: UserViewModel = mavericksViewModel(), - onRoomClicked: (RoomId) -> Unit = { }, - onOpenSettings: () -> Unit = { }, +fun RoomListView( + state: RoomListState, + modifier: Modifier = Modifier, + onRoomClicked: (RoomId) -> Unit = {}, + onOpenSettings: () -> Unit = {}, ) { - val filter by viewModel.collectAsState(RoomListViewState::filter) - LogCompositions(tag = "RoomListScreen", msg = "Root") - val roomSummaries by viewModel.collectAsState(RoomListViewState::rooms) - val matrixUser by userViewModel.collectAsState(UserViewState::user) - RoomListContent( - roomSummaries = roomSummaries().orEmpty().toImmutableList(), - matrixUser = matrixUser(), + fun onFilterChanged(filter: String) { + state.eventSink(RoomListEvents.UpdateFilter(filter)) + } + + fun onVisibleRangedChanged(range: IntRange) { + state.eventSink(RoomListEvents.UpdateVisibleRange(range)) + } + + RoomListView( + roomSummaries = state.roomList, + matrixUser = state.matrixUser, + filter = state.filter, + modifier = modifier, onRoomClicked = onRoomClicked, + onFilterChanged = ::onFilterChanged, onOpenSettings = onOpenSettings, - filter = filter, - onFilterChanged = viewModel::filterRoom, - onScrollOver = viewModel::updateVisibleRange + onScrollOver = ::onVisibleRangedChanged, ) } @Composable -fun RoomListContent( +fun RoomListView( roomSummaries: ImmutableList, matrixUser: MatrixUser?, filter: String, @@ -148,15 +148,11 @@ fun RoomListContent( private fun RoomListRoomSummary.contentType() = isPlaceholder -private fun LazyListState.isScrolled(): Boolean { - return firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 -} - @Preview @Composable -fun PreviewableRoomListContent() { +fun PreviewableRoomListView() { ElementXTheme(darkTheme = false) { - RoomListContent( + RoomListView( roomSummaries = stubbedRoomSummaries(), matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("U")), onRoomClicked = {}, @@ -169,9 +165,9 @@ fun PreviewableRoomListContent() { @Preview @Composable -fun PreviewableDarkRoomListContent() { +fun PreviewableDarkRoomListView() { ElementXTheme(darkTheme = true) { - RoomListContent( + RoomListView( roomSummaries = stubbedRoomSummaries(), matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("U")), onRoomClicked = {}, diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt deleted file mode 100644 index e1fdb5cfa6..0000000000 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.roomlist - -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Success -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.coroutine.parallelMap -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.designsystem.components.avatar.AvatarSize -import io.element.android.x.di.SessionScope -import io.element.android.x.features.roomlist.model.RoomListRoomSummary -import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders -import io.element.android.x.features.roomlist.model.RoomListViewState -import io.element.android.x.matrix.MatrixClient -import io.element.android.x.matrix.room.RoomSummary -import io.element.android.x.matrix.ui.MatrixItemHelper -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private const val extendedRangeSize = 40 - -@ContributesViewModel(SessionScope::class) -class RoomListViewModel @AssistedInject constructor( - private val client: MatrixClient, - @Assisted initialState: RoomListViewState -) : MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - private val lastMessageFormatter = LastMessageFormatter() - private val matrixUserHelper = MatrixItemHelper(client) - - init { - handleInit() - } - - fun filterRoom(filter: String) { - setState { - copy( - filter = filter - ) - } - } - - fun updateVisibleRange(range: IntRange) { - viewModelScope.launch { - if (range.isEmpty()) return@launch - val midExtendedRangeSize = extendedRangeSize / 2 - val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0) - // Safe to give bigger size than room list - val extendedRangeEnd = range.last + midExtendedRangeSize - val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) - client.roomSummaryDataSource().setSlidingSyncRange(extendedRange) - } - } - - private fun handleInit() { - // Observe the room list and the filter - combine( - client.roomSummaryDataSource().roomSummaries() - .map(::mapRoomSummaries) - .flowOn(Dispatchers.Default), - stateFlow - .map { it.filter } - .distinctUntilChanged(), - ) { list, filter -> - if (filter.isEmpty()) { - list - } else { - list.filter { it.name.contains(filter, ignoreCase = true) } - } - } - .execute { - copy( - rooms = when { - it is Loading || - // Note: this second case will prevent to handle correctly the empty case - (it is Success && it().isEmpty() && filter.isEmpty()) -> { - // Show fake placeholders to avoid having empty screen - Loading(RoomListRoomSummaryPlaceholders.createFakeList(size = 16)) - } - else -> { - it - } - } - ) - } - } - - private suspend fun mapRoomSummaries( - roomSummaries: List - ): List { - return roomSummaries.parallelMap { roomSummary -> - when (roomSummary) { - is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) - is RoomSummary.Filled -> { - val avatarData = matrixUserHelper.loadAvatarData( - roomSummary = roomSummary, - size = AvatarSize.MEDIUM - ) - RoomListRoomSummary( - id = roomSummary.identifier(), - name = roomSummary.details.name, - hasUnread = roomSummary.details.unreadNotificationCount > 0, - timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), - lastMessage = roomSummary.details.lastMessage, - avatarData = avatarData, - ) - } - } - } - } -} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListEvents.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListEvents.kt new file mode 100644 index 0000000000..e4cbf4430b --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.features.roomlist.model + +sealed interface RoomListEvents { + data class UpdateFilter(val newFilter: String) : RoomListEvents + data class UpdateVisibleRange(val range: IntRange) : RoomListEvents +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt index 2f17cc0b25..4bac53544c 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListRoomSummary.kt @@ -16,11 +16,11 @@ package io.element.android.x.features.roomlist.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.matrix.core.RoomId -@Stable +@Immutable data class RoomListRoomSummary( val id: String, val roomId: RoomId = RoomId(id), diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewState.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListState.kt similarity index 60% rename from libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewState.kt rename to features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListState.kt index 60d7c5bd48..063f05bc5e 100644 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewState.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,17 @@ * limitations under the License. */ -package io.element.android.x.matrix.ui.viewmodels.user +package io.element.android.x.features.roomlist.model -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized +import androidx.compose.runtime.Immutable import io.element.android.x.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList -data class UserViewState( - val user: Async = Uninitialized, -) : MavericksState +@Immutable +data class RoomListState( + val matrixUser: MatrixUser?, + val roomList: ImmutableList, + val filter: String, + val isLoginOut: Boolean, + val eventSink: (RoomListEvents) -> Unit = {} +) diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt deleted file mode 100644 index 4fb02b054c..0000000000 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.features.roomlist.model - -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized -import io.element.android.x.matrix.core.RoomId - -data class RoomListViewState( - // Will contain the filtered rooms, using ::filter (if filter is not empty) - val rooms: Async> = Uninitialized, - val filter: String = "", - val canLoadMore: Boolean = false, - val roomsById: Map = emptyMap() -) : MavericksState diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1de907595b..0a936ed73c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ android_gradle_plugin = "7.3.1" firebase_gradle_plugin = "3.0.2" kotlin = "1.7.20" ksp = "1.7.20-1.0.7" +molecule = "0.6.1" # AndroidX material = "1.6.1" @@ -16,7 +17,6 @@ constraintlayout = "2.1.4" recyclerview = "1.2.1" lifecycle = "2.5.1" activity_compose = "1.6.1" -fragment = "1.5.5" startup = "1.1.1" # Compose @@ -41,7 +41,6 @@ test_hamcrest = "2.2" test_orchestrator = "1.4.1" #other -mavericks = "3.0.1" coil = "2.2.1" datetime = "0.4.0" wysiwyg = "0.7.0.1" @@ -49,6 +48,7 @@ serialization_json = "1.4.1" showkase = "1.0.0-beta14" compose_destinations = "1.7.23-beta" jsoup = "1.15.3" +appyx = "1.0.1" seismic = "1.0.3" dependencycheck = "7.4.4" @@ -78,9 +78,7 @@ androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt androidx_lifecycle_compose = { module = "androidx.lifecycle:compose", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } -androidx_lifecycle_viewmodel_compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" } -androidx_fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" } androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } @@ -116,7 +114,6 @@ test_hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "test_hamcrest test_orchestrator = { module = "androidx.test:orchestrator", version.ref = "test_orchestrator" } # Others -mavericks_compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" } coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } @@ -126,7 +123,7 @@ compose_destinations_processor = { module = "io.github.raamcosta.compose-destina showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } - +appyx_core = {module = "com.bumble.appyx:core", version.ref = "appyx"} # Di inject = { module = "javax.inject:javax.inject", version = "1" } @@ -150,5 +147,6 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +molecule = {id = "app.cash.molecule", version.ref = "molecule"} dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } diff --git a/libraries/architecture/.gitignore b/libraries/architecture/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/architecture/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/architecture/build.gradle.kts b/libraries/architecture/build.gradle.kts new file mode 100644 index 0000000000..1bae19e00b --- /dev/null +++ b/libraries/architecture/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.molecule) +} + +android { + namespace = "io.element.android.x.libraries.presentation" +} + +dependencies { + api(project(":libraries:core")) + api(libs.dagger) + api(libs.appyx.core) + api(libs.androidx.lifecycle.runtime) +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/AssistedNodeFactory.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/AssistedNodeFactory.kt new file mode 100644 index 0000000000..eb1d416fa7 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/AssistedNodeFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin + +interface AssistedNodeFactory { + fun create(buildContext: BuildContext, plugins: List): NODE +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt new file mode 100644 index 0000000000..9428eda509 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/Async.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable + +@Stable +sealed interface Async { + object Uninitialized : Async + data class Loading(val prevState: T? = null) : Async + data class Failure(val error: Throwable, val prevState: T? = null) : Async + data class Success(val state: T) : Async + + fun dataOrNull(): T? { + return when (this) { + is Failure -> prevState + is Loading -> prevState + is Success -> state + Uninitialized -> null + } + } +} + +suspend fun (suspend () -> T).execute(state: MutableState>) { + try { + state.value = Async.Loading() + state.value = Async.Success(this()) + } catch (error: Throwable) { + state.value = Async.Failure(error) + } +} + +suspend fun (suspend () -> Result).executeResult(state: MutableState>) { + state.value = Async.Loading() + this().fold( + onSuccess = { + state.value = Async.Success(it) + }, + onFailure = { + state.value = Async.Failure(it) + } + ) +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/Bindings.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/Bindings.kt new file mode 100644 index 0000000000..9844a99896 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/Bindings.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import android.content.Context +import android.content.ContextWrapper +import com.bumble.appyx.core.node.Node +import io.element.android.x.core.di.DaggerComponentOwner + +inline fun Node.bindings() = bindings(T::class.java) +inline fun Context.bindings() = bindings(T::class.java) + +fun Context.bindings(klass: Class): T { + // search dagger components in the context hierarchy + return generateSequence(this) { (it as? ContextWrapper)?.baseContext } + .plus(applicationContext) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} + +fun Node.bindings(klass: Class): T { + // search dagger components in node hierarchy + return generateSequence(this, Node::parent) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/NodeFactories.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/NodeFactories.kt new file mode 100644 index 0000000000..c7a82bd481 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/NodeFactories.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin + +inline fun Node.createNode(context: BuildContext, plugins: List = emptyList()): NODE { + val nodeClass = NODE::class.java + val bindings: NodeFactoriesBindings = bindings() + val nodeFactoryMap = bindings.nodeFactories() + val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.") + + @Suppress("UNCHECKED_CAST") + val castedNodeFactory = nodeFactory as? AssistedNodeFactory + val node = castedNodeFactory?.create(context, plugins) + return node as NODE +} + +interface NodeFactoriesBindings { + fun nodeFactories(): Map, AssistedNodeFactory<*>> +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/NodeKey.kt similarity index 74% rename from libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt rename to libraries/architecture/src/main/java/io/element/android/x/architecture/NodeKey.kt index 6e772440fc..59150edf5f 100644 --- a/libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/NodeKey.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.x.core.di +package io.element.android.x.architecture -import com.airbnb.mvrx.MavericksViewModel +import com.bumble.appyx.core.node.Node import dagger.MapKey import kotlin.reflect.KClass @Retention(AnnotationRetention.RUNTIME) -@Target(AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @MapKey -annotation class ViewModelKey(val value: KClass>) +annotation class NodeKey(val value: KClass) diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/Presenter.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/Presenter.kt new file mode 100644 index 0000000000..6d5e7de444 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/Presenter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import androidx.compose.runtime.Composable + +interface Presenter { + @Composable + fun present(): State +} diff --git a/libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt b/libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt new file mode 100644 index 0000000000..61659c7f80 --- /dev/null +++ b/libraries/architecture/src/main/java/io/element/android/x/architecture/PresenterConnector.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.architecture + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import app.cash.molecule.AndroidUiDispatcher +import app.cash.molecule.RecompositionClock +import app.cash.molecule.launchMolecule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +inline fun LifecycleOwner.presenterConnector(presenter: Presenter): LifecyclePresenterConnector = + LifecyclePresenterConnector(lifecycleOwner = this, presenter = presenter) + +class LifecyclePresenterConnector(lifecycleOwner: LifecycleOwner, presenter: Presenter) { + + private val moleculeScope = CoroutineScope(lifecycleOwner.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main) + + val stateFlow: StateFlow = moleculeScope.launchMolecule(RecompositionClock.Immediate) { + presenter.present() + } +} diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index 77d6ab35f8..4004ca1041 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -21,9 +21,3 @@ plugins { android { namespace = "io.element.android.x.core" } - -dependencies { - api(libs.mavericks.compose) - api(libs.dagger) - api(libs.androidx.fragment) -} diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt b/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt deleted file mode 100644 index c5e2f67a1a..0000000000 --- a/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.core.di - -import android.content.Context -import android.content.ContextWrapper -import androidx.fragment.app.Fragment - -/** - * Use this to get the Dagger "Bindings" for your module. Bindings are used if you need to directly interact with a dagger component such as: - * * an inject function: `inject(MyFragment frag)` - * * an explicit getter: `fun myClass(): MyClass` - * - * Anvil will make your Dagger component implement these bindings so that you can call any of these functions on an instance of your component. - * - * [bindings] will walk up the Fragment/Activity hierarchy and check for [DaggerComponentOwner] to see if any of its components implement the - * specified bindings. Most of the time this will "just work" and you don't have to think about it. - * - * For example, if your class has @Inject properties: - * 1) Create an bindings interface such as `YourModuleBindings` - * 1) Add an inject function like `fun inject(yourClass: YourClass)` - * 2) Contribute your interface to the correct component via `@ContributesTo(AppScope::class)`. - * 3) Call bindings().inject(this). - */ -inline fun Context.bindings() = bindings(T::class.java) - -/** - * @see bindings - */ -inline fun Fragment.bindings() = bindings(T::class.java) - -/** Use no-arg extension function instead: [Context.bindings]. */ -fun Context.bindings(klass: Class): T { - // search dagger components in the context hierarchy - return generateSequence(this) { (it as? ContextWrapper)?.baseContext } - .plus(applicationContext) - .filterIsInstance() - .map { it.daggerComponent } - .flatMap { if (it is Collection<*>) it else listOf(it) } - .filterIsInstance(klass) - .firstOrNull() - ?: error("Unable to find bindings for ${klass.name}") -} - -/** Use no-arg extension function instead: [Fragment.bindings]. */ -fun Fragment.bindings(klass: Class): T { - // search dagger components in fragment hierarchy, then fallback to activity and application - return generateSequence(this, Fragment::getParentFragment) - .filterIsInstance() - .map { it.daggerComponent } - .flatMap { if (it is Collection<*>) it else listOf(it) } - .filterIsInstance(klass) - .firstOrNull() - ?: requireActivity().bindings(klass) -} diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt b/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt deleted file mode 100644 index 42f1b5663c..0000000000 --- a/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.core.di - -import com.airbnb.mvrx.FragmentViewModelContext -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.ViewModelContext - -/** - * To connect Mavericks ViewModel creation with Anvil's dependency injection, add the following to your MavericksViewModel. - * - * Example: - * - * @ContributesViewModel(YourScope::class) - * class MyViewModel @AssistedInject constructor( - * @Assisted initialState: MyState, - * …, - * ): MavericksViewModel(...) { - * … - * - * companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - * } - */ - -inline fun , S : MavericksState> daggerMavericksViewModelFactory() = DaggerMavericksViewModelFactory(VM::class.java) - -/** - * A [MavericksViewModelFactory] makes it easy to create instances of a ViewModel - * using its AssistedInject Factory. This class should be implemented by the companion object - * of every ViewModel which uses AssistedInject via [daggerMavericksViewModelFactory]. - * - * @param VM The ViewModel type - * @param S The ViewState type - * @param viewModelClass The [Class] of the ViewModel being requested for creation - * - * This class accesses the map of ViewModel class to [AssistedViewModelFactory]s from the nearest [DaggerComponentOwner] and - * uses it to retrieve the requested ViewModel's factory class. It then creates an instance of this ViewModel - * using the retrieved factory and returns it. - * @see daggerMavericksViewModelFactory - */ -class DaggerMavericksViewModelFactory, S : MavericksState>( - private val viewModelClass: Class -) : MavericksViewModelFactory { - - override fun create(viewModelContext: ViewModelContext, state: S): VM { - val bindings: DaggerMavericksBindings = when (viewModelContext) { - is FragmentViewModelContext -> viewModelContext.fragment.bindings() - else -> viewModelContext.activity.bindings() - } - val viewModelFactoryMap = bindings.viewModelFactories() - val viewModelFactory = viewModelFactoryMap[viewModelClass] ?: error("Cannot find ViewModelFactory for ${viewModelClass.name}.") - - @Suppress("UNCHECKED_CAST") - val castedViewModelFactory = viewModelFactory as? AssistedViewModelFactory - val viewModel = castedViewModelFactory?.create(state) - return viewModel as VM - } -} - -interface DaggerMavericksBindings { - fun viewModelFactories(): Map>, AssistedViewModelFactory<*, *>> -} diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/avatar/AvatarData.kt index 346aeca336..d4fb492901 100644 --- a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/avatar/AvatarData.kt @@ -16,9 +16,9 @@ package io.element.android.x.designsystem.components.avatar -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class AvatarData( val name: String = "", val model: ByteArray? = null, diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt index 6fca5f5504..8fd26193a3 100644 --- a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/preferences/PreferenceScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.unit.sp @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PreferenceScreen( +fun PreferenceView( title: String, modifier: Modifier = Modifier, onBackPressed: () -> Unit = {}, @@ -113,7 +113,7 @@ fun PreferenceTopAppBar( @Composable @Preview(showBackground = false) fun PreferenceScreenPreview() { - PreferenceScreen( + PreferenceView( title = "Preference screen" ) { PreferenceCategoryPreview() diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt b/libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt similarity index 78% rename from features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt rename to libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt index e728e8345c..bcdb76c31d 100644 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingViewState.kt +++ b/libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt @@ -14,10 +14,6 @@ * limitations under the License. */ -package io.element.android.x.features.onboarding +package io.element.android.x.di -import com.airbnb.mvrx.MavericksState - -data class OnBoardingViewState( - val currentPage: Int = 0, -) : MavericksState +abstract class RoomScope private constructor() diff --git a/libraries/matrix/build.gradle.kts b/libraries/matrix/build.gradle.kts index 2a12b764bf..baba67dfdd 100644 --- a/libraries/matrix/build.gradle.kts +++ b/libraries/matrix/build.gradle.kts @@ -33,6 +33,7 @@ anvil { dependencies { api(project(":libraries:rustsdk")) implementation(project(":libraries:di")) + implementation(libs.dagger) implementation(project(":libraries:core")) implementation("net.java.dev.jna:jna:5.12.1@aar") implementation(libs.androidx.datastore.preferences) diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt index e2771051d0..71c649e13e 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt @@ -21,32 +21,27 @@ import io.element.android.x.core.coroutine.CoroutineDispatchers import io.element.android.x.di.AppScope import io.element.android.x.di.ApplicationContext import io.element.android.x.di.SingleIn +import io.element.android.x.matrix.core.SessionId import io.element.android.x.matrix.session.SessionStore +import io.element.android.x.matrix.session.sessionId import io.element.android.x.matrix.util.logError -import java.io.File -import java.util.concurrent.Executors -import javax.inject.Inject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.AuthenticationService import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder import timber.log.Timber +import java.io.File +import javax.inject.Inject @SingleIn(AppScope::class) class Matrix @Inject constructor( private val coroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, @ApplicationContext context: Context, ) { - private val coroutineDispatchers = CoroutineDispatchers( - io = Dispatchers.IO, - computation = Dispatchers.Default, - main = Dispatchers.Main, - diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - ) + private val baseDirectory = File(context.filesDir, "sessions") private val sessionStore = SessionStore(context) private val authService = AuthenticationService(baseDirectory.absolutePath) @@ -55,6 +50,10 @@ class Matrix @Inject constructor( return sessionStore.isLoggedIn() } + suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { + sessionStore.getLatestSession()?.sessionId() + } + suspend fun restoreSession() = withContext(coroutineDispatchers.io) { sessionStore.getLatestSession() ?.let { session -> @@ -84,7 +83,7 @@ class Matrix @Inject constructor( } } - suspend fun login(username: String, password: String): MatrixClient = + suspend fun login(username: String, password: String): SessionId = withContext(coroutineDispatchers.io) { val client = try { authService.login(username, password, "ElementX Android", null) @@ -92,12 +91,13 @@ class Matrix @Inject constructor( Timber.e(failure, "Fail login") throw failure } - sessionStore.storeData(client.session()) - createMatrixClient(client) + val session = client.session() + sessionStore.storeData(session) + session.sessionId() } private fun createMatrixClient(client: Client): MatrixClient { - return MatrixClient( + return RustMatrixClient( client = client, sessionStore = sessionStore, coroutineScope = coroutineScope, diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt index 55dd355678..d0d446909b 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/MatrixClient.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,185 +16,30 @@ package io.element.android.x.matrix -import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.core.RoomId +import io.element.android.x.matrix.core.SessionId import io.element.android.x.matrix.core.UserId import io.element.android.x.matrix.media.MediaResolver -import io.element.android.x.matrix.media.RustMediaResolver import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.RoomSummaryDataSource -import io.element.android.x.matrix.room.RustRoomSummaryDataSource -import io.element.android.x.matrix.session.SessionStore -import io.element.android.x.matrix.sync.SlidingSyncObserverProxy -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.MediaSource -import org.matrix.rustcomponents.sdk.RequiredState -import org.matrix.rustcomponents.sdk.SlidingSyncMode -import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder -import org.matrix.rustcomponents.sdk.StoppableSpawn -import timber.log.Timber import java.io.Closeable -import java.io.File -import java.util.concurrent.atomic.AtomicBoolean -class MatrixClient internal constructor( - private val client: Client, - private val sessionStore: SessionStore, - private val coroutineScope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val baseDirectory: File, -) : Closeable { - - val sessionId: String - get() = "${client.session().userId}_${client.session().deviceId}" - - private val clientDelegate = object : ClientDelegate { - override fun didReceiveAuthError(isSoftLogout: Boolean) { - Timber.v("didReceiveAuthError()") - } - - override fun didReceiveSyncUpdate() { - Timber.v("didReceiveSyncUpdate()") - } - - override fun didUpdateRestoreToken() { - Timber.v("didUpdateRestoreToken()") - } - } - - private val slidingSyncView = SlidingSyncViewBuilder() - .timelineLimit(limit = 10u) - .requiredState( - requiredState = listOf( - RequiredState(key = "m.room.avatar", value = ""), - RequiredState(key = "m.room.encryption", value = ""), - ) - ) - .name(name = "HomeScreenView") - .syncMode(mode = SlidingSyncMode.SELECTIVE) - .addRange(0u, 30u) - .build() - - private val slidingSync = client - .slidingSync() - .homeserver("https://slidingsync.lab.element.dev") - .withCommonExtensions() - // .coldCache("ElementX") - .addView(slidingSyncView) - .build() - - private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope, dispatchers) - private val roomSummaryDataSource: RustRoomSummaryDataSource = - RustRoomSummaryDataSource( - slidingSyncObserverProxy.updateSummaryFlow, - slidingSync, - slidingSyncView, - dispatchers, - ::onRestartSync - ) - private var slidingSyncObserverToken: StoppableSpawn? = null - - private val mediaResolver = RustMediaResolver(this) - private val isSyncing = AtomicBoolean(false) - - init { - client.setDelegate(clientDelegate) - } - - private fun onRestartSync() { - slidingSyncObserverToken = slidingSync.sync() - } - - fun getRoom(roomId: String): MatrixRoom? { - val slidingSyncRoom = slidingSync.getRoom(roomId) ?: return null - val room = slidingSyncRoom.fullRoom() ?: return null - return MatrixRoom( - slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow, - slidingSyncRoom = slidingSyncRoom, - room = room, - coroutineScope = coroutineScope, - coroutineDispatchers = dispatchers - ) - } - - fun startSync() { - if (isSyncing.compareAndSet(false, true)) { - roomSummaryDataSource.startSync() - slidingSync.setObserver(slidingSyncObserverProxy) - slidingSyncObserverToken = slidingSync.sync() - } - } - - fun stopSync() { - if (isSyncing.compareAndSet(true, false)) { - roomSummaryDataSource.stopSync() - slidingSync.setObserver(null) - slidingSyncObserverToken?.cancel() - } - } - - fun roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource - - fun mediaResolver(): MediaResolver = mediaResolver - - override fun close() { - stopSync() - roomSummaryDataSource.close() - client.setDelegate(null) - } - - suspend fun logout() = withContext(dispatchers.io) { - close() - try { - client.logout() - } catch (failure: Throwable) { - Timber.e(failure, "Fail to call logout on HS. Still delete local files.") - } - baseDirectory.deleteSessionDirectory(userID = client.userId()) - sessionStore.reset() - } - - fun userId(): UserId = UserId(client.userId()) - - suspend fun loadUserDisplayName(): Result = withContext(dispatchers.io) { - runCatching { - client.displayName() - } - } - - suspend fun loadUserAvatarURLString(): Result = withContext(dispatchers.io) { - runCatching { - client.avatarUrl() - } - } - - @OptIn(ExperimentalUnsignedTypes::class) - suspend fun loadMediaContentForSource(source: MediaSource): Result = - withContext(dispatchers.io) { - runCatching { - client.getMediaContent(source).toUByteArray().toByteArray() - } - } - - @OptIn(ExperimentalUnsignedTypes::class) +interface MatrixClient : Closeable { + val sessionId: SessionId + fun getRoom(roomId: RoomId): MatrixRoom? + fun startSync() + fun stopSync() + fun roomSummaryDataSource(): RoomSummaryDataSource + fun mediaResolver(): MediaResolver + suspend fun logout() + fun userId(): UserId + suspend fun loadUserDisplayName(): Result + suspend fun loadUserAvatarURLString(): Result + suspend fun loadMediaContentForSource(source: MediaSource): Result suspend fun loadMediaThumbnailForSource( source: MediaSource, width: Long, height: Long - ): Result = - withContext(dispatchers.io) { - runCatching { - client.getMediaThumbnail(source, width.toULong(), height.toULong()).toUByteArray() - .toByteArray() - } - } - - private fun File.deleteSessionDirectory(userID: String): Boolean { - // Rust sanitises the user ID replacing invalid characters with an _ - val sanitisedUserID = userID.replace(":", "_") - val sessionDirectory = File(this, sanitisedUserID) - return sessionDirectory.deleteRecursively() - } + ): Result } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/RustMatrixClient.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/RustMatrixClient.kt new file mode 100644 index 0000000000..433b23df04 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/RustMatrixClient.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix + +import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.core.RoomId +import io.element.android.x.matrix.core.SessionId +import io.element.android.x.matrix.core.UserId +import io.element.android.x.matrix.media.MediaResolver +import io.element.android.x.matrix.media.RustMediaResolver +import io.element.android.x.matrix.room.MatrixRoom +import io.element.android.x.matrix.room.RoomSummaryDataSource +import io.element.android.x.matrix.room.RustMatrixRoom +import io.element.android.x.matrix.room.RustRoomSummaryDataSource +import io.element.android.x.matrix.session.SessionStore +import io.element.android.x.matrix.session.sessionId +import io.element.android.x.matrix.sync.SlidingSyncObserverProxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.MediaSource +import org.matrix.rustcomponents.sdk.RequiredState +import org.matrix.rustcomponents.sdk.SlidingSyncMode +import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder +import org.matrix.rustcomponents.sdk.StoppableSpawn +import timber.log.Timber +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal class RustMatrixClient internal constructor( + private val client: Client, + private val sessionStore: SessionStore, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + private val baseDirectory: File, +) : MatrixClient { + + override val sessionId: SessionId = client.session().sessionId() + + private val clientDelegate = object : ClientDelegate { + override fun didReceiveAuthError(isSoftLogout: Boolean) { + Timber.v("didReceiveAuthError()") + } + + override fun didReceiveSyncUpdate() { + Timber.v("didReceiveSyncUpdate()") + } + + override fun didUpdateRestoreToken() { + Timber.v("didUpdateRestoreToken()") + } + } + + private val slidingSyncView = SlidingSyncViewBuilder() + .timelineLimit(limit = 10u) + .requiredState( + requiredState = listOf( + RequiredState(key = "m.room.avatar", value = ""), + RequiredState(key = "m.room.encryption", value = ""), + ) + ) + .name(name = "HomeScreenView") + .syncMode(mode = SlidingSyncMode.SELECTIVE) + .addRange(0u, 30u) + .build() + + private val slidingSync = client + .slidingSync() + .homeserver("https://slidingsync.lab.element.dev") + .withCommonExtensions() + // .coldCache("ElementX") + .addView(slidingSyncView) + .build() + + private val slidingSyncObserverProxy = SlidingSyncObserverProxy(coroutineScope, dispatchers) + private val roomSummaryDataSource: RustRoomSummaryDataSource = + RustRoomSummaryDataSource( + slidingSyncObserverProxy.updateSummaryFlow, + slidingSync, + slidingSyncView, + dispatchers, + ::onRestartSync + ) + private var slidingSyncObserverToken: StoppableSpawn? = null + + private val mediaResolver = RustMediaResolver(this) + private val isSyncing = AtomicBoolean(false) + + init { + client.setDelegate(clientDelegate) + } + + private fun onRestartSync() { + slidingSyncObserverToken = slidingSync.sync() + } + + override fun getRoom(roomId: RoomId): MatrixRoom? { + val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null + val room = slidingSyncRoom.fullRoom() ?: return null + return RustMatrixRoom( + slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow, + slidingSyncRoom = slidingSyncRoom, + room = room, + coroutineScope = coroutineScope, + coroutineDispatchers = dispatchers + ) + } + + override fun startSync() { + if (isSyncing.compareAndSet(false, true)) { + roomSummaryDataSource.startSync() + slidingSync.setObserver(slidingSyncObserverProxy) + slidingSyncObserverToken = slidingSync.sync() + } + } + + override fun stopSync() { + if (isSyncing.compareAndSet(true, false)) { + roomSummaryDataSource.stopSync() + slidingSync.setObserver(null) + slidingSyncObserverToken?.cancel() + } + } + + override fun roomSummaryDataSource(): RoomSummaryDataSource = roomSummaryDataSource + + override fun mediaResolver(): MediaResolver = mediaResolver + + override fun close() { + stopSync() + roomSummaryDataSource.close() + client.setDelegate(null) + } + + override suspend fun logout() = withContext(dispatchers.io) { + close() + try { + client.logout() + } catch (failure: Throwable) { + Timber.e(failure, "Fail to call logout on HS. Still delete local files.") + } + baseDirectory.deleteSessionDirectory(userID = client.userId()) + sessionStore.reset() + } + + override fun userId(): UserId = UserId(client.userId()) + + override suspend fun loadUserDisplayName(): Result = withContext(dispatchers.io) { + runCatching { + client.displayName() + } + } + + override suspend fun loadUserAvatarURLString(): Result = withContext(dispatchers.io) { + runCatching { + client.avatarUrl() + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun loadMediaContentForSource(source: MediaSource): Result = + withContext(dispatchers.io) { + runCatching { + client.getMediaContent(source).toUByteArray().toByteArray() + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun loadMediaThumbnailForSource( + source: MediaSource, + width: Long, + height: Long + ): Result = + withContext(dispatchers.io) { + runCatching { + client.getMediaThumbnail(source, width.toULong(), height.toULong()).toUByteArray() + .toByteArray() + } + } + + private fun File.deleteSessionDirectory(userID: String): Boolean { + // Rust sanitises the user ID replacing invalid characters with an _ + val sanitisedUserID = userID.replace(":", "_") + val sessionDirectory = File(this, sanitisedUserID) + return sessionDirectory.deleteRecursively() + } +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/core/SessionId.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/core/SessionId.kt new file mode 100644 index 0000000000..d75f4c15a7 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/core/SessionId.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.core + +import java.io.Serializable + +@JvmInline +value class SessionId(val value: String) : Serializable diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt index df86749a15..f2ecdb2b70 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,117 +16,32 @@ package io.element.android.x.matrix.room -import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.core.EventId import io.element.android.x.matrix.core.RoomId import io.element.android.x.matrix.timeline.MatrixTimeline -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Room -import org.matrix.rustcomponents.sdk.SlidingSyncRoom -import org.matrix.rustcomponents.sdk.UpdateSummary -import org.matrix.rustcomponents.sdk.genTransactionId -import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown - -class MatrixRoom( - private val slidingSyncUpdateFlow: Flow, - private val slidingSyncRoom: SlidingSyncRoom, - private val room: Room, - private val coroutineScope: CoroutineScope, - private val coroutineDispatchers: CoroutineDispatchers, -) { - - fun syncUpdateFlow(): Flow { - return slidingSyncUpdateFlow - .filter { - it.rooms.contains(room.id()) - } - .map { } - .onStart { emit(Unit) } - } - - fun timeline(): MatrixTimeline { - return MatrixTimeline( - matrixRoom = this, - room = room, - slidingSyncRoom = slidingSyncRoom, - coroutineScope = coroutineScope, - coroutineDispatchers = coroutineDispatchers - ) - } - - val roomId = RoomId(room.id()) +interface MatrixRoom { + val roomId: RoomId val name: String? - get() { - return slidingSyncRoom.name() - } - val bestName: String - get() { - return name?.takeIf { it.isNotEmpty() } ?: room.id() - } - val displayName: String - get() { - return room.displayName() - } - val topic: String? - get() { - return room.topic() - } - val avatarUrl: String? - get() { - return room.avatarUrl() - } - suspend fun userDisplayName(userId: String): Result = - withContext(coroutineDispatchers.io) { - runCatching { - room.memberDisplayName(userId) - } - } + fun syncUpdateFlow(): Flow - suspend fun userAvatarUrl(userId: String): Result = - withContext(coroutineDispatchers.io) { - runCatching { - room.memberAvatarUrl(userId) - } - } + fun timeline(): MatrixTimeline - suspend fun sendMessage(message: String): Result = withContext(coroutineDispatchers.io) { - val transactionId = genTransactionId() - val content = messageEventContentFromMarkdown(message) - runCatching { - room.send(content, transactionId) - } - } + suspend fun userDisplayName(userId: String): Result - suspend fun editMessage(originalEventId: String, message: String): Result = withContext(coroutineDispatchers.io) { - val transactionId = genTransactionId() - // val content = messageEventContentFromMarkdown(message) - runCatching { - room.edit(/* TODO use content */ message, originalEventId, transactionId) - } - } + suspend fun userAvatarUrl(userId: String): Result - suspend fun replyMessage(eventId: String, message: String): Result = withContext(coroutineDispatchers.io) { - val transactionId = genTransactionId() - // val content = messageEventContentFromMarkdown(message) - runCatching { - room.sendReply(/* TODO use content */ message, eventId, transactionId) - } - } + suspend fun sendMessage(message: String): Result - suspend fun redactEvent(eventId: String, reason: String? = null) = withContext(coroutineDispatchers.io) { - val transactionId = genTransactionId() - runCatching { - room.redact(eventId, reason, transactionId) - } - } + suspend fun editMessage(originalEventId: EventId, message: String): Result + + suspend fun replyMessage(eventId: EventId, message: String): Result + + suspend fun redactEvent(eventId: EventId, reason: String? = null): Result } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RustMatrixRoom.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RustMatrixRoom.kt new file mode 100644 index 0000000000..7d7c2414fb --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/RustMatrixRoom.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.room + +import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.core.EventId +import io.element.android.x.matrix.core.RoomId +import io.element.android.x.matrix.timeline.MatrixTimeline +import io.element.android.x.matrix.timeline.RustMatrixTimeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.SlidingSyncRoom +import org.matrix.rustcomponents.sdk.UpdateSummary +import org.matrix.rustcomponents.sdk.genTransactionId +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown + +class RustMatrixRoom( + private val slidingSyncUpdateFlow: Flow, + private val slidingSyncRoom: SlidingSyncRoom, + private val room: Room, + private val coroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, +) : MatrixRoom { + + override fun syncUpdateFlow(): Flow { + return slidingSyncUpdateFlow + .filter { + it.rooms.contains(room.id()) + } + .map { + System.currentTimeMillis() + } + .onStart { emit(System.currentTimeMillis()) } + } + + override fun timeline(): MatrixTimeline { + return RustMatrixTimeline( + matrixRoom = this, + room = room, + slidingSyncRoom = slidingSyncRoom, + coroutineScope = coroutineScope, + coroutineDispatchers = coroutineDispatchers + ) + } + + override val roomId = RoomId(room.id()) + + override val name: String? + get() { + return slidingSyncRoom.name() + } + + override val bestName: String + get() { + return name?.takeIf { it.isNotEmpty() } ?: room.id() + } + + override val displayName: String + get() { + return room.displayName() + } + + override val topic: String? + get() { + return room.topic() + } + + override val avatarUrl: String? + get() { + return room.avatarUrl() + } + + override suspend fun userDisplayName(userId: String): Result = + withContext(coroutineDispatchers.io) { + runCatching { + room.memberDisplayName(userId) + } + } + + override suspend fun userAvatarUrl(userId: String): Result = + withContext(coroutineDispatchers.io) { + runCatching { + room.memberAvatarUrl(userId) + } + } + + override suspend fun sendMessage(message: String): Result = withContext(coroutineDispatchers.io) { + val transactionId = genTransactionId() + val content = messageEventContentFromMarkdown(message) + runCatching { + room.send(content, transactionId) + } + } + + override suspend fun editMessage(originalEventId: EventId, message: String): Result = withContext(coroutineDispatchers.io) { + val transactionId = genTransactionId() + // val content = messageEventContentFromMarkdown(message) + runCatching { + room.edit(/* TODO use content */ message, originalEventId.value, transactionId) + } + } + + override suspend fun replyMessage(eventId: EventId, message: String): Result = withContext(coroutineDispatchers.io) { + val transactionId = genTransactionId() + // val content = messageEventContentFromMarkdown(message) + runCatching { + room.sendReply(/* TODO use content */ message, eventId.value, transactionId) + } + } + + override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(coroutineDispatchers.io) { + val transactionId = genTransactionId() + runCatching { + room.redact(eventId.value, reason, transactionId) + } + } +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/session/Session.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/session/Session.kt new file mode 100644 index 0000000000..53b69ce975 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/session/Session.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.session + +import io.element.android.x.matrix.core.SessionId +import org.matrix.rustcomponents.sdk.Session + +fun Session.sessionId() = SessionId("${userId}_${deviceId}") diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt index 6bd1031ba7..2f383cebf2 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,149 +16,31 @@ package io.element.android.x.matrix.timeline -import io.element.android.x.core.coroutine.CoroutineDispatchers -import io.element.android.x.matrix.room.MatrixRoom -import java.util.Collections -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview +import io.element.android.x.matrix.core.EventId import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.sample -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.PaginationOutcome -import org.matrix.rustcomponents.sdk.Room -import org.matrix.rustcomponents.sdk.SlidingSyncRoom -import org.matrix.rustcomponents.sdk.TimelineChange -import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineListener -import timber.log.Timber -class MatrixTimeline( - private val matrixRoom: MatrixRoom, - private val room: Room, - private val slidingSyncRoom: SlidingSyncRoom, - private val coroutineScope: CoroutineScope, - private val coroutineDispatchers: CoroutineDispatchers, -) : TimelineListener { +interface MatrixTimeline { + var callback: Callback? + val hasMoreToLoad: Boolean interface Callback { fun onUpdatedTimelineItem(timelineItem: MatrixTimelineItem) = Unit fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) = Unit } - var callback: Callback? = null - - private val paginationOutcome = MutableStateFlow(PaginationOutcome(true)) - private val timelineItems: MutableStateFlow> = - MutableStateFlow(emptyList()) - - @OptIn(FlowPreview::class) - fun timelineItems(): Flow> { - return timelineItems.sample(50) - } - - val hasMoreToLoad: Boolean - get() { - return paginationOutcome.value.moreMessages - } - - private fun MutableList.applyDiff(diff: TimelineDiff) { - when (diff.change()) { - TimelineChange.PUSH -> { - Timber.v("Apply push on list with size: $size") - val item = diff.push()?.asMatrixTimelineItem() ?: return - callback?.onPushedTimelineItem(item) - add(item) - } - TimelineChange.UPDATE_AT -> { - val updateAtData = diff.updateAt() ?: return - Timber.v("Apply $updateAtData on list with size: $size") - val item = updateAtData.item.asMatrixTimelineItem() - callback?.onUpdatedTimelineItem(item) - set(updateAtData.index.toInt(), item) - } - TimelineChange.INSERT_AT -> { - val insertAtData = diff.insertAt() ?: return - Timber.v("Apply $insertAtData on list with size: $size") - val item = insertAtData.item.asMatrixTimelineItem() - add(insertAtData.index.toInt(), item) - } - TimelineChange.MOVE -> { - val moveData = diff.move() ?: return - Timber.v("Apply $moveData on list with size: $size") - Collections.swap(this, moveData.oldIndex.toInt(), moveData.newIndex.toInt()) - } - TimelineChange.REMOVE_AT -> { - val removeAtData = diff.removeAt() ?: return - Timber.v("Apply $removeAtData on list with size: $size") - removeAt(removeAtData.toInt()) - } - TimelineChange.REPLACE -> { - Timber.v("Apply REPLACE on list with size: $size") - clear() - val items = diff.replace()?.map { it.asMatrixTimelineItem() } ?: return - addAll(items) - } - TimelineChange.POP -> { - Timber.v("Apply POP on list with size: $size") - removeLast() - } - TimelineChange.CLEAR -> { - Timber.v("Apply CLEAR on list with size: $size") - clear() - } - } - } - - suspend fun paginateBackwards(count: Int): Result = withContext(coroutineDispatchers.io) { - if (!paginationOutcome.value.moreMessages) { - return@withContext Result.failure(IllegalStateException("no more message")) - } - runCatching { - paginationOutcome.value = room.paginateBackwards(count.toUShort()) - } - } - - private suspend fun updateTimelineItems(block: MutableList.() -> Unit) = - withContext(coroutineDispatchers.diffUpdateDispatcher) { - val mutableTimelineItems = timelineItems.value.toMutableList() - block(mutableTimelineItems) - timelineItems.value = mutableTimelineItems - } - - fun addListener(timelineListener: TimelineListener) { - slidingSyncRoom.addTimelineListener(timelineListener) - } - - fun initialize() { - addListener(this) - } - - fun dispose() { - slidingSyncRoom.removeTimeline() - } + fun timelineItems(): Flow> + suspend fun paginateBackwards(count: Int): Result + fun addListener(timelineListener: TimelineListener) + fun initialize() + fun dispose() /** * @param message markdown message */ - suspend fun sendMessage(message: String): Result { - return matrixRoom.sendMessage(message) - } + suspend fun sendMessage(message: String): Result - suspend fun editMessage(originalEventId: String, message: String): Result { - return matrixRoom.editMessage(originalEventId, message = message) - } + suspend fun editMessage(originalEventId: EventId, message: String): Result - suspend fun replyMessage(inReplyToEventId: String, message: String): Result { - return matrixRoom.replyMessage(inReplyToEventId, message) - } - - override fun onUpdate(update: TimelineDiff) { - coroutineScope.launch { - updateTimelineItems { - applyDiff(update) - } - } - } + suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result } diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/RustMatrixTimeline.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/RustMatrixTimeline.kt new file mode 100644 index 0000000000..ecfce4af7a --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/RustMatrixTimeline.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.timeline + +import io.element.android.x.core.coroutine.CoroutineDispatchers +import io.element.android.x.matrix.core.EventId +import io.element.android.x.matrix.room.RustMatrixRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.PaginationOutcome +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.SlidingSyncRoom +import org.matrix.rustcomponents.sdk.TimelineChange +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineListener +import timber.log.Timber +import java.util.Collections + +class RustMatrixTimeline( + private val matrixRoom: RustMatrixRoom, + private val room: Room, + private val slidingSyncRoom: SlidingSyncRoom, + private val coroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, +) : TimelineListener, MatrixTimeline { + + override var callback: MatrixTimeline.Callback? = null + + private val paginationOutcome = MutableStateFlow(PaginationOutcome(true)) + private val timelineItems: MutableStateFlow> = + MutableStateFlow(emptyList()) + + @OptIn(FlowPreview::class) + override fun timelineItems(): Flow> { + return timelineItems.sample(50) + } + + override val hasMoreToLoad: Boolean + get() { + return paginationOutcome.value.moreMessages + } + + private fun MutableList.applyDiff(diff: TimelineDiff) { + when (diff.change()) { + TimelineChange.PUSH -> { + Timber.v("Apply push on list with size: $size") + val item = diff.push()?.asMatrixTimelineItem() ?: return + callback?.onPushedTimelineItem(item) + add(item) + } + TimelineChange.UPDATE_AT -> { + val updateAtData = diff.updateAt() ?: return + Timber.v("Apply $updateAtData on list with size: $size") + val item = updateAtData.item.asMatrixTimelineItem() + callback?.onUpdatedTimelineItem(item) + set(updateAtData.index.toInt(), item) + } + TimelineChange.INSERT_AT -> { + val insertAtData = diff.insertAt() ?: return + Timber.v("Apply $insertAtData on list with size: $size") + val item = insertAtData.item.asMatrixTimelineItem() + add(insertAtData.index.toInt(), item) + } + TimelineChange.MOVE -> { + val moveData = diff.move() ?: return + Timber.v("Apply $moveData on list with size: $size") + Collections.swap(this, moveData.oldIndex.toInt(), moveData.newIndex.toInt()) + } + TimelineChange.REMOVE_AT -> { + val removeAtData = diff.removeAt() ?: return + Timber.v("Apply $removeAtData on list with size: $size") + removeAt(removeAtData.toInt()) + } + TimelineChange.REPLACE -> { + Timber.v("Apply REPLACE on list with size: $size") + clear() + val items = diff.replace()?.map { it.asMatrixTimelineItem() } ?: return + addAll(items) + } + TimelineChange.POP -> { + Timber.v("Apply POP on list with size: $size") + removeLast() + } + TimelineChange.CLEAR -> { + Timber.v("Apply CLEAR on list with size: $size") + clear() + } + } + } + + override suspend fun paginateBackwards(count: Int): Result = withContext(coroutineDispatchers.io) { + if (!paginationOutcome.value.moreMessages) { + return@withContext Result.failure(IllegalStateException("no more message")) + } + runCatching { + paginationOutcome.value = room.paginateBackwards(count.toUShort()) + } + } + + private suspend fun updateTimelineItems(block: MutableList.() -> Unit) = + withContext(coroutineDispatchers.diffUpdateDispatcher) { + val mutableTimelineItems = timelineItems.value.toMutableList() + block(mutableTimelineItems) + timelineItems.value = mutableTimelineItems + } + + override fun addListener(timelineListener: TimelineListener) { + slidingSyncRoom.addTimelineListener(timelineListener) + } + + override fun initialize() { + addListener(this) + } + + override fun dispose() { + slidingSyncRoom.removeTimeline() + } + + /** + * @param message markdown message + */ + override suspend fun sendMessage(message: String): Result { + return matrixRoom.sendMessage(message) + } + + override suspend fun editMessage(originalEventId: EventId, message: String): Result { + return matrixRoom.editMessage(originalEventId, message = message) + } + + override suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result { + return matrixRoom.replyMessage(inReplyToEventId, message) + } + + override fun onUpdate(update: TimelineDiff) { + coroutineScope.launch { + updateTimelineItems { + applyDiff(update) + } + } + } +} diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index ee6f72d447..9e32aea0a8 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(project(":anvilannotations")) anvil(project(":anvilcodegen")) implementation(project(":libraries:di")) + implementation(project(":libraries:architecture")) implementation(project(":libraries:matrix")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:core")) diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixItemHelper.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixItemHelper.kt index a040e18585..49a97b3a5f 100644 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixItemHelper.kt +++ b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixItemHelper.kt @@ -26,8 +26,9 @@ import io.element.android.x.matrix.ui.model.MatrixUser import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import javax.inject.Inject -class MatrixItemHelper( +class MatrixItemHelper @Inject constructor( private val client: MatrixClient ) { /** diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixUi.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixUi.kt deleted file mode 100644 index db4de72d7d..0000000000 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/MatrixUi.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.matrix.ui - -import coil.ComponentRegistry -import io.element.android.x.matrix.MatrixClient -import io.element.android.x.matrix.ui.media.MediaFetcher -import io.element.android.x.matrix.ui.media.MediaKeyer -import javax.inject.Inject - -class MatrixUi @Inject constructor() { - - fun registerCoilComponents( - builder: ComponentRegistry.Builder, - activeClientProvider: () -> MatrixClient? - ) { - builder.add(MediaKeyer()) - builder.add(MediaFetcher.Factory(activeClientProvider)) - } -} diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/di/MatrixUIBindings.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/di/MatrixUIBindings.kt new file mode 100644 index 0000000000..557d896cb8 --- /dev/null +++ b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/di/MatrixUIBindings.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.ui.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.x.di.SessionScope +import io.element.android.x.matrix.ui.media.LoggedInImageLoaderFactory +import io.element.android.x.matrix.ui.media.NotLoggedInImageLoaderFactory + +@ContributesTo(SessionScope::class) +interface MatrixUIBindings { + fun loggedInImageLoaderFactory(): LoggedInImageLoaderFactory + fun notLoggedInImageLoaderFactory(): NotLoggedInImageLoaderFactory +} diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/ImageLoaderFactories.kt new file mode 100644 index 0000000000..eecb25f9f2 --- /dev/null +++ b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/ImageLoaderFactories.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.matrix.ui.media + +import android.content.Context +import coil.ImageLoader +import coil.ImageLoaderFactory +import io.element.android.x.di.ApplicationContext +import io.element.android.x.matrix.MatrixClient +import javax.inject.Inject + +class LoggedInImageLoaderFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, +) : ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader + .Builder(context) + .components { + add(MediaKeyer()) + add(MediaFetcher.Factory(matrixClient)) + } + .build() + } +} + +class NotLoggedInImageLoaderFactory @Inject constructor( + @ApplicationContext private val context: Context, +) : ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader + .Builder(context) + .build() + } +} diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/MediaFetcher.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/MediaFetcher.kt index d345f26984..a443f7486d 100644 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/MediaFetcher.kt +++ b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/media/MediaFetcher.kt @@ -37,16 +37,15 @@ internal class MediaFetcher( return imageLoader.components.newFetcher(byteBuffer, options, imageLoader)?.first?.fetch() } - class Factory(private val activeClientProvider: () -> MatrixClient?) : + class Factory(private val client: MatrixClient) : Fetcher.Factory { override fun create( data: MediaResolver.Meta, options: Options, imageLoader: ImageLoader ): Fetcher { - val activeClient = activeClientProvider() return MediaFetcher( - mediaResolver = activeClient?.mediaResolver(), + mediaResolver = client.mediaResolver(), meta = data, options = options, imageLoader = imageLoader diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/model/MatrixUser.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/model/MatrixUser.kt index 5aaa69e6e6..835ea35a7a 100644 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/model/MatrixUser.kt +++ b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/model/MatrixUser.kt @@ -16,11 +16,11 @@ package io.element.android.x.matrix.ui.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.matrix.core.UserId -@Stable +@Immutable data class MatrixUser( val id: UserId, val username: String? = null, diff --git a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewModel.kt b/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewModel.kt deleted file mode 100644 index 9ec53ee912..0000000000 --- a/libraries/matrixui/src/main/java/io/element/android/x/matrix/ui/viewmodels/user/UserViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.matrix.ui.viewmodels.user - -import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.designsystem.components.avatar.AvatarSize -import io.element.android.x.di.SessionScope -import io.element.android.x.matrix.MatrixClient -import io.element.android.x.matrix.ui.MatrixItemHelper - -@ContributesViewModel(SessionScope::class) -class UserViewModel @AssistedInject constructor( - client: MatrixClient, - @Assisted initialState: UserViewState -) : MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - private val matrixUserHelper = MatrixItemHelper(client) - - init { - handleInit() - } - - private fun handleInit() { - matrixUserHelper.getCurrentUserData(avatarSize = AvatarSize.SMALL).execute { - copy(user = it) - } - } -} diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts index 7ef94fcf8e..cda96fb319 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/build.gradle.kts @@ -19,6 +19,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) + id("kotlin-parcelize") } android { @@ -31,6 +32,7 @@ android { dependencies { implementation(project(":libraries:elementresources")) implementation(project(":libraries:core")) + implementation(project(":libraries:matrix")) implementation(libs.wysiwyg) implementation(libs.androidx.constraintlayout) implementation("com.google.android.material:material:1.7.0") diff --git a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt index bf5b556525..715b9c6cd6 100644 --- a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt @@ -16,25 +16,33 @@ package io.element.android.x.textcomposer -sealed interface MessageComposerMode { +import android.os.Parcelable +import io.element.android.x.matrix.core.EventId +import kotlinx.parcelize.Parcelize + +sealed interface MessageComposerMode : Parcelable { + @Parcelize data class Normal(val content: CharSequence?) : MessageComposerMode - sealed class Special(open val eventId: String, open val defaultContent: CharSequence) : + sealed class Special(open val eventId: EventId, open val defaultContent: CharSequence) : MessageComposerMode - data class Edit(override val eventId: String, override val defaultContent: CharSequence) : + @Parcelize + data class Edit(override val eventId: EventId, override val defaultContent: CharSequence) : Special(eventId, defaultContent) - class Quote(override val eventId: String, override val defaultContent: CharSequence) : + @Parcelize + class Quote(override val eventId: EventId, override val defaultContent: CharSequence) : Special(eventId, defaultContent) + @Parcelize class Reply( val senderName: String, - override val eventId: String, + override val eventId: EventId, override val defaultContent: CharSequence ) : Special(eventId, defaultContent) - val relatedEventId: String? + val relatedEventId: EventId? get() = when (this) { is Normal -> null is Edit -> eventId diff --git a/plugins/src/main/java/extension/DependencyHandleScope.kt b/plugins/src/main/java/extension/DependencyHandleScope.kt index 71d94f6ad0..bdfa1460bf 100644 --- a/plugins/src/main/java/extension/DependencyHandleScope.kt +++ b/plugins/src/main/java/extension/DependencyHandleScope.kt @@ -43,7 +43,6 @@ fun DependencyHandlerScope.composeDependencies() { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") implementation("androidx.activity:activity-compose:1.6.1") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1") - implementation("com.airbnb.android:mavericks-compose:3.0.1") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") implementation("com.airbnb.android:showkase:1.0.0-beta14") diff --git a/settings.gradle.kts b/settings.gradle.kts index b6ac63d837..245c0b2402 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,3 +51,4 @@ include(":libraries:designsystem") include(":libraries:di") include(":anvilannotations") include(":anvilcodegen") +include(":libraries:architecture")