Files
andros 5a982be512 Report post via in-app sheet and mailto
Closes the last UGC requirement from App Store guideline 1.2: a
mechanism for users to flag objectionable content. Apple rejects bare
mailto: hand-offs but accepts a sheet that captures the report content
in-app and then opens a pre-filled mailto, which is the pattern current
Mastodon clients shipped before federated reports.

The flag icon sits in the post action row next to Share, hidden on
your own posts. Tapping it opens ReportPostSheet, which has a reason
picker (spam, harassment, sexual content involving minors, violence,
IP infringement, other illegal, other), an optional comment field, and
a "What gets sent" preview so the user knows exactly what the email
will contain. Submit assembles a mailto:hi@andros.dev URL with subject
"Org Social iOS — post report" and a body that includes the reason,
the author's social.org URL, the post date in RFC 3339, the full post
URL, and the user's comment, then dismisses the sheet.

Channel choice rationale: option A (mailto from a captured sheet).
Zero new infrastructure, zero cost, defensible to App Review, and
swappable for an HTTP webhook in one place later if volume justifies
it. The footer text states the 24 h response commitment that already
lives in PRIVACY.md.
2026-05-02 08:52:06 +02:00

139 lines
5.1 KiB
Swift

import SwiftUI
import OrgSocialKit
/// Sheet presented from a post's "..." menu so the user can flag content
/// for the developer to act on. App Store guideline 1.2 mandates an
/// in-app reporting affordance with developer follow-up within 24 hours.
///
/// On Submit we hand a fully-prefilled `mailto:` to the system, captured
/// AFTER the user picks a reason and adds an optional comment so reviewers
/// see the in-app capture flow Apple requires (a bare `mailto:` link is
/// rejected; a sheet that captures content + opens mailto is accepted).
struct ReportPostSheet: View {
enum Reason: String, CaseIterable, Identifiable {
case spam = "Spam"
case harassment = "Harassment or hate"
case minors = "Sexual content involving minors"
case violence = "Violence or threats"
case ipInfringement = "Intellectual-property infringement"
case illegal = "Other illegal content"
case other = "Other"
var id: String { rawValue }
}
/// Email destination shown in the mailto. Hard-coded to the developer
/// so the user has nothing to configure.
static let abuseInbox = "hi@andros.dev"
let post: OrgSocialPost
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var reason: Reason = .spam
@State private var comment: String = ""
/// Author's social.org URL (everything before the `#timestamp`).
private var authorFeedURL: String { post.feedURL?.absoluteString ?? post.authorURL?.absoluteString ?? "" }
/// `URL#timestamp` per the Org Social spec.
private var postURL: String {
guard let feedURL = post.feedURL else { return "" }
return "\(feedURL.absoluteString)#\(post.timestamp)"
}
var body: some View {
NavigationStack {
Form {
Section {
Picker("Reason", selection: $reason) {
ForEach(Reason.allCases) { r in
Text(r.rawValue).tag(r)
}
}
} header: {
Text("Reason")
} footer: {
Text("We act on objectionable-content reports within 24 hours.")
}
Section {
TextEditor(text: $comment)
.frame(minHeight: 100)
.autocorrectionDisabled()
} header: {
Text("Optional comment")
} footer: {
Text("Anything you want us to know. Leaving this empty is fine.")
}
Section {
LabeledContent("Author") {
Text(authorFeedURL)
.font(.footnote)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
}
LabeledContent("Date") {
Text(post.timestamp)
.font(.footnote)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
}
} header: {
Text("What gets sent")
} footer: {
Text("Tapping Submit opens Mail with the destination address, subject and body filled in. You still need to send the email yourself.")
}
}
.navigationTitle("Report post")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Submit") { submit() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.medium, .large])
}
private func submit() {
guard let url = mailtoURL() else { dismiss(); return }
openURL(url) { _ in dismiss() }
}
/// Builds the `mailto:` URL with everything pre-filled, percent-encoded
/// per RFC 6068 (the mailto spec). Lets the user review and Send from
/// their Mail composer; we never send on their behalf.
private func mailtoURL() -> URL? {
let subject = "Org Social iOS — post report"
let body = """
Reason: \(reason.rawValue)
Author social.org URL: \(authorFeedURL)
Post date: \(post.timestamp)
Post URL: \(postURL)
Comment:
\(comment.isEmpty ? "(none)" : comment)
---
Sent from Org Social for iOS.
"""
var components = URLComponents()
components.scheme = "mailto"
components.path = ReportPostSheet.abuseInbox
components.queryItems = [
URLQueryItem(name: "subject", value: subject),
URLQueryItem(name: "body", value: body)
]
return components.url
}
}