import SwiftUI // "Report a problem" (spec §15.26): the person's note and this call's // diagnostics, with the people in it named by short peer ids, sent to the // server's log under a reference the person can quote. From the call's // controls, from the ⓘ beside "Copy call diagnostics", and for ten minutes // after the call from the notice that it ended and from the home screen. /// What became of a report, from the bridge's answer (bridge.SendProblem). public enum ProblemOutcome: Equatable, Sendable { case sent(ref: String) case tooMany case gone case failed /// `{"ref":"K7Q2M9PD"}` or `{"outcome":"too-many"|"gone"|"failed"}`. public init(bridgeAnswer: String) { struct Answer: Decodable { let ref: String? let outcome: String? } guard let answer = try? JSONDecoder().decode(Answer.self, from: Data(bridgeAnswer.utf8)) else { self = .failed return } if let ref = answer.ref, !ref.isEmpty { self = .sent(ref: ref) return } switch answer.outcome { case "too-many": self = .tooMany case "gone": self = .gone default: self = .failed } } } /// The words, exactly as the ruling has them. public enum ProblemWords { public static let title = "Report a problem" public static let noteLabel = "What went wrong?" public static let disclosure = "Sends this call's connection diagnostics to the Calls server log. No audio or video." public static let sent = "Sent — thank you" public static func reference(_ ref: String) -> String { "Reference \(ref)" } public static let tooMany = "Too many reports from this call — try again in a minute." public static let gone = "This call can no longer be reported." public static let failed = "The report could not be sent." static func problem(_ outcome: ProblemOutcome) -> String? { switch outcome { case .sent: nil case .tooMany: tooMany case .gone: gone case .failed: failed } } } /// The sheet's state: editing, sending, sent with a reference, or a /// sentence about why not (and the note kept, to send again). @MainActor public final class ProblemReportModel: ObservableObject, Identifiable { public enum Phase: Equatable, Sendable { case editing case sending case sent(ref: String) case refused(String) } @Published public var note = "" { didSet { // The server counts characters as code points (utf8.RuneCount). if note.unicodeScalars.count > QualityBounds.note { note = String(String.UnicodeScalarView(note.unicodeScalars.prefix(QualityBounds.note))) } } } @Published public private(set) var phase = Phase.editing private let deliver: @Sendable (String) async -> ProblemOutcome /// `deliver` sends the note (with whatever else the report carries) and /// answers what became of it. Call supplies one; the tests supply theirs. public init(deliver: @escaping @Sendable (String) async -> ProblemOutcome) { self.deliver = deliver } /// "312/1000". public var counter: String { "\(note.unicodeScalars.count)/\(QualityBounds.note)" } /// The counter, for VoiceOver: "312 of 1000 characters". public var counterWords: String { "\(note.unicodeScalars.count) of \(QualityBounds.note) characters" } public var canSend: Bool { switch phase { case .editing, .refused: true case .sending, .sent: false } } public func send() async { guard canSend else { return } phase = .sending let outcome = await deliver(note) switch outcome { case .sent(let ref): phase = .sent(ref: ref) default: phase = .refused(ProblemWords.problem(outcome) ?? ProblemWords.failed) } } } /// The body a report is sent as: protocol.ProblemReport. struct ProblemReportBody: Encodable { let note: String let diagnostics: String let client: QualityClient /// JSON within the server's 64 KB: diagnostics are cut from the front, /// keeping the newest lines, which are the ones about the problem. func encoded() -> String { var diagnostics = self.diagnostics while true { let body = ProblemReportBody(note: note, diagnostics: diagnostics, client: client) let data = (try? JSONEncoder().encode(body)) ?? Data() if data.count <= QualityBounds.problemBody - 256 || diagnostics.isEmpty { return String(data: data, encoding: .utf8) ?? "{}" } diagnostics = "(earlier lines cut)\n" + String(diagnostics.suffix(diagnostics.count * 3 / 4)) } } } /// The sheet. public struct ReportProblemSheet: View { @ObservedObject var model: ProblemReportModel let done: () -> Void @State private var copied = false @FocusState private var noteFocused: Bool public init(model: ProblemReportModel, done: @escaping () -> Void) { self.model = model self.done = done } public var body: some View { NavigationStack { Form { if case .sent(let ref) = model.phase { sent(ref) } else { compose } } .formStyle(.grouped) .navigationTitle(ProblemWords.title) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { if case .sent = model.phase { ToolbarItem(placement: .confirmationAction) { Button("Done", action: done).accessibilityIdentifier("reportProblemDone") } } else { ToolbarItem(placement: .cancellationAction) { Button("Cancel", action: done).accessibilityIdentifier("reportProblemCancel") } ToolbarItem(placement: .confirmationAction) { Button("Send") { Task { await model.send() } } .disabled(!model.canSend) .accessibilityIdentifier("reportProblemSend") } } } } #if os(macOS) .frame(width: 440) .frame(minHeight: 340) #endif .tint(CallsAppearance.accent) } @ViewBuilder private var compose: some View { Section { TextEditor(text: $model.note) .frame(minHeight: 110) .focused($noteFocused) .accessibilityLabel(ProblemWords.noteLabel) .accessibilityHint("Optional") .accessibilityIdentifier("reportProblemNote") .disabled(model.phase == .sending) HStack { Text("Optional").foregroundStyle(.secondary) Spacer() Text(model.counter).monospacedDigit().foregroundStyle(.secondary) .accessibilityLabel(model.counterWords) .accessibilityIdentifier("reportProblemCounter") } .font(.footnote) } header: { Text(ProblemWords.noteLabel) } footer: { Text(ProblemWords.disclosure) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("reportProblemDisclosure") } if model.phase == .sending { Section { ProgressView("Sending…") } } if case .refused(let words) = model.phase { Section { Label(words, systemImage: "exclamationmark.circle.fill") .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) .accessibilityLabel("Error: \(words)") .accessibilityIdentifier("reportProblemError") } } } @ViewBuilder private func sent(_ ref: String) -> some View { Section { Label(ProblemWords.sent, systemImage: "checkmark.circle.fill") .font(.headline) .accessibilityIdentifier("reportProblemSent") HStack { Text(ProblemWords.reference(ref)) .font(.body.monospaced()) .textSelection(.enabled) .accessibilityIdentifier("reportProblemReference") Spacer() Button(copied ? "Copied" : "Copy", systemImage: "doc.on.doc") { AudioDiagnosticsReport.copy(ref) copied = true } #if os(iOS) .buttonStyle(.borderless) #endif .accessibilityLabel(copied ? "Reference copied" : "Copy reference") .accessibilityIdentifier("reportProblemCopy") } } footer: { Text("Quote the reference if you write to us about this call.") } } } /// "Report a problem" as a plain button, wherever it appears. public struct ReportProblemButton: View { let action: () -> Void public init(action: @escaping () -> Void) { self.action = action } public var body: some View { Button(ProblemWords.title, systemImage: "exclamationmark.bubble", action: action) .accessibilityIdentifier("reportProblem") } } extension View { /// The sheet, shown while `call.problemReport` is set. public func reportProblemSheet(_ call: Call) -> some View { modifier(ReportProblemPresenter(call: call)) } } private struct ReportProblemPresenter: ViewModifier { @ObservedObject var call: Call func body(content: Content) -> some View { content.sheet(item: $call.problemReport) { model in ReportProblemSheet(model: model) { call.problemReport = nil } #if os(iOS) .presentationDetents([.medium, .large]) #endif } } }