Merge pull request #38 from vector-im/feature/fga/navigation
Setup Architecture
This commit is contained in:
commit
8e81d1beda
177 changed files with 5216 additions and 3816 deletions
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds view model to the specified component graph.
|
* Adds Node to the specified component graph.
|
||||||
* Equivalent to the following declaration in a dagger module:
|
* Equivalent to the following declaration:
|
||||||
*
|
*
|
||||||
* @Binds
|
* @Module
|
||||||
* @IntoMap
|
* @ContributesTo(Scope::class)
|
||||||
* @ViewModelKey(YourViewModel::class)
|
* abstract class YourNodeModule {
|
||||||
* public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *>
|
|
||||||
|
* @Binds
|
||||||
|
* @IntoMap
|
||||||
|
* @NodeKey(YourNode::class)
|
||||||
|
* abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
|
||||||
|
*}
|
||||||
|
|
||||||
*/
|
*/
|
||||||
@Target(AnnotationTarget.CLASS)
|
@Target(AnnotationTarget.CLASS)
|
||||||
annotation class ContributesViewModel(
|
annotation class ContributesNode(
|
||||||
val scope: KClass<*>,
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.multibindings.IntoMap
|
import dagger.multibindings.IntoMap
|
||||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
import io.element.android.x.anvilannotations.ContributesNode
|
||||||
import java.io.File
|
|
||||||
import org.jetbrains.kotlin.descriptors.ModuleDescriptor
|
import org.jetbrains.kotlin.descriptors.ModuleDescriptor
|
||||||
import org.jetbrains.kotlin.name.FqName
|
import org.jetbrains.kotlin.name.FqName
|
||||||
import org.jetbrains.kotlin.psi.KtFile
|
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.
|
* handle the rest of the Dagger wiring required for constructor injection.
|
||||||
*/
|
*/
|
||||||
@AutoService(CodeGenerator::class)
|
@AutoService(CodeGenerator::class)
|
||||||
class ContributesViewModelCodeGenerator : CodeGenerator {
|
class ContributesNodeCodeGenerator : CodeGenerator {
|
||||||
|
|
||||||
override fun isApplicable(context: AnvilContext): Boolean = true
|
override fun isApplicable(context: AnvilContext): Boolean = true
|
||||||
|
|
||||||
override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile>): Collection<GeneratedFile> {
|
override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile>): Collection<GeneratedFile> {
|
||||||
return projectFiles.classAndInnerClassReferences(module)
|
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)) }
|
.flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateModule(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||||
val generatedPackage = vmClass.packageFqName.toString()
|
val generatedPackage = nodeClass.packageFqName.toString()
|
||||||
val moduleClassName = "${vmClass.shortName}_Module"
|
val moduleClassName = "${nodeClass.shortName}_Module"
|
||||||
val scope = vmClass.annotations.single { it.fqName == ContributesViewModel::class.fqName }.scope()
|
val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope()
|
||||||
val content = FileSpec.buildFile(generatedPackage, moduleClassName) {
|
val content = FileSpec.buildFile(generatedPackage, moduleClassName) {
|
||||||
addType(
|
addType(
|
||||||
TypeSpec.classBuilder(moduleClassName)
|
TypeSpec.classBuilder(moduleClassName)
|
||||||
|
|
@ -79,17 +79,17 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||||
.addAnnotation(Module::class)
|
.addAnnotation(Module::class)
|
||||||
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build())
|
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build())
|
||||||
.addFunction(
|
.addFunction(
|
||||||
FunSpec.builder("bind${vmClass.shortName}Factory")
|
FunSpec.builder("bind${nodeClass.shortName}Factory")
|
||||||
.addModifiers(KModifier.ABSTRACT)
|
.addModifiers(KModifier.ABSTRACT)
|
||||||
.addParameter("factory", ClassName(generatedPackage, "${vmClass.shortName}_AssistedFactory"))
|
.addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory"))
|
||||||
.returns(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(STAR, STAR))
|
.returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR))
|
||||||
.addAnnotation(Binds::class)
|
.addAnnotation(Binds::class)
|
||||||
.addAnnotation(IntoMap::class)
|
.addAnnotation(IntoMap::class)
|
||||||
.addAnnotation(
|
.addAnnotation(
|
||||||
AnnotationSpec.Companion
|
AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember(
|
||||||
.builder(viewModelKeyFqName.asClassName(module))
|
"%T::class",
|
||||||
.addMember("%T::class", vmClass.asClassName())
|
nodeClass.asClassName()
|
||||||
.build()
|
).build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
|
@ -99,35 +99,46 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||||
return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content)
|
return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateAssistedFactory(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||||
val generatedPackage = vmClass.packageFqName.toString()
|
val generatedPackage = nodeClass.packageFqName.toString()
|
||||||
val assistedFactoryClassName = "${vmClass.shortName}_AssistedFactory"
|
val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory"
|
||||||
val constructor = vmClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
|
val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
|
||||||
val assistedParameter = constructor?.parameters?.singleOrNull { it.isAnnotatedWith(Assisted::class.fqName) }
|
val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty()
|
||||||
if (constructor == null || assistedParameter == null) {
|
if (constructor == null || assistedParameters.size != 2) {
|
||||||
throw AnvilCompilationException(
|
throw AnvilCompilationException(
|
||||||
"${vmClass.fqName} must have an @AssistedInject constructor with @Assisted initialState: S parameter",
|
"${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters",
|
||||||
element = vmClass.clazz,
|
element = nodeClass.clazz,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (assistedParameter.name != "initialState") {
|
val contextAssistedParam = assistedParameters[0]
|
||||||
|
if (contextAssistedParam.name != "buildContext") {
|
||||||
throw AnvilCompilationException(
|
throw AnvilCompilationException(
|
||||||
"${vmClass.fqName} @Assisted parameter must be named initialState",
|
"${nodeClass.fqName} @Assisted parameter must be named buildContext",
|
||||||
element = assistedParameter.parameter,
|
element = contextAssistedParam.parameter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val vmClassName = vmClass.asClassName()
|
val pluginsAssistedParam = assistedParameters[1]
|
||||||
val stateClassName = assistedParameter.type().asTypeName()
|
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) {
|
val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) {
|
||||||
addType(
|
addType(
|
||||||
TypeSpec.interfaceBuilder(assistedFactoryClassName)
|
TypeSpec.interfaceBuilder(assistedFactoryClassName)
|
||||||
.addSuperinterface(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(vmClassName, stateClassName))
|
.addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName))
|
||||||
.addAnnotation(AssistedFactory::class)
|
.addAnnotation(AssistedFactory::class)
|
||||||
.addFunction(
|
.addFunction(
|
||||||
FunSpec.builder("create")
|
FunSpec.builder("create")
|
||||||
.addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT)
|
.addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT)
|
||||||
.addParameter("initialState", stateClassName)
|
.addParameter("buildContext", buildContextClassName)
|
||||||
.returns(vmClassName)
|
.addParameter("plugins", pluginsClassName)
|
||||||
|
.returns(nodeClassName)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
|
|
@ -137,7 +148,7 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val assistedViewModelFactoryFqName = FqName("io.element.android.x.core.di.AssistedViewModelFactory")
|
private val assistedNodeFactoryFqName = FqName("io.element.android.x.architecture.AssistedNodeFactory")
|
||||||
private val viewModelKeyFqName = FqName("io.element.android.x.core.di.ViewModelKey")
|
private val nodeKeyFqName = FqName("io.element.android.x.architecture.NodeKey")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
*
|
*
|
||||||
|
|
@ -24,6 +26,7 @@ plugins {
|
||||||
alias(libs.plugins.kapt)
|
alias(libs.plugins.kapt)
|
||||||
id("com.google.firebase.appdistribution") version "3.0.2"
|
id("com.google.firebase.appdistribution") version "3.0.2"
|
||||||
id("org.jetbrains.kotlinx.knit") version "0.4.0"
|
id("org.jetbrains.kotlinx.knit") version "0.4.0"
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -156,6 +159,7 @@ dependencies {
|
||||||
implementation(project(":libraries:matrix"))
|
implementation(project(":libraries:matrix"))
|
||||||
implementation(project(":libraries:matrixui"))
|
implementation(project(":libraries:matrixui"))
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
|
implementation(project(":libraries:architecture"))
|
||||||
implementation(project(":features:onboarding"))
|
implementation(project(":features:onboarding"))
|
||||||
implementation(project(":features:login"))
|
implementation(project(":features:login"))
|
||||||
implementation(project(":features:logout"))
|
implementation(project(":features:logout"))
|
||||||
|
|
@ -167,17 +171,17 @@ dependencies {
|
||||||
implementation(project(":anvilannotations"))
|
implementation(project(":anvilannotations"))
|
||||||
anvil(project(":anvilcodegen"))
|
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)
|
implementation(libs.compose.destinations)
|
||||||
ksp(libs.compose.destinations.processor)
|
ksp(libs.compose.destinations.processor)
|
||||||
|
implementation(libs.appyx.core)
|
||||||
|
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.lifecycle.runtime)
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.startup)
|
implementation(libs.androidx.startup)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(libs.mavericks.compose)
|
|
||||||
|
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
kapt(libs.dagger.compiler)
|
kapt(libs.dagger.compiler)
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,10 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ElementX"
|
android:theme="@style/Theme.ElementX"
|
||||||
tools:targetApi="33">
|
tools:targetApi="33">
|
||||||
<!-- Note: temporary block orientation to sensorPortrait because the RichTextEditor library is crashing on configuration change -->
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="sensorPortrait"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
tools:ignore="LockedOrientationActivity">
|
tools:ignore="LockedOrientationActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
@ -47,7 +46,7 @@
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
tools:node="remove"/>
|
tools:node="remove" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,35 +19,26 @@ package io.element.android.x
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.startup.AppInitializer
|
import androidx.startup.AppInitializer
|
||||||
import io.element.android.x.core.di.DaggerComponentOwner
|
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.AppComponent
|
||||||
import io.element.android.x.di.DaggerAppComponent
|
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.CrashInitializer
|
||||||
import io.element.android.x.initializer.MatrixInitializer
|
import io.element.android.x.initializer.MatrixInitializer
|
||||||
import io.element.android.x.initializer.MavericksInitializer
|
|
||||||
import io.element.android.x.initializer.TimberInitializer
|
import io.element.android.x.initializer.TimberInitializer
|
||||||
|
|
||||||
class ElementXApplication : Application(), DaggerComponentOwner {
|
class ElementXApplication : Application(), DaggerComponentOwner {
|
||||||
|
|
||||||
private lateinit var appComponent: AppComponent
|
private lateinit var appComponent: AppComponent
|
||||||
private var sessionComponentsOwner: SessionComponentsOwner? = null
|
|
||||||
|
|
||||||
override val daggerComponent: Any
|
override val daggerComponent: Any
|
||||||
get() = listOfNotNull(sessionComponentsOwner?.activeSessionComponent, appComponent)
|
get() = appComponent
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
appComponent = DaggerAppComponent.factory().create(applicationContext)
|
appComponent = DaggerAppComponent.factory().create(applicationContext)
|
||||||
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
|
|
||||||
AppInitializer.getInstance(this).apply {
|
AppInitializer.getInstance(this).apply {
|
||||||
initializeComponent(CrashInitializer::class.java)
|
initializeComponent(CrashInitializer::class.java)
|
||||||
initializeComponent(TimberInitializer::class.java)
|
initializeComponent(TimberInitializer::class.java)
|
||||||
initializeComponent(MatrixInitializer::class.java)
|
initializeComponent(MatrixInitializer::class.java)
|
||||||
initializeComponent(CoilInitializer::class.java)
|
|
||||||
initializeComponent(MavericksInitializer::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,199 +14,45 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(
|
|
||||||
ExperimentalAnimationApi::class,
|
|
||||||
ExperimentalMaterialNavigationApi::class
|
|
||||||
)
|
|
||||||
|
|
||||||
package io.element.android.x
|
package io.element.android.x
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
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.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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Surface
|
||||||
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.ui.Modifier
|
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.core.view.WindowCompat
|
||||||
import androidx.navigation.NavHostController
|
import com.bumble.appyx.core.integration.NodeHost
|
||||||
import com.airbnb.android.showkase.models.Showkase
|
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||||
import com.airbnb.mvrx.compose.mavericksActivityViewModel
|
import io.element.android.x.architecture.bindings
|
||||||
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
|
import io.element.android.x.core.di.DaggerComponentOwner
|
||||||
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 io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
|
import io.element.android.x.di.AppBindings
|
||||||
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
|
import io.element.android.x.node.RootFlowNode
|
||||||
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
|
|
||||||
|
|
||||||
private const val transitionAnimationDuration = 500
|
class MainActivity : NodeComponentActivity() {
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
val appBindings = bindings<AppBindings>()
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
ElementXTheme {
|
ElementXTheme {
|
||||||
MainScreen(viewModel = mavericksActivityViewModel())
|
Surface(
|
||||||
}
|
modifier = Modifier.fillMaxSize(),
|
||||||
}
|
color = MaterialTheme.colorScheme.background
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
) {
|
||||||
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 com.squareup.anvil.annotations.ContributesTo
|
||||||
import io.element.android.x.matrix.Matrix
|
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
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface AppBindings {
|
interface AppBindings {
|
||||||
fun coroutineScope(): CoroutineScope
|
fun coroutineScope(): CoroutineScope
|
||||||
|
fun rootPresenter(): RootPresenter
|
||||||
fun matrix(): Matrix
|
fun matrix(): Matrix
|
||||||
fun matrixUi(): MatrixUi
|
|
||||||
fun sessionComponentsOwner(): SessionComponentsOwner
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import android.content.Context
|
||||||
import com.squareup.anvil.annotations.MergeComponent
|
import com.squareup.anvil.annotations.MergeComponent
|
||||||
import dagger.BindsInstance
|
import dagger.BindsInstance
|
||||||
import dagger.Component
|
import dagger.Component
|
||||||
import io.element.android.x.core.di.DaggerMavericksBindings
|
import io.element.android.x.architecture.NodeFactoriesBindings
|
||||||
|
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
@MergeComponent(AppScope::class)
|
@MergeComponent(AppScope::class)
|
||||||
interface AppComponent : DaggerMavericksBindings {
|
interface AppComponent : NodeFactoriesBindings {
|
||||||
|
|
||||||
@Component.Factory
|
@Component.Factory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ package io.element.android.x.di
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
import io.element.android.x.core.coroutine.CoroutineDispatchers
|
||||||
import kotlinx.coroutines.CoroutineName
|
import kotlinx.coroutines.CoroutineName
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
|
|
@ -33,4 +37,15 @@ object AppModule {
|
||||||
fun providesAppCoroutineScope(): CoroutineScope {
|
fun providesAppCoroutineScope(): CoroutineScope {
|
||||||
return MainScope() + CoroutineName("ElementX Scope")
|
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 com.squareup.anvil.annotations.MergeSubcomponent
|
||||||
import dagger.BindsInstance
|
import dagger.BindsInstance
|
||||||
import dagger.Subcomponent
|
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
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
|
||||||
@SingleIn(SessionScope::class)
|
@SingleIn(SessionScope::class)
|
||||||
@MergeSubcomponent(SessionScope::class)
|
@MergeSubcomponent(SessionScope::class)
|
||||||
interface SessionComponent : DaggerMavericksBindings {
|
interface SessionComponent : NodeFactoriesBindings, RoomComponent.ParentBindings {
|
||||||
|
|
||||||
fun matrixClient(): MatrixClient
|
|
||||||
|
|
||||||
@Subcomponent.Builder
|
@Subcomponent.Builder
|
||||||
interface 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.kotlin.android) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.anvil) apply false
|
alias(libs.plugins.anvil) apply false
|
||||||
|
alias(libs.plugins.molecule) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kapt) apply false
|
alias(libs.plugins.kapt) apply false
|
||||||
alias(libs.plugins.dependencycheck) apply false
|
alias(libs.plugins.dependencycheck) apply false
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -35,10 +36,11 @@ dependencies {
|
||||||
anvil(project(":anvilcodegen"))
|
anvil(project(":anvilcodegen"))
|
||||||
implementation(project(":libraries:di"))
|
implementation(project(":libraries:di"))
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
|
implementation(project(":libraries:architecture"))
|
||||||
implementation(project(":libraries:matrix"))
|
implementation(project(":libraries:matrix"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
implementation(project(":libraries:elementresources"))
|
implementation(project(":libraries:elementresources"))
|
||||||
implementation(libs.mavericks.compose)
|
implementation(libs.appyx.core)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
androidTestImplementation(libs.test.junitext)
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
package io.element.android.x.features.login.changeserver
|
||||||
|
|
||||||
import com.airbnb.mvrx.Async
|
import io.element.android.x.architecture.Async
|
||||||
import com.airbnb.mvrx.Loading
|
|
||||||
import com.airbnb.mvrx.MavericksState
|
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
|
|
||||||
data class ChangeServerViewState(
|
data class ChangeServerState(
|
||||||
val homeserver: String = "",
|
val homeserver: String = "",
|
||||||
val changeServerAction: Async<Unit> = Uninitialized,
|
val changeServerAction: Async<Unit> = Async.Uninitialized,
|
||||||
) : MavericksState {
|
val eventSink: (ChangeServerEvents) -> Unit = {},
|
||||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
|
) {
|
||||||
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.airbnb.mvrx.Fail
|
import io.element.android.x.architecture.Async
|
||||||
import com.airbnb.mvrx.Loading
|
import io.element.android.x.core.compose.textFieldState
|
||||||
import com.airbnb.mvrx.Success
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
|
||||||
import io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.designsystem.components.VectorIcon
|
import io.element.android.x.designsystem.components.VectorIcon
|
||||||
import io.element.android.x.features.login.R
|
import io.element.android.x.features.login.R
|
||||||
import io.element.android.x.features.login.error.changeServerError
|
import io.element.android.x.features.login.error.changeServerError
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChangeServerScreen(
|
fun ChangeServerView(
|
||||||
viewModel: ChangeServerViewModel = mavericksViewModel(),
|
state: ChangeServerState,
|
||||||
onChangeServerSuccess: () -> Unit = { }
|
|
||||||
) {
|
|
||||||
val state: ChangeServerViewState by viewModel.collectAsState()
|
|
||||||
ChangeServerContent(
|
|
||||||
state = state,
|
|
||||||
onChangeServer = viewModel::setServer,
|
|
||||||
onChangeServerSubmit = viewModel::setServerSubmit,
|
|
||||||
onChangeServerSuccess = onChangeServerSuccess
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChangeServerContent(
|
|
||||||
state: ChangeServerViewState,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: (String) -> Unit = {},
|
|
||||||
onChangeServerSubmit: () -> Unit = {},
|
|
||||||
onChangeServerSuccess: () -> Unit = {},
|
onChangeServerSuccess: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
|
val eventSink = state.eventSink
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -101,7 +84,7 @@ fun ChangeServerContent(
|
||||||
)
|
)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
val isError = state.changeServerAction is Fail
|
val isError = state.changeServerAction is Async.Failure
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 99.dp)
|
.padding(top = 99.dp)
|
||||||
|
|
@ -142,12 +125,16 @@ fun ChangeServerContent(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.secondary
|
color = MaterialTheme.colorScheme.secondary
|
||||||
)
|
)
|
||||||
|
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.homeserver,
|
value = homeserverFieldState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 200.dp),
|
.padding(top = 200.dp),
|
||||||
onValueChange = onChangeServer,
|
onValueChange = {
|
||||||
|
homeserverFieldState = it
|
||||||
|
eventSink(ChangeServerEvents.SetServer(it))
|
||||||
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(text = "Server")
|
Text(text = "Server")
|
||||||
},
|
},
|
||||||
|
|
@ -157,10 +144,10 @@ fun ChangeServerContent(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { onChangeServerSubmit() }
|
onDone = { eventSink(ChangeServerEvents.Submit) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (state.changeServerAction is Fail) {
|
if (state.changeServerAction is Async.Failure) {
|
||||||
Text(
|
Text(
|
||||||
text = changeServerError(
|
text = changeServerError(
|
||||||
state.homeserver,
|
state.homeserver,
|
||||||
|
|
@ -172,7 +159,7 @@ fun ChangeServerContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onChangeServerSubmit,
|
onClick = { eventSink(ChangeServerEvents.Submit) },
|
||||||
enabled = state.submitEnabled,
|
enabled = state.submitEnabled,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -180,11 +167,11 @@ fun ChangeServerContent(
|
||||||
) {
|
) {
|
||||||
Text(text = "Continue")
|
Text(text = "Continue")
|
||||||
}
|
}
|
||||||
if (state.changeServerAction is Success) {
|
if (state.changeServerAction is Async.Success) {
|
||||||
onChangeServerSuccess()
|
onChangeServerSuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.changeServerAction is Loading) {
|
if (state.changeServerAction is Async.Loading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
|
|
@ -197,8 +184,8 @@ fun ChangeServerContent(
|
||||||
@Preview
|
@Preview
|
||||||
fun ChangeServerContentPreview() {
|
fun ChangeServerContentPreview() {
|
||||||
ElementXTheme {
|
ElementXTheme {
|
||||||
ChangeServerContent(
|
ChangeServerView(
|
||||||
state = ChangeServerViewState(homeserver = "matrix.org"),
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.x.core.uri.isValidUrl
|
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.element.resources.R as ElementR
|
||||||
import io.element.android.x.features.login.LoginFormState
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun loginError(
|
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)
|
@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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -58,51 +57,20 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.airbnb.mvrx.Fail
|
import io.element.android.x.core.compose.textFieldState
|
||||||
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.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.features.login.error.loginError
|
import io.element.android.x.features.login.error.loginError
|
||||||
import io.element.android.x.matrix.MatrixClient
|
import io.element.android.x.matrix.core.SessionId
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginContent(
|
fun LoginRootScreen(
|
||||||
state: LoginViewState,
|
state: LoginRootState,
|
||||||
formState: LoginFormState,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: () -> Unit = {},
|
onChangeServer: () -> Unit = {},
|
||||||
onLoginChanged: (String) -> Unit = {},
|
onLoginWithSuccess: (SessionId) -> Unit = {},
|
||||||
onPasswordChanged: (String) -> Unit = {},
|
|
||||||
onSubmitClicked: () -> Unit = {},
|
|
||||||
onLoginWithSuccess: (MatrixClient) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
|
val eventSink = state.eventSink
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
|
@ -114,6 +82,9 @@ fun LoginContent(
|
||||||
.imePadding()
|
.imePadding()
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||||
|
var passwordFieldState by textFieldState(stateValue = state.formState.password)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(
|
.verticalScroll(
|
||||||
|
|
@ -121,7 +92,7 @@ fun LoginContent(
|
||||||
)
|
)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
val isError = state.loggedInClient is Fail
|
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
text = "Welcome back",
|
text = "Welcome back",
|
||||||
|
|
@ -162,30 +133,36 @@ fun LoginContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = formState.login,
|
value = loginFieldState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 60.dp),
|
.padding(top = 60.dp),
|
||||||
label = {
|
label = {
|
||||||
Text(text = "Email or username")
|
Text(text = "Email or username")
|
||||||
},
|
},
|
||||||
onValueChange = onLoginChanged,
|
onValueChange = {
|
||||||
|
loginFieldState = it
|
||||||
|
eventSink(LoginRootEvents.SetLogin(it))
|
||||||
|
},
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
imeAction = ImeAction.Next
|
imeAction = ImeAction.Next
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
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
|
// Ensure password is hidden when user submits the form
|
||||||
passwordVisible = false
|
passwordVisible = false
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = formState.password,
|
value = passwordFieldState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 24.dp),
|
.padding(top = 24.dp),
|
||||||
onValueChange = onPasswordChanged,
|
onValueChange = {
|
||||||
|
passwordFieldState = it
|
||||||
|
eventSink(LoginRootEvents.SetPassword(it))
|
||||||
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(text = "Password")
|
Text(text = "Password")
|
||||||
},
|
},
|
||||||
|
|
@ -206,12 +183,12 @@ fun LoginContent(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { onSubmitClicked() }
|
onDone = { eventSink(LoginRootEvents.Submit) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (state.loggedInClient is Fail) {
|
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
|
||||||
Text(
|
Text(
|
||||||
text = loginError(state.formState, state.loggedInClient.error),
|
text = loginError(state.formState, state.loggedInState.failure),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
|
@ -220,7 +197,7 @@ fun LoginContent(
|
||||||
}
|
}
|
||||||
// Submit
|
// Submit
|
||||||
Button(
|
Button(
|
||||||
onClick = onSubmitClicked,
|
onClick = { eventSink(LoginRootEvents.Submit) },
|
||||||
enabled = state.submitEnabled,
|
enabled = state.submitEnabled,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -228,12 +205,12 @@ fun LoginContent(
|
||||||
) {
|
) {
|
||||||
Text(text = "Continue")
|
Text(text = "Continue")
|
||||||
}
|
}
|
||||||
when (val loggedInClient = state.loggedInClient) {
|
when (val loggedInState = state.loggedInState) {
|
||||||
is Success -> onLoginWithSuccess(loggedInClient())
|
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.loggedInClient is Loading) {
|
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
|
|
@ -246,11 +223,10 @@ fun LoginContent(
|
||||||
@Preview
|
@Preview
|
||||||
fun LoginContentPreview() {
|
fun LoginContentPreview() {
|
||||||
ElementXTheme(darkTheme = false) {
|
ElementXTheme(darkTheme = false) {
|
||||||
LoginContent(
|
LoginRootScreen(
|
||||||
state = LoginViewState(
|
state = LoginRootState(
|
||||||
homeserver = "matrix.org",
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,27 +14,34 @@
|
||||||
* limitations under the License.
|
* 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 android.os.Parcelable
|
||||||
import com.airbnb.mvrx.Loading
|
import io.element.android.x.matrix.core.SessionId
|
||||||
import com.airbnb.mvrx.MavericksState
|
import kotlinx.parcelize.Parcelize
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
import io.element.android.x.matrix.MatrixClient
|
|
||||||
|
|
||||||
data class LoginViewState(
|
data class LoginRootState(
|
||||||
val homeserver: String = "",
|
val homeserver: String = "",
|
||||||
val loggedInClient: Async<MatrixClient> = Uninitialized,
|
val loggedInState: LoggedInState = LoggedInState.NotLoggedIn,
|
||||||
val formState: LoginFormState = LoginFormState.Default,
|
val formState: LoginFormState = LoginFormState.Default,
|
||||||
) : MavericksState {
|
val eventSink: (LoginRootEvents) -> Unit = {}
|
||||||
|
) {
|
||||||
val submitEnabled =
|
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(
|
data class LoginFormState(
|
||||||
val login: String,
|
val login: String,
|
||||||
val password: String
|
val password: String
|
||||||
) {
|
) : Parcelable {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val Default = LoginFormState("", "")
|
val Default = LoginFormState("", "")
|
||||||
|
|
@ -34,11 +34,11 @@ dependencies {
|
||||||
implementation(project(":anvilannotations"))
|
implementation(project(":anvilannotations"))
|
||||||
anvil(project(":anvilcodegen"))
|
anvil(project(":anvilcodegen"))
|
||||||
implementation(project(":libraries:di"))
|
implementation(project(":libraries:di"))
|
||||||
|
implementation(project(":libraries:architecture"))
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
implementation(project(":libraries:matrix"))
|
implementation(project(":libraries:matrix"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
implementation(project(":libraries:elementresources"))
|
implementation(project(":libraries:elementresources"))
|
||||||
implementation(libs.mavericks.compose)
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
androidTestImplementation(libs.test.junitext)
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Logout
|
import androidx.compose.material.icons.filled.Logout
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.airbnb.mvrx.Loading
|
import io.element.android.x.architecture.Async
|
||||||
import com.airbnb.mvrx.Success
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
|
||||||
import io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.designsystem.components.ProgressDialog
|
import io.element.android.x.designsystem.components.ProgressDialog
|
||||||
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
|
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
|
import io.element.android.x.element.resources.R as ElementR
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LogoutPreference(
|
fun LogoutPreferenceView(
|
||||||
viewModel: LogoutViewModel = mavericksViewModel(),
|
state: LogoutPreferenceState,
|
||||||
onSuccessLogout: () -> Unit = { },
|
onSuccessLogout: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val state: LogoutViewState by viewModel.collectAsState()
|
val eventSink = state.eventSink
|
||||||
if (state.logoutAction is Success) {
|
if (state.logoutAction is Async.Success) {
|
||||||
onSuccessLogout()
|
LaunchedEffect(state.logoutAction) {
|
||||||
|
onSuccessLogout()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val openDialog = remember { mutableStateOf(false) }
|
val openDialog = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LogoutPreferenceContent(
|
LogoutPreferenceContent(
|
||||||
|
|
@ -65,7 +63,7 @@ fun LogoutPreference(
|
||||||
},
|
},
|
||||||
onSubmitClicked = {
|
onSubmitClicked = {
|
||||||
openDialog.value = false
|
openDialog.value = false
|
||||||
viewModel.logout()
|
eventSink(LogoutPreferenceEvents.Logout)
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
openDialog.value = false
|
openDialog.value = false
|
||||||
|
|
@ -73,7 +71,7 @@ fun LogoutPreference(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.logoutAction is Loading) {
|
if (state.logoutAction is Async.Loading) {
|
||||||
ProgressDialog(text = "Login out...")
|
ProgressDialog(text = "Login out...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +93,6 @@ fun LogoutPreferenceContent(
|
||||||
@Preview
|
@Preview
|
||||||
fun LogoutContentPreview() {
|
fun LogoutContentPreview() {
|
||||||
ElementXTheme(darkTheme = false) {
|
ElementXTheme(darkTheme = false) {
|
||||||
LogoutPreference()
|
LogoutPreferenceView(LogoutPreferenceState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,10 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.x.features.logout
|
package io.element.android.x.features.logout
|
||||||
|
|
||||||
import com.airbnb.mvrx.Async
|
import io.element.android.x.architecture.Async
|
||||||
import com.airbnb.mvrx.MavericksState
|
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
|
|
||||||
data class LogoutViewState(
|
data class LogoutPreferenceState(
|
||||||
val logoutAction: Async<Unit> = Uninitialized,
|
val logoutAction: Async<Unit> = Async.Uninitialized,
|
||||||
) : MavericksState
|
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"))
|
anvil(project(":anvilcodegen"))
|
||||||
implementation(project(":libraries:di"))
|
implementation(project(":libraries:di"))
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
|
implementation(project(":libraries:architecture"))
|
||||||
implementation(project(":libraries:matrix"))
|
implementation(project(":libraries:matrix"))
|
||||||
implementation(project(":libraries:matrixui"))
|
implementation(project(":libraries:matrixui"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
implementation(project(":libraries:textcomposer"))
|
implementation(project(":libraries:textcomposer"))
|
||||||
implementation(libs.mavericks.compose)
|
implementation(libs.appyx.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
implementation(libs.accompanist.flowlayout)
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,18 +14,11 @@
|
||||||
* limitations under the License.
|
* 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) :
|
sealed interface MessagesEvents {
|
||||||
MavericksViewModel<OnBoardingViewState>(initialState) {
|
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
|
||||||
|
|
||||||
fun onPageChanged(page: Int) {
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
currentPage = page,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,21 +14,21 @@
|
||||||
* limitations under the License.
|
* 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.annotation.DrawableRes
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.x.designsystem.VectorIcons
|
import io.element.android.x.designsystem.VectorIcons
|
||||||
|
|
||||||
@Stable
|
@Immutable
|
||||||
sealed class MessagesItemAction(
|
sealed class TimelineItemAction(
|
||||||
val title: String,
|
val title: String,
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
val destructive: Boolean = false
|
val destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward)
|
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
|
||||||
object Copy : MessagesItemAction("Copy", VectorIcons.Copy)
|
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
||||||
object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true)
|
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
||||||
object Reply : MessagesItemAction("Reply", VectorIcons.Reply)
|
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
||||||
object Edit : MessagesItemAction("Edit", VectorIcons.Edit)
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
* limitations under the License.
|
* 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 io.element.android.x.matrix.core.EventId
|
||||||
import com.airbnb.mvrx.MavericksViewModel
|
|
||||||
|
|
||||||
interface AssistedViewModelFactory<VM : MavericksViewModel<S>, S : MavericksState> {
|
sealed interface TimelineEvents {
|
||||||
fun create(initialState: S): VM
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,29 +14,31 @@
|
||||||
* limitations under the License.
|
* 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 androidx.recyclerview.widget.DiffUtil
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarSize
|
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.timeline.diff.CacheInvalidator
|
||||||
import io.element.android.x.features.messages.diff.MatrixTimelineItemsDiffCallback
|
import io.element.android.x.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback
|
||||||
import io.element.android.x.features.messages.model.AggregatedReaction
|
import io.element.android.x.features.messages.timeline.model.AggregatedReaction
|
||||||
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
|
import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition
|
||||||
import io.element.android.x.features.messages.model.MessagesItemReactionState
|
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
import io.element.android.x.features.messages.timeline.model.TimelineItemReactions
|
||||||
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.features.messages.model.content.MessagesTimelineItemEmoteContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEmoteContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemNoticeContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemNoticeContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextContent
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent
|
||||||
import io.element.android.x.features.messages.util.invalidateLast
|
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.media.MediaResolver
|
||||||
import io.element.android.x.matrix.room.MatrixRoom
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
||||||
import io.element.android.x.matrix.ui.MatrixItemHelper
|
import io.element.android.x.matrix.ui.MatrixItemHelper
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.MessageFormat
|
||||||
import org.matrix.rustcomponents.sdk.MessageType
|
import org.matrix.rustcomponents.sdk.MessageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
class MessageTimelineItemStateFactory(
|
class TimelineItemsFactory @Inject constructor(
|
||||||
private val matrixItemHelper: MatrixItemHelper,
|
private val matrixItemHelper: MatrixItemHelper,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val timelineItemStates = MutableStateFlow<List<MessagesTimelineItemState>>(emptyList())
|
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
|
||||||
private val timelineItemStatesCache = arrayListOf<MessagesTimelineItemState?>()
|
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
||||||
|
|
||||||
// Items from rust sdk, used for diffing
|
// 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 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(
|
suspend fun replaceWith(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
|
|
@ -83,17 +86,17 @@ class MessageTimelineItemStateFactory(
|
||||||
) = withContext(dispatcher) {
|
) = withContext(dispatcher) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
// Makes sure to invalidate last as we need to recompute some data (like groupPosition)
|
// Makes sure to invalidate last as we need to recompute some data (like groupPosition)
|
||||||
timelineItemStatesCache.invalidateLast()
|
timelineItemsCache.invalidateLast()
|
||||||
timelineItemStatesCache.add(null)
|
timelineItemsCache.add(null)
|
||||||
timelineItems = timelineItems + timelineItem
|
matrixTimelineItems = matrixTimelineItems + timelineItem
|
||||||
buildAndEmitTimelineItemStates(timelineItems)
|
buildAndEmitTimelineItemStates(matrixTimelineItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
||||||
val newTimelineItemStates = ArrayList<MessagesTimelineItemState>()
|
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||||
for (index in timelineItemStatesCache.indices.reversed()) {
|
for (index in timelineItemsCache.indices.reversed()) {
|
||||||
val cacheItem = timelineItemStatesCache[index]
|
val cacheItem = timelineItemsCache[index]
|
||||||
if (cacheItem == null) {
|
if (cacheItem == null) {
|
||||||
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
||||||
newTimelineItemStates.add(timelineItemState)
|
newTimelineItemStates.add(timelineItemState)
|
||||||
|
|
@ -102,18 +105,18 @@ class MessageTimelineItemStateFactory(
|
||||||
newTimelineItemStates.add(cacheItem)
|
newTimelineItemStates.add(cacheItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timelineItemStates.emit(newTimelineItemStates)
|
this.timelineItems.emit(newTimelineItemStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
|
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
|
||||||
val timeToDiff = measureTimeMillis {
|
val timeToDiff = measureTimeMillis {
|
||||||
val diffCallback =
|
val diffCallback =
|
||||||
MatrixTimelineItemsDiffCallback(
|
MatrixTimelineItemsDiffCallback(
|
||||||
oldList = timelineItems,
|
oldList = matrixTimelineItems,
|
||||||
newList = newTimelineItems
|
newList = newTimelineItems
|
||||||
)
|
)
|
||||||
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
|
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
|
||||||
timelineItems = newTimelineItems
|
matrixTimelineItems = newTimelineItems
|
||||||
diffResult.dispatchUpdatesTo(cacheInvalidator)
|
diffResult.dispatchUpdatesTo(cacheInvalidator)
|
||||||
}
|
}
|
||||||
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
|
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(
|
private suspend fun buildAndCacheItem(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
index: Int
|
index: Int
|
||||||
): MessagesTimelineItemState? {
|
): TimelineItem? {
|
||||||
val timelineItemState =
|
val timelineItemState =
|
||||||
when (val currentTimelineItem = timelineItems[index]) {
|
when (val currentTimelineItem = timelineItems[index]) {
|
||||||
is MatrixTimelineItem.Event -> {
|
is MatrixTimelineItem.Event -> {
|
||||||
|
|
@ -132,12 +135,12 @@ class MessageTimelineItemStateFactory(
|
||||||
timelineItems,
|
timelineItems,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
|
is MatrixTimelineItem.Virtual -> TimelineItem.Virtual(
|
||||||
"virtual_item_$index"
|
"virtual_item_$index"
|
||||||
)
|
)
|
||||||
MatrixTimelineItem.Other -> null
|
MatrixTimelineItem.Other -> null
|
||||||
}
|
}
|
||||||
timelineItemStatesCache[index] = timelineItemState
|
timelineItemsCache[index] = timelineItemState
|
||||||
return timelineItemState
|
return timelineItemState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ class MessageTimelineItemStateFactory(
|
||||||
currentTimelineItem: MatrixTimelineItem.Event,
|
currentTimelineItem: MatrixTimelineItem.Event,
|
||||||
index: Int,
|
index: Int,
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
): MessagesTimelineItemState.MessageEvent {
|
): TimelineItem.MessageEvent {
|
||||||
val currentSender = currentTimelineItem.event.sender()
|
val currentSender = currentTimelineItem.event.sender()
|
||||||
val groupPosition =
|
val groupPosition =
|
||||||
computeGroupPosition(currentTimelineItem, timelineItems, index)
|
computeGroupPosition(currentTimelineItem, timelineItems, index)
|
||||||
|
|
@ -157,8 +160,8 @@ class MessageTimelineItemStateFactory(
|
||||||
url = senderAvatarUrl,
|
url = senderAvatarUrl,
|
||||||
size = AvatarSize.SMALL
|
size = AvatarSize.SMALL
|
||||||
)
|
)
|
||||||
return MessagesTimelineItemState.MessageEvent(
|
return TimelineItem.MessageEvent(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = EventId(currentTimelineItem.uniqueId),
|
||||||
senderId = currentSender,
|
senderId = currentSender,
|
||||||
senderDisplayName = senderDisplayName,
|
senderDisplayName = senderDisplayName,
|
||||||
senderAvatar = senderAvatarData,
|
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 {
|
val aggregatedReactions = event.reactions().map {
|
||||||
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false)
|
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()
|
val content = event.content()
|
||||||
content.asUnableToDecrypt()?.let { encryptedMessage ->
|
content.asUnableToDecrypt()?.let { encryptedMessage ->
|
||||||
return MessagesTimelineItemEncryptedContent(encryptedMessage)
|
return TimelineItemEncryptedContent(encryptedMessage)
|
||||||
}
|
}
|
||||||
if (content.isRedactedMessage()) {
|
if (content.isRedactedMessage()) {
|
||||||
return MessagesTimelineItemRedactedContent
|
return TimelineItemRedactedContent
|
||||||
}
|
}
|
||||||
val contentAsMessage = content.asMessage()
|
val contentAsMessage = content.asMessage()
|
||||||
return when (val messageType = contentAsMessage?.msgtype()) {
|
return when (val messageType = contentAsMessage?.msgtype()) {
|
||||||
is MessageType.Emote -> MessagesTimelineItemEmoteContent(
|
is MessageType.Emote -> TimelineItemEmoteContent(
|
||||||
body = messageType.content.body,
|
body = messageType.content.body,
|
||||||
htmlDocument = messageType.content.formatted?.toHtmlDocument()
|
htmlDocument = messageType.content.formatted?.toHtmlDocument()
|
||||||
)
|
)
|
||||||
|
|
@ -198,7 +201,7 @@ class MessageTimelineItemStateFactory(
|
||||||
} else {
|
} else {
|
||||||
0.7f
|
0.7f
|
||||||
}
|
}
|
||||||
MessagesTimelineItemImageContent(
|
TimelineItemImageContent(
|
||||||
body = messageType.content.body,
|
body = messageType.content.body,
|
||||||
imageMeta = MediaResolver.Meta(
|
imageMeta = MediaResolver.Meta(
|
||||||
source = messageType.content.source,
|
source = messageType.content.source,
|
||||||
|
|
@ -208,15 +211,15 @@ class MessageTimelineItemStateFactory(
|
||||||
aspectRatio = aspectRatio
|
aspectRatio = aspectRatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MessageType.Notice -> MessagesTimelineItemNoticeContent(
|
is MessageType.Notice -> TimelineItemNoticeContent(
|
||||||
body = messageType.content.body,
|
body = messageType.content.body,
|
||||||
htmlDocument = messageType.content.formatted?.toHtmlDocument()
|
htmlDocument = messageType.content.formatted?.toHtmlDocument()
|
||||||
)
|
)
|
||||||
is MessageType.Text -> MessagesTimelineItemTextContent(
|
is MessageType.Text -> TimelineItemTextContent(
|
||||||
body = messageType.content.body,
|
body = messageType.content.body,
|
||||||
htmlDocument = messageType.content.formatted?.toHtmlDocument()
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.x.initializer
|
package io.element.android.x.features.messages.timeline
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.startup.Initializer
|
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||||
import com.airbnb.mvrx.Mavericks
|
import io.element.android.x.matrix.core.EventId
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
class MavericksInitializer : Initializer<Unit> {
|
@Immutable
|
||||||
|
data class TimelineState(
|
||||||
override fun create(context: Context) {
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
Mavericks.initialize(context)
|
val hasMoreToLoad: Boolean,
|
||||||
}
|
val highlightedEventId: EventId?,
|
||||||
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf()
|
)
|
||||||
}
|
|
||||||
|
|
@ -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.
|
* 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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
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.SystemGrey5Light
|
||||||
import io.element.android.x.designsystem.SystemGrey6Dark
|
import io.element.android.x.designsystem.SystemGrey6Dark
|
||||||
import io.element.android.x.designsystem.SystemGrey6Light
|
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
|
private val BUBBLE_RADIUS = 16.dp
|
||||||
|
|
||||||
|
|
@ -14,20 +14,20 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun MessagesTimelineItemEncryptedView(
|
fun TimelineItemEncryptedView(
|
||||||
content: MessagesTimelineItemEncryptedContent,
|
content: TimelineItemEncryptedContent,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
MessagesTimelineItemInformativeView(
|
TimelineItemInformativeView(
|
||||||
text = "Decryption error",
|
text = "Decryption error",
|
||||||
iconDescription = "Warning",
|
iconDescription = "Warning",
|
||||||
icon = Icons.Default.Warning,
|
icon = Icons.Default.Warning,
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
@file:OptIn(ExperimentalFoundationApi::class)
|
@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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -33,11 +33,11 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
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
|
@Composable
|
||||||
fun MessagesTimelineItemImageView(
|
fun TimelineItemImageView(
|
||||||
content: MessagesTimelineItemImageContent,
|
content: TimelineItemImageContent,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val widthPercent = if (content.aspectRatio > 1f) {
|
val widthPercent = if (content.aspectRatio > 1f) {
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesTimelineItemInformativeView(
|
fun TimelineItemInformativeView(
|
||||||
text: String,
|
text: String,
|
||||||
iconDescription: String,
|
iconDescription: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Row
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.google.accompanist.flowlayout.FlowRow
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
import io.element.android.x.features.messages.model.AggregatedReaction
|
import io.element.android.x.features.messages.timeline.model.AggregatedReaction
|
||||||
import io.element.android.x.features.messages.model.MessagesItemReactionState
|
import io.element.android.x.features.messages.timeline.model.TimelineItemReactions
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesReactionsView(
|
fun TimelineItemReactionsView(
|
||||||
reactionsState: MessagesItemReactionState,
|
reactionsState: TimelineItemReactions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (reactionsState.reactions.isEmpty()) return
|
if (reactionsState.reactions.isEmpty()) return
|
||||||
|
|
@ -14,20 +14,20 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun MessagesTimelineItemRedactedView(
|
fun TimelineItemRedactedView(
|
||||||
content: MessagesTimelineItemRedactedContent,
|
content: TimelineItemRedactedContent,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
MessagesTimelineItemInformativeView(
|
TimelineItemInformativeView(
|
||||||
text = "This message has been deleted",
|
text = "This message has been deleted",
|
||||||
iconDescription = "Delete",
|
iconDescription = "Delete",
|
||||||
icon = Icons.Default.Delete,
|
icon = Icons.Default.Delete,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.SpannableString
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
|
|
@ -30,12 +30,12 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.core.text.util.LinkifyCompat
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.x.designsystem.LinkColor
|
import io.element.android.x.designsystem.LinkColor
|
||||||
import io.element.android.x.designsystem.components.ClickableLinkText
|
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.timeline.components.html.HtmlDocument
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesTimelineItemTextView(
|
fun TimelineItemTextView(
|
||||||
content: MessagesTimelineItemTextBasedContent,
|
content: TimelineItemTextBasedContent,
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onTextClicked: () -> Unit = {},
|
onTextClicked: () -> Unit = {},
|
||||||
|
|
@ -14,20 +14,20 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun MessagesTimelineItemUnknownView(
|
fun TimelineItemUnknownView(
|
||||||
content: MessagesTimelineItemUnknownContent,
|
content: TimelineItemUnknownContent,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
MessagesTimelineItemInformativeView(
|
TimelineItemInformativeView(
|
||||||
text = "Event not handled by EAX",
|
text = "Event not handled by EAX",
|
||||||
iconDescription = "Info",
|
iconDescription = "Info",
|
||||||
icon = Icons.Default.Info,
|
icon = Icons.Default.Info,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,14 +14,14 @@
|
||||||
* limitations under the License.
|
* 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 androidx.recyclerview.widget.ListUpdateCallback
|
||||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||||
import io.element.android.x.features.messages.util.invalidateLast
|
import io.element.android.x.features.messages.timeline.util.invalidateLast
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
internal class CacheInvalidator(private val itemStatesCache: MutableList<MessagesTimelineItemState?>) :
|
internal class CacheInvalidator(private val itemStatesCache: MutableList<TimelineItem?>) :
|
||||||
ListUpdateCallback {
|
ListUpdateCallback {
|
||||||
|
|
||||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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 androidx.recyclerview.widget.DiffUtil
|
||||||
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,27 +14,30 @@
|
||||||
* limitations under the License.
|
* 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.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(
|
data class Virtual(
|
||||||
val id: String
|
val id: String
|
||||||
) : MessagesTimelineItemState
|
) : TimelineItem
|
||||||
|
|
||||||
data class MessageEvent(
|
data class MessageEvent(
|
||||||
val id: String,
|
val id: EventId,
|
||||||
val senderId: String,
|
val senderId: String,
|
||||||
val senderDisplayName: String?,
|
val senderDisplayName: String?,
|
||||||
val senderAvatar: AvatarData,
|
val senderAvatar: AvatarData,
|
||||||
val content: MessagesTimelineItemContent,
|
val content: TimelineItemContent,
|
||||||
val sentTime: String = "",
|
val sentTime: String = "",
|
||||||
val isMine: Boolean = false,
|
val isMine: Boolean = false,
|
||||||
val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None,
|
val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None,
|
||||||
val reactionsState: MessagesItemReactionState
|
val reactionsState: TimelineItemReactions
|
||||||
) : MessagesTimelineItemState {
|
) : TimelineItem {
|
||||||
|
|
||||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,10 +14,12 @@
|
||||||
* limitations under the License.
|
* 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
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface MessagesItemGroupPosition {
|
sealed interface MessagesItemGroupPosition {
|
||||||
object First : MessagesItemGroupPosition
|
object First : MessagesItemGroupPosition
|
||||||
object Middle : 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(
|
override val values = sequenceOf(
|
||||||
MessagesItemGroupPosition.First,
|
MessagesItemGroupPosition.First,
|
||||||
MessagesItemGroupPosition.Middle,
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,16 +14,14 @@
|
||||||
* limitations under the License.
|
* 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 TimelineItemReactions(
|
||||||
data class MessagesItemReactionState(
|
val reactions: ImmutableList<AggregatedReaction>
|
||||||
val reactions: List<AggregatedReaction>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class AggregatedReaction(
|
data class AggregatedReaction(
|
||||||
val key: String,
|
val key: String,
|
||||||
val count: String,
|
val count: String,
|
||||||
|
|
@ -14,32 +14,32 @@
|
||||||
* limitations under the License.
|
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
||||||
|
|
||||||
sealed interface MessagesTimelineItemContent
|
sealed interface TimelineItemContent
|
||||||
|
|
||||||
class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTimelineItemContent> {
|
class MessagesTimelineItemContentProvider : PreviewParameterProvider<TimelineItemContent> {
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
MessagesTimelineItemEmoteContent(
|
TimelineItemEmoteContent(
|
||||||
body = "Emote",
|
body = "Emote",
|
||||||
htmlDocument = null
|
htmlDocument = null
|
||||||
),
|
),
|
||||||
MessagesTimelineItemEncryptedContent(
|
TimelineItemEncryptedContent(
|
||||||
encryptedMessage = EncryptedMessage.Unknown
|
encryptedMessage = EncryptedMessage.Unknown
|
||||||
),
|
),
|
||||||
// TODO MessagesTimelineItemImageContent(),
|
// TODO MessagesTimelineItemImageContent(),
|
||||||
MessagesTimelineItemNoticeContent(
|
TimelineItemNoticeContent(
|
||||||
body = "Notice",
|
body = "Notice",
|
||||||
htmlDocument = null
|
htmlDocument = null
|
||||||
),
|
),
|
||||||
MessagesTimelineItemRedactedContent,
|
TimelineItemRedactedContent,
|
||||||
MessagesTimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = "Text",
|
body = "Text",
|
||||||
htmlDocument = null
|
htmlDocument = null
|
||||||
),
|
),
|
||||||
MessagesTimelineItemUnknownContent,
|
TimelineItemUnknownContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class MessagesTimelineItemTextContent(
|
data class TimelineItemEmoteContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?
|
override val htmlDocument: Document?
|
||||||
) : MessagesTimelineItemTextBasedContent
|
) : TimelineItemTextBasedContent
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.matrix.rustcomponents.sdk.EncryptedMessage
|
||||||
|
|
||||||
data class MessagesTimelineItemEncryptedContent(
|
data class TimelineItemEncryptedContent(
|
||||||
val encryptedMessage: EncryptedMessage
|
val encryptedMessage: EncryptedMessage
|
||||||
) : MessagesTimelineItemContent
|
) : TimelineItemContent
|
||||||
|
|
@ -14,13 +14,13 @@
|
||||||
* limitations under the License.
|
* 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
|
import io.element.android.x.matrix.media.MediaResolver
|
||||||
|
|
||||||
data class MessagesTimelineItemImageContent(
|
data class TimelineItemImageContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
val imageMeta: MediaResolver.Meta,
|
val imageMeta: MediaResolver.Meta,
|
||||||
val blurhash: String?,
|
val blurhash: String?,
|
||||||
val aspectRatio: Float
|
val aspectRatio: Float
|
||||||
) : MessagesTimelineItemContent
|
) : TimelineItemContent
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class MessagesTimelineItemEmoteContent(
|
data class TimelineItemNoticeContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?
|
override val htmlDocument: Document?
|
||||||
) : MessagesTimelineItemTextBasedContent
|
) : TimelineItemTextBasedContent
|
||||||
|
|
@ -14,6 +14,6 @@
|
||||||
* limitations under the License.
|
* 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.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent {
|
sealed interface TimelineItemTextBasedContent : TimelineItemContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
}
|
}
|
||||||
|
|
@ -14,11 +14,11 @@
|
||||||
* limitations under the License.
|
* 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
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class MessagesTimelineItemNoticeContent(
|
data class TimelineItemTextContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?
|
override val htmlDocument: Document?
|
||||||
) : MessagesTimelineItemTextBasedContent
|
) : TimelineItemTextBasedContent
|
||||||
|
|
@ -14,6 +14,6 @@
|
||||||
* limitations under the License.
|
* 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.
|
* 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() {
|
internal inline fun <reified T> MutableList<T?>.invalidateLast() {
|
||||||
val indexOfLast = size
|
val indexOfLast = size
|
||||||
|
|
@ -29,9 +29,10 @@ dependencies {
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
implementation(project(":libraries:elementresources"))
|
implementation(project(":libraries:elementresources"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
implementation(libs.mavericks.compose)
|
implementation(project(":libraries:architecture"))
|
||||||
implementation(libs.accompanist.pager)
|
implementation(libs.accompanist.pager)
|
||||||
implementation(libs.accompanist.pagerindicator)
|
implementation(libs.accompanist.pagerindicator)
|
||||||
|
implementation(libs.appyx.core)
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
ksp(libs.showkase.processor)
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
import com.google.accompanist.pager.HorizontalPager
|
||||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||||
|
|
@ -55,25 +53,9 @@ import io.element.android.x.designsystem.components.VectorButton
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
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)
|
@OptIn(ExperimentalPagerApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnBoardingContent(
|
fun OnBoardingScreen(
|
||||||
state: OnBoardingViewState,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onPageChanged: (Int) -> Unit = {},
|
onPageChanged: (Int) -> Unit = {},
|
||||||
onSignUp: () -> Unit = {},
|
onSignUp: () -> Unit = {},
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@ import io.element.android.x.element.resources.R as ElementR
|
||||||
class SplashCarouselStateFactory {
|
class SplashCarouselStateFactory {
|
||||||
fun create(): SplashCarouselState {
|
fun create(): SplashCarouselState {
|
||||||
val lightTheme = true
|
val lightTheme = true
|
||||||
|
|
||||||
fun background(@DrawableRes lightDrawable: Int) =
|
fun background(@DrawableRes lightDrawable: Int) =
|
||||||
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
||||||
|
|
||||||
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
||||||
if (lightTheme) lightDrawable else darkDrawable
|
if (lightTheme) lightDrawable else darkDrawable
|
||||||
|
|
||||||
return SplashCarouselState(
|
return SplashCarouselState(
|
||||||
listOf(
|
listOf(
|
||||||
SplashCarouselState.Item(
|
SplashCarouselState.Item(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue