336 lines
12 KiB
Swift
336 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 {
|
|
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
|
|
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
|
|
|
|
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: Self.pendingMastodonAccountKey)
|
|
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
|
}
|
|
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: Self.pendingMastodonErrorKey)
|
|
UserDefaults.standard.set(accountData, forKey: Self.pendingMastodonAccountKey)
|
|
|
|
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: Self.pendingMastodonErrorKey) {
|
|
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
|
throw PostingError(message: oauthError)
|
|
}
|
|
if UserDefaults.standard.data(forKey: Self.pendingMastodonAccountKey) == 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)
|
|
}
|
|
}
|