import SwiftUI import UserNotifications /// A row that leaves the app, which its mark says, where one that stays has a chevron. private struct LeavingRow: 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) } } }