feat: report a room

updated tests

updated tests

added a feature flag to report room

removed delay

rename
This commit is contained in:
Mauro Romito
2025-04-02 17:44:24 +02:00
committed by Mauro
parent 8149107aef
commit 2ee5c1b403
31 changed files with 750 additions and 104 deletions
@@ -17,6 +17,7 @@ struct HomeScreenCoordinatorParameters {
enum HomeScreenCoordinatorAction {
case presentRoom(roomIdentifier: String)
case presentRoomDetails(roomIdentifier: String)
case presentReportRoom(roomIdentifier: String)
case roomLeft(roomIdentifier: String)
case presentSettingsScreen
case presentFeedbackScreen
@@ -58,6 +59,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier))
case .presentRoomDetails(roomIdentifier: let roomIdentifier):
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .presentReportRoom(let roomIdentifier):
actionsSubject.send(.presentReportRoom(roomIdentifier: roomIdentifier))
case .roomLeft(roomIdentifier: let roomIdentifier):
actionsSubject.send(.roomLeft(roomIdentifier: roomIdentifier))
case .presentFeedbackScreen:
@@ -12,6 +12,7 @@ import UIKit
enum HomeScreenViewModelAction: Equatable {
case presentRoom(roomIdentifier: String)
case presentRoomDetails(roomIdentifier: String)
case presentReportRoom(roomIdentifier: String)
case roomLeft(roomIdentifier: String)
case presentSecureBackupSettings
case presentRecoveryKeyScreen
@@ -29,6 +30,7 @@ enum HomeScreenViewAction {
case showRoomDetails(roomIdentifier: String)
case leaveRoom(roomIdentifier: String)
case confirmLeaveRoom(roomIdentifier: String)
case reportRoom(roomIdentifier: String)
case showSettings
case startChat
case setupRecovery
@@ -98,6 +100,10 @@ struct HomeScreenViewState: BindableState {
var selectedRoomID: String?
var hideInviteAvatars = false
var reportRoomEnabled = false
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
return placeholderRooms
@@ -105,9 +111,7 @@ struct HomeScreenViewState: BindableState {
return rooms
}
var hideInviteAvatars = false
var bindings = HomeScreenViewStateBindings()
var placeholderRooms: [HomeScreenRoom] {
@@ -105,6 +105,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
appSettings.$reportRoomEnabled
.weakAssign(to: \.state.reportRoomEnabled, on: self)
.store(in: &cancellables)
let isSearchFieldFocused = context.$viewState.map(\.bindings.isSearchFieldFocused)
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
let activeFilters = context.$viewState.map(\.bindings.filtersState.activeFilters)
@@ -136,12 +140,14 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
switch viewAction {
case .selectRoom(let roomIdentifier):
actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier))
case .showRoomDetails(roomIdentifier: let roomIdentifier):
case .showRoomDetails(let roomIdentifier):
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .leaveRoom(roomIdentifier: let roomIdentifier):
case .leaveRoom(let roomIdentifier):
startLeaveRoomProcess(roomID: roomIdentifier)
case .confirmLeaveRoom(roomIdentifier: let roomIdentifier):
case .confirmLeaveRoom(let roomIdentifier):
Task { await leaveRoom(roomID: roomIdentifier) }
case .reportRoom(let roomIdentifier):
actionsSubject.send(.presentReportRoom(roomIdentifier: roomIdentifier))
case .showSettings:
actionsSubject.send(.presentSettingsScreen)
case .setupRecovery:
@@ -69,6 +69,14 @@ struct HomeScreenRoomList: View {
Label(L10n.commonSettings, icon: \.settings)
}
if context.viewState.reportRoomEnabled {
Button(role: .destructive) {
context.send(viewAction: .reportRoom(roomIdentifier: room.id))
} label: {
Label(L10n.actionReportRoom, icon: \.chatProblem)
}
}
Button(role: .destructive) {
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
} label: {
@@ -0,0 +1,54 @@
//
// Copyright 2022-2024 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.
//
// periphery:ignore:all - this is just a reportRoom remove this comment once generating the final file
import Combine
import SwiftUI
struct ReportRoomScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ReportRoomScreenCoordinatorAction {
case dismiss(shouldLeaveRoom: Bool)
}
final class ReportRoomScreenCoordinator: CoordinatorProtocol {
private let parameters: ReportRoomScreenCoordinatorParameters
private let viewModel: ReportRoomScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<ReportRoomScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<ReportRoomScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: ReportRoomScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ReportRoomScreenViewModel(roomProxy: parameters.roomProxy,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss(let shouldLeaveRoom):
actionsSubject.send(.dismiss(shouldLeaveRoom: shouldLeaveRoom))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(ReportRoomScreen(context: viewModel.context))
}
}
@@ -0,0 +1,31 @@
//
// Copyright 2022-2024 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.
//
import Foundation
enum ReportRoomScreenViewModelAction: Equatable {
case dismiss(shouldLeaveRoom: Bool)
}
struct ReportRoomScreenViewState: BindableState {
var bindings = ReportRoomScreenViewStateBindings()
}
struct ReportRoomScreenViewStateBindings {
var reason = ""
var shouldLeaveRoom = false
var alert: AlertInfo<ReportRoomScreenAlertType>?
}
enum ReportRoomScreenViewAction {
case report
case dismiss
}
enum ReportRoomScreenAlertType {
case leaveRoomFailed
}
@@ -0,0 +1,91 @@
//
// Copyright 2022-2024 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.
//
import Combine
import SwiftUI
typealias ReportRoomScreenViewModelType = StateStoreViewModel<ReportRoomScreenViewState, ReportRoomScreenViewAction>
class ReportRoomScreenViewModel: ReportRoomScreenViewModelType, ReportRoomScreenViewModelProtocol {
let roomProxy: JoinedRoomProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<ReportRoomScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ReportRoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol, userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: ReportRoomScreenViewState())
}
// MARK: - Public
override func process(viewAction: ReportRoomScreenViewAction) {
switch viewAction {
case .report:
Task { await report() }
case .dismiss:
actionsSubject.send(.dismiss(shouldLeaveRoom: false))
}
}
private func report() async {
showLoadingIndicator()
let result = await roomProxy.reportRoom(reason: state.bindings.reason.isBlank ? nil : state.bindings.reason)
switch result {
case .success:
if state.bindings.shouldLeaveRoom {
await leaveRoom(showLoading: false)
} else {
hideLoadingIndicator()
userIndicatorController.submitIndicator(.init(title: L10n.dialogRoomReported, iconName: "checkmark"))
actionsSubject.send(.dismiss(shouldLeaveRoom: false))
}
case .failure:
hideLoadingIndicator()
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func leaveRoom(showLoading: Bool) async {
if showLoading {
showLoadingIndicator()
}
let result = await roomProxy.leaveRoom()
hideLoadingIndicator()
switch result {
case .success:
userIndicatorController.submitIndicator(.init(title: L10n.dialogRoomReportedAndLeft, iconName: "checkmark"))
actionsSubject.send(.dismiss(shouldLeaveRoom: true))
case .failure:
state.bindings.alert = .init(id: .leaveRoomFailed,
title: L10n.screenReportRoomLeaveFailedAlertTitle,
message: L10n.screenReportRoomLeaveFailedAlertMessage,
primaryButton: .init(title: L10n.actionDismiss, role: .cancel) { [weak self] in self?.actionsSubject.send(.dismiss(shouldLeaveRoom: false)) },
secondaryButton: .init(title: L10n.actionRetry) { [weak self] in Task { await self?.leaveRoom(showLoading: true) } })
}
}
private static let loadingIndicatorIdentifier = "\(BugReportScreenCoordinator.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2022-2024 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.
//
import Combine
@MainActor
protocol ReportRoomScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<ReportRoomScreenViewModelAction, Never> { get }
var context: ReportRoomScreenViewModelType.Context { get }
}
@@ -0,0 +1,70 @@
//
// Copyright 2022-2024 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.
//
import Compound
import SwiftUI
struct ReportRoomScreen: View {
@ObservedObject var context: ReportRoomScreenViewModel.Context
var body: some View {
Form {
reasonSection
leaveRoomSection
}
.compoundList()
.navigationTitle(L10n.screenReportRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.alert(item: $context.alert)
}
private var reasonSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenReportRoomReasonPlaceholder),
kind: .textField(text: $context.reason, axis: .vertical))
.lineLimit(4, reservesSpace: true)
} footer: {
Text(L10n.screenReportRoomReasonFooter)
.compoundListSectionFooter()
}
}
private var leaveRoomSection: some View {
Section {
ListRow(label: .plain(title: L10n.actionLeaveRoom),
kind: .toggle($context.shouldLeaveRoom))
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .dismiss)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionReport) {
context.send(viewAction: .report)
}
}
}
}
// MARK: - Previews
struct ReportRoomScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = ReportRoomScreenViewModel(roomProxy: JoinedRoomProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
NavigationStack {
ReportRoomScreen(context: viewModel.context)
}
}
}
@@ -33,6 +33,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentMediaEventsTimeline
case presentKnockingRequestsListScreen
case presentSecurityAndPrivacyScreen
case presentReportRoomScreen
}
final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
@@ -91,6 +92,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentSecurityAndPrivacyScreen)
case .requestRecipientDetailsPresentation(let userID):
actionsSubject.send(.presentRecipientDetails(userID: userID))
case .displayReportRoom:
actionsSubject.send(.presentReportRoomScreen)
}
}
.store(in: &cancellables)
@@ -26,6 +26,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
case displayMediaEventsTimeline
case displayKnockingRequests
case displaySecurityAndPrivacy
case displayReportRoom
}
// MARK: View
@@ -64,6 +65,8 @@ struct RoomDetailsScreenViewState: BindableState {
var isKnockableRoom = false
var knockRequestsCount = 0
var reportRoomEnabled = false
var canSeeKnockingRequests: Bool {
knockingEnabled && dmRecipientInfo == nil && isKnockableRoom && (canInviteUsers || canKickUsers || canBanUsers)
}
@@ -218,6 +221,7 @@ enum RoomDetailsScreenViewAction {
case processTapPinnedEvents
case processTapMediaEvents
case processTapRequestsToJoin
case processTapReport
}
enum RoomDetailsScreenViewShortcut {
@@ -78,6 +78,10 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
.weakAssign(to: \.state.knockingEnabled, on: self)
.store(in: &cancellables)
appSettings.$reportRoomEnabled
.weakAssign(to: \.state.reportRoomEnabled, on: self)
.store(in: &cancellables)
appMediator.networkMonitor.reachabilityPublisher
.filter { $0 == .reachable }
.receive(on: DispatchQueue.main)
@@ -174,6 +178,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
return
}
actionsSubject.send(.requestRecipientDetailsPresentation(userID: userID))
case .processTapReport:
actionsSubject.send(.displayReportRoom)
}
}
@@ -275,9 +275,19 @@ struct RoomDetailsScreen: View {
private var leaveRoomTitle: String {
context.viewState.dmRecipientInfo == nil ? L10n.screenRoomDetailsLeaveRoomTitle : L10n.screenRoomDetailsLeaveConversationTitle
}
private var reportRoomTitle: String {
context.viewState.dmRecipientInfo == nil ? L10n.actionReportRoom : L10n.actionReport
}
private var leaveRoomSection: some View {
Section {
if context.viewState.reportRoomEnabled {
ListRow(label: .action(title: reportRoomTitle,
icon: \.chatProblem,
role: .destructive),
kind: .button { context.send(viewAction: .processTapReport) })
}
ListRow(label: .action(title: leaveRoomTitle,
icon: \.leave,
role: .destructive),
@@ -53,7 +53,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
case .kickMember(let member):
var reason: String?
let binding: Binding<String> = .init(get: { reason ?? "" },
set: { reason = $0.isEmpty ? nil : $0 })
set: { reason = $0.isBlank ? nil : $0 })
state.bindings.alertInfo = .init(id: .kickConfirmation,
title: L10n.screenRoomMemberListKickMemberConfirmationTitle,
message: L10n.screenRoomMemberListKickMemberConfirmationDescription,
@@ -66,7 +66,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
case .banMember(let member):
var reason: String?
let binding: Binding<String> = .init(get: { reason ?? "" },
set: { reason = $0.isEmpty ? nil : $0 })
set: { reason = $0.isBlank ? nil : $0 })
state.bindings.alertInfo = .init(id: .banConfirmation,
title: L10n.screenRoomMemberListBanMemberConfirmationTitle,
message: L10n.screenRoomMemberListBanMemberConfirmationDescription,
@@ -44,6 +44,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var enableOnlySignedDeviceIsolationMode: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
var knockingEnabled: Bool { get set }
var reportRoomEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -62,6 +62,13 @@ struct DeveloperOptionsScreen: View {
} footer: {
Text("This setting controls how end-to-end encryption (E2EE) keys are exchanged. Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners.")
}
Section("Reporting") {
Toggle(isOn: $context.reportRoomEnabled) {
Text("Report rooms")
Text("Report API might not work properly")
}
}
Section {
TextField("Leave empty to use EC locally", text: $elementCallURLOverrideString)