From 69723225f3b65bd443f281d9eb71b6d94c5c6a43 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Tue, 1 Jul 2025 16:08:34 +0200 Subject: [PATCH 1/3] Use ProfileID instead of Email for image upload option --- .../DemoUploadImageViewController.swift | 13 +++++++++---- .../Network/Services/ImageUploadService.swift | 8 ++++---- Sources/Gravatar/Options/AvatarSelection.swift | 10 +++++----- .../AvatarPicker/AvatarPickerViewModel.swift | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift index fc9a7471..07a74df6 100644 --- a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift +++ b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift @@ -3,8 +3,14 @@ import Gravatar import Combine class DemoUploadImageViewController: BaseFormViewController { - let emailFormField = TextFormField(placeholder: "Email", keyboardType: .emailAddress) - let tokenFormField = TextFormField(placeholder: "Token", isSecure: true) + @StoredValue(keyName: "QEEmailKey", defaultValue: "") + var savedEmail: String + + @StoredValue(keyName: "QETokenKey", defaultValue: "") + var savedToken: String + + lazy var emailFormField = TextFormField(placeholder: "Email", text: savedEmail, keyboardType: .emailAddress) + lazy var tokenFormField = TextFormField(placeholder: "Token", text: savedToken, isSecure: true) let avatarImageField = ImageFormField(size: .init(width: 300, height: 300)) let resultField = LabelField(title: "", subtitle: "") @@ -136,8 +142,7 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi @objc private func setAvatarSelectionMethod(with email: String, sender: UIView?) { let controller = UIAlertController(title: "Avatar selection behavior:", message: nil, preferredStyle: .actionSheet) - - AvatarSelection.allCases(for: .init(email)).forEach { selectionCase in + AvatarSelection.allCases(for: .email(Email(email))).forEach { selectionCase in controller.addAction(UIAlertAction(title: selectionCase.description, style: .default) { [weak self] action in guard let self else { return } avatarSelectionBehavior = selectionCase diff --git a/Sources/Gravatar/Network/Services/ImageUploadService.swift b/Sources/Gravatar/Network/Services/ImageUploadService.swift index 31a43adb..0b22237f 100644 --- a/Sources/Gravatar/Network/Services/ImageUploadService.swift +++ b/Sources/Gravatar/Network/Services/ImageUploadService.swift @@ -104,15 +104,15 @@ extension URLRequest { extension AvatarSelection { var queryItems: [URLQueryItem] { switch self { - case .selectUploadedImage(let email): + case .selectUploadedImage(let profileID): [ .init(name: "select_avatar", value: "true"), - .init(name: "selected_email_hash", value: email.id), + .init(name: "selected_email_hash", value: profileID.id), ] case .preserveSelection: [.init(name: "select_avatar", value: "false")] - case .selectUploadedImageIfNoneSelected(let email): - [.init(name: "selected_email_hash", value: email.id)] + case .selectUploadedImageIfNoneSelected(let profileID): + [.init(name: "selected_email_hash", value: profileID.id)] } } } diff --git a/Sources/Gravatar/Options/AvatarSelection.swift b/Sources/Gravatar/Options/AvatarSelection.swift index 8d5006b7..4f606002 100644 --- a/Sources/Gravatar/Options/AvatarSelection.swift +++ b/Sources/Gravatar/Options/AvatarSelection.swift @@ -1,14 +1,14 @@ /// Defines how to handle avatar selection after uploading a new avatar public enum AvatarSelection: Equatable, Sendable { case preserveSelection - case selectUploadedImage(for: Email) - case selectUploadedImageIfNoneSelected(for: Email) + case selectUploadedImage(for: ProfileIdentifier) + case selectUploadedImageIfNoneSelected(for: ProfileIdentifier) - public static func allCases(for email: Email) -> [AvatarSelection] { + public static func allCases(for profileID: ProfileIdentifier) -> [AvatarSelection] { [ .preserveSelection, - .selectUploadedImage(for: email), - .selectUploadedImageIfNoneSelected(for: email), + .selectUploadedImage(for: profileID), + .selectUploadedImageIfNoneSelected(for: profileID), ] } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 031d8e37..b23590fc 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -308,7 +308,7 @@ class AvatarPickerViewModel: ObservableObject { let avatar = try await avatarService.upload( squareImage, accessToken: accessToken, - selectionBehavior: .selectUploadedImageIfNoneSelected(for: email) + selectionBehavior: .selectUploadedImageIfNoneSelected(for: .email(email)) ) ImageCache.shared.setEntry(.ready(squareImage), for: avatar.imageURL) From 4f23b0dc06193fefee33736722f7c9955287f2b8 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Thu, 3 Jul 2025 07:57:01 +0200 Subject: [PATCH 2/3] Deprecate `AvatarSelection` and use `AvatarUploadSelectionPolicy` instead --- .../DemoUploadImageViewController.swift | 24 ++++--- Sources/Gravatar/Extensions/URL.swift | 4 +- .../Network/Services/AvatarService.swift | 19 +++-- .../Network/Services/ImageUploadService.swift | 39 ++++++++-- .../Network/Services/ImageUploader.swift | 18 +++++ .../Gravatar/Options/AvatarSelection.swift | 71 ++++++++++++++++++- .../AvatarPicker/AvatarPickerViewModel.swift | 4 +- Tests/GravatarTests/AvatarServiceTests.swift | 8 +-- 8 files changed, 153 insertions(+), 34 deletions(-) diff --git a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift index 07a74df6..84f498a6 100644 --- a/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift +++ b/Demo/Demo/Gravatar-Demo/DemoUploadImageViewController.swift @@ -47,7 +47,7 @@ class DemoUploadImageViewController: BaseFormViewController { } private let activityIndicator = UIActivityIndicatorView(style: .large) - private var avatarSelectionBehavior: AvatarSelection = .preserveSelection + private var avatarSelectionPolicy: AvatarUploadSelectionPolicy = .preserveSelection override func viewDidLoad() { super.viewDidLoad() @@ -95,10 +95,10 @@ class DemoUploadImageViewController: BaseFormViewController { do { let avatarModel = try await service.upload( image, - selectionBehavior: avatarSelectionBehavior, + selectionPolicy: avatarSelectionPolicy, accessToken: token ) - resultField.subtitle = "✅ Avatar id \(avatarModel.id)" + resultField.subtitle = "✅ Avatar id \(avatarModel.imageID)" } catch { resultField.subtitle = "Error \((error as NSError).code): \(error.localizedDescription)" } @@ -142,10 +142,10 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi @objc private func setAvatarSelectionMethod(with email: String, sender: UIView?) { let controller = UIAlertController(title: "Avatar selection behavior:", message: nil, preferredStyle: .actionSheet) - AvatarSelection.allCases(for: .email(Email(email))).forEach { selectionCase in + AvatarUploadSelectionPolicy.allCases(for: .email(Email(email))).forEach { selectionCase in controller.addAction(UIAlertAction(title: selectionCase.description, style: .default) { [weak self] action in guard let self else { return } - avatarSelectionBehavior = selectionCase + avatarSelectionPolicy = selectionCase backendSelectionBehaviorButtonField.subtitle = selectionCase.description update(backendSelectionBehaviorButtonField) }) @@ -158,12 +158,16 @@ extension DemoUploadImageViewController: UIImagePickerControllerDelegate, UINavi } } -extension AvatarSelection { +extension AvatarUploadSelectionPolicy { var description: String { - switch self { - case .selectUploadedImage: return "Select uploaded image" - case .preserveSelection: return "Preserve selection" - case .selectUploadedImageIfNoneSelected: return "Select uploaded image if none selected" + if isSelectUploadedImagePolicy { + "Select uploaded image" + } else if isPreserveSelectionPolicy { + "Preserve selection" + } else if isSelectUploadedImageIfNoneSelectedPolicy { + "Select uploaded image if none selected" + } else { + "Unknown option" } } } diff --git a/Sources/Gravatar/Extensions/URL.swift b/Sources/Gravatar/Extensions/URL.swift index f609cd80..ce6d51d3 100644 --- a/Sources/Gravatar/Extensions/URL.swift +++ b/Sources/Gravatar/Extensions/URL.swift @@ -23,8 +23,8 @@ extension URL { && components.scheme == "https" } - func appendingQueryItems(for selectionBehavior: AvatarSelection) -> URL { - let queryItems = selectionBehavior.queryItems + func appendingQueryItems(for selectionPolicy: AvatarUploadSelectionPolicy) -> URL { + let queryItems = selectionPolicy.queryItems if #available(iOS 16.0, *) { return self.appending(queryItems: queryItems) } else { diff --git a/Sources/Gravatar/Network/Services/AvatarService.swift b/Sources/Gravatar/Network/Services/AvatarService.swift index 4c7f5b8d..f45b11ab 100644 --- a/Sources/Gravatar/Network/Services/AvatarService.swift +++ b/Sources/Gravatar/Network/Services/AvatarService.swift @@ -50,15 +50,22 @@ public struct AvatarService: Sendable { /// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token. /// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar. @discardableResult + @available(*, deprecated, message: "Use `upload(_:accessToken:selectionBehavior:)` instead") public func upload(_ image: UIImage, selectionBehavior: AvatarSelection, accessToken: String) async throws -> AvatarType { - let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior) + let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionPolicy: selectionBehavior.map()) return avatar } + /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws + /// ``ImageUploadError``. + /// - Parameters: + /// - image: The image to be uploaded. + /// - selectionPolicy: How to handle avatar selection after uploading a new avatar + /// - accessToken: The authentication token for the user. This is a Gravatar OAuth2 access token. + /// - Returns: An asynchronously-delivered `AvatarType` instance, containing data of the newly created avatar. @discardableResult - package func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> AvatarDetails { - let avatar: Avatar = try await upload(image, accessToken: accessToken, selectionBehavior: selectionBehavior) - return avatar + public func upload(_ image: UIImage, selectionPolicy: AvatarUploadSelectionPolicy, accessToken: String) async throws -> AvatarDetails { + try await upload(image, accessToken: accessToken, selectionPolicy: selectionPolicy) } /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws @@ -69,12 +76,12 @@ public struct AvatarService: Sendable { /// - avatarSelection: How to handle avatar selection after uploading a new avatar /// - Returns: An asynchronously-delivered `Avatar` instance, containing data of the newly created avatar. @discardableResult - private func upload(_ image: UIImage, accessToken: String, selectionBehavior: AvatarSelection) async throws -> Avatar { + private func upload(_ image: UIImage, accessToken: String, selectionPolicy: AvatarUploadSelectionPolicy) async throws -> Avatar { do { let (data, _) = try await imageUploader.uploadImage( image.squared(), accessToken: accessToken, - avatarSelection: selectionBehavior, + avatarSelectionPolicy: selectionPolicy, additionalHTTPHeaders: nil ) let avatar: Avatar = try data.decode() diff --git a/Sources/Gravatar/Network/Services/ImageUploadService.swift b/Sources/Gravatar/Network/Services/ImageUploadService.swift index 0b22237f..e256e21e 100644 --- a/Sources/Gravatar/Network/Services/ImageUploadService.swift +++ b/Sources/Gravatar/Network/Services/ImageUploadService.swift @@ -11,7 +11,27 @@ struct ImageUploadService: ImageUploader { self.client = URLSessionHTTPClient(urlSession: urlSession) } + func uploadImage( + _ image: UIImage, + accessToken: String, + avatarSelectionPolicy selectionPolicy: AvatarUploadSelectionPolicy, + additionalHTTPHeaders: [HTTPHeaderField]? + ) async throws -> (data: Data, response: HTTPURLResponse) { + guard let data: Data = { + if #available(iOS 17.0, *) { + image.heicData() + } else { + image.jpegData(compressionQuality: 0.8) + } + }() else { + throw ImageUploadError.cannotConvertImageIntoData + } + + return try await uploadImage(data: data, accessToken: accessToken, selectionPolicy: selectionPolicy, additionalHTTPHeaders: additionalHTTPHeaders) + } + @discardableResult + @available(*, deprecated, message: "Use `uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)` instead.") func uploadImage( _ image: UIImage, accessToken: String, @@ -28,20 +48,25 @@ struct ImageUploadService: ImageUploader { throw ImageUploadError.cannotConvertImageIntoData } - return try await uploadImage(data: data, accessToken: accessToken, avatarSelection: avatarSelection, additionalHTTPHeaders: additionalHTTPHeaders) + return try await uploadImage( + data: data, + accessToken: accessToken, + selectionPolicy: avatarSelection.map(), + additionalHTTPHeaders: additionalHTTPHeaders + ) } private func uploadImage( data: Data, accessToken: String, - avatarSelection: AvatarSelection, + selectionPolicy: AvatarUploadSelectionPolicy, additionalHTTPHeaders: [HTTPHeaderField]? ) async throws -> (Data, HTTPURLResponse) { let boundary = "\(UUID().uuidString)" let request = URLRequest.imageUploadRequest( with: boundary, additionalHTTPHeaders: additionalHTTPHeaders, - selectionBehavior: avatarSelection + selectionPolicy: selectionPolicy ).settingAuthorizationHeaderField(with: accessToken) let body = imageUploadBody(with: data, boundary: boundary) @@ -89,9 +114,9 @@ extension URLRequest { fileprivate static func imageUploadRequest( with boundary: String, additionalHTTPHeaders: [HTTPHeaderField]?, - selectionBehavior: AvatarSelection + selectionPolicy: AvatarUploadSelectionPolicy ) -> URLRequest { - var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionBehavior)) + var request = URLRequest(url: .avatarsURL.appendingQueryItems(for: selectionPolicy)) request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" additionalHTTPHeaders?.forEach { headerTuple in @@ -101,9 +126,9 @@ extension URLRequest { } } -extension AvatarSelection { +extension AvatarUploadSelectionPolicy { var queryItems: [URLQueryItem] { - switch self { + switch policy { case .selectUploadedImage(let profileID): [ .init(name: "select_avatar", value: "true"), diff --git a/Sources/Gravatar/Network/Services/ImageUploader.swift b/Sources/Gravatar/Network/Services/ImageUploader.swift index bdcbe54d..08eab6a9 100644 --- a/Sources/Gravatar/Network/Services/ImageUploader.swift +++ b/Sources/Gravatar/Network/Services/ImageUploader.swift @@ -14,10 +14,28 @@ protocol ImageUploader: Sendable { /// - additionalHTTPHeaders: Additional headers to add. /// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task. @discardableResult + @available(*, deprecated, renamed: "uploadImage(_:accessToken:avatarSelectionPolicy:additionalHTTPHeaders:)") func uploadImage( _ image: UIImage, accessToken: String, avatarSelection: AvatarSelection, additionalHTTPHeaders: [HTTPHeaderField]? ) async throws -> (data: Data, response: HTTPURLResponse) + + /// Uploads an image to be used as the user's Gravatar profile image, and returns the `URLResponse` of the network tasks asynchronously. Throws + /// `ImageUploadError`. + /// - Parameters: + /// - image: The image to be uploaded. + /// - email: The user email account. + /// - accessToken: The authentication token for the user. + /// - avatarSelectionPolicy: How to handle avatar selection after uploading a new avatar + /// - additionalHTTPHeaders: Additional headers to add. + /// - Returns: An asynchronously-delivered `URLResponse` instance, containing the response of the upload network task. + @discardableResult + func uploadImage( + _ image: UIImage, + accessToken: String, + avatarSelectionPolicy: AvatarUploadSelectionPolicy, + additionalHTTPHeaders: [HTTPHeaderField]? + ) async throws -> (data: Data, response: HTTPURLResponse) } diff --git a/Sources/Gravatar/Options/AvatarSelection.swift b/Sources/Gravatar/Options/AvatarSelection.swift index 4f606002..5cfd2868 100644 --- a/Sources/Gravatar/Options/AvatarSelection.swift +++ b/Sources/Gravatar/Options/AvatarSelection.swift @@ -1,14 +1,79 @@ /// Defines how to handle avatar selection after uploading a new avatar +@available(*, deprecated, renamed: "AvatarUploadSelectionPolicy") public enum AvatarSelection: Equatable, Sendable { case preserveSelection - case selectUploadedImage(for: ProfileIdentifier) - case selectUploadedImageIfNoneSelected(for: ProfileIdentifier) + case selectUploadedImage(for: Email) + case selectUploadedImageIfNoneSelected(for: Email) - public static func allCases(for profileID: ProfileIdentifier) -> [AvatarSelection] { + public static func allCases(for email: Email) -> [AvatarSelection] { + [ + .preserveSelection, + .selectUploadedImage(for: email), + .selectUploadedImageIfNoneSelected(for: email), + ] + } + + func map() -> AvatarUploadSelectionPolicy { + switch self { + case .preserveSelection: + .preserveSelection + case .selectUploadedImage(let email): + .selectUploadedImage(for: .email(email)) + case .selectUploadedImageIfNoneSelected(let email): + .selectUploadedImageIfNoneSelected(for: .email(email)) + } + } +} + +/// Determines if the uploaded image should be set as the avatar for the profile. +public struct AvatarUploadSelectionPolicy: Equatable, Sendable { + enum SelectionPolicy: Equatable, Sendable { + case preserveSelection + case selectUploadedImage(for: ProfileIdentifier) + case selectUploadedImageIfNoneSelected(for: ProfileIdentifier) + } + + let policy: SelectionPolicy + + // Do not set the uploaded image as the avatar for the profile. + public static let preserveSelection: AvatarUploadSelectionPolicy = .init(policy: .preserveSelection) + // Set the uploaded image as the avatar for the profile. + public static func selectUploadedImage(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { + .init(policy: .selectUploadedImage(for: profileID)) + } + // Set the uploaded image as the avatar for the profile only if there was no other avatar previously selected. + public static func selectUploadedImageIfNoneSelected(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { + .init(policy: .selectUploadedImageIfNoneSelected(for: profileID)) + } + + /// A list of all policies available, set up with the given profile ID. + /// - Parameter profileID: The user's profile ID + /// - Returns: A list of all policies available + public static func allCases(for profileID: ProfileIdentifier) -> [AvatarUploadSelectionPolicy] { [ .preserveSelection, .selectUploadedImage(for: profileID), .selectUploadedImageIfNoneSelected(for: profileID), ] } + + public var isPreserveSelectionPolicy: Bool { + policy == .preserveSelection + } + + public var isSelectUploadedImagePolicy: Bool { + switch policy { + case .selectUploadedImage: + true + default: false + } + } + + public var isSelectUploadedImageIfNoneSelectedPolicy: Bool { + switch policy { + case .selectUploadedImageIfNoneSelected: + true + default: false + } + } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index b23590fc..09077b6c 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -307,8 +307,8 @@ class AvatarPickerViewModel: ObservableObject { do { let avatar = try await avatarService.upload( squareImage, - accessToken: accessToken, - selectionBehavior: .selectUploadedImageIfNoneSelected(for: .email(email)) + selectionPolicy: .selectUploadedImageIfNoneSelected(for: .email(email)), + accessToken: accessToken ) ImageCache.shared.setEntry(.ready(squareImage), for: avatar.imageURL) diff --git a/Tests/GravatarTests/AvatarServiceTests.swift b/Tests/GravatarTests/AvatarServiceTests.swift index 2ec15d6b..a49ebbb0 100644 --- a/Tests/GravatarTests/AvatarServiceTests.swift +++ b/Tests/GravatarTests/AvatarServiceTests.swift @@ -25,9 +25,9 @@ final class AvatarServiceTests: XCTestCase { let sessionMock = URLSessionMock(returnData: Bundle.imageUploadJsonData!, response: successResponse) let service = avatarService(with: sessionMock) - let avatar = try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken") + let avatar = try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken") - XCTAssertEqual(avatar.id, "6f3eac1c67f970f2a0c2ea8") + XCTAssertEqual(avatar.imageID, "6f3eac1c67f970f2a0c2ea8") let request = await sessionMock.request XCTAssertEqual(request?.url?.absoluteString, "https://api.gravatar.com/v3/me/avatars?select_avatar=false") @@ -44,7 +44,7 @@ final class AvatarServiceTests: XCTestCase { let service = avatarService(with: sessionMock) do { - try await service.upload(ImageHelper.testImage, selectionBehavior: .preserveSelection, accessToken: "AccessToken") + try await service.upload(ImageHelper.testImage, selectionPolicy: .preserveSelection, accessToken: "AccessToken") XCTFail("This should throw an error") } catch ImageUploadError.responseError(reason: let reason) where reason.httpStatusCode == responseCode { // Expected error has occurred. @@ -59,7 +59,7 @@ final class AvatarServiceTests: XCTestCase { let service = avatarService(with: sessionMock) do { - try await service.upload(UIImage(), selectionBehavior: .preserveSelection, accessToken: "AccessToken") + try await service.upload(UIImage(), selectionPolicy: .preserveSelection, accessToken: "AccessToken") XCTFail("This should throw an error") } catch let error as ImageUploadError { XCTAssertEqual(error, ImageUploadError.cannotConvertImageIntoData) From 356d43dacd780d6476f503e0a4c0af090fd83762 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Thu, 3 Jul 2025 08:03:47 +0200 Subject: [PATCH 3/3] Format --- Sources/Gravatar/Options/AvatarSelection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Gravatar/Options/AvatarSelection.swift b/Sources/Gravatar/Options/AvatarSelection.swift index 5cfd2868..01f1cad6 100644 --- a/Sources/Gravatar/Options/AvatarSelection.swift +++ b/Sources/Gravatar/Options/AvatarSelection.swift @@ -41,6 +41,7 @@ public struct AvatarUploadSelectionPolicy: Equatable, Sendable { public static func selectUploadedImage(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { .init(policy: .selectUploadedImage(for: profileID)) } + // Set the uploaded image as the avatar for the profile only if there was no other avatar previously selected. public static func selectUploadedImageIfNoneSelected(for profileID: ProfileIdentifier) -> AvatarUploadSelectionPolicy { .init(policy: .selectUploadedImageIfNoneSelected(for: profileID))