Merge pull request #38 from vector-im/feature/fga/navigation
Setup Architecture
This commit is contained in:
@@ -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<*>,
|
||||
)
|
||||
@@ -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<KtFile>): Collection<GeneratedFile> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -29,11 +29,10 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ElementX"
|
||||
tools:targetApi="33">
|
||||
<!-- Note: temporary block orientation to sensorPortrait because the RichTextEditor library is crashing on configuration change -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
@@ -47,7 +46,7 @@
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="remove"/>
|
||||
tools:node="remove" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -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<AppBindings>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppBindings>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MainState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<MainViewModel, MainState> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppBindings>().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<AppBindings>().sessionComponentsOwner()
|
||||
PreferencesScreen(
|
||||
onBackPressed = navigator::navigateUp,
|
||||
onOpenRageShake = {
|
||||
navigator.navigate(BugReportScreenNavigationDestination)
|
||||
},
|
||||
onSuccessLogout = {
|
||||
sessionComponentsOwner.releaseActiveSession()
|
||||
navigator.navigate(OnBoardingScreenNavigationDestination) {
|
||||
popUpTo(RoomListScreenNavigationDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
41
app/src/main/java/io/element/android/x/di/RoomComponent.kt
Normal file
41
app/src/main/java/io/element/android/x/di/RoomComponent.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String, SessionComponent>()
|
||||
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<SessionComponent.ParentBindings>().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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
Coil.setImageLoader(ElementImageLoaderFactory(context))
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
||||
private class ElementImageLoaderFactory(
|
||||
private val context: Context
|
||||
) : ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader
|
||||
.Builder(context)
|
||||
.components {
|
||||
val appBindings = context.bindings<AppBindings>()
|
||||
val matrixUi = appBindings.matrixUi()
|
||||
val matrixClientProvider = {
|
||||
appBindings
|
||||
.sessionComponentsOwner().activeSessionComponent?.matrixClient()
|
||||
}
|
||||
matrixUi.registerCoilComponents(this, matrixClientProvider)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -32,5 +32,5 @@ class MatrixInitializer : Initializer<Unit> {
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
|
||||
}
|
||||
|
||||
129
app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt
Normal file
129
app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt
Normal file
@@ -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<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.RoomList,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<LoggedInFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
), DaggerComponentOwner {
|
||||
|
||||
override val daggerComponent: Any by lazy {
|
||||
parent!!.bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(matrixClient).build()
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
matrixClient.startSync()
|
||||
},
|
||||
onDestroy = {
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().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<RoomListNode>(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)
|
||||
}
|
||||
}
|
||||
@@ -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<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.OnBoarding,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<NotLoggedInFlowNode.NavTarget>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/io/element/android/x/node/RoomFlowNode.kt
Normal file
75
app/src/main/java/io/element/android/x/node/RoomFlowNode.kt
Normal file
@@ -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<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<RoomFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
), DaggerComponentOwner {
|
||||
|
||||
override val daggerComponent: Any by lazy {
|
||||
parent!!.bindings<RoomComponent.ParentBindings>().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<MessagesNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Messages : NavTarget
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
157
app/src/main/java/io/element/android/x/node/RootFlowNode.kt
Normal file
157
app/src/main/java/io/element/android/x/node/RootFlowNode.kt
Normal file
@@ -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<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.SplashScreen,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
private val appComponentOwner: DaggerComponentOwner,
|
||||
private val matrix: Matrix,
|
||||
rootPresenter: RootPresenter
|
||||
) :
|
||||
ParentNode<RootFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext,
|
||||
),
|
||||
|
||||
DaggerComponentOwner by appComponentOwner {
|
||||
|
||||
private val matrixClientsHolder = ConcurrentHashMap<SessionId, MatrixClient>()
|
||||
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<BugReportNode>(buildContext, plugins = listOf(bugReportNodeCallback))
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/src/main/java/io/element/android/x/root/RootEvents.kt
Normal file
21
app/src/main/java/io/element/android/x/root/RootEvents.kt
Normal file
@@ -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
|
||||
}
|
||||
61
app/src/main/java/io/element/android/x/root/RootPresenter.kt
Normal file
61
app/src/main/java/io/element/android/x/root/RootPresenter.kt
Normal file
@@ -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<RootState> {
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/io/element/android/x/root/RootState.kt
Normal file
32
app/src/main/java/io/element/android/x/root/RootState.kt
Normal file
@@ -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
|
||||
)
|
||||
71
app/src/main/java/io/element/android/x/root/RootView.kt
Normal file
71
app/src/main/java/io/element/android/x/root/RootView.kt
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<LoginFlowNode.NavTarget>(
|
||||
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<LoginRootNode>(buildContext, plugins = listOf(loginRootCallback))
|
||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
@@ -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<LoginViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<LoginViewModel, LoginViewState> 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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ChangeServerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ChangeServerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(matrix.getHomeserverOrDefault())
|
||||
}
|
||||
val changeServerAction: MutableState<Async<Unit>> = 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<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
matrix.setHomeserver(homeserver)
|
||||
}.execute(changeServerAction)
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> = Uninitialized,
|
||||
) : MavericksState {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
|
||||
val changeServerAction: Async<Unit> = Async.Uninitialized,
|
||||
val eventSink: (ChangeServerEvents) -> Unit = {},
|
||||
) {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ChangeServerViewState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<ChangeServerViewModel, ChangeServerViewState> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Plugin>,
|
||||
private val presenter: LoginRootPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val presenterConnector = presenterConnector(presenter)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeHomeServer()
|
||||
}
|
||||
|
||||
private fun onChangeHomeServer() {
|
||||
plugins<Callback>().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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<LoginRootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LoginRootState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(matrix.getHomeserverOrDefault())
|
||||
}
|
||||
val loggedInState: MutableState<LoggedInState> = 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<LoggedInState>) = 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<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||
formState.value = updateLambda(formState.value)
|
||||
}
|
||||
|
||||
private fun refreshHomeServer(homeserver: MutableState<String>) {
|
||||
homeserver.value = matrix.getHomeserverOrDefault()
|
||||
}
|
||||
}
|
||||
@@ -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("", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<MatrixClient> = 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("", "")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<LogoutPreferenceState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LogoutPreferenceState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val logoutAction: MutableState<Async<Unit>> = 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<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
matrixClient.logout()
|
||||
}.execute(logoutAction)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> = Uninitialized,
|
||||
) : MavericksState
|
||||
data class LogoutPreferenceState(
|
||||
val logoutAction: Async<Unit> = Async.Uninitialized,
|
||||
val eventSink: (LogoutPreferenceEvents) -> Unit = {},
|
||||
)
|
||||
@@ -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<LogoutViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<LogoutViewModel, LogoutViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
suspend {
|
||||
client.logout()
|
||||
}.execute {
|
||||
copy(logoutAction = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<OnBoardingViewState>(initialState) {
|
||||
|
||||
fun onPageChanged(page: Int) {
|
||||
setState {
|
||||
copy(
|
||||
currentPage = page,
|
||||
)
|
||||
}
|
||||
}
|
||||
sealed interface MessagesEvents {
|
||||
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
|
||||
}
|
||||
@@ -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<Plugin>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<MessagesState> {
|
||||
|
||||
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<String?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val roomAvatar: MutableState<AvatarData?> = 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<MessagesTimelineItemState>,
|
||||
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<MessagesTimelineItemState>,
|
||||
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<MessagesTimelineItemState>,
|
||||
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<MessagesTimelineItemState>,
|
||||
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<MessagesItemGroupPosition, MessagesTimelineItemContent>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
@@ -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<MessagesViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<MessagesViewModel, MessagesViewState> 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ActionListState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ActionListState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val target: MutableState<ActionListState.Target> = 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<ActionListState.Target>) = 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())
|
||||
}
|
||||
}
|
||||
@@ -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<TimelineItemAction>,
|
||||
) : Target
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<MessagesTimelineItemState>> = Uninitialized,
|
||||
val hasMoreToLoad: Boolean = true,
|
||||
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = 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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<MessageComposerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val text: MutableState<StableCharSequence> = rememberSaveable {
|
||||
mutableStateOf(StableCharSequence(""))
|
||||
}
|
||||
val composerMode: MutableState<MessageComposerMode> = 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<MessageComposerMode>.setToNormal() {
|
||||
value = MessageComposerMode.Normal("")
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>, textState: MutableState<StableCharSequence>) =
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<MessageComposerViewState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
fun onComposerFullScreenChange() {
|
||||
setState {
|
||||
copy(
|
||||
isFullScreen = !isFullScreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateText(newText: CharSequence) {
|
||||
setState {
|
||||
copy(
|
||||
text = StableCharSequence(newText),
|
||||
isSendButtonVisible = newText.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<VM : MavericksViewModel<S>, S : MavericksState> {
|
||||
fun create(initialState: S): VM
|
||||
sealed interface TimelineEvents {
|
||||
object LoadMore : TimelineEvents
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
}
|
||||
@@ -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<List<MessagesTimelineItemState>>(emptyList())
|
||||
private val timelineItemStatesCache = arrayListOf<MessagesTimelineItemState?>()
|
||||
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
|
||||
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
||||
|
||||
// Items from rust sdk, used for diffing
|
||||
private var timelineItems: List<MatrixTimelineItem> = emptyList()
|
||||
private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList()
|
||||
|
||||
private val lock = Mutex()
|
||||
private val cacheInvalidator = CacheInvalidator(timelineItemStatesCache)
|
||||
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
|
||||
|
||||
fun flow(): StateFlow<List<MessagesTimelineItemState>> = timelineItemStates.asStateFlow()
|
||||
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow()
|
||||
|
||||
suspend fun replaceWith(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
@@ -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<MatrixTimelineItem>) {
|
||||
val newTimelineItemStates = ArrayList<MessagesTimelineItemState>()
|
||||
for (index in timelineItemStatesCache.indices.reversed()) {
|
||||
val cacheItem = timelineItemStatesCache[index]
|
||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||
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<MatrixTimelineItem>) {
|
||||
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<MatrixTimelineItem>,
|
||||
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<MatrixTimelineItem>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TimelineState> {
|
||||
|
||||
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<EventId?> = 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<Boolean>) = launch {
|
||||
timeline.paginateBackwards(PAGINATION_COUNT)
|
||||
hasMoreToLoad.value = timeline.hasMoreToLoad
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
Mavericks.initialize(context)
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf()
|
||||
}
|
||||
@Immutable
|
||||
data class TimelineState(
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val hasMoreToLoad: Boolean,
|
||||
val highlightedEventId: EventId?,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
@@ -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<TimelineItem>,
|
||||
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<MessagesItemGroupPosition, TimelineItemContent>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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 = {},
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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<MessagesTimelineItemState?>) :
|
||||
internal class CacheInvalidator(private val itemStatesCache: MutableList<TimelineItem?>) :
|
||||
ListUpdateCallback {
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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<MessagesItemGroupPosition> {
|
||||
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<MessagesItemGroupPosition> {
|
||||
override val values = sequenceOf(
|
||||
MessagesItemGroupPosition.First,
|
||||
MessagesItemGroupPosition.Middle,
|
||||
@@ -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<AggregatedReaction>
|
||||
data class TimelineItemReactions(
|
||||
val reactions: ImmutableList<AggregatedReaction>
|
||||
)
|
||||
|
||||
@Stable
|
||||
data class AggregatedReaction(
|
||||
val key: String,
|
||||
val count: String,
|
||||
@@ -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<MessagesTimelineItemContent> {
|
||||
class MessagesTimelineItemContentProvider : PreviewParameterProvider<TimelineItemContent> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <reified T> MutableList<T?>.invalidateLast() {
|
||||
val indexOfLast = size
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user