[Message Actions] Report messages (#642)

* Add report messages feature

* Try to improve how snackbars are delivered

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-06-23 10:44:47 +02:00 committed by GitHub
parent e1bb5684cd
commit 9251cbf536
36 changed files with 739 additions and 40 deletions

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
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.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
import io.element.android.x.di.AppBindings
import timber.log.Timber
@ -42,11 +44,13 @@ class MainActivity : NodeComponentActivity() {
private lateinit var mainNode: MainNode
private lateinit var appBindings: AppBindings
override fun onCreate(savedInstanceState: Bundle?) {
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
installSplashScreen()
super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
appBindings = bindings<AppBindings>()
appBindings.matrixClientsHolder().restore(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
@ -57,25 +61,29 @@ class MainActivity : NodeComponentActivity() {
@Composable
private fun MainContent(appBindings: AppBindings) {
ElementTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
}
}
}
)
)
)
}
}
}
}

View file

@ -18,10 +18,12 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface AppBindings {
fun matrixClientsHolder(): MatrixClientsHolder
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
fun snackbarDispatcher(): SnackbarDispatcher
}

1
changelog.d/489.feature Normal file
View file

@ -0,0 +1 @@
Add option to report inappropriate content

View file

@ -35,12 +35,14 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
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.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.model.TimelineItem
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.TimelineItemVideoContent
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.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ -83,6 +85,9 @@ class MessagesFlowNode @AssistedInject constructor(
@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()
@ -114,6 +119,10 @@ class MessagesFlowNode @AssistedInject constructor(
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))
}
@ -142,6 +151,10 @@ class MessagesFlowNode @AssistedInject constructor(
}
createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback))
}
is NavTarget.ReportMessage -> {
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs))
}
}
}
@ -197,6 +210,7 @@ class MessagesFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View file

@ -17,9 +17,11 @@
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)
}

View file

@ -50,6 +50,7 @@ class MessagesNode @AssistedInject constructor(
fun onUserDataClicked(userId: UserId)
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
}
private fun onRoomDetailsClicked() {
@ -75,6 +76,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onForwardEventClicked(eventId)
}
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
callback?.onReportMessage(eventId, senderId)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View file

@ -161,7 +161,7 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> notImplementedYet()
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
}
}
@ -241,4 +241,9 @@ class MessagesPresenter @AssistedInject constructor(
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)
}
}

View file

@ -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
}

View file

@ -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
)
}
}

View file

@ -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)
}
}

View file

@ -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
)

View file

@ -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 = {}
)

View file

@ -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,
)
}

View file

@ -18,6 +18,7 @@ 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 {
@ -27,6 +28,9 @@ class FakeMessagesNavigator : MessagesNavigator {
var onForwardEventClickedCount = 0
private set
var onReportContentClickedCount = 0
private set
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++
}
@ -34,4 +38,8 @@ class FakeMessagesNavigator : MessagesNavigator {
override fun onForwardEventClicked(eventId: EventId) {
onForwardEventClickedCount++
}
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
onReportContentClickedCount++
}
}

View file

