diff --git a/.package.resolved b/.package.resolved index f848d3e41..c4caf1bba 100644 --- a/.package.resolved +++ b/.package.resolved @@ -139,8 +139,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "33f7e93be5d4ec027d42af77a8ec4680d1862ad2", - "version" : "11.6.4" + "revision" : "f4d9b95788679d0654c032961f73e7e9c16ca6b4", + "version" : "12.1.0" } }, { diff --git a/Mail/Components/AvatarView.swift b/Mail/Components/AvatarView.swift new file mode 100644 index 000000000..256e99db1 --- /dev/null +++ b/Mail/Components/AvatarView.swift @@ -0,0 +1,45 @@ +/* + 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 InfomaniakCore +import MailCore +import NukeUI +import SwiftUI + +struct AvatarView: View { + let avatarDisplayable: AvatarDisplayable + var size: CGFloat = 28 + + var body: some View { + if let avatarImageRequest = avatarDisplayable.avatarImageRequest { + LazyImage(request: avatarImageRequest) { state in + if let image = state.image { + ContactImage(image: image, size: size) + } else { + InitialsView( + initials: avatarDisplayable.initials, + color: avatarDisplayable.initialsBackgroundColor, + size: size + ) + } + } + } else { + InitialsView(initials: avatarDisplayable.initials, color: avatarDisplayable.initialsBackgroundColor, size: size) + } + } +} diff --git a/Mail/Components/ContactImage.swift b/Mail/Components/ContactImage.swift index ffcad4f16..93f48ae85 100644 --- a/Mail/Components/ContactImage.swift +++ b/Mail/Components/ContactImage.swift @@ -20,17 +20,15 @@ import MailCore import SwiftUI struct ContactImage: View { - var image: Image - var size: CGFloat + let image: Image + let size: CGFloat var body: some View { - ZStack { - image - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .clipShape(Circle()) - } + image + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .clipShape(Circle()) } } diff --git a/Mail/Components/RecipientAutocompletionCell.swift b/Mail/Components/RecipientAutocompletionCell.swift index da0b0ecc7..7e1f31c68 100644 --- a/Mail/Components/RecipientAutocompletionCell.swift +++ b/Mail/Components/RecipientAutocompletionCell.swift @@ -24,7 +24,7 @@ struct RecipientAutocompletionCell: View { var body: some View { HStack { - RecipientImage(recipient: recipient) + AvatarView(avatarDisplayable: recipient, size: 40) if recipient.name.isEmpty { Text(recipient.email) diff --git a/Mail/Components/RecipientImage.swift b/Mail/Components/RecipientImage.swift deleted file mode 100644 index 9bb0220dd..000000000 --- a/Mail/Components/RecipientImage.swift +++ /dev/null @@ -1,59 +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 MailResources -import SwiftUI - -struct RecipientImage: View { - var recipient: Recipient - var size: CGFloat - - @State private var image: Image? - - init(recipient: Recipient, size: CGFloat = 40) { - self.image = recipient.cachedAvatarImage - self.recipient = recipient - self.size = size - } - - var body: some View { - if let image = image { - ContactImage(image: image, size: size) - } else { - InitialsView(initials: recipient.initials, color: recipient.color, size: size) - .task { - await fetchAvatar() - } - } - } - - func fetchAvatar() async { - if let avatarImage = await recipient.avatarImage { - withAnimation { - image = avatarImage - } - } - } -} - -struct RecipientImage_Previews: PreviewProvider { - static var previews: some View { - RecipientImage(recipient: PreviewHelper.sampleRecipient1) - } -} diff --git a/Mail/Components/ThreadCell.swift b/Mail/Components/ThreadCell.swift index d14be98b7..c783460fe 100644 --- a/Mail/Components/ThreadCell.swift +++ b/Mail/Components/ThreadCell.swift @@ -123,7 +123,7 @@ struct ThreadCell: View { Group { if density == .large, let recipient = dataHolder.recipientToDisplay { ZStack { - RecipientImage(recipient: recipient) + AvatarView(avatarDisplayable: recipient, size: 40) CheckboxView(isSelected: isSelected, density: density) .opacity(isSelected ? 1 : 0) } diff --git a/Mail/Views/Bottom sheets/ContactActionsView.swift b/Mail/Views/Bottom sheets/ContactActionsView.swift index 8ebd2b7a4..0b8ab97c1 100644 --- a/Mail/Views/Bottom sheets/ContactActionsView.swift +++ b/Mail/Views/Bottom sheets/ContactActionsView.swift @@ -64,7 +64,7 @@ struct ContactActionsView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - RecipientImage(recipient: recipient, size: 32) + AvatarView(avatarDisplayable: recipient, size: 32) VStack(alignment: .leading) { Text(recipient.contact?.name ?? recipient.formattedName) .textStyle(.bodyMedium) diff --git a/Mail/Views/SplitView.swift b/Mail/Views/SplitView.swift index eeb89dc91..90b7cfd26 100644 --- a/Mail/Views/SplitView.swift +++ b/Mail/Views/SplitView.swift @@ -44,7 +44,6 @@ public class SplitViewManager: ObservableObject { @Published var showSearch = false @Published var selectedFolder: Folder? var splitViewController: UISplitViewController? - @Published var avatarImage = MailResourcesAsset.placeholderAvatar.swiftUIImage init(folder: Folder?) { selectedFolder = folder diff --git a/Mail/Views/Switch User/AccountCellView.swift b/Mail/Views/Switch User/AccountCellView.swift index 371e37660..f17409259 100644 --- a/Mail/Views/Switch User/AccountCellView.swift +++ b/Mail/Views/Switch User/AccountCellView.swift @@ -70,15 +70,9 @@ struct AccountHeaderCell: View { let account: Account @Binding var isSelected: Bool - @State private var avatarImage = MailResourcesAsset.placeholderAvatar.swiftUIImage - var body: some View { HStack(spacing: 8) { - avatarImage - .resizable() - .frame(width: 38, height: 38) - .clipShape(Circle()) - + AvatarView(avatarDisplayable: account.user, size: 38) VStack(alignment: .leading, spacing: 2) { Text(account.user.displayName) .textStyle(.bodyMedium) @@ -93,9 +87,6 @@ struct AccountHeaderCell: View { .foregroundColor(.accentColor) } } - .task { - avatarImage = await account.user.avatarImage - } } } diff --git a/Mail/Views/Switch User/AccountView.swift b/Mail/Views/Switch User/AccountView.swift index 90f4eb3b0..a293b4783 100644 --- a/Mail/Views/Switch User/AccountView.swift +++ b/Mail/Views/Switch User/AccountView.swift @@ -51,7 +51,6 @@ struct AccountView: View { @LazyInjectService private var matomo: MatomoUtils - @State private var avatarImage = MailResourcesAsset.placeholderAvatar.swiftUIImage private let account = AccountManager.instance.currentAccount! @State private var isShowingLogoutAlert = false @State private var isShowingDeleteAccount = false @@ -63,11 +62,7 @@ struct AccountView: View { NavigationView { VStack(spacing: 0) { ScrollView { - // Header - avatarImage - .resizable() - .frame(width: 104, height: 104) - .clipShape(Circle()) + AvatarView(avatarDisplayable: account.user, size: 104) .padding(.top, 24) .padding(.bottom, 16) @@ -127,9 +122,6 @@ struct AccountView: View { Label(MailResourcesStrings.Localizable.buttonClose, systemImage: "xmark") }) } - .task { - avatarImage = await account.user.avatarImage - } .sheet(isPresented: $isShowingDeleteAccount) { DeleteAccountView(account: account, delegate: delegate) } diff --git a/Mail/Views/Thread List/ThreadListView.swift b/Mail/Views/Thread List/ThreadListView.swift index bf91ad20c..7abff9309 100644 --- a/Mail/Views/Thread List/ThreadListView.swift +++ b/Mail/Views/Thread List/ThreadListView.swift @@ -352,16 +352,7 @@ private struct ThreadListToolbar: ViewModifier { Button { isShowingSwitchAccount.toggle() } label: { - splitViewManager.avatarImage - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - .clipShape(Circle()) - } - .task { - if let account = AccountManager.instance.currentAccount { - splitViewManager.avatarImage = await account.user.avatarImage - } + AvatarView(avatarDisplayable: AccountManager.instance.currentAccount.user) } } } diff --git a/Mail/Views/Thread/MessageHeaderSummaryView.swift b/Mail/Views/Thread/MessageHeaderSummaryView.swift index 80df5a55b..3ef8fcbdf 100644 --- a/Mail/Views/Thread/MessageHeaderSummaryView.swift +++ b/Mail/Views/Thread/MessageHeaderSummaryView.swift @@ -43,7 +43,7 @@ struct MessageHeaderSummaryView: View { matomo.track(eventWithCategory: .message, name: "selectAvatar") recipientTapped(recipient) } label: { - RecipientImage(recipient: recipient, size: 40) + AvatarView(avatarDisplayable: recipient, size: 40) } } diff --git a/MailCore/Utils/Nuke+Authorization.swift b/MailCore/API/MailApiFetcher+Nuke.swift similarity index 63% rename from MailCore/Utils/Nuke+Authorization.swift rename to MailCore/API/MailApiFetcher+Nuke.swift index 20f6c9d5c..e81219c59 100644 --- a/MailCore/Utils/Nuke+Authorization.swift +++ b/MailCore/API/MailApiFetcher+Nuke.swift @@ -17,16 +17,24 @@ */ import Foundation +import InfomaniakCore import Nuke -extension ImagePipeline { - public func imageWithAuthentication(for url: URL, delegate: (any ImageTaskDelegate)? = nil) async throws -> ImageResponse { +public extension MailApiFetcher { + func avatarImageRequestForContact(_ contact: MergedContact) -> ImageRequest? { + guard let avatarPath = contact.remote?.avatar else { return nil } + let endpoint = Endpoint.resource(avatarPath) + + return authenticatedImageRequest(endpoint.url) + } + + func authenticatedImageRequest(_ url: URL) -> ImageRequest? { var urlRequest = URLRequest(url: url) urlRequest.addValue( - "Bearer \(AccountManager.instance.currentAccount.token.accessToken)", + "Bearer \(currentToken?.accessToken ?? "")", forHTTPHeaderField: "Authorization" ) - return try await image(for: ImageRequest(urlRequest: urlRequest), delegate: delegate) + return ImageRequest(urlRequest: urlRequest) } } diff --git a/MailCore/Cache/AccountManager.swift b/MailCore/Cache/AccountManager.swift index f1827739a..267790310 100644 --- a/MailCore/Cache/AccountManager.swift +++ b/MailCore/Cache/AccountManager.swift @@ -59,34 +59,6 @@ public extension InfomaniakNetworkLoginable { } } -public extension InfomaniakUser { - var cachedAvatarImage: Image? { - if let avatarURL = URL(string: avatar), - let avatarUIImage = ImagePipeline.shared.cache[avatarURL]?.image { - return Image(uiImage: avatarUIImage) - } - - return nil - } - - var avatarImage: Image { - get async { - if let avatarURL = URL(string: avatar), - let avatarImage = try? await ImagePipeline.shared.image(for: avatarURL).image { - return Image(uiImage: avatarImage) - } else { - let backgroundColor = UIColor.backgroundColor(from: id, with: UIConstants.avatarColors) - let initialsImage = UIImage.getInitialsPlaceholder( - with: displayName, - size: CGSize(width: 40, height: 40), - backgroundColor: backgroundColor - ) - return Image(uiImage: initialsImage) - } - } - } -} - @globalActor actor AccountActor: GlobalActor { static let shared = AccountActor() diff --git a/MailCore/Models/MergedContact.swift b/MailCore/Models/MergedContact.swift index e61f8e71a..0bd28ff40 100644 --- a/MailCore/Models/MergedContact.swift +++ b/MailCore/Models/MergedContact.swift @@ -25,10 +25,13 @@ import SwiftUI import UIKit extension CNContact { - var image: UIImage? { - guard let imageData = imageData else { return nil } - let localImage = UIImage(data: imageData) - return localImage + func pngImageData() async -> Data? { + // We have to load something that Nuke can cache + guard let imageData, + let convertedImage = UIImage(data: imageData)?.pngData() else { + return nil + } + return convertedImage } } @@ -63,37 +66,40 @@ public class MergedContact { return remote != nil } - public var hasAvatar: Bool { - return local?.imageData != nil || remote?.avatar != nil + public init(email: String, remote: Contact?, local: CNContact?) { + self.email = email + self.remote = remote + self.local = local } +} - public var avatarImage: Image? { - get async { - if let localImage = local?.image { - return Image(uiImage: localImage) - } else if let avatarPath = remote?.avatar, - let avatarUIImage = try? await ImagePipeline.shared.imageWithAuthentication(for: Endpoint.resource(avatarPath).url).image { - return Image(uiImage: avatarUIImage) - } +extension MergedContact: AvatarDisplayable { + public var avatarImageRequest: ImageRequest? { + if let localContact = local, localContact.imageDataAvailable { + var imageRequest = ImageRequest(id: localContact.identifier) { + guard let imageData = await localContact.pngImageData() else { + throw MailError.unknownError + } - return nil + return imageData + } + imageRequest.options = [.disableDiskCache] + return imageRequest } - } - public var cachedAvatarImage: Image? { - if let localImage = local?.image { - return Image(uiImage: localImage) - } else if let avatarPath = remote?.avatar, - let avatarUIImage = ImagePipeline.shared.cache[Endpoint.resource(avatarPath).url]?.image { - return Image(uiImage: avatarUIImage) + if let remoteAvatar = remote?.avatar { + let avatarURL = Endpoint.resource(remoteAvatar).url + return AccountManager.instance.currentMailboxManager?.apiFetcher.authenticatedImageRequest(avatarURL) } return nil } - public init(email: String, remote: Contact?, local: CNContact?) { - self.email = email - self.remote = remote - self.local = local + public var initials: String { + "" + } + + public var initialsBackgroundColor: UIColor { + color } } diff --git a/MailCore/Models/Recipient.swift b/MailCore/Models/Recipient.swift index f4f376813..e43fa3d85 100644 --- a/MailCore/Models/Recipient.swift +++ b/MailCore/Models/Recipient.swift @@ -18,6 +18,7 @@ import Foundation import MailResources +import Nuke import RealmSwift import SwiftUI @@ -107,30 +108,17 @@ public class Recipient: EmbeddedObject, Codable { return "\(name) \(emailString)" } } +} - public var avatarImage: Image? { - get async { - if isCurrentUser && isMe { - return await AccountManager.instance.currentAccount.user.avatarImage - } else if let contact = contact, - contact.hasAvatar, - let avatarImage = await contact.avatarImage { - return avatarImage - } else { - return nil - } +extension Recipient: AvatarDisplayable { + public var avatarImageRequest: ImageRequest? { + guard !(isCurrentUser && isMe) else { + return AccountManager.instance.currentAccount.user.avatarImageRequest } + return contact?.avatarImageRequest } - public var cachedAvatarImage: Image? { - if isCurrentUser && isMe { - return AccountManager.instance.currentAccount.user.cachedAvatarImage - } else if let contact = contact, - contact.hasAvatar, - let avatarImage = contact.cachedAvatarImage { - return avatarImage - } else { - return nil - } + public var initialsBackgroundColor: UIColor { + color } } diff --git a/MailCore/Utils/AvatarDisplayable.swift b/MailCore/Utils/AvatarDisplayable.swift new file mode 100644 index 000000000..12959c7a9 --- /dev/null +++ b/MailCore/Utils/AvatarDisplayable.swift @@ -0,0 +1,44 @@ +/* + 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 InfomaniakCore +import Nuke +import SwiftUI +import UIKit + +public protocol AvatarDisplayable { + var avatarImageRequest: ImageRequest? { get } + var initials: String { get } + var initialsBackgroundColor: UIColor { get } +} + +extension UserProfile: AvatarDisplayable { + public var avatarImageRequest: ImageRequest? { + guard let avatarURL = URL(string: avatar) else { return nil } + return ImageRequest(url: avatarURL) + } + + public var initials: String { + displayName.initials + } + + public var initialsBackgroundColor: UIColor { + UIColor.backgroundColor(from: id, with: UIConstants.avatarColors) + } +} diff --git a/MailCore/Utils/LocalContactsHelper.swift b/MailCore/Utils/LocalContactsHelper.swift index 230c4325d..b73180a59 100644 --- a/MailCore/Utils/LocalContactsHelper.swift +++ b/MailCore/Utils/LocalContactsHelper.swift @@ -37,7 +37,14 @@ class LocalContactsHelper { static let shared = LocalContactsHelper() let store = CNContactStore() - let keysToFetch = ([CNContactIdentifierKey, CNContactEmailAddressesKey, CNContactImageDataKey, CNContactNicknameKey, CNContactOrganizationNameKey] as [CNKeyDescriptor]) + [CNContactFormatter.descriptorForRequiredKeys(for: .fullName)] + let keysToFetch = ([ + CNContactIdentifierKey, + CNContactEmailAddressesKey, + CNContactImageDataAvailableKey, + CNContactImageDataKey, + CNContactNicknameKey, + CNContactOrganizationNameKey + ] as [CNKeyDescriptor]) + [CNContactFormatter.descriptorForRequiredKeys(for: .fullName)] func enumerateContacts(usingBlock: @escaping (CNContact, UnsafeMutablePointer) -> Void) async { do { diff --git a/Project.swift b/Project.swift index 9327e30a3..a774f99bd 100644 --- a/Project.swift +++ b/Project.swift @@ -45,7 +45,7 @@ let project = Project(name: "Mail", .package(url: "https://github.com/markiv/SwiftUI-Shimmer", .upToNextMajor(from: "1.0.1")), .package(url: "https://github.com/dkk/WrappingHStack", .upToNextMajor(from: "2.0.0")), .package(url: "https://github.com/SCENEE/FloatingPanel", .upToNextMajor(from: "2.0.0")), - .package(url: "https://github.com/kean/Nuke", .upToNextMajor(from: "11.3.0")), + .package(url: "https://github.com/kean/Nuke", .upToNextMajor(from: "12.0.0")), .package(url: "https://github.com/airbnb/lottie-ios", .exact("3.5.0")), .package(url: "https://github.com/scinfu/SwiftSoup", .upToNextMajor(from: "2.5.3")) ], @@ -164,6 +164,7 @@ let project = Project(name: "Mail", .package(product: "RealmSwift"), .package(product: "SwiftRegex"), .package(product: "Nuke"), + .package(product: "NukeUI"), .package(product: "SwiftSoup") ], settings: .settings(base: baseSettings)