johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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)
}
}
}