diff --git a/.package.resolved b/.package.resolved index db9b21a4c..728e82ed5 100644 --- a/.package.resolved +++ b/.package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Infomaniak/ios-core", "state" : { - "revision" : "e5fe8e03aa06375f20c8e40f4fae3a6af6e4d75e" + "revision" : "ef2811a288a3a4b94dd5d04c5f47ddd771e4176c" } }, { @@ -139,8 +139,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "282b9562c84d76cebbbecd1cc3aaf430448a0cce", - "version" : "12.1.1" + "revision" : "f67266f176af4add9f7a7020486826d82d562473", + "version" : "12.1.2" } }, { @@ -157,8 +157,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-core.git", "state" : { - "revision" : "93cc224680a520a84e1c01aa8866042c52eb7958", - "version" : "13.15.0" + "revision" : "f1434caadda443b4ed2261b91ea4f43ab1ee2aa5", + "version" : "13.15.1" } }, { @@ -166,8 +166,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-swift", "state" : { - "revision" : "2d7d1463a765c8835262dc09da83906e7cea166c", - "version" : "10.40.2" + "revision" : "b287dc102036ff425bd8a88483f0a5596871f05e", + "version" : "10.41.0" } }, { @@ -237,8 +237,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup", "state" : { - "revision" : "0e96a20ffd37a515c5c963952d4335c89bed50a6", - "version" : "2.6.0" + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" } }, { @@ -246,8 +246,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "dafd88cf0cc09d906dcef9d042743ae13fcff0d4", - "version" : "0.6.3" + "revision" : "8bf15ad33a529359200bd419a72ca2dda841089b", + "version" : "0.8.0" } }, { diff --git a/Mail/Components/MailboxListView.swift b/Mail/Components/MailboxListView.swift new file mode 100644 index 000000000..81dc8b4b4 --- /dev/null +++ b/Mail/Components/MailboxListView.swift @@ -0,0 +1,74 @@ +/* + 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 MailResources +import RealmSwift +import SwiftUI + +struct MailboxListView: View { + @Environment(\.window) private var window + + @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor + + @ObservedResults( + Mailbox.self, + configuration: MailboxInfosManager.instance.realmConfiguration, + where: { $0.userId == AccountManager.instance.currentUserId }, + sortDescriptor: SortDescriptor(keyPath: \Mailbox.mailboxId) + ) private var mailboxes + + private var otherMailboxes: [Mailbox] { + return mailboxes.filter { $0.mailboxId != currentMailbox?.mailboxId } + } + + let currentMailbox: Mailbox? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center) { + Text(MailResourcesStrings.Localizable.buttonAccountAssociatedEmailAddresses) + .textStyle(.bodySmallSecondary) + + Spacer() + + NavigationLink { + AddMailboxView() + } label: { + MailResourcesAsset.addCircle.swiftUIImage + .resizable() + .foregroundColor(accentColor.primary) + .frame(width: 16, height: 16) + } + } + .padding(.bottom, 16) + + if let currentMailbox { + MailboxCell(mailbox: currentMailbox, isSelected: true) + .mailboxCellStyle(.account) + } + + ForEach(otherMailboxes) { mailbox in + MailboxCell(mailbox: mailbox) + .mailboxCellStyle(.account) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + } +} diff --git a/Mail/Components/UnavailableMailboxListView.swift b/Mail/Components/UnavailableMailboxListView.swift new file mode 100644 index 000000000..9b73df307 --- /dev/null +++ b/Mail/Components/UnavailableMailboxListView.swift @@ -0,0 +1,78 @@ +/* + 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 MailResources +import RealmSwift +import SwiftUI + +struct UnavailableMailboxListView: View { + @Environment(\.window) private var window + + @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor + + @ObservedResults( + Mailbox.self, + configuration: MailboxInfosManager.instance.realmConfiguration, + where: { $0.userId == AccountManager.instance.currentUserId && $0.isPasswordValid == false }, + sortDescriptor: SortDescriptor(keyPath: \Mailbox.mailboxId) + ) private var passwordBlockedMailboxes + + @ObservedResults( + Mailbox.self, + configuration: MailboxInfosManager.instance.realmConfiguration, + where: { $0.userId == AccountManager.instance.currentUserId && $0.isLocked == true }, + sortDescriptor: SortDescriptor(keyPath: \Mailbox.mailboxId) + ) private var lockedMailboxes + + var body: some View { + VStack(alignment: .leading, spacing: 32) { + if !passwordBlockedMailboxes.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text(MailResourcesStrings.Localizable.blockedPasswordTitlePlural) + ForEach(passwordBlockedMailboxes) { mailbox in + MailboxCell(mailbox: mailbox) + .mailboxCellStyle(.setPassword) + } + } + } + + if !lockedMailboxes.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text(MailResourcesStrings.Localizable.lockedMailboxTitlePlural) + ForEach(lockedMailboxes) { mailbox in + MailboxesManagementButtonView( + icon: MailResourcesAsset.envelope, + text: mailbox.email, + isSelected: false, + isInMaintenance: false + ) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + } +} + +struct UnavailableMailboxListView_Previews: PreviewProvider { + static var previews: some View { + UnavailableMailboxListView() + } +} diff --git a/Mail/Components/UnreadIndicatorView.swift b/Mail/Components/UnreadIndicatorView.swift index fc5087a89..05956d882 100644 --- a/Mail/Components/UnreadIndicatorView.swift +++ b/Mail/Components/UnreadIndicatorView.swift @@ -20,7 +20,8 @@ import MailCore import SwiftUI struct UnreadIndicatorView: View { - let hidden: Bool + var hidden = false + var body: some View { Circle() .frame(width: UIConstants.unreadIconSize, height: UIConstants.unreadIconSize) diff --git a/Mail/Helpers/PreviewHelper.swift b/Mail/Helpers/PreviewHelper.swift index fac270a20..d8572f987 100644 --- a/Mail/Helpers/PreviewHelper.swift +++ b/Mail/Helpers/PreviewHelper.swift @@ -55,23 +55,17 @@ enum PreviewHelper { children: []) static let sampleThread = Thread(uid: "", - messagesCount: 2, - deletedMessagesCount: 0, messages: [sampleMessage], unseenMessages: 1, from: [sampleRecipient1], to: [sampleRecipient2], - cc: [], - bcc: [], subject: "Test thread", date: SentryDebug.knownDebugDate, hasAttachments: true, - hasSwissTransferAttachments: false, hasDrafts: false, flagged: true, answered: true, - forwarded: true, - size: 0) + forwarded: true) static let sampleMessage = Message(uid: "", msgId: "", diff --git a/Mail/Views/Menu Drawer/Folders/FolderCell.swift b/Mail/Views/Menu Drawer/Folders/FolderCell.swift index 75baae4b5..c370469ae 100644 --- a/Mail/Views/Menu Drawer/Folders/FolderCell.swift +++ b/Mail/Views/Menu Drawer/Folders/FolderCell.swift @@ -158,9 +158,14 @@ struct FolderCellContent: View { @ViewBuilder private var accessory: some View { if cellType == .link { - if folder.role != .sent { - Text(folder.formattedUnreadCount) - .textStyle(.bodySmallMediumAccent) + if folder.role != .sent && folder.role != .trash { + if !folder.formattedUnreadCount.isEmpty { + Text(folder.formattedUnreadCount) + .textStyle(.bodySmallMediumAccent) + } else if folder.remoteUnreadCount > 0 { + UnreadIndicatorView() + .accessibilityLabel(MailResourcesStrings.Localizable.contentDescriptionUnreadPastille) + } } } else if isCurrentFolder { MailResourcesAsset.check.swiftUIImage diff --git a/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift b/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift index a39273fb7..01e40b090 100644 --- a/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift +++ b/Mail/Views/Menu Drawer/MailboxManagement/MailboxCell.swift @@ -44,18 +44,18 @@ struct MailboxCell: View { @Environment(\.mailboxCellStyle) private var style: Style @EnvironmentObject private var navigationDrawerState: NavigationDrawerState - let mailbox: Mailbox + @State private var isShowingLockedView = false + @State private var isShowingUpdatePasswordView = false - private var isSelected: Bool { - return AccountManager.instance.currentMailboxManager?.mailbox.objectId == mailbox.objectId - } + let mailbox: Mailbox + var isSelected = false private var detailNumber: Int? { return mailbox.unseenMessages > 0 ? mailbox.unseenMessages : nil } enum Style { - case menuDrawer, account + case menuDrawer, account, setPassword } var body: some View { @@ -64,15 +64,20 @@ struct MailboxCell: View { text: mailbox.email, detailNumber: detailNumber, isSelected: isSelected, - isPasswordValid: mailbox.isPasswordValid + isInMaintenance: !mailbox.isAvailable ) { guard !isSelected else { return } guard mailbox.isPasswordValid else { - IKSnackBar.showSnackBar(message: MailResourcesStrings.Localizable.frelatedMailbox) + isShowingUpdatePasswordView = true + return + } + guard !mailbox.isLocked else { + isShowingLockedView = true return } @InjectService var matomo: MatomoUtils switch style { + case .setPassword: break case .menuDrawer: matomo.track(eventWithCategory: .menuDrawer, name: "switchMailbox") case .account: @@ -81,11 +86,17 @@ struct MailboxCell: View { AccountManager.instance.switchMailbox(newMailbox: mailbox) navigationDrawerState.close() } + .floatingPanel(isPresented: $isShowingLockedView) { + LockedMailboxView(lockedMailbox: mailbox) + } + .sheet(isPresented: $isShowingUpdatePasswordView) { + UpdateMailboxPasswordView(mailbox: mailbox) + } } } struct MailboxCell_Previews: PreviewProvider { static var previews: some View { - MailboxCell(mailbox: PreviewHelper.sampleMailbox) + MailboxCell(mailbox: PreviewHelper.sampleMailbox, isSelected: true) } } diff --git a/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementButtonView.swift b/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementButtonView.swift index b39ba8727..2eefafec0 100644 --- a/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementButtonView.swift +++ b/Mail/Views/Menu Drawer/MailboxManagement/MailboxesManagementButtonView.swift @@ -27,28 +27,30 @@ struct MailboxesManagementButtonView: View { let icon: Image let text: String let detailNumber: Int? - let handleAction: () -> Void + let handleAction: (() -> Void)? let isSelected: Bool - let isPasswordValid: Bool + let isInMaintenance: Bool init( icon: MailResourcesImages, text: String, detailNumber: Int? = nil, isSelected: Bool, - isPasswordValid: Bool, - handleAction: @escaping () -> Void + isInMaintenance: Bool, + handleAction: (() -> Void)? = nil ) { self.icon = icon.swiftUIImage self.text = text self.detailNumber = detailNumber self.isSelected = isSelected - self.isPasswordValid = isPasswordValid + self.isInMaintenance = isInMaintenance self.handleAction = handleAction } var body: some View { - Button(action: handleAction) { + Button { + handleAction?() + } label: { HStack { HStack(spacing: 16) { icon @@ -62,10 +64,15 @@ struct MailboxesManagementButtonView: View { } .frame(maxWidth: .infinity, alignment: .leading) - if !isPasswordValid { + if isInMaintenance && style != .setPassword { MailResourcesAsset.warning.swiftUIImage } else { switch style { + case .setPassword: + MailResourcesAsset.arrowRight.swiftUIImage + .resizable() + .frame(width: 12, height: 12) + .foregroundColor(MailResourcesAsset.textPrimaryColor.swiftUIColor) case .menuDrawer: if let detailNumber { Text(detailNumber < 100 ? "\(detailNumber)" : "99+") @@ -87,17 +94,13 @@ struct MailboxesManagementButtonView: View { struct MailboxesManagementButtonView_Previews: PreviewProvider { static var previews: some View { - MailboxesManagementButtonView(icon: MailResourcesAsset.folder, text: "Hello", isSelected: false, isPasswordValid: true) { - /* Empty for test */ - } + MailboxesManagementButtonView(icon: MailResourcesAsset.folder, text: "Hello", isSelected: false, isInMaintenance: true) MailboxesManagementButtonView( icon: MailResourcesAsset.folder, text: "Hello", detailNumber: 10, isSelected: false, - isPasswordValid: true - ) { - /* Empty for test */ - } + isInMaintenance: true + ) } } diff --git a/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift b/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift index 78914808d..d5eb8f4e3 100644 --- a/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift +++ b/Mail/Views/New Message/Attachments/AttachmentsHeaderView.swift @@ -31,7 +31,7 @@ struct AttachmentsHeaderView: View { HStack(spacing: 8) { ForEach(attachmentsManager.attachments) { attachment in AttachmentUploadCell(attachment: attachment, - uploadTask: attachmentsManager.attachmentUploadTaskFor(uuid: attachment.uuid)) { attachmentRemoved in + uploadTask: attachmentsManager.attachmentUploadTaskOrFinishedTask(for: attachment.uuid)) { attachmentRemoved in attachmentsManager.removeAttachment(attachmentRemoved) } } diff --git a/Mail/Views/New Message/Attachments/AttachmentsManager.swift b/Mail/Views/New Message/Attachments/AttachmentsManager.swift index ca89aa2ee..ab91efc8b 100644 --- a/Mail/Views/New Message/Attachments/AttachmentsManager.swift +++ b/Mail/Views/New Message/Attachments/AttachmentsManager.swift @@ -59,7 +59,7 @@ class AttachmentsManager: ObservableObject { func completeUploadedAttachments() { for attachment in attachments { - let uploadTask = attachmentUploadTaskFor(uuid: attachment.uuid) + let uploadTask = attachmentUploadTaskOrCreate(for: attachment.uuid) uploadTask.progress = 1 } objectWillChange.send() @@ -84,11 +84,36 @@ class AttachmentsManager: ObservableObject { objectWillChange.send() } - func attachmentUploadTaskFor(uuid: String) -> AttachmentUploadTask { - if attachmentUploadTasks[uuid] == nil { - attachmentUploadTasks[uuid] = AttachmentUploadTask() + /// Lookup and return. New object created and returned instead + func attachmentUploadTaskOrCreate(for uuid: String) -> AttachmentUploadTask { + guard let attachment = attachmentUploadTask(for: uuid) else { + let newTask = AttachmentUploadTask() + attachmentUploadTasks[uuid] = newTask + return newTask } - return attachmentUploadTasks[uuid]! + + return attachment + } + + /// Lookup and return. New object representing a finished task instead. + func attachmentUploadTaskOrFinishedTask(for uuid: String) -> AttachmentUploadTask { + guard let attachment = attachmentUploadTask(for: uuid) else { + let finishedTask = AttachmentUploadTask() + finishedTask.progress = 1 + attachmentUploadTasks[uuid] = finishedTask + return finishedTask + } + + return attachment + } + + /// Lookup and return, nil if not found + private func attachmentUploadTask(for uuid: String) -> AttachmentUploadTask? { + guard let attachment = attachmentUploadTasks[uuid] else { + return nil + } + + return attachment } func removeAttachment(_ attachment: Attachment) { diff --git a/Mail/Views/New Message/ComposeMessageView.swift b/Mail/Views/New Message/ComposeMessageView.swift index 69a910fac..3c3d34423 100644 --- a/Mail/Views/New Message/ComposeMessageView.swift +++ b/Mail/Views/New Message/ComposeMessageView.swift @@ -63,14 +63,11 @@ struct ComposeMessageView: View { @LazyInjectService private var matomo: MatomoUtils @LazyInjectService private var draftManager: DraftManager - @State private var isLoadingContent: Bool + @State private var isLoadingContent = true @State private var isShowingCancelAttachmentsError = false @State private var autocompletionType: ComposeViewFieldType? @State private var editorFocus = false - /// Something to track the initial loading of a default signature - @StateObject private var signatureManager: SignaturesManager - @StateObject private var mailboxManager: MailboxManager @StateObject private var attachmentsManager: AttachmentsManager @StateObject private var alert = NewMessageAlert() @@ -83,7 +80,9 @@ struct ComposeMessageView: View { } } - let messageReply: MessageReply? + private let messageReply: MessageReply? + private let draftContentManager: DraftContentManager + private let mailboxManager: MailboxManager private var isSendButtonDisabled: Bool { let disabledState = draft.identityId == nil @@ -93,7 +92,7 @@ struct ComposeMessageView: View { return disabledState } - // MAK: - Int + // MARK: - Init init(draft: Draft, mailboxManager: MailboxManager, messageReply: MessageReply? = nil) { self.messageReply = messageReply @@ -101,14 +100,17 @@ struct ComposeMessageView: View { Self.saveNewDraftInRealm(mailboxManager.getRealm(), draft: draft) _draft = StateRealmObject(wrappedValue: draft) - _isLoadingContent = State(wrappedValue: (draft.messageUid != nil && draft.remoteUUID.isEmpty) || messageReply != nil) + draftContentManager = DraftContentManager( + incompleteDraft: draft, + messageReply: messageReply, + mailboxManager: mailboxManager + ) - _signatureManager = StateObject(wrappedValue: SignaturesManager(mailboxManager: mailboxManager)) - _mailboxManager = StateObject(wrappedValue: mailboxManager) + self.mailboxManager = mailboxManager _attachmentsManager = StateObject(wrappedValue: AttachmentsManager(draft: draft, mailboxManager: mailboxManager)) } - // MAK: - View + // MARK: - View var body: some View { NavigationView { @@ -139,7 +141,7 @@ struct ComposeMessageView: View { VStack(spacing: 0) { ComposeMessageHeaderView(draft: draft, focusedField: _focusedField, autocompletionType: $autocompletionType) - if autocompletionType == nil { + if autocompletionType == nil && !isLoadingContent { ComposeMessageBodyView( draft: draft, isLoadingContent: $isLoadingContent, @@ -153,21 +155,15 @@ struct ComposeMessageView: View { } } .task { - await prepareCompleteDraft() - } - .task { - await prepareReplyForwardBodyAndAttachments() - } - .onChange(of: signatureManager.loadingSignatureState) { state in - switch state { - case .success: - setSignature() - case .error: + do { + isLoadingContent = true + try await draftContentManager.prepareCompleteDraft() + attachmentsManager.completeUploadedAttachments() + isLoadingContent = false + } catch { // Unable to get signatures, "An error occurred" and close modal. IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) dismiss() - case .progress: - break } } .background(MailResourcesAsset.backgroundColor.swiftUIColor) @@ -183,7 +179,7 @@ struct ComposeMessageView: View { draftManager.syncDraft(mailboxManager: mailboxManager) } .overlay { - if isLoadingContent || signatureManager.loadingSignatureState == .progress { + if isLoadingContent { progressView } } @@ -252,76 +248,6 @@ struct ComposeMessageView: View { realm.add(draft, update: .modified) } } - - private func prepareCompleteDraft() async { - guard draft.messageUid != nil && draft.remoteUUID.isEmpty else { return } - - do { - try await mailboxManager.draft(partialDraft: draft) - isLoadingContent = false - } catch { - dismiss() - IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) - } - } - - private func prepareReplyForwardBodyAndAttachments() async { - guard let messageReply else { return } - - let prepareTask = Task.detached { - try await prepareBody(message: messageReply.message, replyMode: messageReply.replyMode) - try await prepareAttachments(message: messageReply.message, replyMode: messageReply.replyMode) - } - - do { - _ = try await prepareTask.value - - isLoadingContent = false - } catch { - dismiss() - IKSnackBar.showSnackBar(message: MailError.unknownError.localizedDescription) - } - } - - private func prepareBody(message: Message, replyMode: ReplyMode) async throws { - if !message.fullyDownloaded { - try await mailboxManager.message(message: message) - } - - guard let freshMessage = message.thaw() else { return } - freshMessage.realm?.refresh() - $draft.body.wrappedValue = Draft.replyingBody(message: freshMessage, replyMode: replyMode) - } - - private func prepareAttachments(message: Message, replyMode: ReplyMode) async throws { - guard replyMode == .forward else { return } - let attachments = try await mailboxManager.apiFetcher.attachmentsToForward( - mailbox: mailboxManager.mailbox, - message: message - ).attachments - - for attachment in attachments { - $draft.attachments.append(attachment) - } - attachmentsManager.completeUploadedAttachments() - } - - private func setSignature() { - guard draft.identityId == nil || draft.identityId?.isEmpty == true else { - return - } - - guard let defaultSignature = mailboxManager.getStoredSignatures().defaultSignature else { - return - } - - let body = $draft.body.wrappedValue - let signedBody = defaultSignature.appendSignature(to: body) - - // At this point we have signatures in base up to date, we use the default one. - $draft.identityId.wrappedValue = "\(defaultSignature.id)" - $draft.body.wrappedValue = signedBody - } } struct ComposeMessageView_Previews: PreviewProvider { diff --git a/Mail/Views/New Message/Signatures/SignaturesManager.swift b/Mail/Views/New Message/Signatures/SignaturesManager.swift deleted file mode 100644 index 22bafb0c3..000000000 --- a/Mail/Views/New Message/Signatures/SignaturesManager.swift +++ /dev/null @@ -1,82 +0,0 @@ -/* - 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 Sentry -import SwiftUI - -final class SignaturesManager: ObservableObject { - /// Represents the loading state - enum SignaturesLoadingState: Equatable { - static func == (lhs: SignaturesManager.SignaturesLoadingState, rhs: SignaturesManager.SignaturesLoadingState) -> Bool { - switch (lhs, rhs) { - case (.success, .success): - return true - case (.progress, .progress): - return true - case (.error(let left), .error(let right)): - return left == right - default: - return false - } - } - - case success - case progress - case error(_ wrapping: NSError) - } - - @Published var loadingSignatureState: SignaturesLoadingState = .progress - - private let mailboxManager: MailboxManager - init(mailboxManager: MailboxManager) { - self.mailboxManager = mailboxManager - - loadRemoteSignatures() - } - - /// Load the signatures every time at init, set `doneLoadingDefaultSignature` to true when done - private func loadRemoteSignatures() { - Task { - do { - // load all signatures every time - try await mailboxManager.refreshAllSignatures() - - // If after a refresh we have no default signature we bail - guard mailboxManager.getStoredSignatures().defaultSignature != nil else { - throw MailError.defaultSignatureMissing - } - - await MainActor.run { - loadingSignatureState = .success - } - } catch { - await MainActor.run { - loadingSignatureState = .error(error as NSError) - } - - SentrySDK.capture(message: "We failed to fetch Signatures. This will close the Editor.") { scope in - scope.setExtras([ - "errorMessage": error.localizedDescription, - "error": "\(error)" - ]) - } - } - } - } -} diff --git a/Mail/Views/NoMailboxView.swift b/Mail/Views/NoMailboxView.swift index 54b806c8d..5bd9d9455 100644 --- a/Mail/Views/NoMailboxView.swift +++ b/Mail/Views/NoMailboxView.swift @@ -56,6 +56,9 @@ struct NoMailboxView: View { .frame(height: UIConstants.onboardingButtonHeight + UIConstants.onboardingBottomButtonPadding, alignment: .top) .padding(.horizontal, 24) } + .sheet(isPresented: $isShowingAddMailboxView) { + AddMailboxView() + } .matomoView(view: ["NoMailboxView"]) .sheet(isPresented: $isShowingAddMailboxView) { AddMailboxView() diff --git a/Mail/Views/Settings/General/SettingsNotificationsView.swift b/Mail/Views/Settings/General/SettingsNotificationsView.swift index 6b364d3b0..f9260d2ab 100644 --- a/Mail/Views/Settings/General/SettingsNotificationsView.swift +++ b/Mail/Views/Settings/General/SettingsNotificationsView.swift @@ -28,6 +28,8 @@ struct SettingsNotificationsView: View { @LazyInjectService private var notificationService: InfomaniakNotifications @LazyInjectService private var matomo: MatomoUtils + @EnvironmentObject var mailboxManager: MailboxManager + @AppStorage(UserDefaults.shared.key(.notificationsEnabled)) private var notificationsEnabled = DefaultPreferences .notificationsEnabled @State var subscribedTopics: [String]? @@ -132,7 +134,7 @@ struct SettingsNotificationsView: View { } func currentTopics() async { - let currentSubscription = await notificationService.subscriptionForUser(id: AccountManager.instance.currentUserId) + let currentSubscription = await notificationService.subscriptionForUser(id: mailboxManager.mailbox.userId) withAnimation { self.subscribedTopics = currentSubscription?.topics } @@ -140,9 +142,8 @@ struct SettingsNotificationsView: View { func updateTopicsForCurrentUserIfNeeded() { Task { - guard let currentApiFetcher = AccountManager.instance.currentMailboxManager?.apiFetcher, - let subscribedTopics else { return } - await notificationService.updateTopicsIfNeeded(subscribedTopics, userApiFetcher: currentApiFetcher) + guard let subscribedTopics else { return } + await notificationService.updateTopicsIfNeeded(subscribedTopics, userApiFetcher: mailboxManager.apiFetcher) } } diff --git a/Mail/Views/Switch User/AccountCellView.swift b/Mail/Views/Switch User/AccountCellView.swift index d84388268..6ef9e0dfe 100644 --- a/Mail/Views/Switch User/AccountCellView.swift +++ b/Mail/Views/Switch User/AccountCellView.swift @@ -45,6 +45,8 @@ struct AccountCellView: View { VStack { Button { + guard !isSelected else { return } + @InjectService var matomo: MatomoUtils matomo.track(eventWithCategory: .account, name: "switch") dismissModal() diff --git a/Mail/Views/Switch User/AccountView.swift b/Mail/Views/Switch User/AccountView.swift index e662881a4..14c0cd0b4 100644 --- a/Mail/Views/Switch User/AccountView.swift +++ b/Mail/Views/Switch User/AccountView.swift @@ -43,6 +43,9 @@ class AccountViewDelegate: DeleteAccountDelegate { } struct AccountView: View { + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var mailboxManager: MailboxManager @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor @LazyInjectService private var matomo: MatomoUtils @@ -52,13 +55,6 @@ struct AccountView: View { @State private var isShowingDeleteAccount = false @State private var delegate = AccountViewDelegate() - @State var mailboxes: [Mailbox] - - let selectedMailbox = AccountManager.instance.currentMailboxManager?.mailbox - var otherMailbox: [Mailbox] { - return mailboxes.filter { $0.mailboxId != selectedMailbox?.mailboxId } - } - var body: some View { VStack(spacing: 0) { ScrollView { @@ -102,15 +98,7 @@ struct AccountView: View { } .padding(.bottom, 16) - if let currentMailbox = selectedMailbox { - MailboxCell(mailbox: currentMailbox) - .mailboxCellStyle(.account) - } - - ForEach(otherMailbox) { mailbox in - MailboxCell(mailbox: mailbox) - .mailboxCellStyle(.account) - } + MailboxListView(currentMailbox: mailboxManager.mailbox) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 24) @@ -148,6 +136,6 @@ struct AccountView: View { struct AccountView_Previews: PreviewProvider { static var previews: some View { - AccountView(mailboxes: [PreviewHelper.sampleMailbox]) + AccountView() } } diff --git a/Mail/Views/Thread List/ThreadListModifiers.swift b/Mail/Views/Thread List/ThreadListModifiers.swift index e58275987..71e0711a5 100644 --- a/Mail/Views/Thread List/ThreadListModifiers.swift +++ b/Mail/Views/Thread List/ThreadListModifiers.swift @@ -128,7 +128,7 @@ struct ThreadListToolbar: ViewModifier { } .accessibilityLabel(MailResourcesStrings.Localizable.contentDescriptionUserAvatar) .sheet(isPresented: $isShowingSwitchAccount) { - AccountView(mailboxes: AccountManager.instance.mailboxes) + AccountView() } } } diff --git a/Mail/Views/Thread/MessageHeaderView.swift b/Mail/Views/Thread/MessageHeaderView.swift index e1d2dc553..5f9ae03f5 100644 --- a/Mail/Views/Thread/MessageHeaderView.swift +++ b/Mail/Views/Thread/MessageHeaderView.swift @@ -52,7 +52,7 @@ struct MessageHeaderView: View { .onTapGesture { if message.isDraft { DraftUtils.editDraft(from: message, mailboxManager: mailboxManager, editedMessageDraft: $editedDraft) - } else if message.originalThread?.messagesCount ?? 0 > 1 { + } else if message.originalThread?.messages.isEmpty == false { withAnimation { isHeaderExpanded = false isMessageExpanded.toggle() diff --git a/Mail/Views/Thread/MessageView.swift b/Mail/Views/Thread/MessageView.swift index 756b5680e..2e70149b1 100644 --- a/Mail/Views/Thread/MessageView.swift +++ b/Mail/Views/Thread/MessageView.swift @@ -18,8 +18,6 @@ import CocoaLumberjackSwift import InfomaniakCore -import InfomaniakCoreUI -import InfomaniakDI import MailCore import MailResources import RealmSwift @@ -27,8 +25,6 @@ import SwiftUI /// Something that can display an email struct MessageView: View { - @LazyInjectService var matomo: MatomoUtils - @EnvironmentObject var mailboxManager: MailboxManager @State var presentableBody: PresentableBody diff --git a/Mail/Views/Thread/ThreadView.swift b/Mail/Views/Thread/ThreadView.swift index 806230e07..4e01acd83 100644 --- a/Mail/Views/Thread/ThreadView.swift +++ b/Mail/Views/Thread/ThreadView.swift @@ -78,6 +78,14 @@ struct ThreadView: View { .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in displayNavigationTitle = offset.y < -85 } + .onAppear { + matomo.track( + eventWithCategory: .userInfo, + action: .data, + name: "nbMessagesInThread", + value: Float(thread.messages.count) + ) + } .task { if thread.hasUnseenMessages { try? await mailboxManager.toggleRead(threads: [thread]) diff --git a/Mail/Views/Thread/WebView.swift b/Mail/Views/Thread/WebView.swift index f0c4ec707..49eca084a 100644 --- a/Mail/Views/Thread/WebView.swift +++ b/Mail/Views/Thread/WebView.swift @@ -29,7 +29,7 @@ enum JavaScriptDeclaration { var description: String { switch self { case .normalizeMessageWidth(let width, let messageUid): - return "normalizeMessageWidth(\(width), \(messageUid))" + return "normalizeMessageWidth(\(width), '\(messageUid)')" case .removeAllProperties: return "removeAllProperties()" case .documentReadyState: diff --git a/Mail/Views/Unavailable Mailbox/LockedMailboxView.swift b/Mail/Views/Unavailable Mailbox/LockedMailboxView.swift new file mode 100644 index 000000000..74f3785c5 --- /dev/null +++ b/Mail/Views/Unavailable Mailbox/LockedMailboxView.swift @@ -0,0 +1,60 @@ +/* + 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 MailResources +import SwiftUI + +struct LockedMailboxView: View { + @Environment(\.dismiss) private var dismiss + + let lockedMailbox: Mailbox + var body: some View { + VStack(spacing: 16) { + MailResourcesAsset.mailboxError.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: 64) + Text(MailResourcesStrings.Localizable.lockedMailboxTitle) + .textStyle(.header2) + .multilineTextAlignment(.center) + Text(MailResourcesStrings.Localizable.lockedMailboxDescription) + .textStyle(.bodySecondary) + .multilineTextAlignment(.center) + .padding(.vertical, 24) + + MailButton(label: MailResourcesStrings.Localizable.buttonClose) { + dismiss() + } + .mailButtonStyle(.link) + .mailButtonFullWidth(true) + } + .padding(.horizontal, UIConstants.bottomSheetHorizontalPadding) + .padding(.top, 16) + .matomoView(view: ["LockedMailboxView"]) + } +} + +struct LockedMailboxView_Previews: PreviewProvider { + static var previews: some View { + Text("Preview") + .floatingPanel(isPresented: .constant(true)) { + LockedMailboxView(lockedMailbox: PreviewHelper.sampleMailbox) + } + } +} diff --git a/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift b/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift new file mode 100644 index 000000000..6062d0bf9 --- /dev/null +++ b/Mail/Views/Unavailable Mailbox/UnavailableMailboxesView.swift @@ -0,0 +1,102 @@ +/* + 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 InfomaniakCoreUI +import InfomaniakDI +import MailCore +import MailResources +import SwiftUI + +struct UnavailableMailboxesView: View { + @LazyInjectService private var matomo: MatomoUtils + + @Environment(\.window) private var window + + @State var isShowingNewAccountView = false + @State private var showAddMailbox = false + + var body: some View { + NavigationView { + VStack(spacing: 16) { + ScrollView { + VStack(spacing: 16) { + MailResourcesAsset.logoText.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: UIConstants.onboardingLogoHeight) + .padding(.top, UIConstants.onboardingLogoPaddingTop) + + MailResourcesAsset.mailboxError.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: 64) + Text(MailResourcesStrings.Localizable.lockedMailboxTitlePlural) + .textStyle(.header2) + .multilineTextAlignment(.center) + Text(MailResourcesStrings.Localizable.lockedMailboxDescriptionPlural) + .textStyle(.bodySecondary) + .multilineTextAlignment(.center) + .padding(.top, 24) + + UnavailableMailboxListView() + } + } + Spacer() + + NavigationLink(isActive: $showAddMailbox) { + AddMailboxView() + } label: { + MailButton(label: MailResourcesStrings.Localizable.buttonAddEmailAddress) { + matomo.track(eventWithCategory: .noValidMailboxes, name: "addMailbox") + showAddMailbox.toggle() + } + .mailButtonFullWidth(true) + .mailButtonStyle(.large) + } + + NavigationLink { + AccountListView() + } label: { + Text(MailResourcesStrings.Localizable.buttonAccountSwitch) + .textStyle(.bodyMediumAccent) + } + .simultaneousGesture( + TapGesture() + .onEnded { + matomo.track(eventWithCategory: .noValidMailboxes, name: "switchAccount") + } + ) + } + .padding(.horizontal, 16) + .frame(maxWidth: 900) + .matomoView(view: ["UnavailableMailboxesView"]) + } + .navigationViewStyle(.stack) + .fullScreenCover(isPresented: $isShowingNewAccountView) { + AppDelegate.orientationLock = .all + } content: { + OnboardingView(page: 4, isScrollEnabled: false) + } + } +} + +struct UnavailableMailboxesView_Previews: PreviewProvider { + static var previews: some View { + UnavailableMailboxesView() + } +} diff --git a/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift b/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift new file mode 100644 index 000000000..0beded17c --- /dev/null +++ b/Mail/Views/Unavailable Mailbox/UpdateMailboxPasswordView.swift @@ -0,0 +1,133 @@ +/* + 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 InfomaniakCoreUI +import InfomaniakDI +import MailCore +import MailResources +import SwiftUI + +struct UpdateMailboxPasswordView: View { + @LazyInjectService private var matomo: MatomoUtils + + @Environment(\.window) private var window + + @State private var updatedMailboxPassword = "" + @State private var isShowingError = false + @State private var isLoading = false + + private var disableButton: Bool { + return isLoading || showPasswordLengthWarning + } + + private var showPasswordLengthWarning: Bool { + return !updatedMailboxPassword.isEmpty && (updatedMailboxPassword.count < 5 || updatedMailboxPassword.count > 80) + } + + let mailbox: Mailbox + var body: some View { + VStack(alignment: .leading, spacing: 32) { + VStack(alignment: .leading, spacing: 8) { + Text(MailResourcesStrings.Localizable.enterPasswordDescription(mailbox.email)) + MailButton(label: MailResourcesStrings.Localizable.buttonDetachMailbox) { + matomo.track(eventWithCategory: .invalidPasswordMailbox, name: "detachMailbox") + detachAddress() + } + .mailButtonStyle(.link) + .disabled(isLoading) + } + + VStack(alignment: .leading) { + SecureField(MailResourcesStrings.Localizable.enterPasswordTitle, text: $updatedMailboxPassword) + .textContentType(.password) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke( + isShowingError ? MailResourcesAsset.redColor.swiftUIColor : MailResourcesAsset.elementsColor + .swiftUIColor, + lineWidth: 1 + ) + } + .disabled(isLoading) + + if isShowingError { + Text(MailResourcesStrings.Localizable.errorInvalidCredentials) + .textStyle(.labelError) + } else if showPasswordLengthWarning { + Text(MailResourcesStrings.Localizable.errorMailboxPasswordLength) + .textStyle(.labelSecondary) + } + } + + MailButton(label: MailResourcesStrings.Localizable.buttonConfirm) { + matomo.track(eventWithCategory: .invalidPasswordMailbox, name: "updatePassword") + updateMailboxPassword() + } + .mailButtonFullWidth(true) + .disabled(isLoading) + + MailButton(label: MailResourcesStrings.Localizable.buttonPasswordForgotten) { + // Empty for now, WIP + } + .mailButtonStyle(.link) + .mailButtonFullWidth(true) + .hidden() + + Spacer() + } + .padding() + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(MailResourcesStrings.Localizable.enterPasswordTitle) + .sheetViewStyle() + .matomoView(view: ["UpdateMailboxPasswordView"]) + } + + func updateMailboxPassword() { + Task { + isLoading = true + do { + try await AccountManager.instance.updateMailboxPassword(mailbox: mailbox, password: updatedMailboxPassword) + //await (window?.windowScene?.delegate as? SceneDelegate)?.showMainView() + } catch { + isShowingError = true + } + isLoading = false + } + } + + func detachAddress() { + Task { + isLoading = true + do { + try await AccountManager.instance.detachMailbox(mailbox: mailbox) + //await (window?.windowScene?.delegate as? SceneDelegate)?.showMainView() + } catch { + isShowingError = true + } + isLoading = false + } + } +} + +struct UpdateMailboxPasswordView_Previews: PreviewProvider { + static var previews: some View { + UpdateMailboxPasswordView(mailbox: PreviewHelper.sampleMailbox) + } +} diff --git a/MailCore/API/Endpoint.swift b/MailCore/API/Endpoint.swift index 936b4882d..68d1c0eeb 100644 --- a/MailCore/API/Endpoint.swift +++ b/MailCore/API/Endpoint.swift @@ -83,6 +83,15 @@ public extension Endpoint { return .base.appending(path: "/securedProxy/profile/workspace/mailbox") } + static func updateMailboxPassword(mailboxId: Int) -> Endpoint { + return .base + .appending(path: "/securedProxy/cache/invalidation/profile/workspace/mailbox/\(mailboxId)/update_password") + } + + static func detachMailbox(mailboxId: Int) -> Endpoint { + return addMailbox.appending(path: "/\(mailboxId)") + } + static func backups(hostingId: Int, mailboxName: String) -> Endpoint { return .baseManager.appending(path: "/\(hostingId)/mailboxes/\(mailboxName)/backups") } diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 1b004afc1..6390e6ebb 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -93,6 +93,18 @@ public class MailApiFetcher: ApiFetcher { )).data } + public func updateMailboxPassword(mailbox: Mailbox, password: String) async throws -> Bool { + try await perform(request: authenticatedRequest( + .updateMailboxPassword(mailboxId: mailbox.mailboxId), + method: .put, + parameters: ["password": password] + )).data + } + + public func detachMailbox(mailbox: Mailbox) async throws -> Bool { + try await perform(request: authenticatedRequest(.detachMailbox(mailboxId: mailbox.mailboxId), method: .delete)).data + } + func permissions(mailbox: Mailbox) async throws -> MailboxPermissions { try await perform(request: authenticatedRequest(.permissions(mailbox: mailbox))).data } diff --git a/MailCore/Cache/AccountManager.swift b/MailCore/Cache/AccountManager.swift index ca929229e..949a0fe90 100644 --- a/MailCore/Cache/AccountManager.swift +++ b/MailCore/Cache/AccountManager.swift @@ -107,7 +107,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { public var currentMailboxManager: MailboxManager? { if let currentMailboxManager = getMailboxManager(for: currentMailboxId, userId: currentUserId) { return currentMailboxManager - } else if let newCurrentMailbox = mailboxes.first { + } else if let newCurrentMailbox = mailboxes.first(where: { $0.isAvailable }) { setCurrentMailboxForCurrentAccount(mailbox: newCurrentMailbox) return getMailboxManager(for: newCurrentMailbox) } else { @@ -150,10 +150,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { if let account = account(for: currentUserId) ?? accounts.first { setCurrentAccount(account: account) - if let currentMailbox = MailboxInfosManager.instance - .getMailbox(id: currentMailboxId, userId: currentUserId) ?? mailboxes.first { - setCurrentMailboxForCurrentAccount(mailbox: currentMailbox) - } + switchToFirstValidMailboxManager() } } @@ -271,7 +268,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { throw MailError.noMailbox } - matomo.track(eventWithCategory: .userInfo, name: "nbMailboxes", value: Float(mailboxesResponse.count)) + matomo.track(eventWithCategory: .userInfo, action: .data, name: "nbMailboxes", value: Float(mailboxesResponse.count)) let newAccount = Account(apiToken: token) newAccount.user = user @@ -333,6 +330,10 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { MailboxManager.deleteUserMailbox(userId: user.id, mailboxId: mailboxRemoved.mailboxId) } + if currentMailboxManager?.mailbox.isAvailable == false { + switchToFirstValidMailboxManager() + } + saveAccounts() } @@ -369,6 +370,23 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { } } + public func switchToFirstValidMailboxManager() { + // Current mailbox is valid + if let firstValidMailboxManager = currentMailboxManager, + !firstValidMailboxManager.mailbox.isLocked && firstValidMailboxManager.mailbox.isPasswordValid { + return + } + + // At least one mailbox is valid + if let firstValidMailbox = mailboxes.first(where: { !$0.isLocked && $0.isPasswordValid && $0.userId == currentUserId }) { + switchMailbox(newMailbox: firstValidMailbox) + return + } + + // No valid mailbox for current user + currentMailboxId = 0 + } + public func switchAccount(newAccount: Account) { setCurrentAccount(account: newAccount) if let defaultMailbox = (mailboxes.first(where: { $0.isPrimary }) ?? mailboxes.first) { @@ -407,6 +425,18 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { switchMailbox(newMailbox: addedMailbox) } + public func updateMailboxPassword(mailbox: Mailbox, password: String) async throws { + guard let apiFetcher = currentApiFetcher else { return } + _ = try await apiFetcher.updateMailboxPassword(mailbox: mailbox, password: password) + try await updateUser(for: currentAccount) + } + + public func detachMailbox(mailbox: Mailbox) async throws { + guard let apiFetcher = currentApiFetcher else { return } + _ = try await apiFetcher.detachMailbox(mailbox: mailbox) + try await updateUser(for: currentAccount) + } + public func setCurrentAccount(account: Account) { currentAccount = account currentUserId = account.userId diff --git a/MailCore/Cache/DraftContentManager.swift b/MailCore/Cache/DraftContentManager.swift new file mode 100644 index 000000000..db4c8dea4 --- /dev/null +++ b/MailCore/Cache/DraftContentManager.swift @@ -0,0 +1,178 @@ +/* + 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 Foundation +import Sentry + +public class DraftContentManager: ObservableObject { + struct CompleteDraftResult { + let body: String + let attachments: [Attachment] + let shouldAddSignatureText: Bool + } + + let messageReply: MessageReply? + let mailboxManager: MailboxManager + let incompleteDraft: Draft + + public init(incompleteDraft: Draft, messageReply: MessageReply?, mailboxManager: MailboxManager) { + self.incompleteDraft = incompleteDraft.freezeIfNeeded() + self.messageReply = messageReply + self.mailboxManager = mailboxManager + } + + public func prepareCompleteDraft() async throws { + async let draftBodyResult = try await loadCompleteDraftBody() + async let signature = try await loadDefaultRemoteSignature() + + try await writeCompleteDraft( + completeBody: draftBodyResult.body, + signature: signature, + shouldAddSignatureText: draftBodyResult.shouldAddSignatureText, + attachments: draftBodyResult.attachments + ) + } + + private func loadCompleteDraftBody() async throws -> CompleteDraftResult { + var completeDraftBody: String + var attachments = [Attachment]() + let shouldAddSignatureText: Bool + + if let messageReply { + // New draft created either with reply or forward + async let completeDraftReplyingBody = try await loadReplyingBody( + message: messageReply.message, + replyMode: messageReply.replyMode + ) + async let replyingAttachments = try await loadReplyingAttachments( + message: messageReply.message, + replyMode: messageReply.replyMode + ) + + completeDraftBody = try await completeDraftReplyingBody + attachments = try await replyingAttachments + shouldAddSignatureText = true + } else if incompleteDraft.messageUid != nil && incompleteDraft.remoteUUID.isEmpty { + // Draft loaded remotely + completeDraftBody = try await loadCompleteDraftIfNeeded() + shouldAddSignatureText = false + } else if !incompleteDraft.remoteUUID.isEmpty { + // Draft loaded remotely but we have it locally + completeDraftBody = incompleteDraft.body + shouldAddSignatureText = false + } else { + // New draft + completeDraftBody = "" + shouldAddSignatureText = true + } + + return CompleteDraftResult( + body: completeDraftBody, + attachments: attachments, + shouldAddSignatureText: shouldAddSignatureText + ) + } + + private func writeCompleteDraft( + completeBody: String, + signature: Signature, + shouldAddSignatureText: Bool, + attachments: [Attachment] + ) throws { + let realm = mailboxManager.getRealm() + guard let liveIncompleteDraft = realm.object(ofType: Draft.self, forPrimaryKey: incompleteDraft.localUUID) else { + throw MailError.unknownError + } + + try? realm.write { + if liveIncompleteDraft.identityId == nil || liveIncompleteDraft.identityId?.isEmpty == true { + liveIncompleteDraft.identityId = "\(signature.id)" + if shouldAddSignatureText { + liveIncompleteDraft.body = signature.appendSignature(to: completeBody) + } + } else { + liveIncompleteDraft.body = completeBody + } + + for attachment in attachments { + liveIncompleteDraft.attachments.append(attachment) + } + } + } + + private func loadDefaultRemoteSignature() async throws -> Signature { + do { + // load all signatures every time + try await mailboxManager.refreshAllSignatures() + + // If after a refresh we have no default signature we bail + guard let defaultSignature = mailboxManager.getStoredSignatures().defaultSignature else { + throw MailError.defaultSignatureMissing + } + + return defaultSignature.freezeIfNeeded() + } catch { + SentrySDK.capture(message: "We failed to fetch Signatures. This will close the Editor.") { scope in + scope.setExtras([ + "errorMessage": error.localizedDescription, + "error": "\(error)" + ]) + } + throw error + } + } + + private func loadReplyingBody(message: Message, replyMode: ReplyMode) async throws -> String { + if !message.fullyDownloaded { + try await mailboxManager.message(message: message) + } + + guard let freshMessage = message.thaw() else { throw MailError.unknownError } + freshMessage.realm?.refresh() + return Draft.replyingBody(message: freshMessage, replyMode: replyMode) + } + + private func loadReplyingAttachments(message: Message, replyMode: ReplyMode) async throws -> [Attachment] { + guard replyMode == .forward else { return [] } + let attachments = try await mailboxManager.apiFetcher.attachmentsToForward( + mailbox: mailboxManager.mailbox, + message: message + ).attachments + + return attachments + } + + private func loadCompleteDraftIfNeeded() async throws -> String { + guard let associatedMessage = mailboxManager.getRealm() + .object(ofType: Message.self, forPrimaryKey: incompleteDraft.messageUid)?.freeze() + else { throw MailError.localMessageNotFound } + + let remoteDraft = try await mailboxManager.apiFetcher.draft(from: associatedMessage) + + remoteDraft.localUUID = incompleteDraft.localUUID + remoteDraft.action = .save + remoteDraft.delay = incompleteDraft.delay + + let realm = mailboxManager.getRealm() + try? realm.safeWrite { + realm.add(remoteDraft.detached(), update: .modified) + } + + return remoteDraft.body + } +} diff --git a/MailCore/Cache/MailboxInfosManager.swift b/MailCore/Cache/MailboxInfosManager.swift index d86feaaf0..8c5e1f374 100644 --- a/MailCore/Cache/MailboxInfosManager.swift +++ b/MailCore/Cache/MailboxInfosManager.swift @@ -38,7 +38,9 @@ public class MailboxInfosManager { public func getRealm() -> Realm { do { - return try Realm(configuration: realmConfiguration) + let realm = try Realm(configuration: realmConfiguration) + realm.refresh() + return realm } catch { // We can't recover from this error but at least we report it correctly on Sentry Logging.reportRealmOpeningError(error, realmConfiguration: realmConfiguration) diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift index 68a5d6b65..4949b9fe9 100644 --- a/MailCore/Cache/MailboxManager.swift +++ b/MailCore/Cache/MailboxManager.swift @@ -575,22 +575,16 @@ public class MailboxManager: ObservableObject { let newThread = Thread( uid: "offlineThread\(message.uid)", - messagesCount: 1, - deletedMessagesCount: 0, messages: [newMessage], unseenMessages: 0, from: Array(message.from.detached()), to: Array(message.to.detached()), - cc: Array(message.cc.detached()), - bcc: Array(message.bcc.detached()), date: newMessage.date, hasAttachments: newMessage.hasAttachments, - hasSwissTransferAttachments: newMessage.hasAttachments, hasDrafts: newMessage.isDraft, flagged: newMessage.flagged, answered: newMessage.answered, - forwarded: newMessage.forwarded, - size: newMessage.size + forwarded: newMessage.forwarded ) newThread.fromSearch = true newThread.subject = message.subject @@ -680,7 +674,8 @@ public class MailboxManager: ObservableObject { deletedUids: messageDeltaResult.deletedShortUids .map { Constants.longUid(from: $0, folderId: folder.id) }, updated: messageDeltaResult.updated, - cursor: messageDeltaResult.cursor + cursor: messageDeltaResult.cursor, + folderUnreadCount: messageDeltaResult.unreadCount ) } @@ -694,6 +689,9 @@ public class MailboxManager: ObservableObject { if previousCursor == nil && messagesUids.addedShortUids.count < Constants.pageSize { folder.completeHistoryInfo() } + if let newUnreadCount = messagesUids.folderUnreadCount { + folder.remoteUnreadCount = newUnreadCount + } folder.computeUnreadCount() folder.cursor = messagesUids.cursor folder.lastUpdate = Date() @@ -1120,28 +1118,6 @@ public class MailboxManager: ObservableObject { return realm.objects(Draft.self).where { $0.action != nil } } - public func draft(partialDraft: Draft) async throws { - guard let associatedMessage = getRealm().object(ofType: Message.self, forPrimaryKey: partialDraft.messageUid)?.freeze() - else { throw MailError.localMessageNotFound } - - // Get from API - let draft = try await apiFetcher.draft(from: associatedMessage) - - await backgroundRealm.execute { realm in - draft.localUUID = partialDraft.localUUID - draft.action = .save - - // We made sure beforehand to have an up to date signature. - // If the server does not return an identityId, we want to keep the original one - draft.identityId = partialDraft.identityId ?? draft.identityId - draft.delay = partialDraft.delay - - try? realm.safeWrite { - realm.add(draft.detached(), update: .modified) - } - } - } - public func draft(messageUid: String, using realm: Realm? = nil) -> Draft? { let realm = realm ?? getRealm() return realm.objects(Draft.self).where { $0.messageUid == messageUid }.first diff --git a/MailCore/Models/Folder.swift b/MailCore/Models/Folder.swift index 7c7ac7854..fa4f91c38 100644 --- a/MailCore/Models/Folder.swift +++ b/MailCore/Models/Folder.swift @@ -106,6 +106,7 @@ public class Folder: Object, Codable, Comparable, Identifiable { @Persisted public var name: String @Persisted public var role: FolderRole? @Persisted public var unreadCount = 0 + @Persisted public var remoteUnreadCount = 0 @Persisted public var isFavorite: Bool @Persisted public var separator: String @Persisted public var children: MutableSet @@ -223,6 +224,7 @@ public class Folder: Object, Codable, Comparable, Identifiable { case isFavorite case separator case children + case remoteUnreadCount = "unreadCount" } public convenience init( diff --git a/MailCore/Models/Mailbox.swift b/MailCore/Models/Mailbox.swift index 384972f17..e0d4120e0 100644 --- a/MailCore/Models/Mailbox.swift +++ b/MailCore/Models/Mailbox.swift @@ -58,6 +58,10 @@ public class Mailbox: Object, Codable, Identifiable { return mailboxId } + public var isAvailable: Bool { + return isPasswordValid && !isLocked + } + public var notificationTopicName: String { return "mailbox-\(mailboxId)" } diff --git a/MailCore/Models/Message.swift b/MailCore/Models/Message.swift index 442146954..a584e9835 100644 --- a/MailCore/Models/Message.swift +++ b/MailCore/Models/Message.swift @@ -79,12 +79,14 @@ public final class MessageDeltaResult: Decodable { public let addedShortUids: [String] public let updated: [MessageFlags] public let cursor: String + public let unreadCount: Int private enum CodingKeys: String, CodingKey { case deletedShortUids = "deleted" case addedShortUids = "added" case updated case cursor = "signature" + case unreadCount } // FIXME: Remove this constructor when mixed Int/String arrayis fixed by backend @@ -103,6 +105,7 @@ public final class MessageDeltaResult: Decodable { } updated = try container.decode([MessageFlags].self, forKey: .updated) cursor = try container.decode(String.self, forKey: .cursor) + unreadCount = try container.decode(Int.self, forKey: .unreadCount) } } @@ -111,6 +114,7 @@ public struct MessagesUids { public var deletedUids = [String]() public var updated = [MessageFlags]() public let cursor: String + public var folderUnreadCount: Int? } public class MessageFlags: Decodable { @@ -428,23 +432,17 @@ public final class Message: Object, Decodable, Identifiable { public func toThread() -> Thread { let thread = Thread( uid: "\(folderId)_\(uid)", - messagesCount: 1, - deletedMessagesCount: 1, messages: [self], unseenMessages: seen ? 0 : 1, from: Array(from), to: Array(to), - cc: Array(cc), - bcc: Array(bcc), subject: subject, date: date, hasAttachments: !attachments.isEmpty, - hasSwissTransferAttachments: false, hasDrafts: !(draftResource?.isEmpty ?? true), flagged: flagged, answered: answered, - forwarded: forwarded, - size: size + forwarded: forwarded ) thread.messageIds = linkedUids return thread diff --git a/MailCore/Models/Thread.swift b/MailCore/Models/Thread.swift index 707627c1a..6af5cb01d 100644 --- a/MailCore/Models/Thread.swift +++ b/MailCore/Models/Thread.swift @@ -34,23 +34,17 @@ public struct ThreadResult: Decodable { public class Thread: Object, Decodable, Identifiable { @Persisted(primaryKey: true) public var uid: String - @Persisted public var messagesCount: Int - @Persisted public var deletedMessagesCount: Int @Persisted public var messages: List @Persisted public var unseenMessages: Int @Persisted public var from: List @Persisted public var to: List - @Persisted public var cc: List - @Persisted public var bcc: List @Persisted public var subject: String? @Persisted(indexed: true) public var date: Date @Persisted public var hasAttachments: Bool - @Persisted public var hasSwissTransferAttachments: Bool @Persisted public var hasDrafts: Bool @Persisted public var flagged: Bool @Persisted public var answered: Bool @Persisted public var forwarded: Bool - @Persisted public var size: Int @Persisted(originProperty: "threads") private var folders: LinkingObjects @Persisted public var fromSearch = false @@ -126,13 +120,11 @@ public class Thread: Object, Decodable, Identifiable { messageIds = messages.flatMap { $0.linkedUids }.toRealmSet() updateUnseenMessages() from = messages.flatMap { $0.from.detached() }.toRealmList() - size = messages.sum(of: \.size) hasAttachments = messages.contains { $0.hasAttachments } hasDrafts = messages.map { $0.isDraft }.contains(true) updateFlagged() answered = messages.map { $0.answered }.contains(true) forwarded = messages.map { $0.forwarded }.contains(true) - messagesCount = messages.count messages = messages.sorted { $0.date.compare($1.date) == .orderedAscending @@ -205,65 +197,47 @@ public class Thread: Object, Decodable, Identifiable { private enum CodingKeys: String, CodingKey { case uid - case messagesCount - case deletedMessagesCount case messages case unseenMessages case from case to - case cc - case bcc case subject case date case hasAttachments - case hasSwissTransferAttachments = "hasStAttachments" case hasDrafts case flagged case answered case forwarded - case size } public convenience init( uid: String, - messagesCount: Int, - deletedMessagesCount: Int, messages: [Message], unseenMessages: Int, from: [Recipient], to: [Recipient], - cc: [Recipient], - bcc: [Recipient], subject: String? = nil, date: Date, hasAttachments: Bool, - hasSwissTransferAttachments: Bool, hasDrafts: Bool, flagged: Bool, answered: Bool, - forwarded: Bool, - size: Int + forwarded: Bool ) { self.init() self.uid = uid - self.messagesCount = messagesCount - self.deletedMessagesCount = deletedMessagesCount self.messages = messages.toRealmList() self.unseenMessages = unseenMessages self.from = from.toRealmList() self.to = to.toRealmList() - self.cc = cc.toRealmList() - self.bcc = bcc.toRealmList() self.subject = subject self.date = date self.hasAttachments = hasAttachments - self.hasSwissTransferAttachments = hasSwissTransferAttachments self.hasDrafts = hasDrafts self.flagged = flagged self.answered = answered self.forwarded = forwarded - self.size = size } } diff --git a/MailCore/Utils/Constants.swift b/MailCore/Utils/Constants.swift index 34a2e6bca..11e5ae046 100644 --- a/MailCore/Utils/Constants.swift +++ b/MailCore/Utils/Constants.swift @@ -158,7 +158,9 @@ public enum Constants { let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String? ?? "Mail" let release = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String? ?? "x.x" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String? ?? "x" - return "\(appName) iOS version \(release)-beta\(build)" + let betaRelease = Bundle.main.isRunningInTestFlight ? "beta" : "" + + return "\(appName) iOS version \(release)-\(betaRelease)\(build)" } public static let searchFolderId = "search_folder_id" diff --git a/MailCore/Utils/Matomo+Extension.swift b/MailCore/Utils/Matomo+Extension.swift index 1378d7a9c..430e93fb0 100644 --- a/MailCore/Utils/Matomo+Extension.swift +++ b/MailCore/Utils/Matomo+Extension.swift @@ -33,10 +33,12 @@ public extension MatomoUtils.View { public extension MatomoUtils.EventCategory { static let createFolder = MatomoUtils.EventCategory(displayName: "createFolder") + static let invalidPasswordMailbox = MatomoUtils.EventCategory(displayName: "invalidPasswordMailbox") static let menuDrawer = MatomoUtils.EventCategory(displayName: "menuDrawer") static let message = MatomoUtils.EventCategory(displayName: "message") static let multiSelection = MatomoUtils.EventCategory(displayName: "multiSelection") static let newMessage = MatomoUtils.EventCategory(displayName: "newMessage") + static let noValidMailboxes = MatomoUtils.EventCategory(displayName: "noValidMailboxes") static let onboarding = MatomoUtils.EventCategory(displayName: "onboarding") static let replyBottomSheet = MatomoUtils.EventCategory(displayName: "replyBottomSheet") static let restoreEmailsBottomSheet = MatomoUtils.EventCategory(displayName: "restoreEmailsBottomSheet") diff --git a/MailResources/Assets.xcassets/mailbox-error.imageset/Contents.json b/MailResources/Assets.xcassets/mailbox-error.imageset/Contents.json new file mode 100644 index 000000000..123594b85 --- /dev/null +++ b/MailResources/Assets.xcassets/mailbox-error.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "mailbox-error.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MailResources/Assets.xcassets/mailbox-error.imageset/mailbox-error.svg b/MailResources/Assets.xcassets/mailbox-error.imageset/mailbox-error.svg new file mode 100644 index 000000000..697ec4e11 --- /dev/null +++ b/MailResources/Assets.xcassets/mailbox-error.imageset/mailbox-error.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/MailResources/Localizable/de.lproj/Localizable.strings b/MailResources/Localizable/de.lproj/Localizable.strings index 9937cb631..7f4cda23c 100644 Binary files a/MailResources/Localizable/de.lproj/Localizable.strings and b/MailResources/Localizable/de.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/en.lproj/Localizable.strings b/MailResources/Localizable/en.lproj/Localizable.strings index 034ffeb6f..25c01f046 100644 Binary files a/MailResources/Localizable/en.lproj/Localizable.strings and b/MailResources/Localizable/en.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/es.lproj/Localizable.strings b/MailResources/Localizable/es.lproj/Localizable.strings index 0d332b46a..c4cc91886 100644 Binary files a/MailResources/Localizable/es.lproj/Localizable.strings and b/MailResources/Localizable/es.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/fr.lproj/Localizable.strings b/MailResources/Localizable/fr.lproj/Localizable.strings index 2707ed1a0..28583e497 100644 Binary files a/MailResources/Localizable/fr.lproj/Localizable.strings and b/MailResources/Localizable/fr.lproj/Localizable.strings differ diff --git a/MailResources/Localizable/it.lproj/Localizable.strings b/MailResources/Localizable/it.lproj/Localizable.strings index e4290bbdd..2e7724b88 100644 Binary files a/MailResources/Localizable/it.lproj/Localizable.strings and b/MailResources/Localizable/it.lproj/Localizable.strings differ diff --git a/Project.swift b/Project.swift index 5e8cf2183..cdf1d1406 100644 --- a/Project.swift +++ b/Project.swift @@ -22,14 +22,14 @@ import ProjectDescription let deploymentTarget = DeploymentTarget.iOS(targetVersion: "15.0", devices: [.iphone, .ipad]) let baseSettings = SettingsDictionary() .currentProjectVersion("1") - .marketingVersion("1.0.0") + .marketingVersion("1.0.1") .automaticCodeSigning(devTeam: "864VDCS2QY") let project = Project(name: "Mail", packages: [ .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "1.1.6")), - .package(url: "https://github.com/Infomaniak/ios-core", .revision("e5fe8e03aa06375f20c8e40f4fae3a6af6e4d75e")), + .package(url: "https://github.com/Infomaniak/ios-core", .revision("ef2811a288a3a4b94dd5d04c5f47ddd771e4176c")), .package(url: "https://github.com/Infomaniak/ios-core-ui", .upToNextMajor(from: "2.3.0")), .package(url: "https://github.com/Infomaniak/ios-notifications", .upToNextMajor(from: "2.1.0")), .package(url: "https://github.com/Infomaniak/ios-create-account", .upToNextMajor(from: "1.1.0")),