summaryrefslogtreecommitdiff
path: root/Rune/ViewModels
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
commitfcf864a15b70e4ecb5bd789b1db3116221c34394 (patch)
treef15315324b9f95286b6bf7392dd0ccaee210c2de /Rune/ViewModels
parentcf587aa0573ac4f34e1effe70108a9eca82093ac (diff)
downloadrune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.gz
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.bz2
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.zip
add FUNDING.yml
Diffstat (limited to 'Rune/ViewModels')
-rw-r--r--Rune/ViewModels/DomainViewModel.swift248
-rw-r--r--Rune/ViewModels/TokenViewModel.swift55
2 files changed, 257 insertions, 46 deletions
diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift
index efad5b9..1da1bbe 100644
--- a/Rune/ViewModels/DomainViewModel.swift
+++ b/Rune/ViewModels/DomainViewModel.swift
@@ -6,45 +6,67 @@ final class DomainViewModel: ObservableObject {
@Published private(set) var domains: [Domain] = []
@Published private(set) var selectedDomain: Domain?
@Published private(set) var records: [DNSRecord] = []
+ @Published private(set) var forwards: [EmailForward] = []
@Published private(set) var isLoadingDomains = false
@Published private(set) var isLoadingDetail = false
@Published private(set) var isLoadingRecords = false
+ @Published private(set) var isLoadingForwards = false
@Published private(set) var isSaving = false
- @Published var errorMessage: String?
+ @Published private(set) var hasLoadedDomains = false
+ @Published var domainsErrorMessage: String?
+ @Published var detailErrorMessage: String?
+ @Published var recordsErrorMessage: String?
+ @Published var forwardsErrorMessage: String?
+ @Published var mutationErrorMessage: String?
+
+ private var recordsRefreshTask: Task<Void, Never>?
func reset() {
domains = []
selectedDomain = nil
records = []
+ forwards = []
isLoadingDomains = false
isLoadingDetail = false
isLoadingRecords = false
+ isLoadingForwards = false
isSaving = false
- errorMessage = nil
+ hasLoadedDomains = false
+ domainsErrorMessage = nil
+ detailErrorMessage = nil
+ recordsErrorMessage = nil
+ forwardsErrorMessage = nil
+ mutationErrorMessage = nil
+ stopAutoRefreshRecords()
}
func loadDomains(client: NjallaClient) async {
+ guard !isLoadingDomains else { return }
+
isLoadingDomains = true
defer {
isLoadingDomains = false
+ hasLoadedDomains = true
}
do {
domains = try await client.listDomains().sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
- errorMessage = nil
+ domainsErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ domainsErrorMessage = error.userFacingMessage
}
}
func loadDomainDetail(named name: String, client: NjallaClient) async {
+ guard !isLoadingDetail else { return }
+
isLoadingDetail = true
defer {
isLoadingDetail = false
@@ -56,32 +78,98 @@ final class DomainViewModel: ObservableObject {
if let index = domains.firstIndex(where: { $0.name == name }) {
domains[index] = domain
}
- errorMessage = nil
+ detailErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ detailErrorMessage = error.userFacingMessage
}
}
func updateDomain(named name: String, request: DomainUpdateRequest, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updated = try await client.editDomain(named: name, request: request)
+ guard request.isSatisfied(by: updated) else {
+ throw NjallaError.api(message: "The API response did not reflect the requested domain changes.")
+ }
+ selectedDomain = updated
+ if let index = domains.firstIndex(where: { $0.name == updated.name }) {
+ domains[index] = updated
+ }
+ detailErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadDomainDetail(named: updated.name, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
+ }
+
+ func loadRecords(for domain: String, client: NjallaClient) async {
+ await fetchRecords(for: domain, client: client)
+ }
+
+ func startAutoRefreshRecords(for domain: String, client: NjallaClient) {
+ guard recordsRefreshTask == nil else { return }
+
+ recordsRefreshTask = Task { [weak self] in
+ guard let self else { return }
- let updated = try await client.editDomain(named: name, request: request)
- selectedDomain = updated
- if let index = domains.firstIndex(where: { $0.name == updated.name }) {
- domains[index] = updated
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(5))
+
+ guard !Task.isCancelled else { return }
+ await self.fetchRecords(for: domain, client: client)
+ }
}
- errorMessage = nil
}
- func loadRecords(for domain: String, client: NjallaClient) async {
+ func stopAutoRefreshRecords() {
+ recordsRefreshTask?.cancel()
+ recordsRefreshTask = nil
+ }
+
+ func dismissMutationError() {
+ mutationErrorMessage = nil
+ }
+
+ func loadForwards(for domain: String, client: NjallaClient) async {
+ guard !isLoadingForwards else { return }
+
+ isLoadingForwards = true
+ defer {
+ isLoadingForwards = false
+ }
+
+ do {
+ forwards = try await client.listForwards(for: domain).sorted {
+ ($0.from, $0.to) < ($1.from, $1.to)
+ }
+ forwardsErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ forwardsErrorMessage = error.userFacingMessage
+ }
+ }
+
+ private func fetchRecords(for domain: String, client: NjallaClient) async {
+ guard !isLoadingRecords else { return }
+
isLoadingRecords = true
defer {
isLoadingRecords = false
@@ -91,53 +179,151 @@ final class DomainViewModel: ObservableObject {
records = try await client.listRecords(for: domain).sorted {
($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
}
- errorMessage = nil
+ recordsErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ recordsErrorMessage = error.userFacingMessage
}
}
func addRecord(for domain: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let record = try await client.addRecord(for: domain, draft: draft).withDomain(domain)
+ guard record.id.isEmpty == false,
+ record.type == draft.type.rawValue,
+ record.name == draft.trimmedName else {
+ throw NjallaError.api(message: "The API response did not reflect the requested record change.")
+ }
+ records = sortedRecords(records + [record])
+ recordsErrorMessage = nil
isSaving = false
+ Task {
+ await loadRecords(for: domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
-
- _ = try await client.addRecord(for: domain, draft: draft)
- try await reloadRecords(for: domain, client: client)
- errorMessage = nil
}
func editRecord(for domain: String, recordID: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updatedRecord = try await client.editRecord(for: domain, id: recordID, draft: draft).withDomain(domain)
+ guard updatedRecord.id == recordID,
+ updatedRecord.type == draft.type.rawValue,
+ updatedRecord.name == draft.trimmedName else {
+ throw NjallaError.api(message: "The API response did not reflect the requested record change.")
+ }
+ if let index = records.firstIndex(where: { $0.id == recordID }) {
+ records[index] = updatedRecord
+ } else {
+ records.append(updatedRecord)
+ }
+ records = sortedRecords(records)
+ recordsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadRecords(for: domain, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
-
- _ = try await client.editRecord(for: domain, id: recordID, draft: draft)
- try await reloadRecords(for: domain, client: client)
- errorMessage = nil
}
func removeRecord(_ record: DNSRecord, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
isSaving = true
- defer {
+ mutationErrorMessage = nil
+
+ do {
+ let updatedRecords = try await client.removeRecord(record)
+ guard updatedRecords.contains(where: { $0.id == record.id }) == false else {
+ throw NjallaError.api(message: "The API response indicates the record was not removed.")
+ }
+ records = sortedRecords(updatedRecords)
+ recordsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadRecords(for: record.domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
+ }
+ }
+
+ func addForward(_ forward: EmailForward, client: NjallaClient) async throws {
+ guard !isSaving else { return }
+
+ isSaving = true
+ mutationErrorMessage = nil
+
+ do {
+ try await client.addForward(forward: forward)
+ if forwards.contains(forward) == false {
+ forwards = sortedForwards(forwards + [forward])
+ }
+ forwardsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadForwards(for: forward.domain, client: client)
+ }
+ } catch {
isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
}
+ }
+
+ func removeForward(_ forward: EmailForward, client: NjallaClient) async throws {
+ guard !isSaving else { return }
- try await client.removeRecord(record)
- try await reloadRecords(for: record.domain, client: client)
- errorMessage = nil
+ isSaving = true
+ mutationErrorMessage = nil
+
+ do {
+ try await client.removeForward(forward)
+ forwards.removeAll { $0 == forward }
+ forwardsErrorMessage = nil
+ isSaving = false
+ Task {
+ await loadForwards(for: forward.domain, client: client)
+ }
+ } catch {
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
+ throw error
+ }
}
- private func reloadRecords(for domain: String, client: NjallaClient) async throws {
- records = try await client.listRecords(for: domain).sorted {
+ private func sortedRecords(_ records: [DNSRecord]) -> [DNSRecord] {
+ records.sorted {
($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
}
}
+
+ private func sortedForwards(_ forwards: [EmailForward]) -> [EmailForward] {
+ forwards.sorted {
+ ($0.from, $0.to) < ($1.from, $1.to)
+ }
+ }
}
diff --git a/Rune/ViewModels/TokenViewModel.swift b/Rune/ViewModels/TokenViewModel.swift
index 40495b4..eab3185 100644
--- a/Rune/ViewModels/TokenViewModel.swift
+++ b/Rune/ViewModels/TokenViewModel.swift
@@ -6,16 +6,20 @@ final class TokenViewModel: ObservableObject {
@Published private(set) var tokens: [APIToken] = []
@Published private(set) var isLoading = false
@Published private(set) var isSaving = false
- @Published var errorMessage: String?
+ @Published var listErrorMessage: String?
+ @Published var mutationErrorMessage: String?
func reset() {
tokens = []
isLoading = false
isSaving = false
- errorMessage = nil
+ listErrorMessage = nil
+ mutationErrorMessage = nil
}
func loadTokens(client: NjallaClient) async {
+ guard !isLoading else { return }
+
isLoading = true
defer {
isLoading = false
@@ -25,61 +29,76 @@ final class TokenViewModel: ObservableObject {
tokens = try await client.listTokens().sorted {
tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending
}
- errorMessage = nil
+ listErrorMessage = nil
} catch is CancellationError {
return
} catch {
if (error as? URLError)?.code == .cancelled {
return
}
- errorMessage = error.localizedDescription
+ listErrorMessage = error.userFacingMessage
}
}
func addToken(request: TokenCreateRequest, client: NjallaClient) async -> Bool {
+ guard !isSaving else { return false }
+
isSaving = true
- defer {
- isSaving = false
- }
+ mutationErrorMessage = nil
do {
try await client.addToken(request: request)
- tokens = try await client.listTokens()
- errorMessage = nil
+ isSaving = false
+ Task {
+ await loadTokens(client: client)
+ }
return true
} catch is CancellationError {
+ isSaving = false
return false
} catch {
if (error as? URLError)?.code == .cancelled {
+ isSaving = false
return false
}
- errorMessage = error.localizedDescription
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
return false
}
}
func removeToken(_ token: APIToken, client: NjallaClient) async -> Bool {
+ guard !isSaving else { return false }
+
isSaving = true
- defer {
- isSaving = false
- }
+ mutationErrorMessage = nil
do {
try await client.removeToken(key: token.key)
tokens.removeAll { $0.key == token.key }
- errorMessage = nil
+ isSaving = false
+ Task {
+ await loadTokens(client: client)
+ }
return true
} catch is CancellationError {
+ isSaving = false
return false
} catch {
if (error as? URLError)?.code == .cancelled {
+ isSaving = false
return false
}
- errorMessage = error.localizedDescription
+ isSaving = false
+ mutationErrorMessage = error.userFacingMessage
return false
}
}
+ func dismissMutationError() {
+ mutationErrorMessage = nil
+ }
+
func tokenLabel(for token: APIToken) -> String {
let trimmedComment = token.comment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedComment.isEmpty {
@@ -88,4 +107,10 @@ final class TokenViewModel: ObservableObject {
return String(token.key.prefix(8))
}
+
+ private func sortedTokens(_ tokens: [APIToken]) -> [APIToken] {
+ tokens.sorted {
+ tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending
+ }
+ }
}