Skip to content

Commit 852f696

Browse files
feat(Snooze): Unsnooze threads with new message (#1754)
2 parents 1f51ad6 + c90e57e commit 852f696

File tree

10 files changed

+130
-14
lines changed

10 files changed

+130
-14
lines changed

Mail/Views/AI Writer/AIModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ extension AIModel {
134134
mailbox: mailboxManager.mailbox
135135
)
136136
handleAIResponse(response)
137-
} catch let error as MailApiError where error == .apiAIContextIdExpired {
137+
} catch let error as MailApiError where error == .apiObjectNotFound {
138138
await executeShortcutAndRecreateConversation(shortcut)
139139
} catch {
140140
handleError(error)

MailCore/API/Endpoint/Endpoint+Snooze.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
import InfomaniakCore
2020

2121
public extension Endpoint {
22-
static func snooze(uuid: String) -> Endpoint {
23-
return .mailbox(uuid: uuid).appending(path: "/snoozes")
22+
static func snooze(mailboxUuid: String) -> Endpoint {
23+
return .mailbox(uuid: mailboxUuid).appending(path: "/snoozes")
24+
}
25+
26+
static func snoozeAction(mailboxUuid: String, snoozeUuid: String) -> Endpoint {
27+
return .snooze(mailboxUuid: mailboxUuid).appending(path: "/\(snoozeUuid)")
2428
}
2529
}

MailCore/API/MailApiError.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,7 @@ public class MailApiError: MailError {
5050
/// The server does not know bout the identity used in the request
5151
public static let apiIdentityNotFound = MailApiError(code: MailApiErrorCode.identityNotFound, shouldDisplay: false)
5252

53-
/// The context id for the conversation with the AI has expired
54-
public static let apiAIContextIdExpired = MailApiError(code: "object_not_found")
53+
public static let apiObjectNotFound = MailApiError(code: "object_not_found")
5554

5655
/// Maximum number of syntax tokens for a conversation with the AI reached
5756
public static let apiAIMaxSyntaxTokensReached = MailApiError(
@@ -73,11 +72,14 @@ public class MailApiError: MailError {
7372
shouldDisplay: true
7473
)
7574

75+
public static let apiMessageNotSnoozed = MailApiError(code: "mail__message_not_snoozed")
76+
7677
static let allErrors: [MailApiError] = [
7778
// General
7879
MailApiError(code: "not_authorized"),
7980
apiInvalidCredential,
8081
apiInvalidPassword,
82+
apiObjectNotFound,
8183

8284
// Folder
8385
MailApiError(code: "folder__unable_to_create"),
@@ -164,9 +166,14 @@ public class MailApiError: MailError {
164166
apiIdentityNotFound,
165167

166168
// AI Writer
167-
apiAIContextIdExpired,
168169
apiAIMaxSyntaxTokensReached,
169-
apiAITooManyRequests
170+
apiAITooManyRequests,
171+
172+
// Snooze
173+
apiMessageNotSnoozed,
174+
MailApiError(code: "mail__message_snooze_already_scheduled"),
175+
MailApiError(code: "mail__message_max_number_of_scheduled_snooze_reached"),
176+
MailApiError(code: "mail__message_cannot_be_snooze")
170177
]
171178

172179
static func mailApiErrorFromCode(_ code: String) -> MailApiError? {

MailCore/API/MailApiFetcher/MailApiFetcher+Snooze.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public extension MailApiFetcher {
2727
func snooze(messages: [Message], until date: Date, mailbox: Mailbox) async throws {
2828
_ = try await batchOver(values: messages, chunkSize: Self.snoozeAPILimit) { chunk in
2929
let _: Empty = try await self.perform(request: self.authenticatedRequest(
30-
.snooze(uuid: mailbox.uuid),
30+
.snooze(mailboxUuid: mailbox.uuid),
3131
method: .post,
3232
parameters: MessagesToSnooze(endDate: date, uids: chunk.map(\.uid))
3333
))
@@ -37,17 +37,30 @@ public extension MailApiFetcher {
3737
func updateSnooze(messages: [Message], until date: Date, mailbox: Mailbox) async throws {
3838
_ = try await batchOver(values: messages, chunkSize: Self.editSnoozeAPILimit) { chunk in
3939
let _: Empty = try await self.perform(request: self.authenticatedRequest(
40-
.snooze(uuid: mailbox.uuid),
40+
.snooze(mailboxUuid: mailbox.uuid),
4141
method: .put,
4242
parameters: SnoozedMessagesToUpdate(endDate: date, uuids: chunk.compactMap(\.snoozeUuid))
4343
))
4444
}
4545
}
4646

47+
func deleteSnooze(message: Message, mailbox: Mailbox) async throws {
48+
guard let snoozeUuid = message.snoozeUuid else {
49+
throw MailError.missingSnoozeUUID
50+
}
51+
52+
let _: Empty = try await perform(
53+
request: authenticatedRequest(
54+
.snoozeAction(mailboxUuid: mailbox.uuid, snoozeUuid: snoozeUuid),
55+
method: .delete
56+
)
57+
)
58+
}
59+
4760
func deleteSnooze(messages: [Message], mailbox: Mailbox) async throws {
4861
_ = try await batchOver(values: messages, chunkSize: Self.editSnoozeAPILimit) { chunk in
4962
let _: Empty = try await self.perform(request: self.authenticatedRequest(
50-
.snooze(uuid: mailbox.uuid),
63+
.snooze(mailboxUuid: mailbox.uuid),
5164
method: .delete,
5265
parameters: ["uuids": chunk.compactMap(\.snoozeUuid)]
5366
))

MailCore/API/MailError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ public class MailError: LocalizedError, Encodable {
8686
public static let noCalendarAttachmentFound = MailError(code: "noCalendarAttachmentFound")
8787

8888
public static let tooShortScheduleDelay = MailError(code: "tooShortScheduleDelay")
89+
90+
public static let missingSnoozeUUID = MailError(code: "missingSnoozeUUID")
8991
}
9092

9193
extension MailError: Identifiable {

MailCore/Cache/MailboxManager/MailboxManager+Thread.swift

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ enum PageDirection {
4848
// MARK: - Thread
4949

5050
public extension MailboxManager {
51+
private static let maxParallelUnsnooze = 4
52+
5153
/// Fetch messages for given folder
5254
/// Then fetch messages of folder with roles if needed
5355
/// - Parameters:
@@ -141,10 +143,15 @@ public extension MailboxManager {
141143
var messagesToFetch = folderPrevious.remainingOldMessagesToFetch
142144
while messagesToFetch > 0 {
143145
guard !Task.isCancelled else { return }
144-
guard try await fetchOneOldPage(folder: folder) != nil else { return }
146+
guard try await fetchOneOldPage(folder: folder) != nil else { break }
145147

146148
messagesToFetch -= Constants.oldPageSize
147149
}
150+
151+
if folder.role == .inbox {
152+
guard !Task.isCancelled else { return }
153+
try await unsnoozeThreadsWithNewMessage(in: folder)
154+
}
148155
}
149156

150157
private func getMessagesDelta(signature: String, folder: Folder) async throws -> String {
@@ -513,6 +520,68 @@ public extension MailboxManager {
513520
threadsToUpdate.formUnion(oldMessage.threads)
514521
}
515522

523+
// MARK: - Handle snoozed threads
524+
525+
private func unsnoozeThreadsWithNewMessage(in folder: Folder) async throws {
526+
guard UserDefaults.shared.threadMode == .conversation else { return }
527+
528+
let frozenThreadsToUnsnooze = fetchResults(ofType: Thread.self) { partial in
529+
partial.where { thread in
530+
let isInFolder = thread.folderId == folder.remoteId
531+
let isSnoozed = thread.snoozeState == .snoozed && thread.snoozeUuid != nil && thread.snoozeEndDate != nil
532+
let isLastMessageFromFolderNotSnoozed = !thread.isLastMessageFromFolderSnoozed
533+
534+
return isInFolder && isSnoozed && isLastMessageFromFolderNotSnoozed
535+
}
536+
}.freeze()
537+
538+
guard !frozenThreadsToUnsnooze.isEmpty else { return }
539+
540+
let unsnoozedMessages: [String] = await Array(frozenThreadsToUnsnooze).concurrentCompactMap(
541+
customConcurrency: Self.maxParallelUnsnooze
542+
) { thread in
543+
guard let lastMessageSnoozed = thread.messages.last(where: { $0.isSnoozed }),
544+
thread.lastMessageFromFolder?.isSnoozed == false else {
545+
return nil
546+
}
547+
548+
do {
549+
try await self.apiFetcher.deleteSnooze(message: lastMessageSnoozed, mailbox: self.mailbox)
550+
return lastMessageSnoozed.uid
551+
} catch let error as MailApiError where error == .apiMessageNotSnoozed || error == .apiObjectNotFound {
552+
self.manuallyUnsnoozeThreadInRealm(thread: thread)
553+
return nil
554+
} catch {
555+
SentryDebug.captureManuallyUnsnoozeError(error: error)
556+
return nil
557+
}
558+
}
559+
560+
guard !Task.isCancelled, !unsnoozedMessages.isEmpty else { return }
561+
Task {
562+
guard let snoozedFolder = getFolder(with: .snoozed)?.freezeIfNeeded() else { return }
563+
await refreshFolderContent(snoozedFolder)
564+
}
565+
}
566+
567+
private func manuallyUnsnoozeThreadInRealm(thread: Thread) {
568+
try? writeTransaction { writableRealm in
569+
guard let freshThread = thread.fresh(using: writableRealm) else { return }
570+
571+
for message in freshThread.messages {
572+
message.snoozeState = nil
573+
message.snoozeUuid = nil
574+
message.snoozeEndDate = nil
575+
}
576+
577+
try? freshThread.recomputeOrFail()
578+
let duplicatesThreads = Set(freshThread.duplicates.flatMap { $0.threads })
579+
for duplicateThread in duplicatesThreads {
580+
try? duplicateThread.recomputeOrFail()
581+
}
582+
}
583+
}
584+
516585
// MARK: - Utils
517586

518587
private func deleteOrphanMessages(writableRealm: Realm, folderId: String) {

MailCore/Cache/MailboxManager/MailboxManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public final class MailboxManager: ObservableObject, MailboxManageable {
7474
let realmName = "\(mailbox.userId)-\(mailbox.mailboxId).realm"
7575
realmConfiguration = Realm.Configuration(
7676
fileURL: MailboxManager.constants.rootDocumentsURL.appendingPathComponent(realmName),
77-
schemaVersion: 39,
77+
schemaVersion: 40,
7878
migrationBlock: { migration, oldSchemaVersion in
7979
// No migration needed from 0 to 16
8080
if oldSchemaVersion < 17 {

MailCore/Models/Message.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,8 @@ public final class Message: Object, Decodable, ObjectKeyIdentifiable {
464464
bimi: bimi,
465465
snoozeState: snoozeState,
466466
snoozeUuid: snoozeUuid,
467-
snoozeEndDate: snoozeEndDate
467+
snoozeEndDate: snoozeEndDate,
468+
isLastMessageFromFolderSnoozed: isSnoozed
468469
)
469470
thread.messageIds = linkedUids
470471
thread.folderId = folderId

MailCore/Models/Thread.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public class Thread: Object, Decodable, Identifiable {
6262
@Persisted public var snoozeState: SnoozeState?
6363
@Persisted public var snoozeUuid: String?
6464
@Persisted public var snoozeEndDate: Date?
65+
@Persisted public var isLastMessageFromFolderSnoozed = false
6566

6667
/// This property is used to remove threads from list before network call is finished
6768
@Persisted public var isMovedOutLocally = false
@@ -185,6 +186,8 @@ public class Thread: Object, Decodable, Identifiable {
185186
snoozeState = lastSnoozedMessage?.snoozeState
186187
snoozeUuid = lastSnoozedMessage?.snoozeUuid
187188
snoozeEndDate = lastSnoozedMessage?.snoozeEndDate
189+
190+
isLastMessageFromFolderSnoozed = lastMessageFromFolder?.isSnoozed == true
188191
}
189192

190193
private func getLastAction() -> ThreadLastAction? {
@@ -284,7 +287,8 @@ public class Thread: Object, Decodable, Identifiable {
284287
bimi: Bimi? = nil,
285288
snoozeState: SnoozeState? = nil,
286289
snoozeUuid: String? = nil,
287-
snoozeEndDate: Date? = nil
290+
snoozeEndDate: Date? = nil,
291+
isLastMessageFromFolderSnoozed: Bool = false
288292
) {
289293
self.init()
290294

@@ -305,6 +309,7 @@ public class Thread: Object, Decodable, Identifiable {
305309
self.snoozeState = snoozeState
306310
self.snoozeUuid = snoozeUuid
307311
self.snoozeEndDate = snoozeEndDate
312+
self.isLastMessageFromFolderSnoozed = isLastMessageFromFolderSnoozed
308313

309314
numberOfScheduledDraft = messages.count { $0.isScheduledDraft == true }
310315
}

MailCore/Utils/SentryDebug.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ public enum SentryDebug {
100100
}
101101
}
102102

103+
public static func captureManuallyUnsnoozeError(error: Error) {
104+
SentrySDK.capture(message: "Impossible to manually unsnooze thread") { scope in
105+
scope.setLevel(.error)
106+
let errorCode = (error as? MailError)?.code ?? "NA"
107+
scope.setExtra(value: errorCode, key: "MailError")
108+
scope.setContext(
109+
value: [
110+
"Error": error,
111+
"Description": error.localizedDescription
112+
],
113+
key: "Underlying error"
114+
)
115+
}
116+
}
117+
103118
// MARK: - Breadcrumb
104119

105120
public enum Category: String {

0 commit comments

Comments
 (0)