| 1 | import SwiftUI |
| 2 | |
| 3 | // The report sheet (issue #578) — the native twin of the web's openReportSheet |
| 4 | // (shell.js): a required category, the quoted message when one is reported, |
| 5 | // optional words, sealed by THIS client to each admin's public key and posted |
| 6 | // as opaque blobs. The server keeps only queue metadata (who reported whom, |
| 7 | // where) — it cannot read a report, and the sheet says plainly that admins |
| 8 | // will. Shared by iOS and Mac; presented from a message's Report action (with |
| 9 | // the message as evidence) or a profile card's Report button (no evidence). |
| 10 | // The admin QUEUE stays web-only, like level management — this sheet only |
| 11 | // files. |
| 12 | |
| 13 | /// The one reported message a report carries (web: content.evidence[0]) — a |
| 14 | /// client-side snapshot of what the reporter saw. Testimony, not proof; |
| 15 | /// admins read it and use their judgment. |
| 16 | public struct ReportEvidence: Sendable { |
| 17 | public let sid: Int64 |
| 18 | public let uid: String |
| 19 | public let name: String |
| 20 | public let ts: Int64 |
| 21 | public let text: String |
| 22 | public let attachments: [String] |
| 23 | |
| 24 | public init(sid: Int64, uid: String, name: String, ts: Int64, |
| 25 | text: String, attachments: [String]) { |
| 26 | self.sid = sid |
| 27 | self.uid = uid |
| 28 | self.name = name |
| 29 | self.ts = ts |
| 30 | self.text = text |
| 31 | self.attachments = attachments |
| 32 | } |
| 33 | |
| 34 | /// Snapshot a feed message — the exact fields the web sends (chat.js |
| 35 | /// reportMessage → shell.js's evidence entry), attachment names only. |
| 36 | public init(message m: ChatFeedModel.Message) { |
| 37 | self.init(sid: m.id, |
| 38 | uid: m.raw.uid ?? "", |
| 39 | name: m.name, |
| 40 | ts: m.raw.ts ?? 0, |
| 41 | text: m.text, |
| 42 | attachments: m.attachments.map { $0.name ?? $0.type ?? "attachment" }) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | public struct ReportSheet: View { |
| 47 | @ObservedObject var store: ThreadStore |
| 48 | let uid: String |
| 49 | /// The thread the report is about ("" for a person report from a profile |
| 50 | /// card — the web's exact split). |
| 51 | let threadID: String |
| 52 | /// The thread's client-side display name; rides INSIDE the sealed content |
| 53 | /// so admins see where without the server learning names. |
| 54 | let threadName: String |
| 55 | let evidence: ReportEvidence? |
| 56 | |
| 57 | @Environment(\.dismiss) private var dismiss |
| 58 | |
| 59 | @State private var category: String? // "spam" | "harassment" | "other" |
| 60 | @State private var note = "" |
| 61 | @State private var alsoMute = false |
| 62 | @State private var alsoBlock = false |
| 63 | @State private var sending = false |
| 64 | @State private var sent = false |
| 65 | @State private var errorText: String? |
| 66 | |
| 67 | public init(store: ThreadStore, uid: String, threadID: String = "", |
| 68 | threadName: String = "", evidence: ReportEvidence? = nil) { |
| 69 | self.store = store |
| 70 | self.uid = uid |
| 71 | self.threadID = threadID |
| 72 | self.threadName = threadName |
| 73 | self.evidence = evidence |
| 74 | } |
| 75 | |
| 76 | // The familiar triad, matching the web's radio chips. |
| 77 | private static let categories: [(id: String, label: String)] = [ |
| 78 | ("spam", "Spam"), ("harassment", "Harassment"), ("other", "Something else"), |
| 79 | ] |
| 80 | |
| 81 | private var who: String { |
| 82 | let n = store.displayName(for: uid) |
| 83 | return n.isEmpty ? "them" : n |
| 84 | } |
| 85 | |
| 86 | public var body: some View { |
| 87 | VStack(alignment: .leading, spacing: 14) { |
| 88 | Text("Report \(who)") |
| 89 | .font(.title3.weight(.semibold)) |
| 90 | .accessibilityAddTraits(.isHeader) |
| 91 | |
| 92 | // Category — required before Send enables (the web's rule). |
| 93 | HStack(spacing: 6) { |
| 94 | ForEach(Self.categories, id: \.id) { cat in |
| 95 | categoryChip(cat.id, cat.label) |
| 96 | } |
| 97 | } |
| 98 | .accessibilityElement(children: .contain) |
| 99 | .accessibilityLabel("Reason") |
| 100 | |
| 101 | // The quoted evidence — shown so the reporter knows exactly what |
| 102 | // travels (web's .report-quote). |
| 103 | if let ev = evidence, !ev.text.isEmpty || !ev.attachments.isEmpty { |
| 104 | (Text("\(who): ").bold() |
| 105 | + Text(ev.text.isEmpty ? ev.attachments.joined(separator: ", ") : ev.text)) |
| 106 | .font(.callout) |
| 107 | .foregroundStyle(.secondary) |
| 108 | .lineLimit(4) |
| 109 | .padding(.leading, 10) |
| 110 | .overlay(alignment: .leading) { |
| 111 | RoundedRectangle(cornerRadius: 1.5) |
| 112 | .fill(.quaternary) |
| 113 | .frame(width: 3) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | TextEditor(text: $note) |
| 118 | .font(.body) |
| 119 | .frame(minHeight: 64, maxHeight: 96) |
| 120 | .scrollContentBackground(.hidden) |
| 121 | .padding(6) |
| 122 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| 123 | .strokeBorder(.quaternary, lineWidth: 1)) |
| 124 | .overlay(alignment: .topLeading) { |
| 125 | if note.isEmpty { |
| 126 | Text("Anything the admins should know (optional)") |
| 127 | .font(.body) |
| 128 | .foregroundStyle(.tertiary) |
| 129 | .padding(.horizontal, 11) |
| 130 | .padding(.vertical, 14) |
| 131 | .allowsHitTesting(false) |
| 132 | } |
| 133 | } |
| 134 | .onChange(of: note) { _, v in |
| 135 | if v.count > 1000 { note = String(v.prefix(1000)) } // web's maxLength |
| 136 | } |
| 137 | .accessibilityLabel("Anything the admins should know (optional)") |
| 138 | |
| 139 | // Honest copy — testimony, not proof; admins see what you send. |
| 140 | Text("Your report goes to this space's admins, including the message above and what you write. Reports can't be verified automatically — admins read them and use their judgment.") |
| 141 | .font(.footnote) |
| 142 | .foregroundStyle(.secondary) |
| 143 | .fixedSize(horizontal: false, vertical: true) |
| 144 | |
| 145 | // One tap to also stop seeing them (hidden once already muted). |
| 146 | if !store.mutedPeople.contains(uid) { |
| 147 | Toggle("Also mute \(who)", isOn: $alsoMute) |
| 148 | .toggleStyle(.switch) |
| 149 | } |
| 150 | |
| 151 | // …or to also cut contact entirely (hidden once already blocked). |
| 152 | // Checking it wins over Also-mute on send — block implies mute. |
| 153 | if !store.blockedPeople.contains(uid) { |
| 154 | Toggle("Also block \(who) (mutes, ends your DM, stops new contact)", |
| 155 | isOn: $alsoBlock) |
| 156 | .toggleStyle(.switch) |
| 157 | } |
| 158 | |
| 159 | // Status + actions — all inline, never an alert or a toast. |
| 160 | if sent { |
| 161 | Text("Sent. The admins will take it from here.") |
| 162 | .font(.callout.weight(.medium)) |
| 163 | .frame(maxWidth: .infinity, alignment: .center) |
| 164 | .padding(.top, 2) |
| 165 | } else { |
| 166 | if let errorText { |
| 167 | Text(errorText) |
| 168 | .font(.footnote) |
| 169 | .foregroundStyle(.red) |
| 170 | .fixedSize(horizontal: false, vertical: true) |
| 171 | } |
| 172 | HStack { |
| 173 | Spacer() |
| 174 | Button("Cancel") { dismiss() } |
| 175 | .keyboardShortcut(.cancelAction) |
| 176 | Button(sending ? "Sending…" : "Send report") { send() } |
| 177 | .buttonStyle(.borderedProminent) |
| 178 | .disabled(category == nil || sending) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | .padding(24) |
| 183 | .frame(minWidth: 320, idealWidth: 400, maxWidth: 440) |
| 184 | } |
| 185 | |
| 186 | // One radio chip — capsule buttons like the web's .report-cat row, the |
| 187 | // pick carried by tint AND the checkmark (state is never color alone). |
| 188 | private func categoryChip(_ id: String, _ label: String) -> some View { |
| 189 | let on = category == id |
| 190 | return Button { |
| 191 | category = id |
| 192 | } label: { |
| 193 | HStack(spacing: 4) { |
| 194 | if on { Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)) } |
| 195 | Text(label).font(.system(size: 13, weight: .medium)) |
| 196 | } |
| 197 | .padding(.horizontal, 12) |
| 198 | .padding(.vertical, 7) // keeps the target ≥24pt tall |
| 199 | .background(Capsule().fill(on ? AnyShapeStyle(.tint.opacity(0.18)) |
| 200 | : AnyShapeStyle(.primary.opacity(0.06)))) |
| 201 | .overlay(Capsule().strokeBorder(on ? AnyShapeStyle(.tint) |
| 202 | : AnyShapeStyle(.quaternary), lineWidth: 1)) |
| 203 | .contentShape(Capsule()) |
| 204 | } |
| 205 | .buttonStyle(.plain) |
| 206 | .accessibilityAddTraits(on ? [.isSelected] : []) |
| 207 | } |
| 208 | |
| 209 | private func send() { |
| 210 | guard let category, !sending, !sent else { return } |
| 211 | sending = true |
| 212 | errorText = nil |
| 213 | Task { |
| 214 | // Seal to every admin (the server requires ≥1 blob and checks |
| 215 | // each target's role). The reported name inside the content is |
| 216 | // the PUBLISHED roster label, like the web — a report leaves this |
| 217 | // device, so my private nickname for them must not. |
| 218 | let admins = store.reportableAdmins |
| 219 | let reportedLabel = store.roster.first { $0.user_id == uid }?.label ?? "" |
| 220 | guard !admins.isEmpty, |
| 221 | let content = Self.contentData( |
| 222 | category: category, |
| 223 | text: note.trimmingCharacters(in: .whitespacesAndNewlines), |
| 224 | reportedUID: uid, reportedName: reportedLabel, |
| 225 | threadName: threadName, evidence: evidence) |
| 226 | else { |
| 227 | sending = false |
| 228 | errorText = "Couldn't send — try again" |
| 229 | return |
| 230 | } |
| 231 | var blobs: [[String: String]] = [] |
| 232 | for a in admins { |
| 233 | guard let sealed = try? ECIES.seal(bytes: content, |
| 234 | recipientPubB64: a.public_key) else { |
| 235 | sending = false |
| 236 | errorText = "Couldn't send — try again" |
| 237 | return |
| 238 | } |
| 239 | blobs.append(["admin_id": a.user_id, "blob": sealed]) |
| 240 | } |
| 241 | switch await store.fileReport(reportedID: uid, threadID: threadID, blobs: blobs) { |
| 242 | case .ok: |
| 243 | // Fire-and-forget like the web: the mute/block applies |
| 244 | // locally as soon as it lands; block WINS over mute (it |
| 245 | // already implies one), the web's exact else-if. |
| 246 | if alsoBlock { Task { await store.setPersonBlocked(uid: uid, on: true) } } |
| 247 | else if alsoMute { Task { await store.setPersonMuted(uid: uid, muted: true) } } |
| 248 | sending = false |
| 249 | sent = true |
| 250 | try? await Task.sleep(nanoseconds: 1_500_000_000) |
| 251 | dismiss() |
| 252 | case .tooMany: |
| 253 | sending = false |
| 254 | errorText = "Too many reports just now — try later" |
| 255 | case .failed: |
| 256 | sending = false |
| 257 | errorText = "Couldn't send — try again" |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // The sealed content — the cross-platform contract (shell.js |
| 263 | // openReportSheet builds the identical shape): {v, category, text, |
| 264 | // reported:{uid,name}, threadName, evidence?}. `evidence` is omitted |
| 265 | // entirely for a person report, like the web's undefined key. |
| 266 | static func contentData(category: String, text: String, |
| 267 | reportedUID: String, reportedName: String, |
| 268 | threadName: String, evidence: ReportEvidence?) -> Data? { |
| 269 | struct Reported: Encodable { let uid: String; let name: String } |
| 270 | struct Evidence: Encodable { |
| 271 | let sid: Int64; let uid: String; let name: String |
| 272 | let ts: Int64; let text: String; let attachments: [String] |
| 273 | } |
| 274 | struct Content: Encodable { |
| 275 | let v: Int; let category: String; let text: String |
| 276 | let reported: Reported; let threadName: String |
| 277 | let evidence: [Evidence]? |
| 278 | } |
| 279 | let ev = evidence.map { |
| 280 | [Evidence(sid: $0.sid, uid: $0.uid, name: $0.name, |
| 281 | ts: $0.ts, text: $0.text, attachments: $0.attachments)] |
| 282 | } |
| 283 | return try? JSONEncoder().encode(Content( |
| 284 | v: 1, category: category, text: text, |
| 285 | reported: Reported(uid: reportedUID, name: reportedName), |
| 286 | threadName: threadName, evidence: ev)) |
| 287 | } |
| 288 | } |
| 289 | |