@ -284,7 +284,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle action report content`() = runTest {
val presenter = createMessagePresenter()
val navigator = FakeMessagesNavigator()
val presenter = createMessagePresenter(navigator = navigator)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -292,6 +293,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
}
}

View file

@ -69,7 +69,8 @@ class MediaViewerPresenterTest {
fun `present - check all actions `() = runTest {
val mediaLoader = FakeMediaLoader()
val mediaActions = FakeLocalMediaActions()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
val snackbarDispatcher = SnackbarDispatcher()
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -90,26 +91,24 @@ class MediaViewerPresenterTest {
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
}
}
@ -145,6 +144,7 @@ class MediaViewerPresenterTest {
private fun aMediaViewerPresenter(
mediaLoader: FakeMediaLoader,
localMediaActions: FakeLocalMediaActions,
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
@ -155,7 +155,7 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher = snackbarDispatcher,
)
}
}

View file

@ -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,
)
}

View file

@ -22,13 +22,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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
fun handleSnackbarMessage(
snackbarDispatcher: SnackbarDispatcher
): SnackbarMessage? {
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
LaunchedEffect(snackbarMessage) {
if (snackbarMessage != null) {
launch {
snackbarDispatcher.clear()
}
}
}
return snackbarMessage
return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value
}
@Composable
@ -74,6 +72,7 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId)
}
val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect
coroutineScope.launch {
@ -81,6 +80,9 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
if (isActive) {
dispatcher.clear()
}
}
}
return snackbarHostState

View file

@ -112,4 +112,6 @@ interface MatrixRoom : Closeable {
suspend fun setName(name: String): Result<Unit>
suspend fun setTopic(topic: String): Result<Unit>
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
}

View file

@ -329,4 +329,13 @@ class RustMatrixRoom(
innerRoom.setTopic(topic)
}
}
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)
}
}
}
}

View file

@ -76,6 +76,7 @@ class FakeMatrixRoom(
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit)
var sendMediaCount = 0
private set
@ -89,6 +90,9 @@ class FakeMatrixRoom(
var cancelSendCount: Int = 0
private set
var reportedContentCount: Int = 0
private set
var isInviteAccepted: Boolean = false
private set
@ -249,6 +253,15 @@ class FakeMatrixRoom(
setTopicResult
}
override suspend fun reportContent(
eventId: EventId,
reason: String,
blockUserId: UserId?
): Result<Unit> = simulateLongTask {
reportedContentCount++
return reportContentResult
}
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@ -338,4 +351,8 @@ class FakeMatrixRoom(
fun givenForwardEventResult(result: Result<Unit>) {
forwardEventResult = result
}
fun givenReportContentResult(result: Result<Unit>) {
reportContentResult = result
}
}

View file

@ -31,6 +31,7 @@ android {
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.parameter.injector)
testImplementation(projects.libraries.designsystem)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
kspTest(libs.showkase.processor)

View file

@ -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.TestParameterInjector
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.Test
import org.junit.runner.RunWith
@ -96,6 +98,8 @@ class ScreenshotTest {
LocalConfiguration provides Configuration().apply {
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
LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner {
override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c78c731d2a100cd5632d3018a0a2f9ee3c4ecdd69feebb233f8fb60efb29808a
size 44647

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a88c76ee497bc9544dda80517959d35862708ec25539aa910cfcf94d55dc17e
size 46580

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c54a2b29fc3584b3de6e5d11b87200bae6ea9ce371c7382297496f0022110da5
size 46232

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c11907197357eaced1a3ac9dec28ada97ab871d5662ce270d0af000204ebdf11
size 43878

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a6b210c88398341ab21982e485f08c4503198fdc550a077ffea4f123f5513e0
size 35451

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18ec14976369b542ce426d9b559fe4d72faa8db8e7402884be20dca670106ade
size 43921

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:539ed1ad5366e2757a99c5589ea6052bb47cc494805164c0a17f8e4970f0b102
size 45084

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e788d5011a1635273949be92a350a7e4068831935dfae328e5699fc361a9cc6b
size 44662

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:396fa2ca9b01b467fb5160d776130eb6b40351a93d4ed9cc4e61a027c977cac0
size 42258

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5cb0fb381934d4548d10f3b6654ecf04bc0fa29266ed58fa5653dafd322eade0
size 34503

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -113,7 +113,8 @@ Compose:
CompositionLocalAllowlist:
active: true
# You can optionally define a list of CompositionLocals that are allowed here
allowedCompositionLocals: LocalColors, LocalCompoundColors
allowedCompositionLocals: LocalColors, LocalCompoundColors, LocalSnackbarDispatcher
CompositionLocalNaming:
active: true
ContentEmitterReturningValues: