From 39805d080e14992bde32f525d3b97915826434c5 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 3 Aug 2026 22:02:08 +0300 Subject: [PATCH] Fix broken avatar aspect ratio in the share sheet and notifications The media repo serves thumbnails with the scale method, so avatars keep their source aspect ratio. The system stretches whatever INImage we donate to fill a circle, distorting any non-square avatar in the share sheet's conversation suggestions (and notifications, which are donated the same image). Centre-crop donated avatars to a square before handing them to the system, in both the timeline's send message intent donation and the NSE's communication context. --- ElementX/Sources/Other/Avatars.swift | 23 +++++++++++++++++++ .../TimelineController.swift | 5 ++-- NSE/Sources/NotificationContentBuilder.swift | 4 +++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 29711a08c..39f4857d8 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +import UIKit enum Avatars { enum Size { @@ -38,6 +39,28 @@ enum Avatars { } } + /// Media repo thumbnails are scaled, not cropped, so they keep the source's aspect + /// ratio. System UI (share sheet suggestions, notifications) stretches non-square + /// images to fill its circle, so centre-crop to a square before donating them. + nonisolated static func squareAvatarImageData(from data: Data) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + let side = min(image.size.width, image.size.height) + guard side > 0 else { return nil } + + if image.size.width == image.size.height { + return data + } + + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + + return UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format) + .pngData { _ in + image.draw(at: CGPoint(x: (side - image.size.width) / 2, y: (side - image.size.height) / 2)) + } + } + @MainActor static func generatePlaceholderAvatarImageData(name: String, id: String, size: CGSize) -> Data? { let image = PlaceholderAvatarImage(name: name, contentID: id) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index a568d958f..c22dc19ae 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -650,8 +650,9 @@ class TimelineController: TimelineControllerProtocol { } if let avatarURL, let mediaSource = try? MediaSourceProxy(url: avatarURL, mimeType: nil) { - if case let .success(avatarData) = await mediaProvider.loadThumbnailForSource(source: mediaSource, size: .init(width: 100, height: 100)) { - sendMessageIntent.setImage(INImage(imageData: avatarData), forParameterNamed: \.speakableGroupName) + if case let .success(avatarData) = await mediaProvider.loadThumbnailForSource(source: mediaSource, size: .init(width: 100, height: 100)), + let squareAvatarData = Avatars.squareAvatarImageData(from: avatarData) { + sendMessageIntent.setImage(INImage(imageData: squareAvatarData), forParameterNamed: \.speakableGroupName) } else { addPlacehoder() } diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index c4ed82f6b..7bb218394 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -259,7 +259,9 @@ nonisolated struct NotificationContentBuilder { let mediaSource = icon.mediaSource { switch await mediaProvider.loadThumbnailForSource(source: mediaSource, size: .init(width: 100, height: 100)) { case .success(let data): - fetchedImage = INImage(imageData: data) + if let squareData = Avatars.squareAvatarImageData(from: data) { + fetchedImage = INImage(imageData: squareData) + } case .failure(let error): MXLog.error("Couldn't add sender icon: \(error)") }