Feature : Report room (#4654)

* feature (report room) : introduce all presentation classes.

* feature (report room) : branch entry point in the room list

* refactor (matrix ui) : move some code from appnav to matrix ui

* feature (report room) : add api on room

* feature (report room) : adjust ui

* feature (report room) : branch api

* feature (decline invite and block) : move things around and introduce presentation classes

* feature (decline invite and block) : continue to move things

* feature (report room) : remove reference to "conversation" for now

* feature (report room) : add report room action to room detail screen

* feature (report room) : enabled button state

* feature (report room) : improve code and reuse

* feature (report room) : add feature flag

* feature (report room) : change feature flag to static bool

* feature (report room) : add tests

* feature (report room) : fix ui with new api on ListItem

* feature (report room) : clean up and add more tests.

* Update screenshots

* feature (report room) : more test and fix issue

* feature (report room) : update strings

* feature (report room) : fix konsist preview

* feature (report room) : disable feature

* Update screenshots

* var -> val

* Improve preview of AcceptDeclineInviteView

* Improve preview consistency

* Add missing test on DismissErrorAndHideContent

* Update screenshots

* Add missing tests

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
ganfra 2025-05-02 12:25:19 +02:00 committed by GitHub
parent db6bc7c04f
commit c2568f84d2
229 changed files with 3995 additions and 1210 deletions

View file

@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultReportRoomEntryPoint @Inject constructor() : ReportRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, roomId: RoomId): Node {
return parentNode.createNode<ReportRoomNode>(buildContext, plugins = listOf(ReportRoomNode.Inputs(roomId)))
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
interface ReportRoom {
suspend operator fun invoke(
roomId: RoomId,
shouldReport: Boolean,
reason: String,
shouldLeave: Boolean,
): Result<Unit>
sealed class Exception : kotlin.Exception() {
data object RoomNotFound : Exception()
data object LeftRoomFailed : Exception()
data object ReportRoomFailed : Exception()
}
}
@ContributesBinding(SessionScope::class)
class DefaultReportRoom @Inject constructor(
private val client: MatrixClient,
) : ReportRoom {
override suspend operator fun invoke(
roomId: RoomId,
shouldReport: Boolean,
reason: String,
shouldLeave: Boolean
): Result<Unit> {
val room = client.getRoom(roomId)
?: return Result.failure(ReportRoom.Exception.RoomNotFound)
if (shouldReport) {
room
.reportRoom(reason.takeIf { it.isNotBlank() })
.onFailure {
return Result.failure(ReportRoom.Exception.ReportRoomFailed)
}
}
if (shouldLeave) {
room
.leave()
.onFailure {
return Result.failure(ReportRoom.Exception.LeftRoomFailed)
}
}
return Result.success(Unit)
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
sealed interface ReportRoomEvents {
data class UpdateReason(val reason: String) : ReportRoomEvents
data object ToggleLeaveRoom : ReportRoomEvents
data object Report : ReportRoomEvents
data object ClearReportAction : ReportRoomEvents
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
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.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class ReportRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ReportRoomPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val roomId: RoomId) : NodeInputs
private val roomId = inputs<Inputs>().roomId
private val presenter: ReportRoomPresenter = presenterFactory.create(roomId = roomId)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ReportRoomView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
)
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
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.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ReportRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
private val reportRoom: ReportRoom,
) : Presenter<ReportRoomState> {
@AssistedFactory
interface Factory {
fun create(roomId: RoomId): ReportRoomPresenter
}
@Composable
override fun present(): ReportRoomState {
var reason by rememberSaveable { mutableStateOf("") }
var leaveRoom by rememberSaveable { mutableStateOf(false) }
var reportAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: ReportRoomEvents) {
when (event) {
ReportRoomEvents.Report -> coroutineScope.reportRoom(reason, leaveRoom, reportAction)
ReportRoomEvents.ToggleLeaveRoom -> {
leaveRoom = !leaveRoom
}
is ReportRoomEvents.UpdateReason -> {
reason = event.reason
}
ReportRoomEvents.ClearReportAction -> {
reportAction.value = AsyncAction.Uninitialized
}
}
}
return ReportRoomState(
reason = reason,
leaveRoom = leaveRoom,
reportAction = reportAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.reportRoom(
reason: String,
shouldLeave: Boolean,
action: MutableState<AsyncAction<Unit>>
) = launch {
val previousFailure = action.value as? AsyncAction.Failure
val shouldReport = previousFailure?.error !is ReportRoom.Exception.LeftRoomFailed
runUpdatingState(action) {
reportRoom(
roomId = roomId,
shouldReport = shouldReport,
reason = reason,
shouldLeave = shouldLeave
)
}
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import io.element.android.libraries.architecture.AsyncAction
data class ReportRoomState(
val reason: String,
val leaveRoom: Boolean,
val reportAction: AsyncAction<Unit>,
val eventSink: (ReportRoomEvents) -> Unit
) {
val canReport: Boolean = reason.isNotBlank()
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class ReportRoomStateProvider : PreviewParameterProvider<ReportRoomState> {
companion object {
private const val A_REPORT_ROOM_REASON = "Inappropriate content"
}
override val values: Sequence<ReportRoomState>
get() = sequenceOf(
aReportRoomState(),
aReportRoomState(reason = A_REPORT_ROOM_REASON),
aReportRoomState(leaveRoom = true),
aReportRoomState(reason = A_REPORT_ROOM_REASON, reportAction = AsyncAction.Loading),
aReportRoomState(reason = A_REPORT_ROOM_REASON, reportAction = AsyncAction.Failure(Exception("Failed to report"))),
)
}
fun aReportRoomState(
reason: String = "",
leaveRoom: Boolean = false,
reportAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ReportRoomEvents) -> Unit = {}
) = ReportRoomState(
reason = reason,
leaveRoom = leaveRoom,
reportAction = reportAction,
eventSink = eventSink,
)

View file

@ -0,0 +1,154 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import androidx.compose.foundation.layout.Column
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ListItem
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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportRoomView(
state: ReportRoomState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val isReporting = state.reportAction is AsyncAction.Loading
AsyncActionView(
async = state.reportAction,
onSuccess = { onBackClick() },
errorTitle = { failure ->
when (failure) {
is ReportRoom.Exception.LeftRoomFailed -> stringResource(R.string.screen_report_room_leave_failed_alert_title)
else -> stringResource(CommonStrings.dialog_title_error)
}
},
errorMessage = { failure ->
when (failure) {
is ReportRoom.Exception.LeftRoomFailed -> stringResource(R.string.screen_report_room_leave_failed_alert_message)
else -> stringResource(CommonStrings.error_unknown)
}
},
onRetry = {
state.eventSink(ReportRoomEvents.Report)
},
onErrorDismiss = { state.eventSink(ReportRoomEvents.ClearReportAction) }
)
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.screen_report_room_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackClick)
}
)
},
modifier = modifier
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp)
) {
TextField(
value = state.reason,
onValueChange = { state.eventSink(ReportRoomEvents.UpdateReason(it)) },
placeholder = stringResource(R.string.screen_report_room_reason_placeholder),
minLines = 3,
enabled = !isReporting,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(min = 90.dp),
supportingText = stringResource(R.string.screen_report_room_reason_footer),
)
Spacer(modifier = Modifier.height(24.dp))
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
Text(text = stringResource(CommonStrings.action_leave_room))
},
onClick = {
state.eventSink(ReportRoomEvents.ToggleLeaveRoom)
},
trailingContent = ListItemContent.Switch(checked = state.leaveRoom)
)
Spacer(modifier = Modifier.height(24.dp))
Button(
text = stringResource(CommonStrings.action_report),
enabled = state.canReport && !isReporting,
destructive = true,
showProgress = isReporting,
onClick = {
focusManager.clearFocus(force = true)
state.eventSink(ReportRoomEvents.Report)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ReportRoomViewPreview(
@PreviewParameter(ReportRoomStateProvider::class) state: ReportRoomState
) = ElementPreview {
ReportRoomView(
state = state,
onBackClick = {},
)
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Vaše hlášení bylo úspěšně odesláno, ale při pokusu o opuštění místnosti jsme narazili na problém. Zkuste to prosím znovu."</string>
<string name="screen_report_room_leave_failed_alert_title">"Nelze opustit místnost"</string>
<string name="screen_report_room_reason_footer">"Nahlaste tuto místnost svému administrátorovi. Pokud jsou zprávy zašifrované, váš administrátor je nebude moci číst."</string>
<string name="screen_report_room_reason_placeholder">"Popište důvod…"</string>
<string name="screen_report_room_title">"Nahlásit místnost"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Cyflwynwyd eich adroddiad yn llwyddiannus, ond cododd problem wrth geisio gadael yr ystafell. Ceisiwch eto."</string>
<string name="screen_report_room_leave_failed_alert_title">"Methu Gadael yr Ystafell"</string>
<string name="screen_report_room_reason_footer">"Adroddwch yr ystafell hon i\'ch gweinyddwr. Os yw\'r negeseuon wedi\'u hamgryptio, fydd eich gweinyddwr ddim yn gallu eu darllen."</string>
<string name="screen_report_room_reason_placeholder">"Disgrifiwch y rheswm…"</string>
<string name="screen_report_room_title">"Adrodd ar ystafell"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Ihr Bericht wurde erfolgreich übermittelt, aber beim Versuch, den Raum zu verlassen, ist ein Problem aufgetreten. Bitte versuchen Sie es erneut."</string>
<string name="screen_report_room_leave_failed_alert_title">"Der Chatroom kann nicht verlassen werden"</string>
<string name="screen_report_room_reason_footer">"Melden Sie diesen Chatroom Ihrem Administrator. Wenn die Nachrichten verschlüsselt sind, kann Ihr Administrator sie nicht lesen."</string>
<string name="screen_report_room_reason_placeholder">"Beschreiben Sie den Grund…"</string>
<string name="screen_report_room_title">"Chatroom melden"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Η αναφορά σας υποβλήθηκε με επιτυχία, αλλά αντιμετωπίσαμε πρόβλημα κατά την προσπάθεια αποχώρησης από το δωμάτιο. Παρακαλώ προσπαθήστε ξανά."</string>
<string name="screen_report_room_leave_failed_alert_title">"Δεν είναι δυνατή η αποχώρηση από το δωμάτιο"</string>
<string name="screen_report_room_reason_footer">"Αναφέρετε αυτό το δωμάτιο στον διαχειριστή σας. Εάν τα μηνύματα είναι κρυπτογραφημένα, ο διαχειριστής σας δε θα μπορεί να τα διαβάσει."</string>
<string name="screen_report_room_reason_placeholder">"Περιγράψτε τον λόγο αναφοράς…"</string>
<string name="screen_report_room_title">"Αναφορά δωματίου"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Jututoast haldajale teatamine õnnestus, kuid jututost lahkumisel tekkis viga. Palun proovi uuesti lahkuda."</string>
<string name="screen_report_room_leave_failed_alert_title">"Pole võimalik lahkuda jututoast"</string>
<string name="screen_report_room_reason_footer">"Teata sellest jututoast süsteemi haldajale. Kui sõnumid on krüptitud, ei saa haldaja neid lugeda."</string>
<string name="screen_report_room_reason_placeholder">"Kirjelda põhjust…"</string>
<string name="screen_report_room_title">"Teata jututoast"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Ilmoituksesi lähetettiin onnistuneesti, mutta kohtasimme ongelman yrittäessämme poistua huoneesta. Yritä uudelleen."</string>
<string name="screen_report_room_leave_failed_alert_title">"Huoneesta poistuminen epäonnistui"</string>
<string name="screen_report_room_reason_footer">"Ilmoita tästä huoneesta palvelimesi ylläpitäjälle. Jos viestit on salattu, ylläpitäjäsi ei voi lukea niitä."</string>
<string name="screen_report_room_reason_placeholder">"Kuvaile syytä…"</string>
<string name="screen_report_room_title">"Ilmoita huoneesta"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Votre rapport a été envoyé avec succès, mais nous avons rencontré un problème en essayant de quitter le salon. Veuillez réessayer."</string>
<string name="screen_report_room_leave_failed_alert_title">"Impossible de quitter le salon"</string>
<string name="screen_report_room_reason_footer">"Signaler ce salon à votre admin. Si les messages sont chiffrés, votre admin ne pourra pas les lire."</string>
<string name="screen_report_room_reason_placeholder">"Décrivez la raison…"</string>
<string name="screen_report_room_title">"Signaler le salon"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"A jelentése sikeresen el lett küldve, de hibát találtunk a szoba elhagyása során. Próbálja újra."</string>
<string name="screen_report_room_leave_failed_alert_title">"Nem tudja elhagyni a szobát"</string>
<string name="screen_report_room_reason_footer">"A szoba jelentése a rendszergazdának. Ha az üzenetek titkosítva vannak, akkor a rendszergazda nem fogja tudni elolvasni őket."</string>
<string name="screen_report_room_reason_placeholder">"Írja le az okot…"</string>
<string name="screen_report_room_title">"Szoba jelentése"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Rapporten din ble sendt inn, men vi oppdaget et problem da vi prøvde å forlate rommet. Prøv igjen."</string>
<string name="screen_report_room_leave_failed_alert_title">"Kan ikke forlate rommet"</string>
<string name="screen_report_room_reason_footer">"Rapporter dette rommet til administratoren din. Hvis meldingene er kryptert, vil administratoren ikke kunne lese dem."</string>
<string name="screen_report_room_reason_placeholder">"Beskriv årsaken…"</string>
<string name="screen_report_room_title">"Rapporter rom"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Twoje zgłoszenie zostało wysłane pomyślnie, ale napotkaliśmy problem podczas opuszczania pokoju. Spróbuj ponownie."</string>
<string name="screen_report_room_leave_failed_alert_title">"Nie można wyjść z pokoju"</string>
<string name="screen_report_room_reason_footer">"Zgłoś ten pokój swojemu administratorowi. Jeśli wiadomości są zaszyfrowane, administrator nie będzie mógł ich odczytać."</string>
<string name="screen_report_room_reason_placeholder">"Opisz powód…"</string>
<string name="screen_report_room_title">"Zgłoś pokój"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_title">"Невозможно покинуть комнату"</string>
<string name="screen_report_room_reason_footer">"Сообщите об этой комнате своему администратору. Если сообщения зашифрованы, ваш администратор не сможет их прочитать."</string>
<string name="screen_report_room_reason_placeholder">"Опишите причину…"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Vaša správa bola úspešne odoslaná, ale pri pokuse o opustenie miestnosti sme narazili na problém. Skúste to prosím znova."</string>
<string name="screen_report_room_leave_failed_alert_title">"Nie je možné opustiť miestnosť"</string>
<string name="screen_report_room_reason_footer">"Nahláste túto miestnosť svojmu správcovi. Ak sú správy zašifrované, váš správca ich nebude môcť prečítať."</string>
<string name="screen_report_room_reason_placeholder">"Popíšte dôvod…"</string>
<string name="screen_report_room_title">"Nahlásiť miestnosť"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Din anmälan skickades in framgångsrikt, men vi stötte på ett problem när vi försökte lämna rummet. Vänligen försök igen."</string>
<string name="screen_report_room_leave_failed_alert_title">"Kunde inte lämna rummet"</string>
<string name="screen_report_room_reason_footer">"Anmäl det här rummet till din administratör. Om meddelandena är krypterade kommer din administratör inte att kunna läsa dem."</string>
<string name="screen_report_room_reason_placeholder">"Beskriv anledningen …"</string>
<string name="screen_report_room_title">"Rapportera rum"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Ваша скарга надіслана, але ми зіткнулися з проблемою під час спроби вийти з кімнати. Повторіть спробу."</string>
<string name="screen_report_room_leave_failed_alert_title">"Не вдалося вийти з кімнати"</string>
<string name="screen_report_room_reason_footer">"Поскаржтеся на цю кімнату своєму адміністратору. Якщо повідомлення зашифровані, ваш адміністратор не зможе їх прочитати."</string>
<string name="screen_report_room_reason_placeholder">"Опишіть причину…"</string>
<string name="screen_report_room_title">"Поскаржитися на кімнату"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"您的回報已成功遞交,但我們嘗試離開聊天室時遇到了問題。請再試一次。"</string>
<string name="screen_report_room_leave_failed_alert_title">"無法離開聊天室"</string>
<string name="screen_report_room_reason_footer">"將此聊天室回報給您的管理員。若訊息已加密,您的管理員將無法讀取它們。"</string>
<string name="screen_report_room_title">"回報聊天室"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_report_room_leave_failed_alert_message">"Your report was submitted successfully, but we encountered an issue while trying to leave the room. Please try again."</string>
<string name="screen_report_room_leave_failed_alert_title">"Unable to Leave Room"</string>
<string name="screen_report_room_reason_footer">"Report this room to your admin. If the messages are encrypted, your admin will not be able to read them."</string>
<string name="screen_report_room_reason_placeholder">"Describe the reason to report…"</string>
<string name="screen_report_room_title">"Report room"</string>
</resources>

View file

@ -0,0 +1,150 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultReportRoomTest {
private val roomId = A_ROOM_ID
private val successLeaveRoomLambda = lambdaRecorder<Result<Unit>> { -> Result.success(Unit) }
private val successReportRoomLambda =
lambdaRecorder<String?, Result<Unit>> { _ -> Result.success(Unit) }
private val failureLeaveRoomLambda =
lambdaRecorder<Result<Unit>> { -> Result.failure(Exception("Leave room error")) }
private val failureReportRoomLambda =
lambdaRecorder<String?, Result<Unit>> { _ -> Result.failure(Exception("Report room error")) }
@Test
fun `report room, leave=false, report=false, nothing is called`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = successLeaveRoomLambda,
reportRoomResult = successReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = false, reason = "", shouldLeave = false)
assertThat(result.isSuccess).isTrue()
assert(successLeaveRoomLambda).isNeverCalled()
assert(successReportRoomLambda).isNeverCalled()
}
@Test
fun `report room, leave=false, report=true, report room success`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = successLeaveRoomLambda,
reportRoomResult = successReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = false)
assertThat(result.isSuccess).isTrue()
assert(successLeaveRoomLambda).isNeverCalled()
assert(successReportRoomLambda)
.isCalledOnce()
.with(value("Spam"))
}
@Test
fun `report room, leave=true, report=false, leave room success`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = successLeaveRoomLambda,
reportRoomResult = successReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = false, reason = "", shouldLeave = true)
assertThat(result.isSuccess).isTrue()
assert(successLeaveRoomLambda).isCalledOnce()
assert(successReportRoomLambda).isNeverCalled()
}
@Test
fun `report room, leave=true, report=true, leave room success`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = successLeaveRoomLambda,
reportRoomResult = successReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true)
assertThat(result.isSuccess).isTrue()
assert(successLeaveRoomLambda).isCalledOnce()
assert(successReportRoomLambda)
.isCalledOnce()
.with(value("Spam"))
}
@Test
fun `report room, leave=true, report=true, leave room failed`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = failureLeaveRoomLambda,
reportRoomResult = successReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true)
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isEqualTo(ReportRoom.Exception.LeftRoomFailed)
assert(failureLeaveRoomLambda).isCalledOnce()
assert(successReportRoomLambda).isCalledOnce()
}
@Test
fun `report room, leave=true, report=true, report room failed`() = runTest {
val room = FakeBaseRoom(
roomId = roomId,
leaveRoomLambda = successLeaveRoomLambda,
reportRoomResult = failureReportRoomLambda
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(roomId, room)
}
val reportRoom = DefaultReportRoom(client = client)
val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true)
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isEqualTo(ReportRoom.Exception.ReportRoomFailed)
assert(successLeaveRoomLambda).isNeverCalled()
assert(failureReportRoomLambda).isCalledOnce()
}
}

