[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 898985cb9b
commit bdb1841e44
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

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