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?