From abbd7dfda84225e12cd74c6a30d8e9be89bd9131 Mon Sep 17 00:00:00 2001 From: hansimgamr Date: Tue, 11 Aug 2026 20:25:20 -0400 Subject: [PATCH 1/4] feat(contacts): crop profile picture before saving Adds a pan/pinch crop step between picking a photo (library or file import) and saving it as a contact's avatar, instead of using the picked image as-is. Crops via UIImage.draw(in:) rather than raw CGImage cropping so EXIF-rotated photos crop the same region shown in the on-screen preview. --- MC1/Resources/Generated/L10n.swift | 8 + .../Localization/en.lproj/Contacts.strings | 9 ++ MC1/Views/Components/AvatarCropView.swift | 151 ++++++++++++++++++ MC1/Views/Contacts/ContactDetailView.swift | 27 +++- 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 MC1/Views/Components/AvatarCropView.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index d3195ebd9..18b23ea4e 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1336,6 +1336,14 @@ public enum L10n { } } public enum Avatar { + public enum Crop { + /// Location: AvatarCropView.swift - Purpose: Crop screen cancel button + public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel") + /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button + public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") + /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title + public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") + } /// Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick an image file public static let chooseFile = L10n.tr("Contacts", "contacts.detail.avatar.chooseFile", fallback: "Choose File...") /// Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick a photo from the photo library diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 795c09d4e..bde960977 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -284,6 +284,15 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */ "contacts.detail.avatar.invalidImage" = "That file couldn't be used as a profile picture."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Move and Scale"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen cancel button */ +"contacts.detail.avatar.crop.cancel" = "Cancel"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Choose"; + /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Saving photo"; diff --git a/MC1/Views/Components/AvatarCropView.swift b/MC1/Views/Components/AvatarCropView.swift new file mode 100644 index 000000000..d81421d64 --- /dev/null +++ b/MC1/Views/Components/AvatarCropView.swift @@ -0,0 +1,151 @@ +import SwiftUI +import UIKit + +/// Lets the user pan and zoom a picked image within a circular guide before it's +/// saved as a contact's profile picture, and crops it down to just that region. +struct AvatarCropView: View { + let image: UIImage + let onCancel: () -> Void + let onComplete: (UIImage) -> Void + + /// Side length, in points, of the square crop guide shown on screen. + private let cropSize: CGFloat = 300 + + @GestureState private var dragTranslation: CGSize = .zero + @GestureState private var pinchDelta: CGFloat = 1 + + @State private var offset: CGSize = .zero + @State private var scale: CGFloat = 1 + + var body: some View { + NavigationStack { + GeometryReader { _ in + ZStack { + Color.black.ignoresSafeArea() + + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: baseDisplaySize.width, height: baseDisplaySize.height) + .scaleEffect(scale * pinchDelta) + .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) + .frame(width: cropSize, height: cropSize) + .clipped() + .contentShape(Rectangle()) + .gesture(dragGesture) + .simultaneousGesture(magnificationGesture) + + Circle() + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .allowsHitTesting(false) + + dimmingMask + .allowsHitTesting(false) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.cancel, action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.choose) { + onComplete(croppedImage()) + } + } + } + } + } + + /// A full-bleed dark scrim with a circular window cut out over the crop guide, + /// drawn with an even-odd fill so the two shapes combine into a single hole-punched path. + private var dimmingMask: some View { + GeometryReader { proxy in + Path { path in + path.addRect(CGRect(origin: .zero, size: proxy.size)) + let circleRect = CGRect( + x: (proxy.size.width - cropSize) / 2, + y: (proxy.size.height - cropSize) / 2, + width: cropSize, + height: cropSize + ) + path.addEllipse(in: circleRect) + } + .fill(Color.black.opacity(0.5), style: FillStyle(eoFill: true)) + } + } + + /// The image's size, in points, when scaled (via `.scaledToFill`) to just cover the crop square. + private var baseDisplaySize: CGSize { + let imageSize = image.size + guard imageSize.width > 0, imageSize.height > 0 else { return CGSize(width: cropSize, height: cropSize) } + let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height) + return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale) + } + + private var dragGesture: some Gesture { + DragGesture() + .updating($dragTranslation) { value, state, _ in + state = value.translation + } + .onEnded { value in + offset = clampedOffset( + CGSize(width: offset.width + value.translation.width, height: offset.height + value.translation.height) + ) + } + } + + private var magnificationGesture: some Gesture { + MagnificationGesture() + .updating($pinchDelta) { value, state, _ in + state = value + } + .onEnded { value in + scale = min(max(scale * value, 1), 4) + offset = clampedOffset(offset) + } + } + + /// Keeps the displayed image covering the crop square at all times, regardless of pan/zoom. + private func clampedOffset(_ proposed: CGSize) -> CGSize { + let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) + let maxOffsetX = max(0, (displayedSize.width - cropSize) / 2) + let maxOffsetY = max(0, (displayedSize.height - cropSize) / 2) + return CGSize( + width: min(max(proposed.width, -maxOffsetX), maxOffsetX), + height: min(max(proposed.height, -maxOffsetY), maxOffsetY) + ) + } + + /// Renders the portion of the source image currently visible inside the crop guide. + /// + /// Draws via `UIImage.draw(in:)` rather than cropping `cgImage` directly, so that EXIF + /// orientation (e.g. a portrait photo shot with a rotated sensor) is honored exactly as + /// it is in the on-screen preview, which uses the same point-space geometry. + private func croppedImage() -> UIImage { + let outputSide: CGFloat = 1024 + let renderScale = outputSide / cropSize + + let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) + let imageOrigin = CGPoint( + x: (cropSize - displayedSize.width) / 2 + offset.width, + y: (cropSize - displayedSize.height) / 2 + offset.height + ) + + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: outputSide, height: outputSide), format: format) + return renderer.image { _ in + let drawRect = CGRect( + x: imageOrigin.x * renderScale, + y: imageOrigin.y * renderScale, + width: displayedSize.width * renderScale, + height: displayedSize.height * renderScale + ) + image.draw(in: drawRect) + } + } +} diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 59d67c6f1..f683deb3e 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -105,6 +105,8 @@ struct ContactDetailView: View { @State private var showAvatarFileImporter = false @State private var avatarPickerItem: PhotosPickerItem? @State private var isSavingAvatar = false + @State private var avatarImageToCrop: UIImage? + @State private var showAvatarCropSheet = false init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { self.contact = contact @@ -276,6 +278,18 @@ struct ContactDetailView: View { .fileImporter(isPresented: $showAvatarFileImporter, allowedContentTypes: [.image]) { result in Task { await handleAvatarFileImport(result) } } + .fullScreenCover(isPresented: $showAvatarCropSheet) { + if let avatarImageToCrop { + AvatarCropView( + image: avatarImageToCrop, + onCancel: { showAvatarCropSheet = false }, + onComplete: { cropped in + showAvatarCropSheet = false + Task { await saveAvatar(data: cropped.jpegData(compressionQuality: 0.9) ?? Data()) } + } + ) + } + } .task { pathViewModel.configure( dataStore: { appState.services?.dataStore }, @@ -535,7 +549,7 @@ struct ContactDetailView: View { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } - await saveAvatar(data: data) + presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -548,7 +562,7 @@ struct ContactDetailView: View { defer { if didAccess { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) - await saveAvatar(data: data) + presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -557,6 +571,15 @@ struct ContactDetailView: View { } } + private func presentCropSheet(data: Data) { + guard let image = UIImage(data: data) else { + errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage + return + } + avatarImageToCrop = image + showAvatarCropSheet = true + } + private func saveAvatar(data: Data) async { isSavingAvatar = true let processed = await Task.detached(priority: .userInitiated) { From 57aa27dddb606c54b04aba3b8a8a5d1ce4a4932c Mon Sep 17 00:00:00 2001 From: hansimgamr Date: Tue, 11 Aug 2026 20:55:56 -0400 Subject: [PATCH 2/4] fix: regenerate L10n.swift with swiftgen The committed generated file didn't match current swiftgen output ordering, tripping the codegen CI check. --- MC1/Resources/Generated/L10n.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 18b23ea4e..588c04521 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1336,14 +1336,6 @@ public enum L10n { } } public enum Avatar { - public enum Crop { - /// Location: AvatarCropView.swift - Purpose: Crop screen cancel button - public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel") - /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button - public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") - /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title - public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") - } /// Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick an image file public static let chooseFile = L10n.tr("Contacts", "contacts.detail.avatar.chooseFile", fallback: "Choose File...") /// Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick a photo from the photo library @@ -1356,6 +1348,14 @@ public enum L10n { public static let removePhoto = L10n.tr("Contacts", "contacts.detail.avatar.removePhoto", fallback: "Remove Photo") /// Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved public static let savingAnnouncement = L10n.tr("Contacts", "contacts.detail.avatar.savingAnnouncement", fallback: "Saving photo") + public enum Crop { + /// Location: AvatarCropView.swift - Purpose: Crop screen cancel button + public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel") + /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button + public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") + /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title + public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") + } } public enum Error { /// Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error From 2ea581c3432b85a292ba092633ce3fe53d2bca12 Mon Sep 17 00:00:00 2001 From: hansimgamr Date: Tue, 11 Aug 2026 21:06:47 -0400 Subject: [PATCH 3/4] chore: retrigger CI From bcd04da5205ba5ed8d4035e5a7299a09f96173c4 Mon Sep 17 00:00:00 2001 From: romeo Date: Sat, 15 Aug 2026 12:37:35 -0400 Subject: [PATCH 4/4] fix(contacts): address avatar crop review feedback Fixes the modal-presentation race between the picker/importer sheets and the crop cover, decodes and downsamples off the main actor via ImageIO instead of full-resolution UIImage(data:), removes the redundant JPEG re-encode by passing the cropped UIImage straight to the existing avatar processor, swaps the deprecated MagnificationGesture for MagnifyGesture with a clamped live pinch value, and drops an unused outer GeometryReader. Co-Authored-By: Claude Sonnet 5 --- MC1/Views/Components/AvatarCropView.swift | 62 +++++++------- MC1/Views/Contacts/ContactDetailView.swift | 96 ++++++++++++++++------ 2 files changed, 107 insertions(+), 51 deletions(-) diff --git a/MC1/Views/Components/AvatarCropView.swift b/MC1/Views/Components/AvatarCropView.swift index d81421d64..6cf0e5cf3 100644 --- a/MC1/Views/Components/AvatarCropView.swift +++ b/MC1/Views/Components/AvatarCropView.swift @@ -11,6 +11,10 @@ struct AvatarCropView: View { /// Side length, in points, of the square crop guide shown on screen. private let cropSize: CGFloat = 300 + /// Allowed zoom range, applied both live during a pinch and once it ends. + private let minScale: CGFloat = 1 + private let maxScale: CGFloat = 4 + @GestureState private var dragTranslation: CGSize = .zero @GestureState private var pinchDelta: CGFloat = 1 @@ -19,32 +23,30 @@ struct AvatarCropView: View { var body: some View { NavigationStack { - GeometryReader { _ in - ZStack { - Color.black.ignoresSafeArea() - - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: baseDisplaySize.width, height: baseDisplaySize.height) - .scaleEffect(scale * pinchDelta) - .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) - .frame(width: cropSize, height: cropSize) - .clipped() - .contentShape(Rectangle()) - .gesture(dragGesture) - .simultaneousGesture(magnificationGesture) - - Circle() - .strokeBorder(Color.white, lineWidth: 2) - .frame(width: cropSize, height: cropSize) - .allowsHitTesting(false) - - dimmingMask - .allowsHitTesting(false) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + ZStack { + Color.black.ignoresSafeArea() + + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: baseDisplaySize.width, height: baseDisplaySize.height) + .scaleEffect(clampedScale(scale * pinchDelta)) + .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) + .frame(width: cropSize, height: cropSize) + .clipped() + .contentShape(Rectangle()) + .gesture(dragGesture) + .simultaneousGesture(magnificationGesture) + + Circle() + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .allowsHitTesting(false) + + dimmingMask + .allowsHitTesting(false) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -99,16 +101,20 @@ struct AvatarCropView: View { } private var magnificationGesture: some Gesture { - MagnificationGesture() + MagnifyGesture() .updating($pinchDelta) { value, state, _ in - state = value + state = value.magnification } .onEnded { value in - scale = min(max(scale * value, 1), 4) + scale = clampedScale(scale * value.magnification) offset = clampedOffset(offset) } } + private func clampedScale(_ proposed: CGFloat) -> CGFloat { + min(max(proposed, minScale), maxScale) + } + /// Keeps the displayed image covering the crop square at all times, regardless of pan/zoom. private func clampedOffset(_ proposed: CGSize) -> CGSize { let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index f683deb3e..7c330106c 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -73,6 +73,12 @@ struct ContactDetailView: View { } } + /// Wraps a decoded avatar image so `fullScreenCover(item:)` can't present an empty cover. + private struct AvatarCropRequest: Identifiable { + let id = UUID() + let image: UIImage + } + @State private var currentContact: ContactDTO @State private var nickname = "" @State private var isEditingNickname = false @@ -105,8 +111,10 @@ struct ContactDetailView: View { @State private var showAvatarFileImporter = false @State private var avatarPickerItem: PhotosPickerItem? @State private var isSavingAvatar = false - @State private var avatarImageToCrop: UIImage? - @State private var showAvatarCropSheet = false + /// A decoded image waiting for the photo picker / file importer sheet that produced it + /// to finish dismissing, so the crop cover isn't presented while another is still animating out. + @State private var pendingCropImage: UIImage? + @State private var cropRequest: AvatarCropRequest? init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { self.contact = contact @@ -275,20 +283,24 @@ struct ContactDetailView: View { .onChange(of: avatarPickerItem) { _, newItem in Task { await loadPickedAvatarPhoto(newItem) } } + .onChange(of: showAvatarPhotosPicker) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } .fileImporter(isPresented: $showAvatarFileImporter, allowedContentTypes: [.image]) { result in Task { await handleAvatarFileImport(result) } } - .fullScreenCover(isPresented: $showAvatarCropSheet) { - if let avatarImageToCrop { - AvatarCropView( - image: avatarImageToCrop, - onCancel: { showAvatarCropSheet = false }, - onComplete: { cropped in - showAvatarCropSheet = false - Task { await saveAvatar(data: cropped.jpegData(compressionQuality: 0.9) ?? Data()) } - } - ) - } + .onChange(of: showAvatarFileImporter) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } + .fullScreenCover(item: $cropRequest) { request in + AvatarCropView( + image: request.image, + onCancel: { cropRequest = nil }, + onComplete: { cropped in + cropRequest = nil + Task { await saveAvatar(image: cropped) } + } + ) } .task { pathViewModel.configure( @@ -549,7 +561,7 @@ struct ContactDetailView: View { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } - presentCropSheet(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -562,7 +574,7 @@ struct ContactDetailView: View { defer { if didAccess { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) - presentCropSheet(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -571,19 +583,36 @@ struct ContactDetailView: View { } } - private func presentCropSheet(data: Data) { - guard let image = UIImage(data: data) else { + /// Decodes off the main actor and downsamples to a display-sized bound before the crop + /// screen ever sees the image, so a 12-48MP camera photo doesn't hitch the UI or hold + /// its full-resolution bitmap in memory while cropping. + private func presentCropSheet(data: Data) async { + let decoded = await Task.detached(priority: .userInitiated) { + Self.downsampledImage(data: data, maxPixelSize: Self.cropMaxPixelSize) + }.value + guard let image = decoded else { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } - avatarImageToCrop = image - showAvatarCropSheet = true + pendingCropImage = image + // The picker/importer sheet may still be animating its dismissal; onChange above + // presents the pending image once it reports fully closed. If it's already closed + // by the time decoding finishes, present immediately. + if !showAvatarPhotosPicker, !showAvatarFileImporter { + presentPendingCropIfReady() + } + } + + private func presentPendingCropIfReady() { + guard let image = pendingCropImage else { return } + pendingCropImage = nil + cropRequest = AvatarCropRequest(image: image) } - private func saveAvatar(data: Data) async { + private func saveAvatar(image: UIImage) async { isSavingAvatar = true let processed = await Task.detached(priority: .userInitiated) { - Self.processAvatarImage(data: data) + Self.processAvatarImage(image: image) }.value guard let processed else { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage @@ -613,10 +642,31 @@ struct ContactDetailView: View { isSavingAvatar = false } + /// Max pixel dimension the crop screen decodes and displays at; well above the 300pt + /// on-screen guide to stay sharp under the pinch zoom's 4x cap, but bounded so a raw + /// camera photo can't hold its full-resolution bitmap in memory while cropping. + private nonisolated static let cropMaxPixelSize: CGFloat = 1024 + + /// Decodes and downsamples via ImageIO instead of `UIImage(data:)`, so a large source + /// image is never fully decoded into memory. Mirrors `ImageURLDetector.downsampledImage(from:)`. + private nonisolated static func downsampledImage(data: Data, maxPixelSize: CGFloat) -> UIImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { + return nil + } + return UIImage(cgImage: cgImage) + } + /// Downscales to a max 512pt dimension and re-encodes as JPEG so avatars stay small in the store. /// `nonisolated` so it can run on a background thread via `Task.detached` in `saveAvatar`. - private nonisolated static func processAvatarImage(data: Data) -> Data? { - guard let image = UIImage(data: data) else { return nil } + private nonisolated static func processAvatarImage(image: UIImage) -> Data? { let maxDimension: CGFloat = 512 let scale = min(1, maxDimension / max(image.size.width, image.size.height)) let targetSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)