~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
230 lines · 9.7 KB Raw History
import SwiftUI
import UserNotifications

/// A row that leaves the app, which its mark says, where one that stays has a chevron.
private struct LeavingRow<Value: View>: View {
    let title: String
    @ViewBuilder let value: Value

    init(_ title: String, @ViewBuilder value: () -> Value = { EmptyView() }) {
        self.title = title
        self.value = value()
    }

    var body: some View {
        HStack(spacing: Space.s2) {
            // Named colours, as `.primary` in a list's button is the tint.
            Text(title).foregroundStyle(Color.fg)
            Spacer()
            value.foregroundStyle(Color.mute)
            Oct(name: "link-external", size: Icon.md).foregroundStyle(Color.faint)
        }
    }
}

struct SettingsView: View {
    private static let places: [[(title: String, path: String)]] = [
        [("Share feedback", "chadtmiller.com/knotless/issues"), ("Get help", "docs")],
        [("Terms of service", "terms"), ("Privacy policy", "privacy"), ("Community guidelines", "guidelines")],
    ]

    @Environment(Account.self) private var account
    @AppStorage(Appearance.key) private var appearance = Appearance.account
    private let themes = AccountTheme.shared
    @State private var cache = 0
    @State private var deleting = false
    @State private var signingOut = false

    private var version: String {
        let info = Bundle.main.infoDictionary
        return "Knotless \(info?["CFBundleShortVersionString"] as? String ?? "") (\(info?["CFBundleVersion"] as? String ?? ""))"
    }

    var body: some View {
        List {
            Group {
                Section {
                    NavigationLink(value: Route.appearance) {
                        HStack {
                            Text("Appearance")
                            Spacer()
                            Text(appearance.label(themes: themes.themes)).foregroundStyle(Color.mute).lineLimit(1)
                        }
                    }
                    NavigationLink("Notifications", value: Route.notificationSettings)
                    NavigationLink("Blocked accounts", value: Route.blocks)
                }
                ForEach(Self.places, id: \.first?.title) { group in
                    Section {
                        ForEach(group, id: \.title) { place in
                            Link(destination: Client.site.appendingPathComponent(place.path)) { LeavingRow(place.title) }
                        }
                    }
                }
                if let person = account.person {
                    Section {
                        HStack(spacing: Space.s3) {
                            Avatar(url: person.avatar, name: person.handle, did: person.did, size: Size.Avatar.regular)
                            Text(person.handle).lineLimit(1)
                        }
                        Button("Sign out", role: .destructive) { signingOut = true }
                        Button("Delete your knotless data", role: .destructive) { deleting = true }
                    }
                }
                Section {
                    Button {
                        Task {
                            await Client.shared.clearCache()
                            cache = await Client.shared.cacheSize()
                        }
                    } label: {
                        HStack {
                            Text("Clear cache")
                            Spacer()
                            Text(cache.formatted(.byteCount(style: .file))).foregroundStyle(Color.mute)
                        }
                    }
                } footer: {
                    Text(version).frame(maxWidth: .infinity).padding(.top, Space.s3)
                }
            }
            .listRowBackground(Color.card)
        }
        .navigationTitle("Settings")
        .navigationBarTitleDisplayMode(.inline)
        .task { cache = await Client.shared.cacheSize() }
        .confirmationDialog("Sign out of \(account.person?.handle ?? "this account")?", isPresented: $signingOut, titleVisibility: .visible) {
            Button("Sign out", role: .destructive) { Task { await account.signOut() } }
        }
        .sheet(isPresented: $deleting) { DeleteDataSheet() }
    }
}

/// Which notifications the account hears of, as the pages' settings choose them,
/// and whether iOS lets this phone show them.
struct NotificationSettingsView: View {
    var body: some View {
        Loading(load: {
            let answer: Preferences = try await Client.shared.query("getPreferences")
            return answer.notifications ?? NotificationChoices(reasons: [])
        }) { NotificationChoicesForm(kept: $0) }
        .navigationTitle("Notifications")
        .navigationBarTitleDisplayMode(.inline)
    }
}

