eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
16public 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
46public 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