Merge branch 'develop' into feature/fga/room_list_api
This commit is contained in:
commit
e392f15872
114 changed files with 2437 additions and 170 deletions
|
|
@ -198,7 +198,6 @@ dependencies {
|
||||||
allLibrariesImpl()
|
allLibrariesImpl()
|
||||||
allServicesImpl()
|
allServicesImpl()
|
||||||
allFeaturesImpl(rootDir, logger)
|
allFeaturesImpl(rootDir, logger)
|
||||||
implementation(projects.libraries.deeplink)
|
|
||||||
implementation(projects.tests.uitests)
|
implementation(projects.tests.uitests)
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
implementation(projects.appnav)
|
implementation(projects.appnav)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
@ -33,6 +34,7 @@ import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
|
||||||
import io.element.android.x.di.AppBindings
|
import io.element.android.x.di.AppBindings
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -42,11 +44,13 @@ class MainActivity : NodeComponentActivity() {
|
||||||
|
|
||||||
private lateinit var mainNode: MainNode
|
private lateinit var mainNode: MainNode
|
||||||
|
|
||||||
|
private lateinit var appBindings: AppBindings
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
|
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val appBindings = bindings<AppBindings>()
|
appBindings = bindings<AppBindings>()
|
||||||
appBindings.matrixClientsHolder().restore(savedInstanceState)
|
appBindings.matrixClientsHolder().restore(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
|
|
@ -57,25 +61,29 @@ class MainActivity : NodeComponentActivity() {
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainContent(appBindings: AppBindings) {
|
private fun MainContent(appBindings: AppBindings) {
|
||||||
ElementTheme {
|
ElementTheme {
|
||||||
Box(
|
CompositionLocalProvider(
|
||||||
modifier = Modifier
|
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.background),
|
|
||||||
) {
|
) {
|
||||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
Box(
|
||||||
MainNode(
|
modifier = Modifier
|
||||||
it,
|
.fillMaxSize()
|
||||||
appBindings.mainDaggerComponentOwner(),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
plugins = listOf(
|
) {
|
||||||
object : NodeReadyObserver<MainNode> {
|
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||||
override fun init(node: MainNode) {
|
MainNode(
|
||||||
Timber.tag(loggerTag.value).w("onMainNodeInit")
|
it,
|
||||||
mainNode = node
|
appBindings.mainDaggerComponentOwner(),
|
||||||
mainNode.handleIntent(intent)
|
plugins = listOf(
|
||||||
|
object : NodeReadyObserver<MainNode> {
|
||||||
|
override fun init(node: MainNode) {
|
||||||
|
Timber.tag(loggerTag.value).w("onMainNodeInit")
|
||||||
|
mainNode = node
|
||||||
|
mainNode.handleIntent(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ package io.element.android.x.di
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import io.element.android.appnav.di.MatrixClientsHolder
|
import io.element.android.appnav.di.MatrixClientsHolder
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface AppBindings {
|
interface AppBindings {
|
||||||
fun matrixClientsHolder(): MatrixClientsHolder
|
fun matrixClientsHolder(): MatrixClientsHolder
|
||||||
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
|
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
|
||||||
|
fun snackbarDispatcher(): SnackbarDispatcher
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,8 +274,13 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||||
|
val callback = object : RoomFlowNode.Callback {
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
coroutineScope.launch { attachRoom(roomId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
||||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
|
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NavTarget.Settings -> {
|
NavTarget.Settings -> {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
|
@ -66,6 +67,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
|
}
|
||||||
|
|
||||||
interface LifecycleCallback : NodeLifecycleCallback {
|
interface LifecycleCallback : NodeLifecycleCallback {
|
||||||
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
|
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
|
||||||
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
|
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
|
||||||
|
|
@ -77,6 +82,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
|
|
@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
override fun onUserDataClicked(userId: UserId) {
|
override fun onUserDataClicked(userId: UserId) {
|
||||||
backstack.push(NavTarget.RoomMemberDetails(userId))
|
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messagesEntryPoint.createNode(this, buildContext, callback)
|
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/486.feature
Normal file
1
changelog.d/486.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Allow forawrding messages from one room to another
|
||||||
1
changelog.d/489.feature
Normal file
1
changelog.d/489.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add option to report inappropriate content
|
||||||
1
changelog.d/627.feature
Normal file
1
changelog.d/627.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add analytics events for room creation
|
||||||
1
changelog.d/663.feature
Normal file
1
changelog.d/663.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add 'Copy' action to timeline item context menu, for text events
|
||||||
|
|
@ -31,6 +31,7 @@ class FakeAnalyticsService(
|
||||||
|
|
||||||
private var isEnabledFlow = MutableStateFlow(isEnabled)
|
private var isEnabledFlow = MutableStateFlow(isEnabled)
|
||||||
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
||||||
|
var capturedEvents = mutableListOf<VectorAnalyticsEvent>()
|
||||||
|
|
||||||
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
|
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@ class FakeAnalyticsService(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun capture(event: VectorAnalyticsEvent) {
|
override fun capture(event: VectorAnalyticsEvent) {
|
||||||
|
capturedEvents += event
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun screen(screen: VectorAnalyticsScreen) {
|
override fun screen(screen: VectorAnalyticsScreen) {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ dependencies {
|
||||||
implementation(projects.libraries.mediapickers.api)
|
implementation(projects.libraries.mediapickers.api)
|
||||||
implementation(projects.libraries.mediaupload.api)
|
implementation(projects.libraries.mediaupload.api)
|
||||||
implementation(projects.libraries.usersearch.impl)
|
implementation(projects.libraries.usersearch.impl)
|
||||||
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
api(projects.features.createroom.api)
|
api(projects.features.createroom.api)
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(projects.features.analytics.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.mediapickers.test)
|
testImplementation(projects.libraries.mediapickers.test)
|
||||||
testImplementation(projects.libraries.mediaupload.test)
|
testImplementation(projects.libraries.mediaupload.test)
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,35 @@ package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.createroom.impl.di.CreateRoomScope
|
import io.element.android.features.createroom.impl.di.CreateRoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
||||||
@ContributesNode(CreateRoomScope::class)
|
@ContributesNode(CreateRoomScope::class)
|
||||||
class ConfigureRoomNode @AssistedInject constructor(
|
class ConfigureRoomNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: ConfigureRoomPresenter,
|
private val presenter: ConfigureRoomPresenter,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onResume = {
|
||||||
|
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onCreateRoomSuccess(roomId: RoomId)
|
fun onCreateRoomSuccess(roomId: RoomId)
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +62,7 @@ class ConfigureRoomNode @AssistedInject constructor(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = this::navigateUp,
|
onBackPressed = this::navigateUp,
|
||||||
onRoomCreated = this::onRoomCreated
|
onRoomCreated = this::onRoomCreated,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -49,6 +51,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
) : Presenter<ConfigureRoomState> {
|
) : Presenter<ConfigureRoomState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -124,7 +127,10 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
avatar = avatarUrl,
|
avatar = avatarUrl,
|
||||||
)
|
)
|
||||||
matrixClient.createRoom(params).getOrThrow()
|
matrixClient.createRoom(params).getOrThrow()
|
||||||
.also { dataStore.clearCachedData() }
|
.also {
|
||||||
|
dataStore.clearCachedData()
|
||||||
|
analyticsService.capture(CreatedRoom(isDM = false))
|
||||||
|
}
|
||||||
}.execute(createRoomAction)
|
}.execute(createRoomAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
|
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
|
@ -34,6 +36,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||||
import io.element.android.libraries.ui.strings.R
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -43,6 +46,7 @@ class CreateRoomRootNode @AssistedInject constructor(
|
||||||
private val presenter: CreateRoomRootPresenter,
|
private val presenter: CreateRoomRootPresenter,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
|
@ -60,6 +64,12 @@ class CreateRoomRootNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -70,7 +80,7 @@ class CreateRoomRootNode @AssistedInject constructor(
|
||||||
onClosePressed = this::navigateUp,
|
onClosePressed = this::navigateUp,
|
||||||
onNewRoomClicked = callback::onCreateNewRoom,
|
onNewRoomClicked = callback::onCreateNewRoom,
|
||||||
onOpenDM = callback::onStartChatSuccess,
|
onOpenDM = callback::onStartChatSuccess,
|
||||||
onInviteFriendsClicked = { invitePeople(context) }
|
onInviteFriendsClicked = { invitePeople(context) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||||
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListPresenter
|
import io.element.android.features.createroom.impl.userlist.UserListPresenter
|
||||||
|
|
@ -28,10 +29,12 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenterArg
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.execute
|
import io.element.android.libraries.architecture.execute
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.usersearch.api.UserRepository
|
import io.element.android.libraries.usersearch.api.UserRepository
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -41,6 +44,8 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
private val userListDataStore: UserListDataStore,
|
private val userListDataStore: UserListDataStore,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
) : Presenter<CreateRoomRootState> {
|
) : Presenter<CreateRoomRootState> {
|
||||||
|
|
||||||
private val presenter by lazy {
|
private val presenter by lazy {
|
||||||
|
|
@ -79,6 +84,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateRoomRootState(
|
return CreateRoomRootState(
|
||||||
|
applicationName = buildMeta.applicationName,
|
||||||
userListState = userListState,
|
userListState = userListState,
|
||||||
startDmAction = startDmAction.value,
|
startDmAction = startDmAction.value,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
|
|
@ -88,6 +94,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
matrixClient.createDM(user.userId).getOrThrow()
|
matrixClient.createDM(user.userId).getOrThrow()
|
||||||
|
.also { analyticsService.capture(CreatedRoom(isDM = true)) }
|
||||||
}.execute(startDmAction)
|
}.execute(startDmAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
data class CreateRoomRootState(
|
data class CreateRoomRootState(
|
||||||
|
val applicationName: String,
|
||||||
val userListState: UserListState,
|
val userListState: UserListState,
|
||||||
val startDmAction: Async<RoomId>,
|
val startDmAction: Async<RoomId>,
|
||||||
val eventSink: (CreateRoomRootEvents) -> Unit,
|
val eventSink: (CreateRoomRootEvents) -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
|
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
|
||||||
|
|
@ -33,7 +34,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
userListState = aMatrixUser().let {
|
userListState = aMatrixUser().let {
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchQuery = it.userId.value,
|
searchQuery = it.userId.value,
|
||||||
searchResults = SearchBarResultState.Results(persistentListOf(it)),
|
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
|
||||||
selectedUsers = persistentListOf(it),
|
selectedUsers = persistentListOf(it),
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
)
|
)
|
||||||
|
|
@ -44,7 +45,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
userListState = aMatrixUser().let {
|
userListState = aMatrixUser().let {
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchQuery = it.userId.value,
|
searchQuery = it.userId.value,
|
||||||
searchResults = SearchBarResultState.Results(persistentListOf(it)),
|
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
|
||||||
selectedUsers = persistentListOf(it),
|
selectedUsers = persistentListOf(it),
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
)
|
)
|
||||||
|
|
@ -55,6 +56,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
|
|
||||||
fun aCreateRoomRootState() = CreateRoomRootState(
|
fun aCreateRoomRootState() = CreateRoomRootState(
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
applicationName = "Element X Preview",
|
||||||
startDmAction = Async.Uninitialized,
|
startDmAction = Async.Uninitialized,
|
||||||
userListState = aUserListState(),
|
userListState = aUserListState(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ fun CreateRoomRootView(
|
||||||
|
|
||||||
if (!state.userListState.isSearchActive) {
|
if (!state.userListState.isSearchActive) {
|
||||||
CreateRoomActionButtonsList(
|
CreateRoomActionButtonsList(
|
||||||
|
state = state,
|
||||||
onNewRoomClicked = onNewRoomClicked,
|
onNewRoomClicked = onNewRoomClicked,
|
||||||
onInvitePeopleClicked = onInviteFriendsClicked,
|
onInvitePeopleClicked = onInviteFriendsClicked,
|
||||||
)
|
)
|
||||||
|
|
@ -155,6 +156,7 @@ fun CreateRoomRootViewTopBar(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateRoomActionButtonsList(
|
fun CreateRoomActionButtonsList(
|
||||||
|
state: CreateRoomRootState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onNewRoomClicked: () -> Unit = {},
|
onNewRoomClicked: () -> Unit = {},
|
||||||
onInvitePeopleClicked: () -> Unit = {},
|
onInvitePeopleClicked: () -> Unit = {},
|
||||||
|
|
@ -167,7 +169,7 @@ fun CreateRoomActionButtonsList(
|
||||||
)
|
)
|
||||||
CreateRoomActionButton(
|
CreateRoomActionButton(
|
||||||
iconRes = DrawableR.drawable.ic_share,
|
iconRes = DrawableR.drawable.ic_share,
|
||||||
text = stringResource(id = R.string.screen_create_room_action_invite_people),
|
text = stringResource(id = StringR.string.action_invite_friends_to_app, state.applicationName),
|
||||||
onClick = onInvitePeopleClicked,
|
onClick = onInvitePeopleClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||||
|
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||||
|
|
@ -62,6 +64,7 @@ class ConfigureRoomPresenterTests {
|
||||||
private lateinit var fakeMatrixClient: FakeMatrixClient
|
private lateinit var fakeMatrixClient: FakeMatrixClient
|
||||||
private lateinit var fakePickerProvider: FakePickerProvider
|
private lateinit var fakePickerProvider: FakePickerProvider
|
||||||
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
|
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
|
||||||
|
private lateinit var fakeAnalyticsService: FakeAnalyticsService
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
|
|
@ -70,11 +73,13 @@ class ConfigureRoomPresenterTests {
|
||||||
createRoomDataStore = CreateRoomDataStore(userListDataStore)
|
createRoomDataStore = CreateRoomDataStore(userListDataStore)
|
||||||
fakePickerProvider = FakePickerProvider()
|
fakePickerProvider = FakePickerProvider()
|
||||||
fakeMediaPreProcessor = FakeMediaPreProcessor()
|
fakeMediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
fakeAnalyticsService = FakeAnalyticsService()
|
||||||
presenter = ConfigureRoomPresenter(
|
presenter = ConfigureRoomPresenter(
|
||||||
dataStore = createRoomDataStore,
|
dataStore = createRoomDataStore,
|
||||||
matrixClient = fakeMatrixClient,
|
matrixClient = fakeMatrixClient,
|
||||||
mediaPickerProvider = fakePickerProvider,
|
mediaPickerProvider = fakePickerProvider,
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
|
analyticsService = fakeAnalyticsService,
|
||||||
)
|
)
|
||||||
|
|
||||||
mockkStatic(File::readBytes)
|
mockkStatic(File::readBytes)
|
||||||
|
|
@ -214,6 +219,25 @@ class ConfigureRoomPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - record analytics when creating room`() = runTest {
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
|
||||||
|
|
||||||
|
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
|
||||||
|
|
||||||
|
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||||
|
skipItems(2)
|
||||||
|
|
||||||
|
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
|
||||||
|
assertThat(analyticsEvent).isNotNull()
|
||||||
|
assertThat(analyticsEvent?.isDM).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - trigger create room with upload error and retry`() = runTest {
|
fun `present - trigger create room with upload error and retry`() = runTest {
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -229,6 +253,7 @@ class ConfigureRoomPresenterTests {
|
||||||
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val stateAfterCreateRoom = awaitItem()
|
val stateAfterCreateRoom = awaitItem()
|
||||||
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
|
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||||
|
|
||||||
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
|
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
|
||||||
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,15 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||||
|
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
||||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
||||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -43,17 +47,21 @@ class CreateRoomRootPresenterTests {
|
||||||
private lateinit var presenter: CreateRoomRootPresenter
|
private lateinit var presenter: CreateRoomRootPresenter
|
||||||
private lateinit var fakeUserListPresenter: FakeUserListPresenter
|
private lateinit var fakeUserListPresenter: FakeUserListPresenter
|
||||||
private lateinit var fakeMatrixClient: FakeMatrixClient
|
private lateinit var fakeMatrixClient: FakeMatrixClient
|
||||||
|
private lateinit var fakeAnalyticsService: FakeAnalyticsService
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
fakeUserListPresenter = FakeUserListPresenter()
|
fakeUserListPresenter = FakeUserListPresenter()
|
||||||
fakeMatrixClient = FakeMatrixClient()
|
fakeMatrixClient = FakeMatrixClient()
|
||||||
|
fakeAnalyticsService = FakeAnalyticsService()
|
||||||
userRepository = FakeUserRepository()
|
userRepository = FakeUserRepository()
|
||||||
presenter = CreateRoomRootPresenter(
|
presenter = CreateRoomRootPresenter(
|
||||||
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
|
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
|
||||||
userRepository = userRepository,
|
userRepository = userRepository,
|
||||||
userListDataStore = UserListDataStore(),
|
userListDataStore = UserListDataStore(),
|
||||||
matrixClient = fakeMatrixClient
|
matrixClient = fakeMatrixClient,
|
||||||
|
analyticsService = fakeAnalyticsService,
|
||||||
|
buildMeta = aBuildMeta(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +71,11 @@ class CreateRoomRootPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState)
|
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
|
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||||
|
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||||
|
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||||
|
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +99,27 @@ class CreateRoomRootPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - creating a DM records analytics event`() = runTest {
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||||
|
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
|
||||||
|
|
||||||
|
fakeMatrixClient.givenFindDmResult(null)
|
||||||
|
fakeMatrixClient.givenCreateDmResult(createDmResult)
|
||||||
|
|
||||||
|
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||||
|
skipItems(2)
|
||||||
|
|
||||||
|
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
|
||||||
|
assertThat(analyticsEvent).isNotNull()
|
||||||
|
assertThat(analyticsEvent?.isDM).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - trigger retrieve DM action`() = runTest {
|
fun `present - trigger retrieve DM action`() = runTest {
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -102,6 +135,7 @@ class CreateRoomRootPresenterTests {
|
||||||
val stateAfterStartDM = awaitItem()
|
val stateAfterStartDM = awaitItem()
|
||||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
||||||
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
|
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +158,7 @@ class CreateRoomRootPresenterTests {
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val stateAfterStartDM = awaitItem()
|
val stateAfterStartDM = awaitItem()
|
||||||
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
|
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||||
|
|
||||||
// Cancel
|
// Cancel
|
||||||
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
|
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||||
|
|
@ -135,6 +170,7 @@ class CreateRoomRootPresenterTests {
|
||||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val stateAfterSecondAttempt = awaitItem()
|
val stateAfterSecondAttempt = awaitItem()
|
||||||
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
|
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
|
||||||
|
|
||||||
// Retry with success
|
// Retry with success
|
||||||
fakeMatrixClient.givenCreateDmError(null)
|
fakeMatrixClient.givenCreateDmError(null)
|
||||||
|
|
@ -147,3 +183,18 @@ class CreateRoomRootPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aBuildMeta() =
|
||||||
|
BuildMeta(
|
||||||
|
buildType = BuildType.DEBUG,
|
||||||
|
isDebuggable = true,
|
||||||
|
applicationId = "",
|
||||||
|
applicationName = "An Application",
|
||||||
|
lowPrivacyLoggingEnabled = true,
|
||||||
|
versionName = "",
|
||||||
|
gitRevision = "",
|
||||||
|
gitBranchName = "",
|
||||||
|
gitRevisionDate = "",
|
||||||
|
flavorDescription = "",
|
||||||
|
flavorShortDescription = "",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
|
|
@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
fun onUserDataClicked(userId: UserId)
|
fun onUserDataClicked(userId: UserId)
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,21 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||||
|
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
|
||||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
|
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
@ -78,6 +82,12 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ForwardEvent(val eventId: EventId) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||||
|
|
@ -105,6 +115,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
backstack.push(NavTarget.ForwardEvent(eventId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReportMessage(eventId: EventId, senderId: UserId) {
|
||||||
|
backstack.push(NavTarget.ReportMessage(eventId, senderId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +142,19 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
||||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
|
is NavTarget.ForwardEvent -> {
|
||||||
|
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
|
||||||
|
val callback = object : ForwardMessagesNode.Callback {
|
||||||
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
|
this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback))
|
||||||
|
}
|
||||||
|
is NavTarget.ReportMessage -> {
|
||||||
|
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
|
||||||
|
createNode<ReportMessageNode>(buildContext, listOf(inputs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +210,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
Children(
|
Children(
|
||||||
navModel = backstack,
|
navModel = backstack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
transitionHandler = rememberDefaultTransitionHandler(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.features.messages.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
interface MessagesNavigator {
|
||||||
|
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
|
fun onReportContentClicked(eventId: EventId, senderId: UserId)
|
||||||
|
}
|
||||||
|
|
@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
class MessagesNode @AssistedInject constructor(
|
class MessagesNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: MessagesPresenter,
|
private val presenterFactory: MessagesPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(this)
|
||||||
private val callback = plugins<Callback>().firstOrNull()
|
private val callback = plugins<Callback>().firstOrNull()
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
|
@ -48,6 +49,8 @@ class MessagesNode @AssistedInject constructor(
|
||||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
fun onUserDataClicked(userId: UserId)
|
fun onUserDataClicked(userId: UserId)
|
||||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
|
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRoomDetailsClicked() {
|
private fun onRoomDetailsClicked() {
|
||||||
|
|
@ -65,11 +68,18 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private fun onUserDataClicked(userId: UserId) {
|
private fun onUserDataClicked(userId: UserId) {
|
||||||
callback?.onUserDataClicked(userId)
|
callback?.onUserDataClicked(userId)
|
||||||
}
|
}
|
||||||
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
|
||||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
callback?.onForwardEventClicked(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
|
||||||
|
callback?.onReportMessage(eventId, senderId)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -80,7 +90,6 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onEventClicked = this::onEventClicked,
|
onEventClicked = this::onEventClicked,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
onUserDataClicked = this::onUserDataClicked,
|
onUserDataClicked = this::onUserDataClicked,
|
||||||
onItemDebugInfoClicked = this::onShowEventDebugInfoClicked,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
|
@ -25,6 +26,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
|
@ -47,11 +51,13 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -63,9 +69,8 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class MessagesPresenter @Inject constructor(
|
class MessagesPresenter @AssistedInject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val composerPresenter: MessageComposerPresenter,
|
private val composerPresenter: MessageComposerPresenter,
|
||||||
private val timelinePresenter: TimelinePresenter,
|
private val timelinePresenter: TimelinePresenter,
|
||||||
|
|
@ -76,8 +81,15 @@ class MessagesPresenter @Inject constructor(
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val clipboardHelper: ClipboardHelper,
|
||||||
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(navigator: MessagesNavigator): MessagesPresenter
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -146,13 +158,13 @@ class MessagesPresenter @Inject constructor(
|
||||||
composerState: MessageComposerState,
|
composerState: MessageComposerState,
|
||||||
) = launch {
|
) = launch {
|
||||||
when (action) {
|
when (action) {
|
||||||
TimelineItemAction.Copy -> notImplementedYet()
|
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
|
||||||
TimelineItemAction.Forward -> notImplementedYet()
|
|
||||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||||
TimelineItemAction.Developer -> Unit // Handled at UI level
|
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
|
||||||
TimelineItemAction.ReportContent -> notImplementedYet()
|
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||||
|
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,4 +234,33 @@ class MessagesPresenter @Inject constructor(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
||||||
|
if (event.eventId == null) return
|
||||||
|
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleForwardAction(event: TimelineItem.Event) {
|
||||||
|
if (event.eventId == null) return
|
||||||
|
navigator.onForwardEventClicked(event.eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReportAction(event: TimelineItem.Event) {
|
||||||
|
if (event.eventId == null) return
|
||||||
|
navigator.onReportContentClicked(event.eventId, event.senderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleCopyContents(event: TimelineItem.Event) {
|
||||||
|
val content = when (event.content) {
|
||||||
|
is TimelineItemTextBasedContent -> event.content.body
|
||||||
|
is TimelineItemStateContent -> event.content.body
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboardHelper.copyPlainText(content)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_message_copied))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
|
@ -69,18 +67,15 @@ import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -95,7 +90,6 @@ fun MessagesView(
|
||||||
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
|
|
@ -121,12 +115,7 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
when (action) {
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
is TimelineItemAction.Developer -> if (event.eventId != null) {
|
|
||||||
onItemDebugInfoClicked(event.eventId, event.debugInfo)
|
|
||||||
}
|
|
||||||
else -> state.eventSink(MessagesEvents.HandleAction(action, event))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
|
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
|
||||||
|
|
@ -261,12 +250,7 @@ fun MessagesViewTopBar(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBackPressed) {
|
BackButton(onClick = onBackPressed)
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -331,6 +315,5 @@ private fun ContentToPreview(state: MessagesState) {
|
||||||
onEventClicked = {},
|
onEventClicked = {},
|
||||||
onPreviewAttachments = {},
|
onPreviewAttachments = {},
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onItemDebugInfoClicked = { _, _ -> },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -64,7 +65,9 @@ class ActionListPresenter @Inject constructor(
|
||||||
is TimelineItemRedactedContent,
|
is TimelineItemRedactedContent,
|
||||||
is TimelineItemStateContent -> {
|
is TimelineItemStateContent -> {
|
||||||
buildList {
|
buildList {
|
||||||
add(TimelineItemAction.Copy)
|
if (timelineItem.content.canBeCopied()) {
|
||||||
|
add(TimelineItemAction.Copy)
|
||||||
|
}
|
||||||
if (buildMeta.isDebuggable) {
|
if (buildMeta.isDebuggable) {
|
||||||
add(TimelineItemAction.Developer)
|
add(TimelineItemAction.Developer)
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +79,9 @@ class ActionListPresenter @Inject constructor(
|
||||||
if (timelineItem.isMine) {
|
if (timelineItem.isMine) {
|
||||||
add(TimelineItemAction.Edit)
|
add(TimelineItemAction.Edit)
|
||||||
}
|
}
|
||||||
add(TimelineItemAction.Copy)
|
if (timelineItem.content.canBeCopied()) {
|
||||||
|
add(TimelineItemAction.Copy)
|
||||||
|
}
|
||||||
if (buildMeta.isDebuggable) {
|
if (buildMeta.isDebuggable) {
|
||||||
add(TimelineItemAction.Developer)
|
add(TimelineItemAction.Developer)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.forward
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
|
||||||
|
sealed interface ForwardMessagesEvents {
|
||||||
|
data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
|
||||||
|
// TODO remove to restore multi-selection
|
||||||
|
object RemoveSelectedRoom : ForwardMessagesEvents
|
||||||
|
object ToggleSearchActive : ForwardMessagesEvents
|
||||||
|
data class UpdateQuery(val query: String) : ForwardMessagesEvents
|
||||||
|
object ForwardEvent : ForwardMessagesEvents
|
||||||
|
object ClearError : ForwardMessagesEvents
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class ForwardMessagesNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: ForwardMessagesPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Inputs(val eventId: EventId) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs = inputs<Inputs>()
|
||||||
|
private val presenter = presenterFactory.create(inputs.eventId.value)
|
||||||
|
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||||
|
|
||||||
|
private fun onSucceeded(roomIds: ImmutableList<RoomId>) {
|
||||||
|
navigateUp()
|
||||||
|
if (roomIds.size == 1) {
|
||||||
|
val targetRoomId = roomIds.first()
|
||||||
|
callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
ForwardMessagesView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = ::navigateUp,
|
||||||
|
onForwardingSucceeded = ::onSucceeded,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.isLoading
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
|
@Assisted eventId: String,
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val matrixCoroutineScope: CoroutineScope,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
) : Presenter<ForwardMessagesState> {
|
||||||
|
|
||||||
|
private val eventId: EventId = EventId(eventId)
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(eventId: String): ForwardMessagesPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ForwardMessagesState {
|
||||||
|
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
|
||||||
|
var query by remember { mutableStateOf<String>("") }
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
|
||||||
|
val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
||||||
|
val summaries by client.roomSummaryDataSource.roomList().collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(query, summaries) {
|
||||||
|
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
|
||||||
|
.map { it.details }
|
||||||
|
.filter { it.name.contains(query, ignoreCase = true) }
|
||||||
|
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
|
||||||
|
.toPersistentList()
|
||||||
|
results = if (filteredSummaries.isNotEmpty()) {
|
||||||
|
SearchBarResultState.Results(filteredSummaries)
|
||||||
|
} else {
|
||||||
|
SearchBarResultState.NoResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val forwardingSucceeded by remember {
|
||||||
|
derivedStateOf { forwardingActionState.value.dataOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: ForwardMessagesEvents) {
|
||||||
|
when (event) {
|
||||||
|
is ForwardMessagesEvents.SetSelectedRoom -> {
|
||||||
|
selectedRooms = persistentListOf(event.room)
|
||||||
|
// Restore for multi-selection
|
||||||
|
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
|
||||||
|
// selectedRooms = if (index >= 0) {
|
||||||
|
// selectedRooms.removeAt(index)
|
||||||
|
// } else {
|
||||||
|
// selectedRooms.add(event.room)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
|
||||||
|
is ForwardMessagesEvents.UpdateQuery -> query = event.query
|
||||||
|
ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
|
||||||
|
ForwardMessagesEvents.ForwardEvent -> {
|
||||||
|
isSearchActive = false
|
||||||
|
val roomIds = selectedRooms.map { it.roomId }.toPersistentList()
|
||||||
|
matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState)
|
||||||
|
}
|
||||||
|
ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForwardMessagesState(
|
||||||
|
resultState = results,
|
||||||
|
query = query,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
isForwarding = forwardingActionState.value.isLoading(),
|
||||||
|
error = (forwardingActionState.value as? Async.Failure)?.error,
|
||||||
|
forwardingSucceeded = forwardingSucceeded,
|
||||||
|
eventSink = { handleEvents(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.forwardEvent(
|
||||||
|
eventId: EventId,
|
||||||
|
roomIds: ImmutableList<RoomId>,
|
||||||
|
isForwardMessagesState: MutableState<Async<ImmutableList<RoomId>>>,
|
||||||
|
) = launch {
|
||||||
|
isForwardMessagesState.value = Async.Loading()
|
||||||
|
room.forwardEvent(eventId, roomIds).fold(
|
||||||
|
{ isForwardMessagesState.value = Async.Success(roomIds) },
|
||||||
|
{ isForwardMessagesState.value = Async.Failure(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.forward
|
||||||
|
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class ForwardMessagesState(
|
||||||
|
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>,
|
||||||
|
val query: String,
|
||||||
|
val isSearchActive: Boolean,
|
||||||
|
val selectedRooms: ImmutableList<RoomSummaryDetails>,
|
||||||
|
val isForwarding: Boolean,
|
||||||
|
val error: Throwable?,
|
||||||
|
val forwardingSucceeded: ImmutableList<RoomId>?,
|
||||||
|
val eventSink: (ForwardMessagesEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.api.room.message.RoomMessage
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
|
||||||
|
override val values: Sequence<ForwardMessagesState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aForwardMessagesState(),
|
||||||
|
aForwardMessagesState(query = "Test"),
|
||||||
|
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
|
||||||
|
aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain")))
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
isForwarding = true,
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
forwardingSucceeded = persistentListOf(RoomId("!room2:domain")),
|
||||||
|
),
|
||||||
|
aForwardMessagesState(
|
||||||
|
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
|
||||||
|
query = "Test",
|
||||||
|
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))),
|
||||||
|
error = Throwable("error"),
|
||||||
|
),
|
||||||
|
// Add other states here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aForwardMessagesState(
|
||||||
|
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
|
||||||
|
query: String = "",
|
||||||
|
isSearchActive: Boolean = false,
|
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
|
||||||
|
isForwarding: Boolean = false,
|
||||||
|
error: Throwable? = null,
|
||||||
|
forwardingSucceeded: ImmutableList<RoomId>? = null,
|
||||||
|
) = ForwardMessagesState(
|
||||||
|
resultState = resultState,
|
||||||
|
query = query,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
isForwarding = isForwarding,
|
||||||
|
error = error,
|
||||||
|
forwardingSucceeded = forwardingSucceeded,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun aForwardMessagesRoomList() = listOf(
|
||||||
|
aRoomDetailsState(),
|
||||||
|
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aRoomDetailsState(
|
||||||
|
roomId: RoomId = RoomId("!room:domain"),
|
||||||
|
name: String = "roomName",
|
||||||
|
canonicalAlias: String? = null,
|
||||||
|
isDirect: Boolean = true,
|
||||||
|
avatarURLString: String? = null,
|
||||||
|
lastMessage: RoomMessage? = null,
|
||||||
|
lastMessageTimestamp: Long? = null,
|
||||||
|
unreadNotificationCount: Int = 0,
|
||||||
|
inviter: RoomMember? = null,
|
||||||
|
) = RoomSummaryDetails(
|
||||||
|
roomId = roomId,
|
||||||
|
name = name,
|
||||||
|
canonicalAlias = canonicalAlias,
|
||||||
|
isDirect = isDirect,
|
||||||
|
avatarURLString = avatarURLString,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = lastMessageTimestamp,
|
||||||
|
unreadNotificationCount = unreadNotificationCount,
|
||||||
|
inviter = inviter,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.forward
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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 io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Divider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.RadioButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
|
||||||
|
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.ui.components.SelectedRoom
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesView(
|
||||||
|
state: ForwardMessagesState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (state.forwardingSucceeded != null) {
|
||||||
|
onForwardingSucceeded(state.forwardingSucceeded)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
|
||||||
|
// TODO toggle selection when multi-selection is enabled
|
||||||
|
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) {
|
||||||
|
if (isForwarding) return
|
||||||
|
SelectedRooms(
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
onRoomRemoved = ::onRoomRemoved,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackButton(state: ForwardMessagesState) {
|
||||||
|
if (state.isSearchActive) {
|
||||||
|
state.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = { onBackButton(state) })
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) },
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = { onBackButton(state) })
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(
|
||||||
|
enabled = state.selectedRooms.isNotEmpty(),
|
||||||
|
onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(StringR.string.action_send))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.consumeWindowInsets(paddingValues)
|
||||||
|
) {
|
||||||
|
SearchBar<ImmutableList<RoomSummaryDetails>>(
|
||||||
|
placeHolderTitle = stringResource(StringR.string.action_search),
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) },
|
||||||
|
active = state.isSearchActive,
|
||||||
|
onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) },
|
||||||
|
resultState = state.resultState,
|
||||||
|
showBackButton = false,
|
||||||
|
) { summaries ->
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
SelectedRoomsHelper(
|
||||||
|
isForwarding = state.isForwarding,
|
||||||
|
selectedRooms = state.selectedRooms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(summaries, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
Column {
|
||||||
|
RoomSummaryView(
|
||||||
|
roomSummary,
|
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||||
|
onSelection = { roomSummary ->
|
||||||
|
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Divider(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.isSearchActive) {
|
||||||
|
// TODO restore for multi-selection
|
||||||
|
// SelectedRoomsHelper(
|
||||||
|
// isForwarding = state.isForwarding,
|
||||||
|
// selectedRooms = state.selectedRooms
|
||||||
|
// )
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
if (state.resultState is SearchBarResultState.Results) {
|
||||||
|
LazyColumn {
|
||||||
|
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
Column {
|
||||||
|
RoomSummaryView(
|
||||||
|
roomSummary,
|
||||||
|
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||||
|
onSelection = { roomSummary ->
|
||||||
|
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Divider(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isForwarding) {
|
||||||
|
ProgressDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRooms(
|
||||||
|
selectedRooms: ImmutableList<RoomSummaryDetails>,
|
||||||
|
onRoomRemoved: (RoomSummaryDetails) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
|
) {
|
||||||
|
items(selectedRooms, key = { it.roomId.value }) { roomSummary ->
|
||||||
|
SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RoomSummaryView(
|
||||||
|
summary: RoomSummaryDetails,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onSelection: (RoomSummaryDetails) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clickable { onSelection(summary) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val roomAlias = summary.canonicalAlias ?: summary.roomId.value
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.alignByBaseline()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
// Name
|
||||||
|
Text(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
text = summary.name,
|
||||||
|
color = MaterialTheme.roomListRoomName(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
// Id
|
||||||
|
Text(
|
||||||
|
text = roomAlias,
|
||||||
|
color = MaterialTheme.roomListRoomMessage(),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RadioButton(selected = isSelected, onClick = { onSelection(summary) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
ErrorDialog(
|
||||||
|
content = ErrorDialogDefaults.title,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: ForwardMessagesState) {
|
||||||
|
ForwardMessagesView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = {},
|
||||||
|
onForwardingSucceeded = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.report
|
||||||
|
|
||||||
|
sealed interface ReportMessageEvents {
|
||||||
|
data class UpdateReason(val reason: String) : ReportMessageEvents
|
||||||
|
object ToggleBlockUser : ReportMessageEvents
|
||||||
|
object Report : ReportMessageEvents
|
||||||
|
object ClearError : ReportMessageEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.report
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class ReportMessageNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: ReportMessagePresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val eventId: EventId,
|
||||||
|
val senderId: UserId,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs = inputs<Inputs>()
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(
|
||||||
|
ReportMessagePresenter.Inputs(inputs.eventId, inputs.senderId)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
ReportMessageView(
|
||||||
|
state = state,
|
||||||
|
onBackClicked = ::navigateUp,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.report
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.executeResult
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
class ReportMessagePresenter @AssistedInject constructor(
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
@Assisted private val inputs: Inputs,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
) : Presenter<ReportMessageState> {
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val eventId: EventId,
|
||||||
|
val senderId: UserId,
|
||||||
|
)
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(inputs: Inputs): ReportMessagePresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ReportMessageState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var reason by rememberSaveable { mutableStateOf("") }
|
||||||
|
var blockUser by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var result: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
||||||
|
fun handleEvents(event: ReportMessageEvents) {
|
||||||
|
when (event) {
|
||||||
|
is ReportMessageEvents.UpdateReason -> reason = event.reason
|
||||||
|
ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser
|
||||||
|
ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result)
|
||||||
|
ReportMessageEvents.ClearError -> result.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReportMessageState(
|
||||||
|
reason = reason,
|
||||||
|
blockUser = blockUser,
|
||||||
|
result = result.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.report(
|
||||||
|
eventId: EventId,
|
||||||
|
userId: UserId,
|
||||||
|
reason: String,
|
||||||
|
blockUser: Boolean,
|
||||||
|
result: MutableState<Async<Unit>>,
|
||||||
|
) = launch {
|
||||||
|
suspend {
|
||||||
|
val userIdToBlock = userId.takeIf { blockUser }
|
||||||
|
room.reportContent(eventId, reason, userIdToBlock)
|
||||||
|
.onSuccess {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted))
|
||||||
|
}
|
||||||
|
}.executeResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.report
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
|
data class ReportMessageState(
|
||||||
|
val reason: String,
|
||||||
|
val blockUser: Boolean,
|
||||||
|
val result: Async<Unit>,
|
||||||
|
val eventSink: (ReportMessageEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.report
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
|
open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageState> {
|
||||||
|
override val values: Sequence<ReportMessageState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aReportMessageState(),
|
||||||
|
aReportMessageState(reason = "This user is making the chat very toxic."),
|
||||||
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
|
||||||
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()),
|
||||||
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable())),
|
||||||
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)),
|
||||||
|
// Add other states here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aReportMessageState(
|
||||||
|
reason: String = "",
|
||||||
|
blockUser: Boolean = false,
|
||||||
|
result: Async<Unit> = Async.Uninitialized,
|
||||||
|
) = ReportMessageState(
|
||||||
|
reason = reason,
|
||||||
|
blockUser = blockUser,
|
||||||
|
result = result,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.report
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ReportMessageView(
|
||||||
|
state: ReportMessageState,
|
||||||
|
onBackClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val isSending = state.result is Async.Loading
|
||||||
|
when (state.result) {
|
||||||
|
is Async.Success -> {
|
||||||
|
LaunchedEffect(state.result) {
|
||||||
|
onBackClicked()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
is Async.Failure -> {
|
||||||
|
ErrorDialog(
|
||||||
|
content = stringResource(StringR.string.error_unknown),
|
||||||
|
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(StringR.string.action_report_content),
|
||||||
|
style = ElementTextStyles.Regular.callout,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = onBackClicked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.consumeWindowInsets(padding)
|
||||||
|
.imePadding()
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.reason,
|
||||||
|
onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) },
|
||||||
|
placeholder = { Text(stringResource(StringR.string.report_content_hint)) },
|
||||||
|
enabled = !isSending,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 90.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(StringR.string.report_content_explanation),
|
||||||
|
style = ElementTextStyles.Regular.caption1,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.padding(top = 4.dp, bottom = 24.dp, start = 16.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(StringR.string.screen_report_content_block_user),
|
||||||
|
style = ElementTextStyles.Regular.callout,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(StringR.string.screen_report_content_block_user_hint),
|
||||||
|
style = ElementTextStyles.Regular.bodyMD,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
enabled = !isSending,
|
||||||
|
checked = state.blockUser,
|
||||||
|
onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
ButtonWithProgress(
|
||||||
|
text = stringResource(StringR.string.action_send),
|
||||||
|
enabled = state.reason.isNotBlank() && !isSending,
|
||||||
|
showProgress = isSending,
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
state.eventSink(ReportMessageEvents.Report)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ReportMessageViewLightPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ReportMessageViewDarkPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: ReportMessageState) {
|
||||||
|
ReportMessageView(
|
||||||
|
onBackClicked = {},
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,3 +22,14 @@ import androidx.compose.runtime.Immutable
|
||||||
sealed interface TimelineItemEventContent {
|
sealed interface TimelineItemEventContent {
|
||||||
val type: String
|
val type: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only text based content and states can be copied.
|
||||||
|
*/
|
||||||
|
fun TimelineItemEventContent.canBeCopied(): Boolean =
|
||||||
|
when (this) {
|
||||||
|
is TimelineItemTextBasedContent,
|
||||||
|
is TimelineItemStateContent,
|
||||||
|
is TimelineItemRedactedContent -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||||
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
||||||
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
||||||
|
<string name="screen_room_message_copied">"Message copied"</string>
|
||||||
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
|
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
|
||||||
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
||||||
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
class FakeMessagesNavigator : MessagesNavigator {
|
||||||
|
var onShowEventDebugInfoClickedCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var onForwardEventClickedCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var onReportContentClickedCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||||
|
onShowEventDebugInfoClickedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClicked(eventId: EventId) {
|
||||||
|
onForwardEventClickedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
|
||||||
|
onReportContentClickedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,10 +34,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
|
|
@ -100,7 +102,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action forward`() = runTest {
|
fun `present - handle action forward`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val navigator = FakeMessagesNavigator()
|
||||||
|
val presenter = createMessagePresenter(navigator = navigator)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -108,19 +111,23 @@ class MessagesPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action copy`() = runTest {
|
fun `present - handle action copy`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val clipboardHelper = FakeClipboardHelper()
|
||||||
|
val event = aMessageEvent()
|
||||||
|
val presenter = createMessagePresenter(clipboardHelper = clipboardHelper)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +289,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action report content`() = runTest {
|
fun `present - handle action report content`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val navigator = FakeMessagesNavigator()
|
||||||
|
val presenter = createMessagePresenter(navigator = navigator)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -290,6 +298,7 @@ class MessagesPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +317,8 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action show developer info`() = runTest {
|
fun `present - handle action show developer info`() = runTest {
|
||||||
val presenter = createMessagePresenter()
|
val navigator = FakeMessagesNavigator()
|
||||||
|
val presenter = createMessagePresenter(navigator = navigator)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -316,6 +326,7 @@ class MessagesPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +358,9 @@ class MessagesPresenterTest {
|
||||||
|
|
||||||
private fun TestScope.createMessagePresenter(
|
private fun TestScope.createMessagePresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||||
|
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||||
|
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
|
@ -388,6 +401,8 @@ class MessagesPresenterTest {
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
|
navigator = navigator,
|
||||||
|
clipboardHelper = clipboardHelper,
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
|
|
@ -164,6 +166,38 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for a media item`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemImageContent(),
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||||
|
// val loadingState = awaitItem()
|
||||||
|
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
messageEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||||
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute message in non-debuggable build`() = runTest {
|
fun `present - compute message in non-debuggable build`() = runTest {
|
||||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.forward
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.forward.ForwardMessagesEvents
|
||||||
|
import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ForwardMessagesPresenterTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.selectedRooms).isEmpty()
|
||||||
|
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
|
||||||
|
assertThat(initialState.isSearchActive).isFalse()
|
||||||
|
assertThat(initialState.isForwarding).isFalse()
|
||||||
|
assertThat(initialState.error).isNull()
|
||||||
|
assertThat(initialState.forwardingSucceeded).isNull()
|
||||||
|
|
||||||
|
// Search is run automatically
|
||||||
|
val searchState = awaitItem()
|
||||||
|
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - toggle search active`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
assertThat(awaitItem().isSearchActive).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
|
||||||
|
assertThat(awaitItem().isSearchActive).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - update query`() = runTest {
|
||||||
|
val roomSummaryDataSource = FakeRoomSummaryDataSource().apply {
|
||||||
|
postRoomSummary(listOf(RoomSummary.Filled(aRoomSummaryDetail())))
|
||||||
|
}
|
||||||
|
val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||||
|
val presenter = aPresenter(client = client)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail())))
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained"))
|
||||||
|
assertThat(awaitItem().query).isEqualTo("string not contained")
|
||||||
|
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select a room and forward successful`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
// Test successful forwarding
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
|
||||||
|
|
||||||
|
val forwardingState = awaitItem()
|
||||||
|
assertThat(forwardingState.isSearchActive).isFalse()
|
||||||
|
assertThat(forwardingState.isForwarding).isTrue()
|
||||||
|
|
||||||
|
val successfulForwardState = awaitItem()
|
||||||
|
assertThat(successfulForwardState.isForwarding).isFalse()
|
||||||
|
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = aPresenter(fakeMatrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
// Test failed forwarding
|
||||||
|
room.givenForwardEventResult(Result.failure(Throwable("error")))
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ForwardEvent)
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val failedForwardState = awaitItem()
|
||||||
|
assertThat(failedForwardState.isForwarding).isFalse()
|
||||||
|
assertThat(failedForwardState.error).isNotNull()
|
||||||
|
|
||||||
|
// Then clear error
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.ClearError)
|
||||||
|
assertThat(awaitItem().error).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select and remove a room`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
val summary = aRoomSummaryDetail()
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary))
|
||||||
|
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
|
||||||
|
|
||||||
|
initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)
|
||||||
|
assertThat(awaitItem().selectedRooms).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.aPresenter(
|
||||||
|
eventId: EventId = AN_EVENT_ID,
|
||||||
|
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||||
|
coroutineScope: CoroutineScope = this,
|
||||||
|
client: FakeMatrixClient = FakeMatrixClient(),
|
||||||
|
) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -69,7 +69,8 @@ class MediaViewerPresenterTest {
|
||||||
fun `present - check all actions `() = runTest {
|
fun `present - check all actions `() = runTest {
|
||||||
val mediaLoader = FakeMediaLoader()
|
val mediaLoader = FakeMediaLoader()
|
||||||
val mediaActions = FakeLocalMediaActions()
|
val mediaActions = FakeLocalMediaActions()
|
||||||
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -90,26 +91,24 @@ class MediaViewerPresenterTest {
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
state = awaitItem()
|
snackbarDispatcher.clear()
|
||||||
assertThat(state.snackbarMessage).isNull()
|
assertThat(awaitItem().snackbarMessage).isNull()
|
||||||
|
|
||||||
// Check failures
|
// Check failures
|
||||||
mediaActions.shouldFail = true
|
mediaActions.shouldFail = true
|
||||||
state.eventSink(MediaViewerEvents.OpenWith)
|
state.eventSink(MediaViewerEvents.OpenWith)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
state = awaitItem()
|
snackbarDispatcher.clear()
|
||||||
assertThat(state.snackbarMessage).isNull()
|
assertThat(awaitItem().snackbarMessage).isNull()
|
||||||
state.eventSink(MediaViewerEvents.Share)
|
state.eventSink(MediaViewerEvents.Share)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
state = awaitItem()
|
snackbarDispatcher.clear()
|
||||||
assertThat(state.snackbarMessage).isNull()
|
assertThat(awaitItem().snackbarMessage).isNull()
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
state = awaitItem()
|
|
||||||
assertThat(state.snackbarMessage).isNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +144,7 @@ class MediaViewerPresenterTest {
|
||||||
private fun aMediaViewerPresenter(
|
private fun aMediaViewerPresenter(
|
||||||
mediaLoader: FakeMediaLoader,
|
mediaLoader: FakeMediaLoader,
|
||||||
localMediaActions: FakeLocalMediaActions,
|
localMediaActions: FakeLocalMediaActions,
|
||||||
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||||
): MediaViewerPresenter {
|
): MediaViewerPresenter {
|
||||||
return MediaViewerPresenter(
|
return MediaViewerPresenter(
|
||||||
inputs = MediaViewerNode.Inputs(
|
inputs = MediaViewerNode.Inputs(
|
||||||
|
|
@ -155,7 +155,7 @@ class MediaViewerPresenterTest {
|
||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
mediaLoader = mediaLoader,
|
mediaLoader = mediaLoader,
|
||||||
localMediaActions = localMediaActions,
|
localMediaActions = localMediaActions,
|
||||||
snackbarDispatcher = SnackbarDispatcher()
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.report
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.report.ReportMessageEvents
|
||||||
|
import io.element.android.features.messages.impl.report.ReportMessagePresenter
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReportMessagePresenterTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - initial state`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.reason).isEmpty()
|
||||||
|
assertThat(initialState.blockUser).isFalse()
|
||||||
|
assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - update reason`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val reason = "This user is making the chat very toxic."
|
||||||
|
initialState.eventSink(ReportMessageEvents.UpdateReason(reason))
|
||||||
|
|
||||||
|
assertThat(awaitItem().reason).isEqualTo(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - toggle block user`() = runTest {
|
||||||
|
val presenter = aPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
|
||||||
|
|
||||||
|
assertThat(awaitItem().blockUser).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
|
||||||
|
|
||||||
|
assertThat(awaitItem().blockUser).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - handle successful report and block user`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = aPresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ReportMessageEvents.ToggleBlockUser)
|
||||||
|
skipItems(1)
|
||||||
|
initialState.eventSink(ReportMessageEvents.Report)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
|
||||||
|
assertThat(room.reportedContentCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - handle successful report`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = aPresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ReportMessageEvents.Report)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java)
|
||||||
|
assertThat(room.reportedContentCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `presenter - handle failed report`() = runTest {
|
||||||
|
val room = FakeMatrixRoom().apply {
|
||||||
|
givenReportContentResult(Result.failure(Exception("Failed to report content")))
|
||||||
|
}
|
||||||
|
val presenter = aPresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ReportMessageEvents.Report)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java)
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.result).isInstanceOf(Async.Failure::class.java)
|
||||||
|
assertThat(room.reportedContentCount).isEqualTo(1)
|
||||||
|
|
||||||
|
resultState.eventSink(ReportMessageEvents.ClearError)
|
||||||
|
assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aPresenter(
|
||||||
|
inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID),
|
||||||
|
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||||
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||||
|
) = ReportMessagePresenter(
|
||||||
|
inputs = inputs,
|
||||||
|
room = matrixRoom,
|
||||||
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
|
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
|
||||||
<string name="screen_onboarding_sign_up">"Create account"</string>
|
<string name="screen_onboarding_sign_up">"Create account"</string>
|
||||||
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
|
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
|
||||||
|
<string name="screen_onboarding_welcome_message">"Welcome to the fastest Element ever. Supercharged for speed and simplicity."</string>
|
||||||
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
|
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
|
||||||
<string name="screen_onboarding_welcome_title">"Be in your Element"</string>
|
<string name="screen_onboarding_welcome_title">"Be in your element"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
class RoomListNode @AssistedInject constructor(
|
class RoomListNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: RoomListPresenter,
|
private val presenter: RoomListPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
private fun onRoomClicked(roomId: RoomId) {
|
private fun onRoomClicked(roomId: RoomId) {
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||||
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
||||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.22"
|
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.23"
|
||||||
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
|
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
|
|
@ -158,7 +158,7 @@ statemachine = "com.freeletics.flowredux:compose:1.1.0"
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
posthog = "com.posthog.android:posthog:2.0.3"
|
posthog = "com.posthog.android:posthog:2.0.3"
|
||||||
sentry_android = "io.sentry:sentry-android:6.23.0"
|
sentry_android = "io.sentry:sentry-android:6.24.0"
|
||||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
|
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
|
||||||
|
|
||||||
# Di
|
# Di
|
||||||
|
|
@ -190,7 +190,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
|
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
|
||||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||||
ktlint = "org.jlleitschuh.gradle.ktlint:11.4.1"
|
ktlint = "org.jlleitschuh.gradle.ktlint:11.4.2"
|
||||||
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
|
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
|
||||||
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
|
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
|
||||||
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }
|
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,28 @@
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.androidutils"
|
namespace = "io.element.android.libraries.androidutils"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
anvil(projects.anvilcodegen)
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(libs.dagger)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.activity.activity)
|
implementation(libs.androidx.activity.activity)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
implementation(projects.libraries.core)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.androidutils.clipboard
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class AndroidClipboardHelper @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ClipboardHelper {
|
||||||
|
|
||||||
|
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
|
||||||
|
|
||||||
|
override fun copyPlainText(text: String) {
|
||||||
|
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.clipboard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
|
||||||
|
*/
|
||||||
|
interface ClipboardHelper {
|
||||||
|
fun copyPlainText(text: String)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.androidutils.clipboard
|
||||||
|
|
||||||
|
class FakeClipboardHelper : ClipboardHelper {
|
||||||
|
|
||||||
|
var clipboardContents: Any? = null
|
||||||
|
|
||||||
|
override fun copyPlainText(text: String) {
|
||||||
|
clipboardContents = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.BottomSheetDefaults
|
import androidx.compose.material3.BottomSheetDefaults
|
||||||
|
|
@ -52,6 +53,7 @@ fun ModalBottomSheet(
|
||||||
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||||
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
||||||
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||||
|
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.ModalBottomSheet(
|
androidx.compose.material3.ModalBottomSheet(
|
||||||
|
|
@ -64,6 +66,7 @@ fun ModalBottomSheet(
|
||||||
tonalElevation = tonalElevation,
|
tonalElevation = tonalElevation,
|
||||||
scrimColor = scrimColor,
|
scrimColor = scrimColor,
|
||||||
dragHandle = dragHandle,
|
dragHandle = dragHandle,
|
||||||
|
windowInsets = windowInsets,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
@ -52,19 +53,16 @@ class SnackbarDispatcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */
|
||||||
|
val LocalSnackbarDispatcher = compositionLocalOf<SnackbarDispatcher> {
|
||||||
|
error("No SnackbarDispatcher provided")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun handleSnackbarMessage(
|
fun handleSnackbarMessage(
|
||||||
snackbarDispatcher: SnackbarDispatcher
|
snackbarDispatcher: SnackbarDispatcher
|
||||||
): SnackbarMessage? {
|
): SnackbarMessage? {
|
||||||
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
|
return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value
|
||||||
LaunchedEffect(snackbarMessage) {
|
|
||||||
if (snackbarMessage != null) {
|
|
||||||
launch {
|
|
||||||
snackbarDispatcher.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return snackbarMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -74,6 +72,7 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
|
||||||
val snackbarMessageText = snackbarMessage?.let {
|
val snackbarMessageText = snackbarMessage?.let {
|
||||||
stringResource(id = snackbarMessage.messageResId)
|
stringResource(id = snackbarMessage.messageResId)
|
||||||
}
|
}
|
||||||
|
val dispatcher = LocalSnackbarDispatcher.current
|
||||||
LaunchedEffect(snackbarMessage) {
|
LaunchedEffect(snackbarMessage) {
|
||||||
if (snackbarMessageText == null) return@LaunchedEffect
|
if (snackbarMessageText == null) return@LaunchedEffect
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -81,6 +80,9 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
|
||||||
message = snackbarMessageText,
|
message = snackbarMessageText,
|
||||||
duration = snackbarMessage.duration,
|
duration = snackbarMessage.duration,
|
||||||
)
|
)
|
||||||
|
if (isActive) {
|
||||||
|
dispatcher.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return snackbarHostState
|
return snackbarHostState
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
class ForwardEventException(
|
||||||
|
val roomIds: List<RoomId>
|
||||||
|
) : Exception() {
|
||||||
|
|
||||||
|
override val message: String? = "Failed to deliver event to $roomIds rooms"
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
|
@ -86,6 +87,8 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit>
|
||||||
|
|
||||||
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun cancelSend(transactionId: String): Result<Unit>
|
suspend fun cancelSend(transactionId: String): Result<Unit>
|
||||||
|
|
@ -111,4 +114,6 @@ interface MatrixRoom : Closeable {
|
||||||
suspend fun setName(name: String): Result<Unit>
|
suspend fun setName(name: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun setTopic(topic: String): Result<Unit>
|
suspend fun setTopic(topic: String): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
||||||
sealed interface EventSendState {
|
sealed interface EventSendState {
|
||||||
object NotSentYet : EventSendState
|
object NotSentYet : EventSendState
|
||||||
|
object Canceled : EventSendState
|
||||||
|
|
||||||
data class SendingFailed(
|
data class SendingFailed(
|
||||||
val error: String
|
val error: String
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||||
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
||||||
|
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
|
||||||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||||
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.impl.room.roomOrNull
|
import io.element.android.libraries.matrix.impl.room.roomOrNull
|
||||||
|
|
@ -79,7 +80,7 @@ class RustMatrixClient constructor(
|
||||||
|
|
||||||
override val sessionId: UserId = UserId(client.userId())
|
override val sessionId: UserId = UserId(client.userId())
|
||||||
|
|
||||||
private val roomListService = client.roomList()
|
private val roomListService = client.roomListService()
|
||||||
private val sessionCoroutineScope = childScopeOf(appCoroutineScope, dispatchers.main, "Session-${sessionId}")
|
private val sessionCoroutineScope = childScopeOf(appCoroutineScope, dispatchers.main, "Session-${sessionId}")
|
||||||
private val verificationService = RustSessionVerificationService()
|
private val verificationService = RustSessionVerificationService()
|
||||||
private val syncService = RustSyncService(roomListService, sessionCoroutineScope)
|
private val syncService = RustSyncService(roomListService, sessionCoroutineScope)
|
||||||
|
|
@ -112,6 +113,8 @@ class RustMatrixClient constructor(
|
||||||
|
|
||||||
private val roomMembershipObserver = RoomMembershipObserver()
|
private val roomMembershipObserver = RoomMembershipObserver()
|
||||||
|
|
||||||
|
private val roomContentForwarder = RoomContentForwarder(roomListService)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
client.setDelegate(clientDelegate)
|
client.setDelegate(clientDelegate)
|
||||||
syncService.syncState
|
syncService.syncState
|
||||||
|
|
@ -132,7 +135,8 @@ class RustMatrixClient constructor(
|
||||||
innerRoom = fullRoom,
|
innerRoom = fullRoom,
|
||||||
sessionCoroutineScope = sessionCoroutineScope,
|
sessionCoroutineScope = sessionCoroutineScope,
|
||||||
coroutineDispatchers = dispatchers,
|
coroutineDispatchers = dispatchers,
|
||||||
systemClock = clock
|
systemClock = clock,
|
||||||
|
roomContentForwarder = roomContentForwarder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class RustNotificationService(
|
||||||
eventId: EventId
|
eventId: EventId
|
||||||
): Result<NotificationData?> {
|
): Result<NotificationData?> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
client.getNotificationItem(roomId.value, eventId.value).use(notificationMapper::map)
|
client.getNotificationItem(roomId.value, eventId.value)?.use(notificationMapper::map)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.room
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.parallelMap
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.ForwardEventException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomListService
|
||||||
|
import org.matrix.rustcomponents.sdk.SlidingSync
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to forward event contents from a room to a set of other rooms.
|
||||||
|
* @param slidingSync the [SlidingSync] to fetch room instances to forward the event to
|
||||||
|
*/
|
||||||
|
class RoomContentForwarder(
|
||||||
|
private val roomListService: RoomListService,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds].
|
||||||
|
* @param fromRoom the room to forward the event from
|
||||||
|
* @param eventId the id of the event to forward
|
||||||
|
* @param toRoomIds the ids of the rooms to forward the event to
|
||||||
|
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
|
||||||
|
*/
|
||||||
|
suspend fun forward(
|
||||||
|
fromRoom: Room,
|
||||||
|
eventId: EventId,
|
||||||
|
toRoomIds: List<RoomId>,
|
||||||
|
timeoutMs: Long = 5000L
|
||||||
|
) {
|
||||||
|
val content = fromRoom.getTimelineEventContentByEventId(eventId.value)
|
||||||
|
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
|
||||||
|
val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } }
|
||||||
|
val failedForwardingTo = mutableSetOf<RoomId>()
|
||||||
|
targetRooms.parallelMap { room ->
|
||||||
|
room.use { targetRoom ->
|
||||||
|
val result = runCatching {
|
||||||
|
// Sending a message requires a registered timeline listener
|
||||||
|
targetRoom.addTimelineListener(NoOpTimelineListener)
|
||||||
|
withTimeout(timeoutMs.milliseconds) {
|
||||||
|
targetRoom.send(content, genTransactionId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After sending, we remove the timeline
|
||||||
|
targetRoom.removeTimeline()
|
||||||
|
result
|
||||||
|
}.onFailure {
|
||||||
|
failedForwardingTo.add(RoomId(room.id()))
|
||||||
|
if (it is CancellationException) {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedForwardingTo.isNotEmpty()) {
|
||||||
|
throw ForwardEventException(toRoomIds.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoOpTimelineListener : TimelineListener {
|
||||||
|
override fun onUpdate(diff: TimelineDiff) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,40 +3,31 @@ package io.element.android.libraries.matrix.impl.room
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomList
|
||||||
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
|
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
|
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
|
||||||
import org.matrix.rustcomponents.sdk.RoomListEntry
|
import org.matrix.rustcomponents.sdk.RoomListEntry
|
||||||
import org.matrix.rustcomponents.sdk.RoomListInterface
|
|
||||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||||
import org.matrix.rustcomponents.sdk.RoomListState
|
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||||
import org.matrix.rustcomponents.sdk.RoomListStateListener
|
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState
|
import org.matrix.rustcomponents.sdk.RoomListService
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncListStateObserver
|
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomListServiceStateListener
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
fun RoomListInterface.roomListStateFlow(): Flow<RoomListState> =
|
fun RoomList.loadingStateFlow(): Flow<RoomListLoadingState> =
|
||||||
mxCallbackFlow {
|
mxCallbackFlow {
|
||||||
val listener = object : RoomListStateListener {
|
val listener = object : RoomListLoadingStateListener {
|
||||||
override fun onUpdate(state: RoomListState) {
|
override fun onUpdate(state: RoomListLoadingState) {
|
||||||
trySendBlocking(state)
|
trySendBlocking(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state(listener)
|
val result = loadingState(listener)
|
||||||
|
send(result.state)
|
||||||
|
result.stateStream
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RoomListInterface.loadingStateFlow(): Flow<SlidingSyncListLoadingState> =
|
fun RoomList.entriesFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit): Flow<RoomListEntriesUpdate> =
|
||||||
mxCallbackFlow {
|
|
||||||
val listener = object : SlidingSyncListStateObserver {
|
|
||||||
override fun didReceiveUpdate(newState: SlidingSyncListLoadingState) {
|
|
||||||
trySendBlocking(newState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = entriesLoadingState(listener)
|
|
||||||
send(result.entriesLoadingState)
|
|
||||||
result.entriesLoadingStateStream
|
|
||||||
}
|
|
||||||
|
|
||||||
fun RoomListInterface.roomListEntriesUpdateFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit): Flow<RoomListEntriesUpdate> =
|
|
||||||
mxCallbackFlow {
|
mxCallbackFlow {
|
||||||
val listener = object : RoomListEntriesListener {
|
val listener = object : RoomListEntriesListener {
|
||||||
override fun onUpdate(roomEntriesUpdate: RoomListEntriesUpdate) {
|
override fun onUpdate(roomEntriesUpdate: RoomListEntriesUpdate) {
|
||||||
|
|
@ -48,7 +39,7 @@ fun RoomListInterface.roomListEntriesUpdateFlow(onInitialList: suspend (List<Roo
|
||||||
result.entriesStream
|
result.entriesStream
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RoomListInterface.roomOrNull(roomId: String): RoomListItem? {
|
fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
|
||||||
return try {
|
return try {
|
||||||
room(roomId)
|
room(roomId)
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
|
|
@ -56,3 +47,13 @@ fun RoomListInterface.roomOrNull(roomId: String): RoomListItem? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun RoomListService.stateFlow(): Flow<RoomListServiceState> =
|
||||||
|
mxCallbackFlow {
|
||||||
|
val listener = object : RoomListServiceStateListener {
|
||||||
|
override fun onUpdate(state: RoomListServiceState) {
|
||||||
|
trySendBlocking(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state(listener)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import org.matrix.rustcomponents.sdk.RoomMember
|
||||||
import org.matrix.rustcomponents.sdk.RoomSubscription
|
import org.matrix.rustcomponents.sdk.RoomSubscription
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class RustMatrixRoom(
|
class RustMatrixRoom(
|
||||||
|
|
@ -64,6 +65,7 @@ class RustMatrixRoom(
|
||||||
sessionCoroutineScope: CoroutineScope,
|
sessionCoroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val systemClock: SystemClock,
|
private val systemClock: SystemClock,
|
||||||
|
private val roomContentForwarder: RoomContentForwarder,
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
override val roomId = RoomId(innerRoom.id())
|
override val roomId = RoomId(innerRoom.id())
|
||||||
|
|
@ -307,6 +309,14 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -350,9 +360,19 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
|
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.fetchMembers()
|
innerRoom.fetchMembers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
|
||||||
|
if (blockUserId != null) {
|
||||||
|
innerRoom.ignoreUser(blockUserId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,13 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
|
||||||
import org.matrix.rustcomponents.sdk.RoomListEntry
|
import org.matrix.rustcomponents.sdk.RoomListEntry
|
||||||
import org.matrix.rustcomponents.sdk.RoomListException
|
import org.matrix.rustcomponents.sdk.RoomListException
|
||||||
import org.matrix.rustcomponents.sdk.RoomListInput
|
import org.matrix.rustcomponents.sdk.RoomListInput
|
||||||
import org.matrix.rustcomponents.sdk.RoomListInterface
|
|
||||||
import org.matrix.rustcomponents.sdk.RoomListRange
|
import org.matrix.rustcomponents.sdk.RoomListRange
|
||||||
|
import org.matrix.rustcomponents.sdk.RoomListService
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
internal class RustRoomSummaryDataSource(
|
internal class RustRoomSummaryDataSource(
|
||||||
private val roomListService: RoomListInterface,
|
private val roomListService: RoomListService,
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||||
|
|
@ -48,7 +48,7 @@ internal class RustRoomSummaryDataSource(
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
sessionCoroutineScope.launch(coroutineDispatchers.computation) {
|
sessionCoroutineScope.launch(coroutineDispatchers.computation) {
|
||||||
roomListService.roomListEntriesUpdateFlow { roomListEntries ->
|
roomListService.allRooms().entriesFlow { roomListEntries ->
|
||||||
roomList.value = roomListEntries.map(::buildSummaryForRoomListEntry)
|
roomList.value = roomListEntries.map(::buildSummaryForRoomListEntry)
|
||||||
}.onEach { update ->
|
}.onEach { update ->
|
||||||
roomList.getAndUpdate {
|
roomList.getAndUpdate {
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@
|
||||||
package io.element.android.libraries.matrix.impl.sync
|
package io.element.android.libraries.matrix.impl.sync
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
import org.matrix.rustcomponents.sdk.RoomListState
|
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||||
|
|
||||||
internal fun RoomListState.toSyncState(): SyncState {
|
internal fun RoomListServiceState.toSyncState(): SyncState {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
RoomListState.INIT,
|
RoomListServiceState.INIT,
|
||||||
RoomListState.SETTING_UP -> SyncState.Idle
|
RoomListServiceState.SETTING_UP -> SyncState.Idle
|
||||||
RoomListState.RUNNING -> SyncState.Syncing
|
RoomListServiceState.RUNNING -> SyncState.Syncing
|
||||||
RoomListState.ERROR -> SyncState.InError
|
RoomListServiceState.ERROR -> SyncState.InError
|
||||||
RoomListState.TERMINATED -> SyncState.Terminated
|
RoomListServiceState.TERMINATED -> SyncState.Terminated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,18 @@ package io.element.android.libraries.matrix.impl.sync
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
import io.element.android.libraries.matrix.impl.room.roomListStateFlow
|
import io.element.android.libraries.matrix.impl.room.stateFlow
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import org.matrix.rustcomponents.sdk.RoomList
|
import org.matrix.rustcomponents.sdk.RoomListService
|
||||||
import org.matrix.rustcomponents.sdk.RoomListState
|
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
class RustSyncService(
|
class RustSyncService(
|
||||||
private val roomListService: RoomList,
|
private val roomListService: RoomListService,
|
||||||
private val sessionCoroutineScope: CoroutineScope
|
private val sessionCoroutineScope: CoroutineScope
|
||||||
) : SyncService {
|
) : SyncService {
|
||||||
|
|
||||||
|
|
@ -49,8 +47,8 @@ class RustSyncService(
|
||||||
|
|
||||||
override val syncState: StateFlow<SyncState> =
|
override val syncState: StateFlow<SyncState> =
|
||||||
roomListService
|
roomListService
|
||||||
.roomListStateFlow()
|
.stateFlow()
|
||||||
.map(RoomListState::toSyncState)
|
.map(RoomListServiceState::toSyncState)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.stateIn(sessionCoroutineScope, SharingStarted.WhileSubscribed(), SyncState.Idle)
|
.stateIn(sessionCoroutineScope, SharingStarted.WhileSubscribed(), SyncState.Idle)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ fun RustEventSendState?.map(): EventSendState? {
|
||||||
RustEventSendState.NotSentYet -> EventSendState.NotSentYet
|
RustEventSendState.NotSentYet -> EventSendState.NotSentYet
|
||||||
is RustEventSendState.SendingFailed -> EventSendState.SendingFailed(error)
|
is RustEventSendState.SendingFailed -> EventSendState.SendingFailed(error)
|
||||||
is RustEventSendState.Sent -> EventSendState.Sent(EventId(eventId))
|
is RustEventSendState.Sent -> EventSendState.Sent(EventId(eventId))
|
||||||
|
RustEventSendState.Cancelled -> EventSendState.Canceled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ class FakeMatrixRoom(
|
||||||
private var sendReactionResult = Result.success(Unit)
|
private var sendReactionResult = Result.success(Unit)
|
||||||
private var retrySendMessageResult = Result.success(Unit)
|
private var retrySendMessageResult = Result.success(Unit)
|
||||||
private var cancelSendResult = Result.success(Unit)
|
private var cancelSendResult = Result.success(Unit)
|
||||||
|
private var forwardEventResult = Result.success(Unit)
|
||||||
|
private var reportContentResult = Result.success(Unit)
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
@ -88,6 +90,9 @@ class FakeMatrixRoom(
|
||||||
var cancelSendCount: Int = 0
|
var cancelSendCount: Int = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var reportedContentCount: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var isInviteAccepted: Boolean = false
|
var isInviteAccepted: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -218,6 +223,10 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia()
|
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
|
override suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit> = simulateLongTask {
|
||||||
|
forwardEventResult
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fakeSendMedia(): Result<Unit> = simulateLongTask {
|
private suspend fun fakeSendMedia(): Result<Unit> = simulateLongTask {
|
||||||
sendMediaResult.onSuccess {
|
sendMediaResult.onSuccess {
|
||||||
sendMediaCount++
|
sendMediaCount++
|
||||||
|
|
@ -244,6 +253,15 @@ class FakeMatrixRoom(
|
||||||
setTopicResult
|
setTopicResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reportContent(
|
||||||
|
eventId: EventId,
|
||||||
|
reason: String,
|
||||||
|
blockUserId: UserId?
|
||||||
|
): Result<Unit> = simulateLongTask {
|
||||||
|
reportedContentCount++
|
||||||
|
return reportContentResult
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = Unit
|
override fun close() = Unit
|
||||||
|
|
||||||
fun givenLeaveRoomError(throwable: Throwable?) {
|
fun givenLeaveRoomError(throwable: Throwable?) {
|
||||||
|
|
@ -329,4 +347,12 @@ class FakeMatrixRoom(
|
||||||
fun givenCancelSendResult(result: Result<Unit>) {
|
fun givenCancelSendResult(result: Result<Unit>) {
|
||||||
cancelSendResult = result
|
cancelSendResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenForwardEventResult(result: Result<Unit>) {
|
||||||
|
forwardEventResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenReportContentResult(result: Result<Unit>) {
|
||||||
|
reportContentResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectedRoom(
|
||||||
|
roomSummary: RoomSummaryDetails,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRoomRemoved: (RoomSummaryDetails) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier
|
||||||
|
.width(56.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.Custom(56.dp)))
|
||||||
|
Text(
|
||||||
|
text = roomSummary.name,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.size(20.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.clickable(
|
||||||
|
indication = rememberRipple(),
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
onClick = { onRoomRemoved(roomSummary) }
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(id = StringR.string.action_remove),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.padding(2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRoomLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
SelectedRoom(roomSummary =
|
||||||
|
RoomSummaryDetails(
|
||||||
|
roomId = RoomId("!room:domain"),
|
||||||
|
name = "roomName",
|
||||||
|
canonicalAlias = null,
|
||||||
|
isDirect = true,
|
||||||
|
avatarURLString = null,
|
||||||
|
lastMessage = null,
|
||||||
|
lastMessageTimestamp = null,
|
||||||
|
unreadNotificationCount = 0,
|
||||||
|
inviter = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
<string name="action_invite">"Invite"</string>
|
<string name="action_invite">"Invite"</string>
|
||||||
<string name="action_invite_friends">"Invite friends"</string>
|
<string name="action_invite_friends">"Invite friends"</string>
|
||||||
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
|
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
|
||||||
|
<string name="action_invite_people_to_app">"Invite people to %1$s"</string>
|
||||||
<string name="action_invites_list">"Invites"</string>
|
<string name="action_invites_list">"Invites"</string>
|
||||||
<string name="action_learn_more">"Learn more"</string>
|
<string name="action_learn_more">"Learn more"</string>
|
||||||
<string name="action_leave">"Leave"</string>
|
<string name="action_leave">"Leave"</string>
|
||||||
|
|
@ -108,6 +109,7 @@
|
||||||
<string name="common_server_not_supported">"Server not supported"</string>
|
<string name="common_server_not_supported">"Server not supported"</string>
|
||||||
<string name="common_server_url">"Server URL"</string>
|
<string name="common_server_url">"Server URL"</string>
|
||||||
<string name="common_settings">"Settings"</string>
|
<string name="common_settings">"Settings"</string>
|
||||||
|
<string name="common_shared_location">"Shared location"</string>
|
||||||
<string name="common_starting_chat">"Starting chat…"</string>
|
<string name="common_starting_chat">"Starting chat…"</string>
|
||||||
<string name="common_sticker">"Sticker"</string>
|
<string name="common_sticker">"Sticker"</string>
|
||||||
<string name="common_success">"Success"</string>
|
<string name="common_success">"Success"</string>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ private fun DependencyHandlerScope.addImplementationProjects(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allLibrariesImpl() {
|
fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
|
implementation(project(":libraries:androidutils"))
|
||||||
|
implementation(project(":libraries:deeplink"))
|
||||||
implementation(project(":libraries:designsystem"))
|
implementation(project(":libraries:designsystem"))
|
||||||
implementation(project(":libraries:matrix:impl"))
|
implementation(project(":libraries:matrix:impl"))
|
||||||
implementation(project(":libraries:matrixui"))
|
implementation(project(":libraries:matrixui"))
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
|
|
@ -45,7 +46,7 @@ class DefaultAnalyticsService @Inject constructor(
|
||||||
private val sessionObserver: SessionObserver,
|
private val sessionObserver: SessionObserver,
|
||||||
) : AnalyticsService, SessionListener {
|
) : AnalyticsService, SessionListener {
|
||||||
// Cache for the store values
|
// Cache for the store values
|
||||||
private var userConsent: Boolean? = null
|
private val userConsent = AtomicBoolean(false)
|
||||||
|
|
||||||
// Cache for the properties to send
|
// Cache for the properties to send
|
||||||
private var pendingUserProperties: UserProperties? = null
|
private var pendingUserProperties: UserProperties? = null
|
||||||
|
|
@ -104,7 +105,7 @@ class DefaultAnalyticsService @Inject constructor(
|
||||||
getUserConsent()
|
getUserConsent()
|
||||||
.onEach { consent ->
|
.onEach { consent ->
|
||||||
Timber.tag(analyticsTag.value).d("User consent updated to $consent")
|
Timber.tag(analyticsTag.value).d("User consent updated to $consent")
|
||||||
userConsent = consent
|
userConsent.set(consent)
|
||||||
initOrStop()
|
initOrStop()
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
|
|
@ -115,35 +116,33 @@ class DefaultAnalyticsService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initOrStop() {
|
private fun initOrStop() {
|
||||||
userConsent?.let { _userConsent ->
|
if (userConsent.get()) {
|
||||||
when (_userConsent) {
|
analyticsProviders.onEach { it.init() }
|
||||||
true -> {
|
pendingUserProperties?.let {
|
||||||
pendingUserProperties?.let {
|
analyticsProviders.onEach { provider -> provider.updateUserProperties(it) }
|
||||||
analyticsProviders.onEach { provider -> provider.updateUserProperties(it) }
|
pendingUserProperties = null
|
||||||
pendingUserProperties = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false -> {}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
analyticsProviders.onEach { it.stop() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun capture(event: VectorAnalyticsEvent) {
|
override fun capture(event: VectorAnalyticsEvent) {
|
||||||
Timber.tag(analyticsTag.value).d("capture($event)")
|
Timber.tag(analyticsTag.value).d("capture($event)")
|
||||||
if (userConsent == true) {
|
if (userConsent.get()) {
|
||||||
analyticsProviders.onEach { it.capture(event) }
|
analyticsProviders.onEach { it.capture(event) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun screen(screen: VectorAnalyticsScreen) {
|
override fun screen(screen: VectorAnalyticsScreen) {
|
||||||
Timber.tag(analyticsTag.value).d("screen($screen)")
|
Timber.tag(analyticsTag.value).d("screen($screen)")
|
||||||
if (userConsent == true) {
|
if (userConsent.get()) {
|
||||||
analyticsProviders.onEach { it.screen(screen) }
|
analyticsProviders.onEach { it.screen(screen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateUserProperties(userProperties: UserProperties) {
|
override fun updateUserProperties(userProperties: UserProperties) {
|
||||||
if (userConsent == true) {
|
if (userConsent.get()) {
|
||||||
analyticsProviders.onEach { it.updateUserProperties(userProperties) }
|
analyticsProviders.onEach { it.updateUserProperties(userProperties) }
|
||||||
} else {
|
} else {
|
||||||
pendingUserProperties = userProperties
|
pendingUserProperties = userProperties
|
||||||
|
|
@ -151,7 +150,7 @@ class DefaultAnalyticsService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun trackError(throwable: Throwable) {
|
override fun trackError(throwable: Throwable) {
|
||||||
if (userConsent == true) {
|
if (userConsent.get()) {
|
||||||
analyticsProviders.onEach { it.trackError(throwable) }
|
analyticsProviders.onEach { it.trackError(throwable) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ interface AnalyticsProvider: AnalyticsTracker, ErrorTracker {
|
||||||
*/
|
*/
|
||||||
val name: String
|
val name: String
|
||||||
|
|
||||||
suspend fun init()
|
fun init()
|
||||||
|
|
||||||
fun stop()
|
fun stop()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class PosthogAnalyticsProvider @Inject constructor(
|
||||||
private var posthog: PostHog? = null
|
private var posthog: PostHog? = null
|
||||||
private var analyticsId: String? = null
|
private var analyticsId: String? = null
|
||||||
|
|
||||||
override suspend fun init() {
|
override fun init() {
|
||||||
posthog = createPosthog()
|
posthog = createPosthog()
|
||||||
posthog?.optOut(false)
|
posthog?.optOut(false)
|
||||||
identifyPostHog()
|
identifyPostHog()
|
||||||
|
|
@ -66,10 +66,10 @@ class PosthogAnalyticsProvider @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateUserProperties(userProperties: UserProperties) {
|
override fun updateUserProperties(userProperties: UserProperties) {
|
||||||
posthog?.identify(
|
// posthog?.identify(
|
||||||
REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(),
|
// REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(),
|
||||||
IGNORED_OPTIONS
|
// IGNORED_OPTIONS
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun trackError(throwable: Throwable) {
|
override fun trackError(throwable: Throwable) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.test.parameter.injector)
|
testImplementation(libs.test.parameter.injector)
|
||||||
|
testImplementation(projects.libraries.designsystem)
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
kspTest(libs.showkase.processor)
|
kspTest(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import com.android.ide.common.rendering.api.SessionParams
|
||||||
import com.google.testing.junit.testparameterinjector.TestParameter
|
import com.google.testing.junit.testparameterinjector.TestParameter
|
||||||
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
import com.google.testing.junit.testparameterinjector.TestParameterInjector
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -96,6 +98,8 @@ class ScreenshotTest {
|
||||||
LocalConfiguration provides Configuration().apply {
|
LocalConfiguration provides Configuration().apply {
|
||||||
setLocales(LocaleList(localeStr.toLocale()))
|
setLocales(LocaleList(localeStr.toLocale()))
|
||||||
},
|
},
|
||||||
|
// Needed to display Snackbars and avoid crashes during screenshot tests
|
||||||
|
LocalSnackbarDispatcher provides SnackbarDispatcher(),
|
||||||
// Needed so that UI that uses it don't crash during screenshot tests
|
// Needed so that UI that uses it don't crash during screenshot tests
|
||||||
LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner {
|
LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner {
|
||||||
override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle
|
override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dffe981836e1070a47cb815275752526e2e81a26ccc1658de8c503016f699afb
|
oid sha256:ecfc1dc64f45936513a35a0c7531261a16fd34c23c00696953ddab717ac82f42
|
||||||
size 21292
|
size 22954
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:edfafd24c8085ba7fe218c720b9dc2fead18d729b6ddea3b9fc04b51cb7c0e91
|
oid sha256:8856038a1c597587cd0193837106c991bdb9180bb5b119228a6df5fb9e30b7c7
|
||||||
size 9418
|
size 20762
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4d63e57cea65fd8617f19f2775c6cacb58fb1fd933ac26895445b49c8d8638d4
|
oid sha256:72c3b46589ee95e8bc6815a6f1bb08fcae6d6fd120b088581c8aa5f955ef9e7a
|
||||||
size 17363
|
size 28136
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1345fb49ea9a7ea7e31086af10755c222c2093ea59c62cf4c6ce291b98c08e5b
|
oid sha256:6eb721c0a79a326025ac6ff9a915eec9f4f38974a39fcdfcfc685e2e7b0e9f77
|
||||||
size 20965
|
size 22482
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6b92a6d4f1a2287cefe06c6ba7cb2a2210d61cf4c38747ffd308e4455d78f71
|
oid sha256:eb05f2e7cae4b2562ce4bc06a3633b535933dd28b6f24b9bba8e50b49ce9f449
|
||||||
size 9335
|
size 19986
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4d62a75d2faea119344221414f4442bb0682bf74a1bd71ca1cfc5ddfcaea4635
|
oid sha256:c9c039a62f5943e922eee920208cf107454146967ddbd6abb03694cac6d7fb58
|
||||||
size 17342
|
size 27401
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b410a2cd4cdadf7fb69fc0eb307882a1eedb70710ea3a2b8fefff9fe0f4ff3a9
|
||||||
|
size 13266
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c046931631c1d1ee9abc55e5c03d16ec7fb88d1829973342e3c358b2bd99d6c4
|
||||||
|
size 12809
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec44a976cb2a7572df5d18858ccfef5d0c8fe77ef0ed3a0c1d2bd7615aa32324
|
||||||
|
size 33230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ad6debeeb7774b50e8f578c0b8c1b91f92ce15d99ac5ccd8401eec7286e098fb
|
||||||
|
size 32766
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||||
|
size 4457
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851
|
||||||
|
size 33038
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d415f55a0fc2e463c53c2182a70fc56d08e969fd01492b5ba4dd712653aede3
|
||||||
|
size 13018
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:83fb3a09e70802c385ccda4cb46763cc9b949eaeaf9f572c4a38cb8bb1ab6516
|
||||||
|
size 12518
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:42042704bffae9e397b139effe9fa6213ec9c2167c2139db11b2611a6ebfd9cc
|
||||||
|
size 31965
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5726218ec996aff3e52aa6539ece216dfe20294d0ee13939b7f0c9da7bd7555f
|
||||||
|
size 31504
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||||
|
size 4457
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc
|
||||||
|
size 32230
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c78c731d2a100cd5632d3018a0a2f9ee3c4ecdd69feebb233f8fb60efb29808a
|
||||||
|
size 44647
|
||||||
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