qstatus/qStatus/Views/SettingsView.swift
Paweł Orzech 733334df9b
Deduplicate shared constants, MIME helper, and cache normalized URLs
- Extract OAuth UserDefaults keys into shared OAuthKeys enum
- Extract triplicated mimeTypeFor() into shared mimeTypeForImage()
- Cache normalized base URL at client init instead of per-request
2026-03-07 22:22:08 +01:00

333 lines
12 KiB
Swift

import SwiftUI
struct SettingsView: View {
let accountStore: AccountStore
@State private var showAddAccount = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Accounts")
.font(.headline)
Spacer()
Button {
showAddAccount = true
} label: {
Image(systemName: "plus")
}
}
.padding()
Divider()
// Account list
if accountStore.accounts.isEmpty {
VStack(spacing: 8) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No accounts yet")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Add a Mastodon, WordPress, or Micro.blog account to get started.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
List {
ForEach(accountStore.accounts) { account in
HStack {
Image(systemName: account.serviceType.iconName)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(account.displayName)
.font(.body)
Text(account.serviceType.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(role: .destructive) {
accountStore.removeAccount(account)
} label: {
Image(systemName: "trash")
.font(.caption)
}
.buttonStyle(.plain)
.foregroundStyle(.red.opacity(0.7))
}
.padding(.vertical, 2)
}
}
}
}
.frame(width: 360, height: 300)
.sheet(isPresented: $showAddAccount) {
AddAccountView(accountStore: accountStore, isPresented: $showAddAccount)
}
}
}
struct AddAccountView: View {
let accountStore: AccountStore
@Binding var isPresented: Bool
@State private var selectedService: ServiceType = .mastodon
@State private var instanceURL = ""
@State private var token = ""
@State private var username = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
Text("Add Account")
.font(.headline)
// Service picker
Picker("Service", selection: $selectedService) {
ForEach(ServiceType.allCases) { service in
Text(service.displayName).tag(service)
}
}
.pickerStyle(.segmented)
.disabled(waitingForOAuth || isLoading)
if waitingForOAuth {
VStack(spacing: 8) {
ProgressView()
Text("Waiting for authorization in your browser...")
.font(.caption)
.foregroundStyle(.secondary)
Text("Authorize qStatus on your Mastodon instance, then return here.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 8)
} else {
// Service-specific fields
switch selectedService {
case .mastodon:
mastodonFields
case .wordpress:
wordpressFields
case .microblog:
microblogFields
}
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
if waitingForOAuth {
waitingForOAuth = false
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
}
isPresented = false
}
.keyboardShortcut(.cancelAction)
Spacer()
if isLoading && !waitingForOAuth {
ProgressView()
.controlSize(.small)
}
if !waitingForOAuth {
Button("Add") {
Task { await addAccount() }
}
.buttonStyle(.borderedProminent)
.disabled(!canAdd || isLoading)
.keyboardShortcut(.defaultAction)
}
}
}
.padding()
.frame(width: 360)
}
private var canAdd: Bool {
switch selectedService {
case .mastodon:
!instanceURL.trimmingCharacters(in: .whitespaces).isEmpty
case .wordpress:
!instanceURL.trimmingCharacters(in: .whitespaces).isEmpty
&& !username.trimmingCharacters(in: .whitespaces).isEmpty
&& !token.trimmingCharacters(in: .whitespaces).isEmpty
case .microblog:
!token.trimmingCharacters(in: .whitespaces).isEmpty
}
}
@ViewBuilder
private var mastodonFields: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Instance URL")
.font(.caption)
.foregroundStyle(.secondary)
TextField("https://mastodon.social", text: $instanceURL)
.textFieldStyle(.roundedBorder)
}
Text("You'll be redirected to your instance to authorize qStatus.")
.font(.caption)
.foregroundStyle(.secondary)
}
@ViewBuilder
private var wordpressFields: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Site URL")
.font(.caption)
.foregroundStyle(.secondary)
TextField("https://your-site.com", text: $instanceURL)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 4) {
Text("Username")
.font(.caption)
.foregroundStyle(.secondary)
TextField("admin", text: $username)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 4) {
Text("Application Password")
.font(.caption)
.foregroundStyle(.secondary)
SecureField("xxxx xxxx xxxx xxxx", text: $token)
.textFieldStyle(.roundedBorder)
}
Text("Generate an Application Password in WordPress: Users > Profile > Application Passwords")
.font(.caption)
.foregroundStyle(.secondary)
}
@ViewBuilder
private var microblogFields: some View {
VStack(alignment: .leading, spacing: 4) {
Text("App Token")
.font(.caption)
.foregroundStyle(.secondary)
SecureField("Paste your token", text: $token)
.textFieldStyle(.roundedBorder)
}
Text("Get your token at micro.blog/account/apps")
.font(.caption)
.foregroundStyle(.secondary)
}
@State private var waitingForOAuth = false
private func addAccount() async {
isLoading = true
errorMessage = nil
do {
switch selectedService {
case .mastodon:
try await addMastodonAccount()
// Don't close sheet - waiting for OAuth callback
isLoading = false
return
case .wordpress:
try await addWordPressAccount()
case .microblog:
try await addMicroblogAccount()
}
isPresented = false
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func addMastodonAccount() async throws {
let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Instance URL")
let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url)
var account = Account(
serviceType: .mastodon,
displayName: url.replacingOccurrences(of: "https://", with: ""),
instanceURL: url,
username: ""
)
account.mastodonClientID = clientID
account.mastodonClientSecret = clientSecret
let accountData = try JSONEncoder().encode(account)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
UserDefaults.standard.set(accountData, forKey: OAuthKeys.pendingMastodonAccount)
let authURL = try MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
NSWorkspace.shared.open(authURL)
waitingForOAuth = true
// Poll for the account to be added by the URL callback handler
for _ in 0..<120 { // wait up to 2 minutes
try? await Task.sleep(for: .seconds(1))
if let oauthError = UserDefaults.standard.string(forKey: OAuthKeys.pendingMastodonError) {
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
throw PostingError(message: oauthError)
}
if UserDefaults.standard.data(forKey: OAuthKeys.pendingMastodonAccount) == nil {
// Callback was handled, account was added
waitingForOAuth = false
isPresented = false
return
}
}
waitingForOAuth = false
throw PostingError(message: "Authorization timed out. Please try again.")
}
private func addWordPressAccount() async throws {
let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Site URL")
let user = username.trimmingCharacters(in: .whitespaces)
let pass = token.trimmingCharacters(in: .whitespaces)
let valid = try await WordPressClient.verifyCredentials(
siteURL: url, username: user, password: pass
)
guard valid else {
throw PostingError(message: "Invalid credentials. Check username and application password.")
}
let credentials = "\(user):\(pass)"
let account = Account(
serviceType: .wordpress,
displayName: "\(user)@\(url.replacingOccurrences(of: "https://", with: ""))",
instanceURL: url,
username: user
)
try accountStore.addAccount(account, token: credentials)
}
private func addMicroblogAccount() async throws {
let appToken = token.trimmingCharacters(in: .whitespaces)
let blogName = try await MicroblogClient.verifyToken(appToken)
let account = Account(
serviceType: .microblog,
displayName: blogName,
instanceURL: "https://micro.blog",
username: blogName
)
try accountStore.addAccount(account, token: appToken)
}
}