chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app reports, blocks and deletes an account's data, as the pages do
ios/Knotless/API/Account.swift+8−0
| @@ -138,6 +138,14 @@ final class Account { | ||
SessionStore.clear() | ||
person = nil | ||
} | ||
| ||
/// Signed out here, once the server has deleted the account's data and with | ||
/// it the session and the device, so there's nothing to tell it. | ||
func forgotten() { | ||
Pushes.shared.forget() | ||
SessionStore.clear() | ||
person = nil | ||
} | ||
} | ||
| ||
private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationContextProviding { | ||
ios/Knotless/API/Client.swift+9−1
| @@ -82,6 +82,14 @@ actor Client { | ||
_ = try await call(write) | ||
} | ||
| ||
/// One part of deleting the account's data, and whether that was the last. | ||
func deleteStep(_ write: Write) async throws -> DeleteStep { | ||
#if DEBUG | ||
if DevAccount.recording != nil { return DeleteStep(deleted: 0, done: true) } | ||
#endif | ||
return try await decoder.decode(DeleteStep.self, from: call(write)) | ||
} | ||
| ||
/// A picture Markdown names. Those on this site are asked for as the account | ||
/// signed in, as a private repository's must be. | ||
func picture(_ url: URL) async throws -> Data { | ||
| @@ -113,7 +121,7 @@ actor Client { | ||
request.httpMethod = "POST" | ||
request.timeoutInterval = 60 | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
request.httpBody = try JSONSerialization.data(withJSONObject: write.input) | ||
request.httpBody = try JSONSerialization.data(withJSONObject: write.body) | ||
authorize(&request, nil) | ||
let (data, response) = try await session.data(for: request) | ||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | ||
ios/Knotless/API/Models.swift+50−0
| @@ -522,6 +522,56 @@ struct Profile: Decodable { | ||
struct Viewing: Decodable { | ||
/// The asker's follow of them, if they follow them. | ||
var following: String? | ||
/// The asker's block of them from all its repositories, if it has one. | ||
var blocking: String? | ||
} | ||
} | ||
| ||
/// What knotless data the asking account holds, before it deletes it. | ||
struct AccountData: Decodable, Sendable { | ||
let did: String | ||
let collections: [Count] | ||
let repos: [Held] | ||
let spaces: Int | ||
| ||
struct Count: Decodable, Hashable, Sendable { | ||
let collection: String | ||
let count: Int | ||
} | ||
| ||
struct Held: Decodable, Hashable, Sendable { | ||
let name: String | ||
let `private`: Bool | ||
let collaborators: Int | ||
var members: Int? | ||
/// How many other accounts use it. | ||
var sharing: Int { max(collaborators, members ?? 0) } | ||
} | ||
} | ||
| ||
/// One part of deleting an account's data. | ||
struct DeleteStep: Decodable, Sendable { | ||
let deleted: Int | ||
let done: Bool | ||
} | ||
| ||
/// The accounts the asker blocks, the latest first. | ||
struct BlockList: Decodable, Sendable { | ||
let blocks: [Block] | ||
| ||
struct Block: Decodable, Identifiable, Sendable { | ||
let uri: String | ||
let subject: Person | ||
/// The one repository it's blocked from, or from all of them with none. | ||
var repo: From? | ||
let createdAt: String | ||
var id: String { uri } | ||
| ||
struct From: Decodable, Sendable { | ||
let did: String | ||
let name: String | ||
var uri: String { "at://\(did)/dev.knotless.repo/\(name)" } | ||
} | ||
} | ||
} | ||
| ||
ios/Knotless/API/Push.swift+6−0
| @@ -66,6 +66,12 @@ struct PushTarget: Equatable, Sendable { | ||
clearBadge() | ||
} | ||
| ||
/// Forgets this device's token, for an account the server has already forgotten. | ||
func forget() { | ||
token = nil | ||
clearBadge() | ||
} | ||
| ||
func clearBadge() { | ||
UNUserNotificationCenter.current().setBadgeCount(0) | ||
} | ||
ios/Knotless/API/Writes.swift+27−0
| @@ -5,6 +5,14 @@ import Foundation | ||
struct Write: Sendable { | ||
let method: String | ||
let input: [String: String] | ||
var numbers: [String: Int] = [:] | ||
| ||
/// What it's given, as it's sent. | ||
var body: [String: Any] { | ||
var all: [String: Any] = input | ||
for (name, number) in numbers { all[name] = number } | ||
return all | ||
} | ||
} | ||
| ||
enum Writes { | ||
| @@ -59,6 +67,25 @@ enum Writes { | ||
Write(method: undo ? "unfollowActor" : "followActor", input: ["subject": did]) | ||
} | ||
| ||
/// `subject` is a DID, a repository's knotless:// URL or a record's at:// URI. | ||
static func report(subject: String, reason: String, text: String) -> Write { | ||
var input = ["subject": subject, "reason": reason] | ||
if !text.isEmpty { input["text"] = text } | ||
return Write(method: "createReport", input: input) | ||
} | ||
| ||
/// From all the account's repositories, or the one `repo` names. | ||
static func block(subject: String, repo: String? = nil, undo: Bool) -> Write { | ||
var input = ["subject": subject] | ||
if let repo { input["repo"] = repo } | ||
return Write(method: undo ? "unblockActor" : "blockActor", input: input) | ||
} | ||
| ||
/// One part of it: asked again, it carries on. | ||
static func deleteData(did: String, limit: Int) -> Write { | ||
Write(method: "deleteAccountData", input: ["did": did], numbers: ["limit": limit]) | ||
} | ||
| ||
static func seen(at: String) -> Write { | ||
Write(method: "updateSeen", input: ["seenAt": at]) | ||
} | ||
ios/Knotless/Views/Moderation.swiftnew+345−0
| @@ -0,0 +1,345 @@ | ||
import SwiftUI | ||
| ||
/// What's being reported, and how the sheet says it. | ||
struct Reporting: Identifiable { | ||
/// A DID, a repository's knotless:// URL or a record's at:// URI. | ||
let subject: String | ||
let what: String | ||
var id: String { subject } | ||
} | ||
| ||
/// Reports to the moderation service, as whoever is signed in: why, from | ||
/// atproto's reasons, and more in words. | ||
struct ReportSheet: View { | ||
private static let reasons = [ | ||
("spam", "Spam"), | ||
("violation", "Breaks the rules or the law"), | ||
("misleading", "Misleading"), | ||
("sexual", "Sexual content"), | ||
("rude", "Rude or harassing"), | ||
("other", "Something else"), | ||
] | ||
| ||
let reporting: Reporting | ||
| ||
@Environment(\.dismiss) private var dismiss | ||
@State private var reason = "spam" | ||
@State private var text = "" | ||
@State private var sending = false | ||
@State private var sent = false | ||
@State private var failure: String? | ||
| ||
var body: some View { | ||
NavigationStack { | ||
ScrollView { | ||
if sent { | ||
Text("Sent to the moderators. Thank you.") | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.padding(Space.s4) | ||
} else { | ||
form | ||
} | ||
} | ||
.navigationTitle("Report \(reporting.what)") | ||
.navigationBarTitleDisplayMode(.inline) | ||
.toolbar { | ||
ToolbarItem(placement: .cancellationAction) { | ||
Button { dismiss() } label: { Oct(name: "x", size: Icon.xl) } | ||
.tint(.primary) | ||
.accessibilityLabel("Close") | ||
} | ||
} | ||
.interactiveDismissDisabled(sending) | ||
} | ||
} | ||
| ||
private var form: some View { | ||
VStack(spacing: Space.s3) { | ||
ForEach(Self.reasons, id: \.0) { value, said in | ||
Button { reason = value } label: { | ||
HStack(spacing: Space.s3) { | ||
Text(said).font(.body.weight(.medium)).foregroundStyle(.primary) | ||
Spacer(minLength: 0) | ||
if reason == value { | ||
Oct(name: "check-circle-fill", size: Icon.xl).foregroundStyle(Color.accent) | ||
} | ||
} | ||
.padding(Space.s4) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: Radius.card)) | ||
.contentShape(RoundedRectangle(cornerRadius: Radius.card)) | ||
} | ||
.buttonStyle(.plain) | ||
.accessibilityAddTraits(reason == value ? .isSelected : []) | ||
} | ||
TextField("More, for the moderators", text: $text, axis: .vertical) | ||
.lineLimit(3...8) | ||
.padding(Space.s4) | ||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: Radius.card)) | ||
if let failure { | ||
Text(failure).font(.footnote).foregroundStyle(Color.danger) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
} | ||
Button { Task { await send() } } label: { | ||
Text(sending ? "Sending…" : "Send report") | ||
.font(.body.weight(.semibold)) | ||
.foregroundStyle(Color(.systemBackground)) | ||
.frame(maxWidth: .infinity) | ||
.padding(.vertical, Space.s4) | ||
.background(Color.primary.opacity(sending ? 0.4 : 1), in: RoundedRectangle(cornerRadius: Radius.card)) | ||
} | ||
.buttonStyle(.plain) | ||
.disabled(sending) | ||
.padding(.top, Space.s2) | ||
} | ||
.padding(Space.s4) | ||
} | ||
| ||
private func send() async { | ||
sending = true | ||
failure = nil | ||
defer { sending = false } | ||
do { | ||
let words = text.trimmingCharacters(in: .whitespacesAndNewlines) | ||
try await Client.shared.send(Writes.report(subject: reporting.subject, reason: reason, text: words)) | ||
sent = true | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
} | ||
} | ||
| ||
/// The accounts the account blocks, each from all its repositories or from one, | ||
/// the latest first, with a way to unblock each and to block another. | ||
struct BlocksView: View { | ||
private struct Answer: Sendable { let blocks: [BlockList.Block]; let repos: [Repo] } | ||
| ||
@Environment(Account.self) private var account | ||
@State private var who = "" | ||
/// The repository to block it from, or all of them with none. | ||
@State private var from = "" | ||
/// What's under way: `block`, or the block being taken back. | ||
@State private var busy: String? | ||
@State private var failure: String? | ||
/// Moved by a write, so the list is read again. | ||
@State private var edits = 0 | ||
| ||
private var named: String { who.trimmingCharacters(in: .whitespaces) } | ||
| ||
var body: some View { | ||
Loading(load: { [handle = account.person?.handle] in | ||
let blocks: BlockList = try await Client.shared.query("listBlocks") | ||
let repos: RepoList? = if let handle { try? await Client.shared.query("getActorRepos", ["actor": handle]) } else { nil } | ||
return Answer(blocks: blocks.blocks, repos: repos?.repos ?? []) | ||
}, refresh: edits) { answer in | ||
List { | ||
Section { | ||
TextField("handle.example.com", text: $who) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.keyboardType(.URL) | ||
.submitLabel(.done) | ||
.accessibilityLabel("Handle or DID to block") | ||
Picker("Block from", selection: $from) { | ||
Text("All your repositories").tag("") | ||
ForEach(answer.repos) { Text($0.name).tag($0.uri) } | ||
} | ||
Button(busy == "block" ? "Blocking…" : "Block") { | ||
change("block", Writes.block(subject: named, repo: from.isEmpty ? nil : from, undo: false)) { who = "" } | ||
} | ||
.disabled(named.isEmpty || busy != nil) | ||
} footer: { | ||
Text("Nothing an account you block writes about your repositories shows. Its pulls, issues and comments are left out, and you hear nothing it does. Block it from all your repositories, or from one.") | ||
} | ||
if let failure { | ||
Text(failure).font(.footnote).foregroundStyle(Color.danger) | ||
} | ||
Section { | ||
if answer.blocks.isEmpty { | ||
Text("No blocked accounts").foregroundStyle(.secondary) | ||
} | ||
ForEach(answer.blocks) { block in | ||
HStack(spacing: Space.s3) { | ||
Avatar(url: block.subject.avatar, name: block.subject.handle, did: block.subject.did, size: Size.Avatar.medium) | ||
VStack(alignment: .leading, spacing: 0) { | ||
Text(block.subject.handle).lineLimit(1) | ||
Text(block.repo.map { "From \($0.name)" } ?? "From all your repositories") | ||
.font(.footnote).foregroundStyle(.secondary).lineLimit(1) | ||
} | ||
Spacer(minLength: 0) | ||
Button(busy == block.uri ? "Unblocking…" : "Unblock") { | ||
change(block.uri, Writes.block(subject: block.subject.did, repo: block.repo?.uri, undo: true)) | ||
} | ||
.buttonStyle(.bordered) | ||
.disabled(busy != nil) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
.navigationTitle("Blocked accounts") | ||
.navigationBarTitleDisplayMode(.inline) | ||
} | ||
| ||
private func change(_ what: String, _ write: Write, then: @escaping () -> Void = {}) { | ||
busy = what | ||
failure = nil | ||
Task { | ||
do { | ||
try await Client.shared.send(write) | ||
then() | ||
edits += 1 | ||
// A profile on the way back says whether it's blocked. | ||
account.follows += 1 | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
busy = nil | ||
} | ||
} | ||
} | ||
| ||
/// Deletes every knotless record from the account, as `knotless account | ||
/// delete-data` does: what there is, then, once the handle is typed, the | ||
/// deleting, a part a call, then signed out. | ||
struct DeleteDataSheet: View { | ||
/// The records and spaces one call deletes: a part, for the progress shown. | ||
private static let part = 100 | ||
| ||
@Environment(Account.self) private var account | ||
@Environment(\.dismiss) private var dismiss | ||
@State private var typed = "" | ||
@State private var busy = false | ||
@State private var deleted = 0 | ||
@State private var problem: String? | ||
| ||
private var handle: String { account.person?.handle ?? "" } | ||
| ||
var body: some View { | ||
NavigationStack { | ||
Loading(load: { | ||
let held: AccountData = try await Client.shared.query("getAccountData") | ||
return held | ||
}) { held in | ||
content(held) | ||
} | ||
.navigationTitle("Delete your knotless data") | ||
.navigationBarTitleDisplayMode(.inline) | ||
.toolbar { | ||
ToolbarItem(placement: .cancellationAction) { | ||
Button { dismiss() } label: { Oct(name: "x", size: Icon.xl) } | ||
.tint(.primary) | ||
.accessibilityLabel("Close") | ||
.disabled(busy) | ||
} | ||
} | ||
.interactiveDismissDisabled(busy) | ||
} | ||
} | ||
| ||
private func total(_ held: AccountData) -> Int { | ||
held.collections.reduce(0) { $0 + $1.count } + held.spaces | ||
} | ||
| ||
private func summary(_ held: AccountData) -> String { | ||
let records = total(held) - held.spaces | ||
let count = records == 1 ? "1 record" : "\(records.formatted()) records" | ||
if held.spaces == 0 { return "This deletes \(count) from your account, for good." } | ||
let spaces = held.spaces == 1 ? "1 private repository space" : "\(held.spaces) private repository spaces" | ||
return "This deletes \(count) and \(spaces) from your account, for good." | ||
} | ||
| ||
@ViewBuilder private func content(_ held: AccountData) -> some View { | ||
if total(held) == 0 { | ||
Text("Your account holds no knotless data.") | ||
.foregroundStyle(.secondary) | ||
.frame(maxWidth: .infinity, maxHeight: .infinity) | ||
} else { | ||
List { | ||
Section { | ||
Text(summary(held)) | ||
} footer: { | ||
Text("Your repositories go, private ones too, and your pulls, issues, comments, stars and follows. You're then signed out. Your atproto account, and what other apps keep in it, stay.") | ||
} | ||
if !held.repos.isEmpty { | ||
Section { | ||
ForEach(held.repos, id: \.name) { repo($0) } | ||
} header: { | ||
Text("Repositories") | ||
} footer: { | ||
if held.repos.contains(where: { $0.sharing > 0 }) { | ||
Text("Others use a repository shared with them. Transfer it to one of them first to keep it.") | ||
} | ||
} | ||
} | ||
if !held.collections.isEmpty { | ||
Section("Records") { | ||
ForEach(held.collections, id: \.collection) { collection in | ||
HStack { | ||
Text(collection.collection.replacingOccurrences(of: "dev.knotless.", with: "")) | ||
.font(.subheadline.monospaced()) | ||
Spacer() | ||
Text(collection.count.formatted()).foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
} | ||
Section { | ||
TextField(handle, text: $typed) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.keyboardType(.URL) | ||
.disabled(busy) | ||
.accessibilityLabel("To confirm, type \(handle)") | ||
if busy { | ||
ProgressView(value: Double(min(deleted, total(held))), total: Double(total(held))) | ||
Text("Deleted \(deleted.formatted()) of \(total(held).formatted())…") | ||
.font(.footnote).foregroundStyle(.secondary) | ||
} | ||
if let problem { | ||
Text(problem).font(.footnote).foregroundStyle(Color.danger) | ||
} | ||
Button("Delete data", role: .destructive) { Task { await remove(did: held.did) } } | ||
.disabled(busy || typed.trimmingCharacters(in: .whitespaces) != handle) | ||
} header: { | ||
Text("To confirm, type \(handle)") | ||
} | ||
} | ||
} | ||
} | ||
| ||
@ViewBuilder private func repo(_ repo: AccountData.Held) -> some View { | ||
VStack(alignment: .leading, spacing: Space.s0_5) { | ||
HStack(spacing: Space.s2) { | ||
Text(repo.name).lineLimit(1) | ||
if repo.private { Text("Private").font(.footnote).foregroundStyle(.secondary) } | ||
} | ||
if repo.sharing > 0 { | ||
HStack(spacing: Space.s2) { | ||
Text(repo.sharing == 1 ? "Shared with 1 other account" : "Shared with \(repo.sharing.formatted()) other accounts") | ||
.font(.footnote).foregroundStyle(.secondary) | ||
if let page = URL(string: "https://knotless.dev/\(handle)/\(repo.name)/settings") { | ||
Link("Transfer", destination: page).font(.footnote) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
| ||
private func remove(did: String) async { | ||
busy = true | ||
problem = nil | ||
do { | ||
while true { | ||
let step = try await Client.shared.deleteStep(Writes.deleteData(did: did, limit: Self.part)) | ||
deleted += step.deleted | ||
if step.done { break } | ||
ios/Knotless/Views/Moderation.swiftline 336This loop has no guard against a step that deletes nothing without finishing. Consider breaking with a failure when | ||
} | ||
account.forgotten() | ||
dismiss() | ||
} catch { | ||
problem = "\(error.localizedDescription) What's deleted stays deleted. Try again to carry on." | ||
busy = false | ||
} | ||
} | ||
} | ||
ios/Knotless/Views/ProfileView.swift+39−0
| @@ -2,6 +2,7 @@ import SwiftUI | ||
| ||
struct OwnProfileView: View { | ||
@Environment(Account.self) private var account | ||
@State private var deleting = false | ||
| ||
var body: some View { | ||
if let handle = account.person?.handle { | ||
| @@ -10,12 +11,15 @@ struct OwnProfileView: View { | ||
.toolbar { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
Menu { | ||
NavigationLink("Blocked accounts", value: Route.blocks) | ||
Button("Delete your knotless data", role: .destructive) { deleting = true } | ||
Button("Sign out", role: .destructive) { Task { await account.signOut() } } | ||
} label: { | ||
SwiftUI.Label("More", systemImage: "ellipsis") | ||
} | ||
} | ||
} | ||
.sheet(isPresented: $deleting) { DeleteDataSheet() } | ||
} else if account.checking { | ||
ProgressView() | ||
} else { | ||
| @@ -47,6 +51,10 @@ private struct ProfilePage: View { | ||
@Environment(Account.self) private var account | ||
let profile: Profile | ||
let repos: [Repo] | ||
@State private var reporting: Reporting? | ||
@State private var failure: String? | ||
| ||
private var other: Bool { account.signedIn && account.person?.did != profile.did } | ||
| ||
var body: some View { | ||
let p = profile | ||
| @@ -117,6 +125,37 @@ private struct ProfilePage: View { | ||
.padding(.bottom, Space.s6) | ||
} | ||
.background(Color(.systemGroupedBackground)) | ||
.toolbar { | ||
if other { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
Menu { | ||
let blocking = profile.viewer?.blocking != nil | ||
Button("\(blocking ? "Unblock" : "Block") \(profile.handle)") { toggleBlock(undo: blocking) } | ||
Button("Report \(profile.handle)") { reporting = Reporting(subject: profile.did, what: profile.handle) } | ||
NavigationLink("Blocked accounts", value: Route.blocks) | ||
} label: { | ||
SwiftUI.Label("More about \(profile.handle)", systemImage: "ellipsis") | ||
} | ||
} | ||
} | ||
} | ||
.sheet(item: $reporting) { ReportSheet(reporting: $0) } | ||
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { | ||
Button("OK", role: .cancel) {} | ||
} message: { | ||
Text(failure ?? "") | ||
} | ||
} | ||
| ||
private func toggleBlock(undo: Bool) { | ||
Task { | ||
do { | ||
try await Client.shared.send(Writes.block(subject: profile.did, undo: undo)) | ||
account.follows += 1 | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
} | ||
} | ||
| ||
private func count(_ route: Route, _ number: Int, _ word: String) -> some View { | ||
ios/Knotless/Views/RepoView.swift+15−0
| @@ -3,6 +3,9 @@ import SwiftUI | ||
struct RepoView: View { | ||
let repo: RepoRef | ||
| ||
@Environment(Account.self) private var account | ||
@State private var reporting: Reporting? | ||
| ||
private struct Answer: Sendable { let detail: RepoDetail; let tree: Tree?; let readme: FileAnswer? } | ||
| ||
var body: some View { | ||
| @@ -23,7 +26,19 @@ struct RepoView: View { | ||
if let url = URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)") { | ||
ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) } | ||
} | ||
if account.signedIn, account.person?.did != repo.did { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
Menu { | ||
Button("Report this repository") { | ||
reporting = Reporting(subject: "knotless://\(repo.did)/\(repo.name)", what: "this repository") | ||
} | ||
} label: { | ||
SwiftUI.Label("More", systemImage: "ellipsis") | ||
} | ||
} | ||
} | ||
} | ||
.sheet(item: $reporting) { ReportSheet(reporting: $0) } | ||
} | ||
} | ||
| ||
ios/Knotless/Views/ThreadView.swift+39−6
| @@ -24,6 +24,15 @@ struct ThreadView: View { | ||
@State private var canSetState: Bool? | ||
@State private var state: String? | ||
@State private var title: String? | ||
@State private var author: String? | ||
@State private var reporting: Reporting? | ||
| ||
/// What's reported of it: nothing of one's own, or by no one. | ||
private var reportable: Bool { | ||
account.signedIn && author != nil && account.person?.did != author | ||
} | ||
| ||
private var canClose: Bool { canSetState == true && state != "merged" } | ||
| ||
var body: some View { | ||
Loading(load: { [repo, uri] in | ||
| @@ -37,6 +46,7 @@ struct ThreadView: View { | ||
state = thread.item.state | ||
title = thread.item.title | ||
isPull = thread.item.kind == "pull" | ||
author = thread.item.author.did | ||
} | ||
.sheet(isPresented: $composing) { | ||
Composer(repo: repo, uri: uri) { edits += 1 } | ||
| @@ -85,13 +95,19 @@ struct ThreadView: View { | ||
if let url = pageURL { | ||
ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) } | ||
} | ||
if canSetState == true, state != "merged" { | ||
if canClose || reportable { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
Menu { | ||
if state == "open" { | ||
Button("Close", role: .destructive) { setState("closed") } | ||
} else { | ||
Button("Reopen") { setState("open") } | ||
if canClose { | ||
if state == "open" { | ||
Button("Close", role: .destructive) { setState("closed") } | ||
} else { | ||
Button("Reopen") { setState("open") } | ||
} | ||
} | ||
if reportable { | ||
let what = isPull ? "this pull" : "this issue" | ||
Button("Report \(what)") { reporting = Reporting(subject: uri, what: what) } | ||
} | ||
} label: { | ||
SwiftUI.Label("More", systemImage: "ellipsis") | ||
| @@ -99,6 +115,7 @@ struct ThreadView: View { | ||
} | ||
} | ||
} | ||
.sheet(item: $reporting) { ReportSheet(reporting: $0) } | ||
} | ||
| ||
private func setState(_ next: String) { | ||
| @@ -123,7 +140,7 @@ struct ThreadView: View { | ||
| ||
/// The screen, less the back button on one side and the bar's buttons on the other. | ||
private var titleWidth: CGFloat { | ||
let buttons: CGFloat = (pageURL == nil ? 0 : 1) + (canSetState == true && state != "merged" ? 1 : 0) | ||
let buttons: CGFloat = (pageURL == nil ? 0 : 1) + (canClose || reportable ? 1 : 0) | ||
return max(0, screen - 76 - 16 - buttons * 52) | ||
} | ||
| ||
| @@ -458,6 +475,9 @@ struct CommentRow: View { | ||
let comment: Comment | ||
let changed: () -> Void | ||
| ||
@Environment(Account.self) private var account | ||
@State private var reporting: Reporting? | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: Space.s2) { | ||
HStack(spacing: Space.s2) { | ||
| @@ -469,6 +489,18 @@ struct CommentRow: View { | ||
} else if comment.verdict == "changes" { | ||
Oct(name: "alert-fill").foregroundStyle(Color.danger).accessibilityLabel("Asked for changes") | ||
} | ||
if account.signedIn, account.person?.did != comment.author.did, let uri = comment.uri { | ||
Spacer(minLength: 0) | ||
Menu { | ||
Button("Report this comment") { reporting = Reporting(subject: uri, what: "this comment") } | ||
} label: { | ||
Oct(name: "kebab-horizontal") | ||
.foregroundStyle(.secondary) | ||
.frame(width: Size.Avatar.small, height: Size.Avatar.small) | ||
.contentShape(Rectangle()) | ||
} | ||
.accessibilityLabel("More about this comment") | ||
} | ||
} | ||
// An answer sits under the lines its thread starts with. | ||
if let lines = comment.line, comment.reply == nil { | ||
| @@ -483,6 +515,7 @@ struct CommentRow: View { | ||
.padding(.trailing, Space.s4) | ||
.padding(.leading, comment.reply == nil ? 16 : 40) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.sheet(item: $reporting) { ReportSheet(reporting: $0) } | ||
} | ||
} | ||
| ||
ios/Knotless/App.swift+3−0
| @@ -34,6 +34,7 @@ enum Route: Hashable { | ||
case searchCode(String) | ||
case searchRepositories(String) | ||
case searchPeople(String) | ||
case blocks | ||
} | ||
| ||
extension View { | ||
| @@ -63,6 +64,7 @@ extension View { | ||
case .searchCode(let query): CodeResultsView(query: query) | ||
case .searchRepositories(let query): RepositoryResultsView(query: query) | ||
case .searchPeople(let query): PeopleResultsView(query: query) | ||
case .blocks: BlocksView() | ||
} | ||
} | ||
} | ||
| @@ -123,6 +125,7 @@ enum DevRoute { | ||
if p.count == 2, p[0] == "starred" { return (.home, [.starred(p[1])]) } | ||
if p.count == 2, p[0] == "organizations" { return (.home, [.organizations(p[1])]) } | ||
if p.count == 2, p[0] == "followers" { return (.home, [.followers(p[1])]) } | ||
if p == ["blocks"] { return (.home, [.blocks]) } | ||
if p.count == 2, p[0] == "following" { return (.home, [.following(p[1])]) } | ||
if p.count == 2, p[0] == "search-code" { return (.home, [.searchCode(p[1])]) } | ||
if p.count == 2, p[0] == "search-repositories" { return (.home, [.searchRepositories(p[1])]) } | ||