johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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.fill, 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.fill, 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.page)
.frame(maxWidth: .infinity)
.padding(.vertical, Space.s4)
.background(Color.fg.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 {
Group {
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))
}
.quietButton(radius: Radius.card)
.disabled(busy != nil)
}
}
}
}
.listRowBackground(Color.card)
}
}
.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 {
Group {
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)")
}
}
.listRowBackground(Color.card)
}
}
}
@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 }
// Asking again can't help a server that deleted nothing and isn't done.
if step.deleted == 0 { throw APIError(status: 0, name: nil, message: "Nothing more could be deleted.") }
}
account.forgotten()
dismiss()
} catch {
problem = "\(error.localizedDescription) What's deleted stays deleted. Try again to carry on."
busy = false
}
}
}