View file

@ -0,0 +1,151 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.reportroom.impl.fakes.FakeReportRoom
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ReportRoomPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createReportRoomPresenter()
presenter.test {
awaitItem().also { state ->
assertThat(state.reason).isEmpty()
assertThat(state.leaveRoom).isFalse()
assertThat(state.reportAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.canReport).isFalse()
}
}
}
@Test
fun `present - update form values`() = runTest {
val presenter = createReportRoomPresenter()
presenter.test {
awaitItem().also { state ->
assertThat(state.reason).isEmpty()
assertThat(state.canReport).isFalse()
assertThat(state.leaveRoom).isFalse()
state.eventSink(ReportRoomEvents.UpdateReason("Spam"))
}
awaitItem().also { state ->
assertThat(state.reason).isEqualTo("Spam")
assertThat(state.canReport).isTrue()
assertThat(state.leaveRoom).isFalse()
state.eventSink(ReportRoomEvents.ToggleLeaveRoom)
}
awaitItem().also { state ->
assertThat(state.leaveRoom).isTrue()
assertThat(state.canReport).isTrue()
assertThat(state.canReport).isTrue()
}
}
}
@Test
fun `present - report room success`() = runTest {
val roomId = A_ROOM_ID
val reportRoomLambda = lambdaRecorder<RoomId, Boolean, String, Boolean, Result<Unit>> { _, _, _, _ -> Result.success(Unit) }
val reportRoom = FakeReportRoom(
lambda = reportRoomLambda
)
val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom)
presenter.test {
awaitItem().eventSink(ReportRoomEvents.ToggleLeaveRoom)
awaitItem().eventSink(ReportRoomEvents.Report)
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Success::class.java)
}
assert(reportRoomLambda)
.isCalledOnce()
.with(value(roomId), value(true), any(), value(true))
}
}
@Test
fun `present - report failed`() = runTest {
val roomId = A_ROOM_ID
val reportRoomLambda = lambdaRecorder<RoomId, Boolean, String, Boolean, Result<Unit>> { _, _, _, _ ->
Result.failure(ReportRoom.Exception.ReportRoomFailed)
}
val reportRoom = FakeReportRoom(
lambda = reportRoomLambda
)
val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom)
presenter.test {
awaitItem().eventSink(ReportRoomEvents.Report)
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java)
}
assert(reportRoomLambda)
.isCalledOnce()
.with(value(roomId), value(true), any(), any())
}
}
@Test
fun `present - leave room failed after report room success`() = runTest {
val roomId = A_ROOM_ID
val reportRoomLambda = lambdaRecorder<RoomId, Boolean, String, Boolean, Result<Unit>> { _, _, _, _ ->
Result.failure(ReportRoom.Exception.LeftRoomFailed)
}
val reportRoom = FakeReportRoom(
lambda = reportRoomLambda
)
val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom)
presenter.test {
awaitItem().eventSink(ReportRoomEvents.ToggleLeaveRoom)
awaitItem().eventSink(ReportRoomEvents.Report)
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(ReportRoomEvents.Report)
}
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java)
}
assert(reportRoomLambda)
.isCalledExactly(2)
.withSequence(
// The first call should report the room and try leaving it
listOf(value(roomId), value(true), any(), value(true)),
// The second call should not report the room again
listOf(value(roomId), value(false), any(), value(true))
)
}
}
fun createReportRoomPresenter(
roomId: RoomId = A_ROOM_ID,
reportRoom: ReportRoom = FakeReportRoom()
): ReportRoomPresenter {
return ReportRoomPresenter(roomId, reportRoom)
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReportRoomViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<ReportRoomEvents>(expectEvents = false)
ensureCalledOnce {
rule.setReportRoomView(
aReportRoomState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on report when enabled emits the expected event`() {
val eventsRecorder = EventsRecorder<ReportRoomEvents>()
rule.setReportRoomView(
aReportRoomState(
reason = "Spam",
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_report)
eventsRecorder.assertSingle(ReportRoomEvents.Report)
}
@Test
fun `clicking on decline when disabled does not emit event`() {
val eventsRecorder = EventsRecorder<ReportRoomEvents>(expectEvents = false)
rule.setReportRoomView(
aReportRoomState(eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_report)
}
@Test
fun `clicking on leave room option emits the expected event`() {
val eventsRecorder = EventsRecorder<ReportRoomEvents>()
rule.setReportRoomView(
aReportRoomState(eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertSingle(ReportRoomEvents.ToggleLeaveRoom)
}
@Test
fun `typing text in the reason field emits the expected Event`() {
val eventsRecorder = EventsRecorder<ReportRoomEvents>()
rule.setReportRoomView(
aReportRoomState(
eventSink = eventsRecorder,
reason = ""
),
)
rule.onNodeWithText("").performTextInput("Spam!")
eventsRecorder.assertSingle(ReportRoomEvents.UpdateReason("Spam!"))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setReportRoomView(
state: ReportRoomState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ReportRoomView(
state = state,
onBackClick = onBackClick,
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.reportroom.impl.fakes
import io.element.android.features.reportroom.impl.ReportRoom
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeReportRoom(
var lambda: (RoomId, Boolean, String, Boolean) -> Result<Unit> = { _, _, _, _ -> lambdaError() }
) : ReportRoom {
override suspend fun invoke(
roomId: RoomId,
shouldReport: Boolean,
reason: String,
shouldLeave: Boolean
): Result<Unit> = simulateLongTask {
lambda(roomId, shouldReport, reason, shouldLeave)
}
}