Report Content v2 (#659)

* created the empty files

* set up the view content

* connected the Room Coordinator to the ReportContent Coordinator

* added the loading indicators and the dismiss behaviour

* almost completed but I need to display the success indicator when the report is sent succesfully

* completed

* added an untranslated string

* tests

* Update ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* Update ElementX/Sources/Screens/ReportContent/View/ReportContentScreen.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* pr comment

* pr suggestion

* removing unused identifiers

* fixing compilation error

* added a form text editor view

* changelog

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro 2023-03-02 19:24:10 +01:00 committed by GitHub
parent 1c09a7eace
commit eed031c9cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 472 additions and 85 deletions

View File

@ -8,6 +8,8 @@
"message" = "Message";
"sending" = "Sending...";
"screenshot_detected_title" = "You took a screenshot";
"screenshot_detected_message" = "Would you like to submit a bug report?";
@ -86,7 +88,10 @@
"bug_report_screen_logs_description" = "To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting.";
"bug_report_screen_attach_screenshot" = "Attach Screenshot";
"bug_report_screen_edit_screenshot" = "Edit Screenshot";
"bug_report_screen_sending" = "Sending...";
// Report Content
"report_content_info" = "Reporting this message will send its unique event ID to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.";
"report_content_submitted" = "Report Submitted";
// Session verification
/*

View File

@ -26,8 +26,6 @@ extension ElementL10n {
public static let bugReportScreenIncludeLogs = ElementL10n.tr("Untranslated", "bug_report_screen_include_logs")
/// To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting.
public static let bugReportScreenLogsDescription = ElementL10n.tr("Untranslated", "bug_report_screen_logs_description")
/// Sending...
public static let bugReportScreenSending = ElementL10n.tr("Untranslated", "bug_report_screen_sending")
/// Report a bug
public static let bugReportScreenTitle = ElementL10n.tr("Untranslated", "bug_report_screen_title")
/// %@ iOS
@ -96,6 +94,10 @@ extension ElementL10n {
}
/// Notification
public static let notification = ElementL10n.tr("Untranslated", "Notification")
/// Reporting this message will send its unique event ID to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.
public static let reportContentInfo = ElementL10n.tr("Untranslated", "report_content_info")
/// Report Submitted
public static let reportContentSubmitted = ElementL10n.tr("Untranslated", "report_content_submitted")
/// About
public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title")
/// Copy Link
@ -136,6 +138,8 @@ extension ElementL10n {
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
/// You took a screenshot
public static let screenshotDetectedTitle = ElementL10n.tr("Untranslated", "screenshot_detected_title")
/// Sending...
public static let sending = ElementL10n.tr("Untranslated", "sending")
/// You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %@
public static func serverSelectionServerFooter(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "server_selection_server_footer", String(describing: p1))

View File

@ -26,13 +26,12 @@ struct A11yIdentifiers {
static let roomDetailsScreen = RoomDetailsScreen()
static let sessionVerificationScreen = SessionVerificationScreen()
static let softLogoutScreen = SoftLogoutScreen()
struct BugReportScreen {
let report = "bug_report-report"
let sendLogs = "bug_report-send_logs"
let screenshot = "bug_report-screenshot"
let removeScreenshot = "bug_report-remove_screenshot"
let send = "bug_report-send"
let attachScreenshot = "bug-report-attach_screenshot"
}

View File

@ -0,0 +1,64 @@
//
// Copyright 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.
//
import SwiftUI
struct FormTextEditor: View {
@Binding var text: String
let placeholder: String
var editorAccessibilityIdentifier: String?
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.element.formRowBackground)
let textEditor = TextEditor(text: $text)
.tint(.element.brand)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.cornerRadius(14)
.scrollContentBackground(.hidden)
if let editorAccessibilityIdentifier {
textEditor
.accessibilityIdentifier(editorAccessibilityIdentifier)
} else {
textEditor
}
if text.isEmpty {
Text(placeholder)
.font(.element.body)
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.allowsHitTesting(false)
}
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.element.quaternaryContent)
}
.frame(maxWidth: .infinity)
.frame(height: 220)
.font(.body)
}
}
struct FormTextEditor_Previews: PreviewProvider {
static var previews: some View {
FormTextEditor(text: .constant(""), placeholder: "test", editorAccessibilityIdentifier: nil)
}
}

View File

@ -57,7 +57,7 @@ final class BugReportCoordinator: CoordinatorProtocol {
case .cancel:
self.completion?(.cancel)
case let .submitStarted(progressTracker):
self.startLoading(label: ElementL10n.bugReportScreenSending, progressPublisher: progressTracker)
self.startLoading(label: ElementL10n.sending, progressPublisher: progressTracker)
case .submitFinished:
self.stopLoading()
self.completion?(.finish)

View File

@ -61,35 +61,10 @@ struct BugReportScreen: View {
}
}
@ViewBuilder
private var descriptionTextEditor: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.element.formRowBackground)
TextEditor(text: $context.reportText)
.tint(.element.brand)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.cornerRadius(14)
.accessibilityIdentifier(A11yIdentifiers.bugReportScreen.report)
.scrollContentBackground(.hidden)
if context.reportText.isEmpty {
Text(ElementL10n.bugReportScreenDescription)
.font(.element.body)
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.allowsHitTesting(false)
}
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.element.quaternaryContent)
}
.frame(maxWidth: .infinity)
.frame(height: 220)
.font(.body)
FormTextEditor(text: $context.reportText,
placeholder: ElementL10n.bugReportScreenDescription,
editorAccessibilityIdentifier: A11yIdentifiers.bugReportScreen.report)
}
@ViewBuilder
@ -164,7 +139,6 @@ struct BugReportScreen: View {
context.send(viewAction: .submit)
}
.disabled(context.reportText.count < 5)
.accessibilityIdentifier(A11yIdentifiers.bugReportScreen.send)
}
}
}

View File

@ -0,0 +1,90 @@
//
// Copyright 2022 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.
//
import SwiftUI
struct ReportContentCoordinatorParameters {
let itemID: String
let roomProxy: RoomProxyProtocol
weak var userIndicatorController: UserIndicatorControllerProtocol?
}
enum ReportContentCoordinatorAction {
case cancel
case finish
}
final class ReportContentCoordinator: CoordinatorProtocol {
private let parameters: ReportContentCoordinatorParameters
private var viewModel: ReportContentViewModelProtocol
var callback: ((ReportContentCoordinatorAction) -> Void)?
init(parameters: ReportContentCoordinatorParameters) {
self.parameters = parameters
viewModel = ReportContentViewModel(itemID: parameters.itemID, roomProxy: parameters.roomProxy)
}
// MARK: - Public
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
case .submitStarted:
self.startLoading()
case let .submitFailed(error):
self.stopLoading()
self.showError(description: error.localizedDescription)
case .submitFinished:
self.stopLoading()
self.callback?(.finish)
case .cancel:
self.callback?(.cancel)
}
}
}
func stop() {
stopLoading()
}
func toPresentable() -> AnyView {
AnyView(ReportContentScreen(context: viewModel.context))
}
// MARK: - Private
private static let loadingIndicatorIdentifier = "ReportContentLoading"
private func startLoading() {
parameters.userIndicatorController?.submitIndicator(
UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: ElementL10n.sending,
persistent: true)
)
}
private func stopLoading() {
parameters.userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
private func showError(description: String) {
parameters.userIndicatorController?.submitIndicator(UserIndicator(title: description))
}
}

View File

@ -0,0 +1,37 @@
//
// Copyright 2022 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.
//
import Foundation
enum ReportContentViewModelAction {
case cancel
case submitStarted
case submitFinished
case submitFailed(error: Error)
}
struct ReportContentViewState: BindableState {
var bindings: ReportContentViewStateBindings
}
struct ReportContentViewStateBindings {
var reasonText: String
}
enum ReportContentViewAction {
case cancel
case submit
}

View File

@ -0,0 +1,57 @@
//
// Copyright 2022 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.
//
import SwiftUI
typealias ReportContentViewModelType = StateStoreViewModel<ReportContentViewState, ReportContentViewAction>
class ReportContentViewModel: ReportContentViewModelType, ReportContentViewModelProtocol {
var callback: ((ReportContentViewModelAction) -> Void)?
private let itemID: String
private let roomProxy: RoomProxyProtocol
init(itemID: String, roomProxy: RoomProxyProtocol) {
self.itemID = itemID
self.roomProxy = roomProxy
super.init(initialViewState: ReportContentViewState(bindings: ReportContentViewStateBindings(reasonText: "")))
}
// MARK: - Public
override func process(viewAction: ReportContentViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
case .submit:
await submitReport()
}
}
// MARK: Private
private func submitReport() async {
callback?(.submitStarted)
switch await roomProxy.reportContent(itemID, reason: state.bindings.reasonText) {
case .success:
MXLog.info("Submit Report Content succeeded")
callback?(.submitFinished)
case let .failure(error):
MXLog.error("Submit Report Content failed: \(error)")
callback?(.submitFailed(error: error))
}
}
}

View File

@ -0,0 +1,23 @@
//
// Copyright 2022 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.
//
import Foundation
@MainActor
protocol ReportContentViewModelProtocol {
var callback: ((ReportContentViewModelAction) -> Void)? { get set }
var context: ReportContentViewModelType.Context { get }
}

View File

@ -0,0 +1,84 @@
//
// Copyright 2022 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.
//
import SwiftUI
struct ReportContentScreen: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject var context: ReportContentViewModel.Context
private var horizontalPadding: CGFloat {
horizontalSizeClass == .regular ? 50 : 16
}
var body: some View {
ScrollView {
mainContent
.padding(.top, 50)
.padding(.horizontal, horizontalPadding)
}
.scrollDismissesKeyboard(.immediately)
.background(Color.element.formBackground.ignoresSafeArea())
.navigationTitle(ElementL10n.reportContent)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.interactiveDismissDisabled()
}
/// The main content of the view to be shown in a scroll view.
var mainContent: some View {
VStack(alignment: .leading, spacing: 24) {
infoText
reasonTextEditor
}
}
private var infoText: some View {
Text(ElementL10n.reportContentInfo)
.font(.element.body)
.foregroundColor(Color.element.primaryContent)
}
private var reasonTextEditor: some View {
FormTextEditor(text: $context.reasonText, placeholder: ElementL10n.reportContentCustomHint)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(ElementL10n.actionSend) {
context.send(viewAction: .submit)
}
}
}
}
// MARK: - Previews
struct ReportContent_Previews: PreviewProvider {
static let viewModel = ReportContentViewModel(itemID: "", roomProxy: MockRoomProxy(displayName: nil))
static var previews: some View {
ReportContentScreen(context: viewModel.context)
}
}

View File

@ -54,6 +54,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.displayFile(for: fileURL, with: title)
case .displayEmojiPicker(let itemId):
self.displayEmojiPickerScreen(for: itemId)
case .displayReportContent(let itemId):
self.displayReportContent(for: itemId)
}
}
}
@ -115,4 +117,27 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
navigationStackCoordinator.push(coordinator)
}
private func displayReportContent(for itemId: String) {
let navigationCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: NavigationStackCoordinator())
let parameters = ReportContentCoordinatorParameters(itemID: itemId,
roomProxy: parameters.roomProxy,
userIndicatorController: userIndicatorController)
let coordinator = ReportContentCoordinator(parameters: parameters)
coordinator.callback = { [weak self] completion in
self?.navigationStackCoordinator.setSheetCoordinator(nil)
switch completion {
case .cancel: break
case .finish:
self?.showSuccess(label: ElementL10n.reportContentSubmitted)
}
}
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func showSuccess(label: String) {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
}
}

View File

@ -23,6 +23,7 @@ enum RoomScreenViewModelAction {
case displayVideo(videoURL: URL, title: String?)
case displayFile(fileURL: URL, title: String?)
case displayEmojiPicker(itemId: String)
case displayReportContent(itemId: String)
}
enum RoomScreenComposerMode: Equatable {
@ -55,7 +56,6 @@ enum RoomScreenViewAction {
/// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
case report(itemID: String, reason: String)
}
struct RoomScreenViewState: BindableState {
@ -91,25 +91,6 @@ struct RoomScreenViewStateBindings {
var alertInfo: AlertInfo<RoomScreenErrorType>?
var debugInfo: TimelineItemDebugView.DebugInfo?
// Report
var report: ReportAlertItem?
}
final class ReportAlertItem: AlertItem {
init(itemID: String) {
self.itemID = itemID
}
let title = ElementL10n.reportContentCustomHint
let itemID: String
private(set) var reason = ""
lazy var reasonBinding = Binding<String>(get: { [unowned self] in
self.reason
}, set: { [unowned self] newValue in
self.reason = newValue
})
}
enum RoomScreenErrorType: Hashable {

View File

@ -111,8 +111,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
await markRoomAsRead()
case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID)
case let .report(itemID, reason):
await timelineController.reportContent(itemID, reason: reason)
}
}
@ -320,7 +318,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
await timelineController.retryDecryption(for: sessionID)
}
case .report:
state.bindings.report = ReportAlertItem(itemID: itemID)
callback?(.displayReportContent(itemId: itemID))
}
if action.switchToDefaultComposer {

View File

@ -30,8 +30,6 @@ struct RoomScreen: View {
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
.alert(item: $context.alertInfo) { $0.alert }
.alert(item: $context.report,
actions: { reportAlertActions($0) })
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
.task(id: context.viewState.roomId) {
// Give a couple of seconds for items to load and to see them.
@ -41,15 +39,6 @@ struct RoomScreen: View {
context.send(viewAction: .markRoomAsRead)
}
}
@ViewBuilder
func reportAlertActions(_ report: ReportAlertItem) -> some View {
TextField("", text: report.reasonBinding)
Button(ElementL10n.actionSend, action: {
context.send(viewAction: .report(itemID: report.itemID, reason: report.reason))
})
Button(ElementL10n.actionCancel, role: .cancel, action: { })
}
var timeline: some View {
TimelineView()

View File

@ -64,8 +64,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func editMessage(_ newMessage: String, original itemID: String) async { }
func redact(_ itemID: String) async { }
func reportContent(_ itemID: String, reason: String?) async { }
func debugDescription(for itemID: String) -> String {
"Mock debug description"

View File

@ -189,16 +189,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
MXLog.error("Failed redacting message with error: \(error)")
}
}
func reportContent(_ itemID: String, reason: String?) async {
MXLog.info("Send report content in \(roomID)")
switch await roomProxy.reportContent(itemID, reason: reason) {
case .success:
MXLog.info("Finished reporting content")
case .failure(let error):
MXLog.error("Failed reporting content with error: \(error)")
}
}
// Handle this parallel to the timeline items so we're not forced
// to bundle the Rust side objects within them
@ -297,8 +287,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
case .unknown:
return nil
}
return nil
}
private func isItemCollapsible(_ item: TimelineItemProxy) -> Bool {

View File

@ -58,8 +58,6 @@ protocol RoomTimelineControllerProtocol {
func sendReaction(_ reaction: String, to itemID: String) async
func redact(_ itemID: String) async
func reportContent(_ itemID: String, reason: String?) async
func debugDescription(for itemID: String) -> String

View File

@ -300,6 +300,11 @@ class MockScreen: Identifiable {
members: [.mockAlice, .mockBob, .mockCharlie]))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .reportContent:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = ReportContentCoordinator(parameters: .init(itemID: "test", roomProxy: MockRoomProxy(displayName: "test")))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}

View File

@ -43,6 +43,7 @@ enum UITestsScreenIdentifier: String {
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomMemberDetailsScreen
case reportContent
}
extension UITestsScreenIdentifier: CustomStringConvertible {

View File

@ -0,0 +1,25 @@
//
// Copyright 2022 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.
//
import ElementX
import XCTest
class ReportContentScreenUITests: XCTestCase {
func testInitialStateComponents() {
let app = Application.launch(.reportContent)
app.assertScreenshot(.reportContent)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,29 @@
//
// Copyright 2022 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.
//
import XCTest
@testable import ElementX
@MainActor
class ReportContentScreenViewModelTests: XCTestCase {
func testInitialState() {
let viewModel = ReportContentViewModel(itemID: "test-id", roomProxy: MockRoomProxy(displayName: "test"))
let context = viewModel.context
XCTAssertEqual(context.reasonText, "")
}
}

1
changelog.d/115.change Normal file
View File

@ -0,0 +1 @@
Improved report content UI.