diff --git a/.swiftlint.yml b/.swiftlint.yml index b40c9c77f..98d81262b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -33,3 +33,5 @@ opt_in_rules: excluded: - .tuist-bin/* - Derived/Sources/* + - DerivedData/ + - Derived/ diff --git a/Mail/Views/Thread List/ThreadListView.swift b/Mail/Views/Thread List/ThreadListView.swift index aca48d26a..98c48f0b1 100644 --- a/Mail/Views/Thread List/ThreadListView.swift +++ b/Mail/Views/Thread List/ThreadListView.swift @@ -57,7 +57,7 @@ struct ThreadListView: View { @Binding private var editedMessageDraft: Draft? @Binding private var messageReply: MessageReply? - + private var shouldDisplayEmptyView: Bool { viewModel.folder.lastUpdate != nil && viewModel.sections.isEmpty && !viewModel.isLoadingPage } @@ -144,7 +144,7 @@ struct ThreadListView: View { ProgressView() .id(UUID()) .frame(maxWidth: .infinity) - } else if displayLoadMoreButton { + } else if displayLoadMoreButton && !viewModel.filterUnreadOn { MailButton(label: MailResourcesStrings.Localizable.buttonLoadMore) { withAnimation { isLoadingMore = true diff --git a/Mail/Views/Thread List/ThreadListViewModel+Observation.swift b/Mail/Views/Thread List/ThreadListViewModel+Observation.swift new file mode 100644 index 000000000..37837d0a3 --- /dev/null +++ b/Mail/Views/Thread List/ThreadListViewModel+Observation.swift @@ -0,0 +1,208 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import MailCore +import RealmSwift +import SwiftUI + +extension ThreadListViewModel { + private func threadResults() -> Results? { + guard let folder = folder.thaw() else { + sections = [] + return nil + } + + let threadResults: Results + if let predicate = filter.predicate { + threadResults = folder.threads + .filter(predicate + " OR uid == %@", selectedThread?.uid ?? "") + .sorted(by: \.date, ascending: false) + } else { + threadResults = folder.threads.sorted(by: \.date, ascending: false) + } + + return threadResults + } + + // MARK: - Observe global changes + + func observeChanges(animateInitialThreadChanges: Bool = false) { + stopObserveChanges() + + observationThreadToken = threadResults()?.observe(on: observeQueue) { [weak self] changes in + guard let self = self else { + return + } + + switch changes { + case .initial(let results): + let filteredThreads = Array(results.freezeIfNeeded()) + guard let newSections = self.sortThreadsIntoSections(threads: filteredThreads) else { return } + + DispatchQueue.main.sync { + self.filteredThreads = filteredThreads + withAnimation(animateInitialThreadChanges ? .default : nil) { + self.sections = newSections + } + } + case .update(let results, _, _, _): + let filteredThreads = Array(results.freezeIfNeeded()) + guard let newSections = self.sortThreadsIntoSections(threads: filteredThreads) else { return } + + DispatchQueue.main.sync { + self.nextThreadIfNeeded(from: filteredThreads) + self.filteredThreads = filteredThreads + if self.filter != .all, + filteredThreads.count == 1, + !self.filter.accepts(thread: filteredThreads[0]) { + self.filter = .all + } + withAnimation { + self.sections = newSections + } + } + case .error: + break + } + + // We only apply the first update when in "unread" mode + if self.filter == .unseen { + self.stopObserveChanges() + } + } + observationLastUpdateToken = folder.observe(keyPaths: [\Folder.lastUpdate], on: .main) { [weak self] changes in + switch changes { + case .change(let folder, _): + withAnimation { + self?.lastUpdate = folder.lastUpdate + } + default: + break + } + } + } + + func stopObserveChanges() { + observationThreadToken?.invalidate() + observationLastUpdateToken?.invalidate() + } + + // MARK: - Observe filtered results + + static let containAnyOfUIDs = "uid IN %@" + + /// Observe filtered threads, when global observation is disabled. + func observeFilteredResults() { + stopObserveFilteredThreads() + + let allThreadsUIDs = threadResults()?.reduce([String]()) { partialResult, thread in + partialResult + [thread.uid] + } + + guard let allThreadsUIDs = allThreadsUIDs else { + return + } + + let containAnyOf = NSPredicate(format: Self.containAnyOfUIDs, allThreadsUIDs) + let realm = mailboxManager.getRealm() + let allThreads = realm.objects(Thread.self).filter(containAnyOf) + + observeFilteredThreadsToken = allThreads.observe(on: observeQueue) { [weak self] changes in + guard let self = self else { + return + } + + switch changes { + case .initial: + break + case .update(let all, _, _, let modificationIndexes): + refreshInFilterMode(all: all, changes: modificationIndexes) + case .error: + break + } + } + } + + func stopObserveFilteredThreads() { + observeFilteredThreadsToken?.invalidate() + } + + /// Update filtered threads on observation change. + private func refreshInFilterMode(all: Results, changes: [Int]) { + for index in changes { + let updatedThread = all[index] + let uid = updatedThread.uid + + let threadToUpdate: Thread? = sections.reduce(nil as Thread?) { partialResult, section in + partialResult ?? section.threads.first { $0.uid == uid } + } + + let sectionToUpdate = sections.first { section in + section.threads.contains { $0.uid == uid } + } + + guard let threadToUpdate = threadToUpdate, + let sectionToUpdate = sectionToUpdate else { + return + } + + let threadToUpdateIndex = sectionToUpdate.threads.firstIndex(of: threadToUpdate) + guard let threadToUpdateIndex = threadToUpdateIndex else { + return + } + + sectionToUpdate.threads[threadToUpdateIndex] = updatedThread.freeze() + + Task { + await MainActor.run { + objectWillChange.send() + } + } + } + } + + // MARK: - Observe unread count + + /// Observe the unread count to disable filtering when it reaches 0 + func observeUnreadCount() { + stopObserveUnread() + + observationUnreadToken = threadResults()?.observe(on: observeQueue) { [weak self] changes in + guard let self = self else { + return + } + + switch changes { + case .initial(let all), .update(let all, _, _, _): + let unreadCount = all.where { $0.unseenMessages > 0 }.count + Task { + await MainActor.run { + self.unreadCount = unreadCount + } + } + + case .error: + break + } + } + } + + func stopObserveUnread() { + observationUnreadToken?.invalidate() + } +} diff --git a/Mail/Views/Thread List/ThreadListViewModel.swift b/Mail/Views/Thread List/ThreadListViewModel.swift index 618de2814..a70f0ed37 100644 --- a/Mail/Views/Thread List/ThreadListViewModel.swift +++ b/Mail/Views/Thread List/ThreadListViewModel.swift @@ -17,6 +17,7 @@ */ import Foundation +import InfomaniakCore import InfomaniakCoreUI import MailCore import MailResources @@ -25,7 +26,11 @@ import SwiftUI typealias Thread = MailCore.Thread -class DateSection: Identifiable { +final class DateSection: Identifiable, Equatable { + static func == (lhs: DateSection, rhs: DateSection) -> Bool { + lhs.id == rhs.id && lhs.title == rhs.title && lhs.threads == rhs.threads + } + enum ReferenceDate { case future, today, yesterday, thisWeek, lastWeek, thisMonth, older(Date) @@ -43,7 +48,7 @@ class DateSection: Identifiable { return .init(start: .lastWeek.startOfWeek, end: .lastWeek.endOfWeek) case .thisMonth: return .init(start: .now.startOfMonth, end: .now.endOfMonth) - case let .older(date): + case .older(let date): return .init(start: date.startOfMonth, end: date.endOfMonth) } } @@ -62,7 +67,7 @@ class DateSection: Identifiable { return MailResourcesStrings.Localizable.threadListSectionLastWeek case .thisMonth: return MailResourcesStrings.Localizable.threadListSectionThisMonth - case let .older(date): + case .older(let date): let formatStyle = Calendar.current.isDate(date, equalTo: .now, toGranularity: .year) ? Constants.shortDateFormatter : Constants.longDateFormatter @@ -123,18 +128,41 @@ class DateSection: Identifiable { var scrollViewProxy: ScrollViewProxy? var isCompact: Bool - private var observationThreadToken: NotificationToken? - private var observationLastUpdateToken: NotificationToken? - private let observeQueue = DispatchQueue(label: "com.infomaniak.thread-results", qos: .userInteractive) + /// Observe a filtered thread + var observeFilteredThreadsToken: NotificationToken? + /// Observe unread count + var observationUnreadToken: NotificationToken? + var observationThreadToken: NotificationToken? + var observationLastUpdateToken: NotificationToken? + let observeQueue = DispatchQueue(label: "com.infomaniak.thread-results", qos: .userInteractive) + + private let loadNextPageThreshold = 10 + + @Published var unreadCount = 0 { + didSet { + // Disable filter if we have no unread emails left + if unreadCount == 0 && filterUnreadOn { + filterUnreadOn = false + } + } + } @Published var filter = Filter.all { didSet { Task { + if filter == .unseen { + observeFilteredResults() + } else { + stopObserveFilteredThreads() + } + observeChanges(animateInitialThreadChanges: true) - if let topThread = sections.first?.threads.first?.id { - withAnimation { - self.scrollViewProxy?.scrollTo(topThread, anchor: .top) - } + + guard let topThread = sections.first?.threads.first?.id else { + return + } + withAnimation { + self.scrollViewProxy?.scrollTo(topThread, anchor: .top) } } } @@ -149,7 +177,7 @@ class DateSection: Identifiable { } } - private let loadNextPageThreshold = 10 + // MARK: Init init( mailboxManager: MailboxManager, @@ -161,6 +189,7 @@ class DateSection: Identifiable { lastUpdate = folder.lastUpdate self.isCompact = isCompact observeChanges() + observeUnreadCount() } func fetchThreads() async { @@ -201,65 +230,6 @@ class DateSection: Identifiable { } } - func observeChanges(animateInitialThreadChanges: Bool = false) { - observationThreadToken?.invalidate() - observationLastUpdateToken?.invalidate() - guard let folder = folder.thaw() else { - sections = [] - return - } - - let threadResults: Results - if let predicate = filter.predicate { - threadResults = folder.threads.filter(predicate + " OR uid == %@", selectedThread?.uid ?? "") - .sorted(by: \.date, ascending: false) - } else { - threadResults = folder.threads.sorted(by: \.date, ascending: false) - } - - observationThreadToken = threadResults.observe(on: observeQueue) { [weak self] changes in - switch changes { - case let .initial(results): - let filteredThreads = Array(results.freezeIfNeeded()) - guard let newSections = self?.sortThreadsIntoSections(threads: filteredThreads) else { return } - - DispatchQueue.main.sync { - self?.filteredThreads = filteredThreads - withAnimation(animateInitialThreadChanges ? .default : nil) { - self?.sections = newSections - } - } - case let .update(results, _, _, _): - let filteredThreads = Array(results.freezeIfNeeded()) - guard let newSections = self?.sortThreadsIntoSections(threads: filteredThreads) else { return } - - DispatchQueue.main.sync { - self?.nextThreadIfNeeded(from: filteredThreads) - self?.filteredThreads = filteredThreads - if self?.filter != .all && filteredThreads.count == 1 - && self?.filter.accepts(thread: filteredThreads[0]) != true { - self?.filter = .all - } - withAnimation { - self?.sections = newSections - } - } - case .error: - break - } - } - observationLastUpdateToken = folder.observe(keyPaths: [\Folder.lastUpdate], on: .main) { [weak self] changes in - switch changes { - case let .change(folder, _): - withAnimation { - self?.lastUpdate = folder.lastUpdate - } - default: - break - } - } - } - func nextThreadIfNeeded(from threads: [Thread]) { guard !isCompact, !threads.isEmpty, @@ -269,7 +239,7 @@ class DateSection: Identifiable { selectedThread = threads[validIndex] } - private func sortThreadsIntoSections(threads: [Thread]) -> [DateSection]? { + func sortThreadsIntoSections(threads: [Thread]) -> [DateSection]? { var newSections = [DateSection]() var currentSection: DateSection?