chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app has a settings screen
ios/Knotless/API/Client.swift+9−0
| @@ -90,6 +90,15 @@ actor Client { | ||
return try await decoder.decode(DeleteStep.self, from: call(write)) | ||
} | ||
| ||
/// What the disk cache holds, in bytes. | ||
func cacheSize() -> Int { session.configuration.urlCache?.currentDiskUsage ?? 0 } | ||
| ||
/// Forgets what's kept to load faster. Everything can be asked for again. | ||
func clearCache() { | ||
session.configuration.urlCache?.removeAllCachedResponses() | ||
Faces.forget() | ||
} | ||
| ||
/// 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 { | ||
ios/Knotless/API/Models.swift+45−0
| @@ -698,3 +698,48 @@ struct Feed: Decodable { | ||
var cursor: String? | ||
} | ||
| ||
/// What `getPreferences` answers, of the kinds the app acts on. | ||
struct Preferences: Decodable, Sendable { | ||
let notifications: NotificationChoices? | ||
| ||
private struct Kind: Decodable { | ||
let choices: NotificationChoices? | ||
private enum Keys: String, CodingKey { case type = "$type" } | ||
| ||
init(from decoder: Decoder) throws { | ||
let type = try decoder.container(keyedBy: Keys.self).decodeIfPresent(String.self, forKey: .type) | ||
choices = try type == NotificationChoices.type ? NotificationChoices(from: decoder) : nil | ||
} | ||
} | ||
| ||
private enum Keys: String, CodingKey { case preferences } | ||
| ||
init(from decoder: Decoder) throws { | ||
let kinds = try decoder.container(keyedBy: Keys.self).decode([Kind].self, forKey: .preferences) | ||
notifications = kinds.lazy.compactMap(\.choices).first | ||
} | ||
} | ||
| ||
/// Which notifications the account hears of: each reason the server gives, on or | ||
/// off, and whether only of what accounts it follows did. | ||
struct NotificationChoices: Decodable, Equatable, Sendable { | ||
static let type = "dev.knotless.getPreferences#notificationPref" | ||
| ||
struct Reason: Decodable, Equatable, Sendable, Identifiable { | ||
let reason: String | ||
var enabled: Bool | ||
var id: String { reason } | ||
} | ||
| ||
var reasons: [Reason] | ||
var followedOnly: Bool? | ||
| ||
/// As `putPreferences` is given it. | ||
var written: [String: Any] { | ||
[ | ||
"$type": Self.type, | ||
"reasons": reasons.map { ["reason": $0.reason, "enabled": $0.enabled] as [String: Any] }, | ||
"followedOnly": followedOnly ?? false, | ||
] | ||
} | ||
} | ||
ios/Knotless/API/Writes.swift+7−0
| @@ -6,11 +6,13 @@ struct Write: Sendable { | ||
let method: String | ||
let input: [String: String] | ||
var numbers: [String: Int] = [:] | ||
var notifications: NotificationChoices? | ||
| ||
/// 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 } | ||
if let notifications { all["preferences"] = [notifications.written] } | ||
return all | ||
} | ||
} | ||
| @@ -86,6 +88,11 @@ enum Writes { | ||
Write(method: "deleteAccountData", input: ["did": did], numbers: ["limit": limit]) | ||
} | ||
| ||
/// All of them, as the server takes them whole. | ||
static func notifications(_ choices: NotificationChoices) -> Write { | ||
Write(method: "putPreferences", input: [:], notifications: choices) | ||
} | ||
| ||
static func seen(at: String) -> Write { | ||
Write(method: "updateSeen", input: ["seenAt": at]) | ||
} | ||
ios/Knotless/Assets.xcassets/oct-gear.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"gear.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-gear.imageset/gear.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-link-external.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"link-external.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-link-external.imageset/link-external.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Support/Faces.swift+2−0
| @@ -23,6 +23,8 @@ actor Faces { | ||
nonisolated static func address(of did: String) -> String? { addresses.object(forKey: did as NSString) as String? } | ||
nonisolated static func keep(address: String, of did: String) { addresses.setObject(address as NSString, forKey: did as NSString) } | ||
| ||
nonisolated static func forget() { store.removeAllObjects() } | ||
| ||
nonisolated static func kept(_ url: URL) -> UIImage? { store.object(forKey: url as NSURL) } | ||
| ||
func load(_ url: URL) async throws -> UIImage { | ||
ios/Knotless/Views/ProfileView.swift+9−13
| @@ -2,24 +2,11 @@ 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 { | ||
ProfileScreen(actor: handle) | ||
.id(handle) | ||
.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 { | ||
| @@ -44,6 +31,15 @@ struct ProfileScreen: View { | ||
} | ||
.navigationTitle(actor.hasPrefix("did:") ? "Profile" : actor) | ||
.navigationBarTitleDisplayMode(.inline) | ||
// Out here, so that an account whose profile won't load can still sign out. | ||
.toolbar { | ||
if actor == account.person?.handle || actor == account.person?.did { | ||
ToolbarItem(placement: .topBarTrailing) { | ||
NavigationLink(value: Route.settings) { Oct(name: "gear", size: Icon.xl).foregroundStyle(.primary) } | ||
.accessibilityLabel("Settings") | ||
} | ||
} | ||
} | ||
} | ||
} | ||
| ||
ios/Knotless/Views/SettingsView.swiftnew+239−0
| @@ -0,0 +1,239 @@ | ||
import SwiftUI | ||
import UserNotifications | ||
| ||
/// How the app is drawn on this phone. | ||
enum Appearance: String, CaseIterable, Identifiable { | ||
case automatic, light, dark | ||
| ||
static let key = "appearance" | ||
| ||
var id: String { rawValue } | ||
var label: String { rawValue.capitalized } | ||
| ||
/// None follows the system. | ||
var scheme: ColorScheme? { | ||
switch self { | ||
case .automatic: nil | ||
case .light: .light | ||
case .dark: .dark | ||
} | ||
} | ||
} | ||
| ||
/// 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.primary) | ||
Spacer() | ||
value.foregroundStyle(Color.secondary) | ||
Oct(name: "link-external", size: Icon.md).foregroundStyle(Color(.tertiaryLabel)) | ||
} | ||
} | ||
} | ||
| ||
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.automatic | ||
@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 { | ||
Section { | ||
Picker("Appearance", selection: $appearance) { | ||
ForEach(Appearance.allCases) { Text($0.label).tag($0) } | ||
} | ||
.pickerStyle(.navigationLink) | ||
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.secondary) | ||
} | ||
} | ||
} footer: { | ||
Text(version).frame(maxWidth: .infinity).padding(.top, Space.s3) | ||
} | ||
} | ||
.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 { | ||
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.") | ||
} | ||
} | ||
.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) | ||
} | ||
} | ||
} | ||