oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import 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).
10public 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.
39public 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.
109struct 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.
130public 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.
244public 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
253extension 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
260private 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