diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index 778b620..a3050e7 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -66,8 +66,14 @@ struct ContentView: View { } private var displayedMessages: [MailMessage] { - guard !search.isEmpty else { return store.messages } - return searchCandidates.filter(searchFilter.matches) + let candidates = search.isEmpty ? store.messages : searchCandidates.filter(searchFilter.matches) + return candidates.compactMap { message in + guard !store.pendingHiddenMessageIDs.contains(message.id) else { return nil } + guard let read = store.pendingReadStates[message.id] else { return message } + var updated = message + updated.isRead = read + return updated + } } private var accountIndicators: [UUID: AccountIndicator] { @@ -256,7 +262,7 @@ struct ContentView: View { } searchCandidates = store.messagesForSearch( scope: searchScope, - folderID: selectedFolder, + folderID: store.mailboxID(for: selectedFolder), accountID: currentSearchAccountID ) } diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index 38f4d5d..03c05b1 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -8,6 +8,8 @@ final class MailStore { var recipientHistory: [String] = [] var folders: [UUID: [MailFolder]] = [:] var messages: [MailMessage] = [] + private(set) var pendingHiddenMessageIDs: Set = [] + private(set) var pendingReadStates: [String: Bool] = [:] var isBusy = false private(set) var sendingAccountIDs: Set = [] var unreadCounts: [String: Int] = [:] @@ -132,6 +134,14 @@ final class MailStore { items.flatMap { [$0] + allFolders($0.children ?? []) } } + func mailboxID(for sidebarID: String?) -> String? { + guard let sidebarID, sidebarID.hasPrefix("account:"), + let accountID = UUID(uuidString: String(sidebarID.dropFirst("account:".count))) else { + return sidebarID + } + return allFolders(folders[accountID] ?? []).first(where: \.isInbox)?.id + } + var totalUnreadCount: Int { accounts .flatMap { allFolders(folders[$0.id] ?? []) } @@ -216,6 +226,7 @@ final class MailStore { } unreadCounts = try archive.unreadCounts() archiveRevision += 1 + if let resolvedFolderID = mailboxID(for: folderID) { selectedFolderID = resolvedFolderID } if let selectedFolderID { try display(folderID: selectedFolderID) } await MailNotifications.postNewMail( newInboxMessages, @@ -242,6 +253,7 @@ final class MailStore { } func load(folderID: String?, more: Bool = false) async { + let folderID = mailboxID(for: folderID) selectedFolderID = folderID guard let folderID else { messages = []; totalMessages = 0; return } do { @@ -439,12 +451,17 @@ final class MailStore { } func moveMessages(ids: [String], to destination: MailFolder) async { + guard destination.selectable else { + error = "Bitte einen beschreibbaren Zielordner wählen." + return + } + optimisticallyHide(ids: ids) await mutate(refreshAfter: false) { - guard destination.selectable else { throw MailError.message("Bitte einen beschreibbaren Zielordner wählen.") } for id in Set(ids) { try await self.moveOne(id: id, to: destination) } } + pendingHiddenMessageIDs.subtract(ids) } private func moveOne(id: String, to destination: MailFolder) async throws { @@ -463,7 +480,6 @@ final class MailStore { if source.accountID == destination.accountID, capabilities.contains("MOVE") { let assigned = try await sourceClient.move(uid: uid, to: destination.path) try storage().applyMessageChange(id: id, destination: destination.id, assigned: assigned, flags: inventory.flags[uid]) - try updateLocalPresentation() if assigned == nil { try await reconcileDestination(destination) } return } @@ -485,7 +501,6 @@ final class MailStore { throw MailError.message("Die Nachricht wurde ins Ziel kopiert, konnte aber nicht aus der Quelle entfernt werden. Beide Kopien bleiben erhalten. \(error.localizedDescription)") } try storage().applyMessageChange(id: id) - try updateLocalPresentation() } private func updateLocalPresentation() throws { @@ -511,6 +526,7 @@ final class MailStore { } func deleteMessages(ids: [String]) async { + optimisticallyHide(ids: ids) await mutate(refreshAfter: false) { for id in Set(ids) { let record = try self.storage().record(id: id) @@ -522,7 +538,6 @@ final class MailStore { guard inventory.validity == record.validity else { throw MailError.message("Ordnerbestand geändert. Bitte erneut abrufen.") } try await client.remove(uids: inventory.flags[uid] == nil ? [] : [uid]) try self.storage().applyMessageChange(id: id) - try self.updateLocalPresentation() } else { guard let trash = self.allFolders(self.folders[source.accountID] ?? []).first(where: \.isTrash) else { throw MailError.message("Für dieses Konto wurde kein Papierkorb gefunden. Bitte zuerst einen Ordner „Trash“ erstellen.") @@ -531,6 +546,7 @@ final class MailStore { } } } + pendingHiddenMessageIDs.subtract(ids) } func selectionContainsTrash(_ ids: [String]) -> Bool { @@ -549,6 +565,7 @@ final class MailStore { } func restoreToInbox(ids: [String]) async { + optimisticallyHide(ids: ids) await mutate(refreshAfter: false) { for id in Set(ids) { let record = try self.storage().record(id: id) @@ -561,6 +578,7 @@ final class MailStore { try await self.moveOne(id: id, to: inbox) } } + pendingHiddenMessageIDs.subtract(ids) } func createFolder(accountID: UUID, parent: MailFolder?, name: String) async { @@ -642,6 +660,7 @@ final class MailStore { } func setRead(ids: [String], read: Bool) async { + optimisticallySetRead(ids: ids, read: read) await mutate(refreshAfter: false) { for id in Set(ids) { let record = try self.storage().record(id: id) @@ -656,9 +675,38 @@ final class MailStore { var flags = (inventory.flags[uid] ?? []).filter { $0.lowercased() != "\\seen" } if read { flags.append("\\Seen") } try self.storage().applyMessageChange(id: id, flags: flags, removed: false) - try self.updateLocalPresentation() } } + for id in ids { pendingReadStates[id] = nil } + } + + private func optimisticallyHide(ids: [String]) { + let hidden = Set(ids) + guard !hidden.isEmpty else { return } + pendingHiddenMessageIDs.formUnion(hidden) + messages.removeAll { hidden.contains($0.id) } + totalMessages = messages.count + status = "Änderung wird im Hintergrund synchronisiert …" + } + + private func optimisticallySetRead(ids: [String], read: Bool) { + let changed = Set(ids) + guard !changed.isEmpty else { return } + for id in changed { pendingReadStates[id] = read } + messages = messages.map { message in + guard changed.contains(message.id) else { return message } + var updated = message + updated.isRead = read + return updated + } + for id in changed { + guard let record = try? storage().record(id: id) else { continue } + let wasRead = record.flags.contains { $0.lowercased() == "\\seen" } + guard wasRead != read else { continue } + unreadCounts[record.folderID, default: 0] = max(0, + unreadCounts[record.folderID, default: 0] + (read ? -1 : 1)) + } + status = "Änderung wird im Hintergrund synchronisiert …" } func configureIdle() {