Skip to content

feat(Snooze): Unsnooze threads with new message #1754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Mail/Views/AI Writer/AIModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ extension AIModel {
mailbox: mailboxManager.mailbox
)
handleAIResponse(response)
} catch let error as MailApiError where error == .apiAIContextIdExpired {
} catch let error as MailApiError where error == .apiObjectNotFound {
await executeShortcutAndRecreateConversation(shortcut)
} catch {
handleError(error)
Expand Down
8 changes: 6 additions & 2 deletions MailCore/API/Endpoint/Endpoint+Snooze.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
import InfomaniakCore

public extension Endpoint {
static func snooze(uuid: String) -> Endpoint {
return .mailbox(uuid: uuid).appending(path: "/snoozes")
static func snooze(mailboxUuid: String) -> Endpoint {
return .mailbox(uuid: mailboxUuid).appending(path: "/snoozes")
}

static func snoozeAction(mailboxUuid: String, snoozeUuid: String) -> Endpoint {
return .snooze(mailboxUuid: mailboxUuid).appending(path: "/\(snoozeUuid)")
}
}
15 changes: 11 additions & 4 deletions MailCore/API/MailApiError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ public class MailApiError: MailError {
/// The server does not know bout the identity used in the request
public static let apiIdentityNotFound = MailApiError(code: MailApiErrorCode.identityNotFound, shouldDisplay: false)

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

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

public static let apiMessageNotSnoozed = MailApiError(code: "mail__message_not_snoozed")

static let allErrors: [MailApiError] = [
// General
MailApiError(code: "not_authorized"),
apiInvalidCredential,
apiInvalidPassword,
apiObjectNotFound,

// Folder
MailApiError(code: "folder__unable_to_create"),
Expand Down Expand Up @@ -164,9 +166,14 @@ public class MailApiError: MailError {
apiIdentityNotFound,

// AI Writer
apiAIContextIdExpired,
apiAIMaxSyntaxTokensReached,
apiAITooManyRequests
apiAITooManyRequests,

// Snooze
apiMessageNotSnoozed,
MailApiError(code: "mail__message_snooze_already_scheduled"),
MailApiError(code: "mail__message_max_number_of_scheduled_snooze_reached"),
MailApiError(code: "mail__message_cannot_be_snooze")
]

static func mailApiErrorFromCode(_ code: String) -> MailApiError? {
Expand Down
19 changes: 16 additions & 3 deletions MailCore/API/MailApiFetcher/MailApiFetcher+Snooze.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public extension MailApiFetcher {
func snooze(messages: [Message], until date: Date, mailbox: Mailbox) async throws {
_ = try await batchOver(values: messages, chunkSize: Self.snoozeAPILimit) { chunk in
let _: Empty = try await self.perform(request: self.authenticatedRequest(
.snooze(uuid: mailbox.uuid),
.snooze(mailboxUuid: mailbox.uuid),
method: .post,
parameters: MessagesToSnooze(endDate: date, uids: chunk.map(\.uid))
))
Expand All @@ -37,17 +37,30 @@ public extension MailApiFetcher {
func updateSnooze(messages: [Message], until date: Date, mailbox: Mailbox) async throws {
_ = try await batchOver(values: messages, chunkSize: Self.editSnoozeAPILimit) { chunk in
let _: Empty = try await self.perform(request: self.authenticatedRequest(
.snooze(uuid: mailbox.uuid),
.snooze(mailboxUuid: mailbox.uuid),
method: .put,
parameters: SnoozedMessagesToUpdate(endDate: date, uuids: chunk.compactMap(\.snoozeUuid))
))
}
}

func deleteSnooze(message: Message, mailbox: Mailbox) async throws {
guard let snoozeUuid = message.snoozeUuid else {
throw MailError.missingSnoozeUUID
}

let _: Empty = try await perform(
request: authenticatedRequest(
.snoozeAction(mailboxUuid: mailbox.uuid, snoozeUuid: snoozeUuid),
method: .delete
)
)
}

func deleteSnooze(messages: [Message], mailbox: Mailbox) async throws {
_ = try await batchOver(values: messages, chunkSize: Self.editSnoozeAPILimit) { chunk in
let _: Empty = try await self.perform(request: self.authenticatedRequest(
.snooze(uuid: mailbox.uuid),
.snooze(mailboxUuid: mailbox.uuid),
method: .delete,
parameters: ["uuids": chunk.compactMap(\.snoozeUuid)]
))
Expand Down
2 changes: 2 additions & 0 deletions MailCore/API/MailError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ public class MailError: LocalizedError, Encodable {
public static let noCalendarAttachmentFound = MailError(code: "noCalendarAttachmentFound")

public static let tooShortScheduleDelay = MailError(code: "tooShortScheduleDelay")

public static let missingSnoozeUUID = MailError(code: "missingSnoozeUUID")
}

extension MailError: Identifiable {
Expand Down
71 changes: 70 additions & 1 deletion MailCore/Cache/MailboxManager/MailboxManager+Thread.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ enum PageDirection {
// MARK: - Thread

public extension MailboxManager {
private static let maxParallelUnsnooze = 4

/// Fetch messages for given folder
/// Then fetch messages of folder with roles if needed
/// - Parameters:
Expand Down Expand Up @@ -141,10 +143,15 @@ public extension MailboxManager {
var messagesToFetch = folderPrevious.remainingOldMessagesToFetch
while messagesToFetch > 0 {
guard !Task.isCancelled else { return }
guard try await fetchOneOldPage(folder: folder) != nil else { return }
guard try await fetchOneOldPage(folder: folder) != nil else { break }

messagesToFetch -= Constants.oldPageSize
}

if folder.role == .inbox {
guard !Task.isCancelled else { return }
try await unsnoozeThreadsWithNewMessage(in: folder)
}
}

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

// MARK: - Handle snoozed threads

private func unsnoozeThreadsWithNewMessage(in folder: Folder) async throws {
guard UserDefaults.shared.threadMode == .conversation else { return }

let frozenThreadsToUnsnooze = fetchResults(ofType: Thread.self) { partial in
partial.where { thread in
let isInFolder = thread.folderId == folder.remoteId
let isSnoozed = thread.snoozeState == .snoozed && thread.snoozeUuid != nil && thread.snoozeEndDate != nil
let isLastMessageFromFolderNotSnoozed = !thread.isLastMessageFromFolderSnoozed

return isInFolder && isSnoozed && isLastMessageFromFolderNotSnoozed
}
}.freeze()

guard !frozenThreadsToUnsnooze.isEmpty else { return }

let unsnoozedMessages: [String] = await Array(frozenThreadsToUnsnooze).concurrentCompactMap(
customConcurrency: Self.maxParallelUnsnooze
) { thread in
guard let lastMessageSnoozed = thread.messages.last(where: { $0.isSnoozed }),
thread.lastMessageFromFolder?.isSnoozed == false else {
return nil
}

do {
try await self.apiFetcher.deleteSnooze(message: lastMessageSnoozed, mailbox: self.mailbox)
return lastMessageSnoozed.uid
} catch let error as MailApiError where error == .apiMessageNotSnoozed || error == .apiObjectNotFound {
self.manuallyUnsnoozeThreadInRealm(thread: thread)
return nil
} catch {
SentryDebug.captureManuallyUnsnoozeError(error: error)
return nil
}
}

guard !Task.isCancelled, !unsnoozedMessages.isEmpty else { return }
Task {
guard let snoozedFolder = getFolder(with: .snoozed)?.freezeIfNeeded() else { return }
await refreshFolderContent(snoozedFolder)
}
}

private func manuallyUnsnoozeThreadInRealm(thread: Thread) {
try? writeTransaction { writableRealm in
guard let freshThread = thread.fresh(using: writableRealm) else { return }

for message in freshThread.messages {
message.snoozeState = nil
message.snoozeUuid = nil
message.snoozeEndDate = nil
}

try? freshThread.recomputeOrFail()
let duplicatesThreads = Set(freshThread.duplicates.flatMap { $0.threads })
for duplicateThread in duplicatesThreads {
try? duplicateThread.recomputeOrFail()
}
}
}

// MARK: - Utils

private func deleteOrphanMessages(writableRealm: Realm, folderId: String) {
Expand Down
2 changes: 1 addition & 1 deletion MailCore/Cache/MailboxManager/MailboxManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public final class MailboxManager: ObservableObject, MailboxManageable {
let realmName = "\(mailbox.userId)-\(mailbox.mailboxId).realm"
realmConfiguration = Realm.Configuration(
fileURL: MailboxManager.constants.rootDocumentsURL.appendingPathComponent(realmName),
schemaVersion: 39,
schemaVersion: 40,
migrationBlock: { migration, oldSchemaVersion in
// No migration needed from 0 to 16
if oldSchemaVersion < 17 {
Expand Down
3 changes: 2 additions & 1 deletion MailCore/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,8 @@ public final class Message: Object, Decodable, ObjectKeyIdentifiable {
bimi: bimi,
snoozeState: snoozeState,
snoozeUuid: snoozeUuid,
snoozeEndDate: snoozeEndDate
snoozeEndDate: snoozeEndDate,
isLastMessageFromFolderSnoozed: isSnoozed
)
thread.messageIds = linkedUids
thread.folderId = folderId
Expand Down
7 changes: 6 additions & 1 deletion MailCore/Models/Thread.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public class Thread: Object, Decodable, Identifiable {
@Persisted public var snoozeState: SnoozeState?
@Persisted public var snoozeUuid: String?
@Persisted public var snoozeEndDate: Date?
@Persisted public var isLastMessageFromFolderSnoozed = false

/// This property is used to remove threads from list before network call is finished
@Persisted public var isMovedOutLocally = false
Expand Down Expand Up @@ -185,6 +186,8 @@ public class Thread: Object, Decodable, Identifiable {
snoozeState = lastSnoozedMessage?.snoozeState
snoozeUuid = lastSnoozedMessage?.snoozeUuid
snoozeEndDate = lastSnoozedMessage?.snoozeEndDate

isLastMessageFromFolderSnoozed = lastMessageFromFolder?.isSnoozed == true
}

private func getLastAction() -> ThreadLastAction? {
Expand Down Expand Up @@ -284,7 +287,8 @@ public class Thread: Object, Decodable, Identifiable {
bimi: Bimi? = nil,
snoozeState: SnoozeState? = nil,
snoozeUuid: String? = nil,
snoozeEndDate: Date? = nil
snoozeEndDate: Date? = nil,
isLastMessageFromFolderSnoozed: Bool = false
) {
self.init()

Expand All @@ -305,6 +309,7 @@ public class Thread: Object, Decodable, Identifiable {
self.snoozeState = snoozeState
self.snoozeUuid = snoozeUuid
self.snoozeEndDate = snoozeEndDate
self.isLastMessageFromFolderSnoozed = isLastMessageFromFolderSnoozed

numberOfScheduledDraft = messages.count { $0.isScheduledDraft == true }
}
Expand Down
15 changes: 15 additions & 0 deletions MailCore/Utils/SentryDebug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ public enum SentryDebug {
}
}

public static func captureManuallyUnsnoozeError(error: Error) {
SentrySDK.capture(message: "Impossible to manually unsnooze thread") { scope in
scope.setLevel(.error)
let errorCode = (error as? MailError)?.code ?? "NA"
scope.setExtra(value: errorCode, key: "MailError")
scope.setContext(
value: [
"Error": error,
"Description": error.localizedDescription
],
key: "Underlying error"
)
}
}

// MARK: - Breadcrumb

public enum Category: String {
Expand Down
Loading