private struct NotificationChoicesForm: View {
    /// What each reason is about, as the pages say. One the app doesn't know yet
    /// is shown by its name.
    private static let about: [String: (name: String, what: String)] = [
        "star": ("Stars", "Someone stars one of your repositories."),
        "follow": ("Follows", "Someone follows you."),
        "pull": ("Pulls", "Someone opens a pull on one of your repositories."),
        "issue": ("Issues", "Someone opens an issue on one of your repositories."),
        "comment": ("Comments", "Someone comments on your pull or issue, or on one you commented on."),
        "mention": ("Mentions", "Someone mentions you in a pull, an issue or a comment."),
        "review-requested": ("Review requests", "Someone asks you to review a pull, once it is ready."),
        "assigned": ("Assignments", "The owner or a collaborator assigns you a pull or an issue."),
        "invite": ("Invitations", "An organization invites you to join it."),
        "checks-failed": ("Failed checks", "Checks fail on your pull, at its latest run."),
        "ejected": ("Merge queue", "The merge queue takes your pull out, when it conflicts or its checks fail."),
        "subscribed": ("Subscriptions", "Someone comments on a pull or issue you subscribed to."),
        "watching": ("Watching", "Something happens on a repository you watch."),
    ]

    @Environment(\.scenePhase) private var phase
    /// What the toggles show.
    @State private var choices: NotificationChoices
    /// What the server has.
    @State private var kept: NotificationChoices
    @State private var sending = false
    @State private var failure: String?
    @State private var permission = UNAuthorizationStatus.notDetermined

    init(kept: NotificationChoices) {
        _choices = State(initialValue: kept)
        _kept = State(initialValue: kept)
    }

    private var allowed: Bool { ![.denied, .notDetermined].contains(permission) }

    var body: some View {
        List {
            Group {
                Section {
                    Button { Task { await openPermission() } } label: {
                        LeavingRow("Push notifications") { Text(allowed ? "On" : "Off") }
                    }
                } footer: {
                    if !allowed {
                        Text("This phone won't show them until you turn them on in iOS settings.")
                    }
                }
                Section {
                    ForEach($choices.reasons) { $choice in
                        let about = Self.about[choice.reason]
                        Toggle(isOn: $choice.enabled) {
                            Text(about?.name ?? choice.reason.capitalized)
                            if let what = about?.what { Text(what) }
                        }
                    }
                } header: {
                    Text("Notify me when")
                }
                Section {
                    Toggle("Only accounts I follow", isOn: Binding(
                        get: { choices.followedOnly ?? false },
                        set: { choices.followedOnly = $0 }
                    ))
                } footer: {
                    Text("These choices hold everywhere you use knotless, in this inbox, on the pages and on the command line.")
                }
            }
            .listRowBackground(Color.card)
        }
        .onChange(of: choices) { save() }
        .task(id: phase) { await readPermission() }
        .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(failure ?? "")
        }
    }

    /// Sends what's shown until the server has it, one write at a time, as each
    /// says the whole of them and a late one mustn't undo a newer one. One that
    /// fails puts the toggles back.
    private func save() {
        guard !sending, choices != kept else { return }
        sending = true
        Task {
            defer { sending = false }
            while choices != kept {
                let these = choices
                do {
                    try await Client.shared.send(Writes.notifications(these))
                    kept = these
                } catch {
                    failure = error.localizedDescription
                    choices = kept
                    return
                }
            }
        }
    }

    private func readPermission() async {
        permission = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
    }

    /// iOS's own switch, as only it can change this. Until the app has asked, iOS
    /// has no switch to show, so this asks.
    private func openPermission() async {
        if permission == .notDetermined {
            await Pushes.shared.start()
            await readPermission()
        } else if let settings = URL(string: UIApplication.openNotificationSettingsURLString) {
            await UIApplication.shared.open(settings)
        }
    }
}