| 1 | import SwiftUI |
| 2 | |
| 3 | // "Report a problem" (spec §15.26): the person's note and this call's |
| 4 | // diagnostics, with the people in it named by short peer ids, sent to the |
| 5 | // server's log under a reference the person can quote. From the call's |
| 6 | // controls, from the ⓘ beside "Copy call diagnostics", and for ten minutes |
| 7 | // after the call from the notice that it ended and from the home screen. |
| 8 | |
| 9 | /// What became of a report, from the bridge's answer (bridge.SendProblem). |
| 10 | public enum ProblemOutcome: Equatable, Sendable { |
| 11 | case sent(ref: String) |
| 12 | case tooMany |
| 13 | case gone |
| 14 | case failed |
| 15 | |
| 16 | /// `{"ref":"K7Q2M9PD"}` or `{"outcome":"too-many"|"gone"|"failed"}`. |
| 17 | public init(bridgeAnswer: String) { |
| 18 | struct Answer: Decodable { |
| 19 | let ref: String? |
| 20 | let outcome: String? |
| 21 | } |
| 22 | guard let answer = try? JSONDecoder().decode(Answer.self, from: Data(bridgeAnswer.utf8)) else { |
| 23 | self = .failed |
| 24 | return |
| 25 | } |
| 26 | if let ref = answer.ref, !ref.isEmpty { |
| 27 | self = .sent(ref: ref) |
| 28 | return |
| 29 | } |
| 30 | switch answer.outcome { |
| 31 | case "too-many": self = .tooMany |
| 32 | case "gone": self = .gone |
| 33 | default: self = .failed |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /// The words, exactly as the ruling has them. |
| 39 | public enum ProblemWords { |
| 40 | public static let title = "Report a problem" |
| 41 | public static let noteLabel = "What went wrong?" |
| 42 | public static let disclosure = "Sends this call's connection diagnostics to the Calls server log. No audio or video." |
| 43 | public static let sent = "Sent — thank you" |
| 44 | public static func reference(_ ref: String) -> String { "Reference \(ref)" } |
| 45 | public static let tooMany = "Too many reports from this call — try again in a minute." |
| 46 | public static let gone = "This call can no longer be reported." |
| 47 | public static let failed = "The report could not be sent." |
| 48 | |
| 49 | static func problem(_ outcome: ProblemOutcome) -> String? { |
| 50 | switch outcome { |
| 51 | case .sent: nil |
| 52 | case .tooMany: tooMany |
| 53 | case .gone: gone |
| 54 | case .failed: failed |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// The sheet's state: editing, sending, sent with a reference, or a |
| 60 | /// sentence about why not (and the note kept, to send again). |
| 61 | @MainActor public final class ProblemReportModel: ObservableObject, Identifiable { |
| 62 | public enum Phase: Equatable, Sendable { |
| 63 | case editing |
| 64 | case sending |
| 65 | case sent(ref: String) |
| 66 | case refused(String) |
| 67 | } |
| 68 | |
| 69 | @Published public var note = "" { |
| 70 | didSet { |
| 71 | // The server counts characters as code points (utf8.RuneCount). |
| 72 | if note.unicodeScalars.count > QualityBounds.note { |
| 73 | note = String(String.UnicodeScalarView(note.unicodeScalars.prefix(QualityBounds.note))) |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | @Published public private(set) var phase = Phase.editing |
| 78 | private let deliver: @Sendable (String) async -> ProblemOutcome |
| 79 | |
| 80 | /// `deliver` sends the note (with whatever else the report carries) and |
| 81 | /// answers what became of it. Call supplies one; the tests supply theirs. |
| 82 | public init(deliver: @escaping @Sendable (String) async -> ProblemOutcome) { |
| 83 | self.deliver = deliver |
| 84 | } |
| 85 | |
| 86 | /// "312/1000". |
| 87 | public var counter: String { "\(note.unicodeScalars.count)/\(QualityBounds.note)" } |
| 88 | /// The counter, for VoiceOver: "312 of 1000 characters". |
| 89 | public var counterWords: String { "\(note.unicodeScalars.count) of \(QualityBounds.note) characters" } |
| 90 | public var canSend: Bool { |
| 91 | switch phase { |
| 92 | case .editing, .refused: true |
| 93 | case .sending, .sent: false |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | public func send() async { |
| 98 | guard canSend else { return } |
| 99 | phase = .sending |
| 100 | let outcome = await deliver(note) |
| 101 | switch outcome { |
| 102 | case .sent(let ref): phase = .sent(ref: ref) |
| 103 | default: phase = .refused(ProblemWords.problem(outcome) ?? ProblemWords.failed) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// The body a report is sent as: protocol.ProblemReport. |
| 109 | struct ProblemReportBody: Encodable { |
| 110 | let note: String |
| 111 | let diagnostics: String |
| 112 | let client: QualityClient |
| 113 | |
| 114 | /// JSON within the server's 64 KB: diagnostics are cut from the front, |
| 115 | /// keeping the newest lines, which are the ones about the problem. |
| 116 | func encoded() -> String { |
| 117 | var diagnostics = self.diagnostics |
| 118 | while true { |
| 119 | let body = ProblemReportBody(note: note, diagnostics: diagnostics, client: client) |
| 120 | let data = (try? JSONEncoder().encode(body)) ?? Data() |
| 121 | if data.count <= QualityBounds.problemBody - 256 || diagnostics.isEmpty { |
| 122 | return String(data: data, encoding: .utf8) ?? "{}" |
| 123 | } |
| 124 | diagnostics = "(earlier lines cut)\n" + String(diagnostics.suffix(diagnostics.count * 3 / 4)) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /// The sheet. |
| 130 | public struct ReportProblemSheet: View { |
| 131 | @ObservedObject var model: ProblemReportModel |
| 132 | let done: () -> Void |
| 133 | @State private var copied = false |
| 134 | @FocusState private var noteFocused: Bool |
| 135 | |
| 136 | public init(model: ProblemReportModel, done: @escaping () -> Void) { |
| 137 | self.model = model |
| 138 | self.done = done |
| 139 | } |
| 140 | |
| 141 | public var body: some View { |
| 142 | NavigationStack { |
| 143 | Form { |
| 144 | if case .sent(let ref) = model.phase { |
| 145 | sent(ref) |
| 146 | } else { |
| 147 | compose |
| 148 | } |
| 149 | } |
| 150 | .formStyle(.grouped) |
| 151 | .navigationTitle(ProblemWords.title) |
| 152 | #if os(iOS) |
| 153 | .navigationBarTitleDisplayMode(.inline) |
| 154 | #endif |
| 155 | .toolbar { |
| 156 | if case .sent = model.phase { |
| 157 | ToolbarItem(placement: .confirmationAction) { |
| 158 | Button("Done", action: done).accessibilityIdentifier("reportProblemDone") |
| 159 | } |
| 160 | } else { |
| 161 | ToolbarItem(placement: .cancellationAction) { |
| 162 | Button("Cancel", action: done).accessibilityIdentifier("reportProblemCancel") |
| 163 | } |
| 164 | ToolbarItem(placement: .confirmationAction) { |
| 165 | Button("Send") { Task { await model.send() } } |
| 166 | .disabled(!model.canSend) |
| 167 | .accessibilityIdentifier("reportProblemSend") |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | #if os(macOS) |
| 173 | .frame(width: 440) |
| 174 | .frame(minHeight: 340) |
| 175 | #endif |
| 176 | .tint(CallsAppearance.accent) |
| 177 | } |
| 178 | |
| 179 | @ViewBuilder private var compose: some View { |
| 180 | Section { |
| 181 | TextEditor(text: $model.note) |
| 182 | .frame(minHeight: 110) |
| 183 | .focused($noteFocused) |
| 184 | .accessibilityLabel(ProblemWords.noteLabel) |
| 185 | .accessibilityHint("Optional") |
| 186 | .accessibilityIdentifier("reportProblemNote") |
| 187 | .disabled(model.phase == .sending) |
| 188 | HStack { |
| 189 | Text("Optional").foregroundStyle(.secondary) |
| 190 | Spacer() |
| 191 | Text(model.counter).monospacedDigit().foregroundStyle(.secondary) |
| 192 | .accessibilityLabel(model.counterWords) |
| 193 | .accessibilityIdentifier("reportProblemCounter") |
| 194 | } |
| 195 | .font(.footnote) |
| 196 | } header: { |
| 197 | Text(ProblemWords.noteLabel) |
| 198 | } footer: { |
| 199 | Text(ProblemWords.disclosure) |
| 200 | .fixedSize(horizontal: false, vertical: true) |
| 201 | .accessibilityIdentifier("reportProblemDisclosure") |
| 202 | } |
| 203 | if model.phase == .sending { |
| 204 | Section { ProgressView("Sending…") } |
| 205 | } |
| 206 | if case .refused(let words) = model.phase { |
| 207 | Section { |
| 208 | Label(words, systemImage: "exclamationmark.circle.fill") |
| 209 | .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| 210 | .accessibilityLabel("Error: \(words)") |
| 211 | .accessibilityIdentifier("reportProblemError") |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | @ViewBuilder private func sent(_ ref: String) -> some View { |
| 217 | Section { |
| 218 | Label(ProblemWords.sent, systemImage: "checkmark.circle.fill") |
| 219 | .font(.headline) |
| 220 | .accessibilityIdentifier("reportProblemSent") |
| 221 | HStack { |
| 222 | Text(ProblemWords.reference(ref)) |
| 223 | .font(.body.monospaced()) |
| 224 | .textSelection(.enabled) |
| 225 | .accessibilityIdentifier("reportProblemReference") |
| 226 | Spacer() |
| 227 | Button(copied ? "Copied" : "Copy", systemImage: "doc.on.doc") { |
| 228 | AudioDiagnosticsReport.copy(ref) |
| 229 | copied = true |
| 230 | } |
| 231 | #if os(iOS) |
| 232 | .buttonStyle(.borderless) |
| 233 | #endif |
| 234 | .accessibilityLabel(copied ? "Reference copied" : "Copy reference") |
| 235 | .accessibilityIdentifier("reportProblemCopy") |
| 236 | } |
| 237 | } footer: { |
| 238 | Text("Quote the reference if you write to us about this call.") |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// "Report a problem" as a plain button, wherever it appears. |
| 244 | public struct ReportProblemButton: View { |
| 245 | let action: () -> Void |
| 246 | public init(action: @escaping () -> Void) { self.action = action } |
| 247 | public var body: some View { |
| 248 | Button(ProblemWords.title, systemImage: "exclamationmark.bubble", action: action) |
| 249 | .accessibilityIdentifier("reportProblem") |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | extension View { |
| 254 | /// The sheet, shown while `call.problemReport` is set. |
| 255 | public func reportProblemSheet(_ call: Call) -> some View { |
| 256 | modifier(ReportProblemPresenter(call: call)) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | private struct ReportProblemPresenter: ViewModifier { |
| 261 | @ObservedObject var call: Call |
| 262 | func body(content: Content) -> some View { |
| 263 | content.sheet(item: $call.problemReport) { model in |
| 264 | ReportProblemSheet(model: model) { call.problemReport = nil } |
| 265 | #if os(iOS) |
| 266 | .presentationDetents([.medium, .large]) |
| 267 | #endif |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |