From 5ec3d674b6a5b86866b57627bde055ace2532f27 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Thu, 12 Mar 2026 18:20:48 +0000 Subject: [PATCH 1/6] fix: add NSCache image cache to prevent flicker on re-render NSTableView recycles rows, which resets @State and causes images to flash back to placeholder. Add an NSCache-based image cache to MatrixClient with a synchronous cachedImage() lookup. AvatarImage, MessageImageView, and MatrixImageView all pre-populate their @State from the cache in init, so the first frame already has the image. Also suppresses implicit animation on the avatar swap. MatrixImageView now shows a gray placeholder immediately when mediaSource is nil instead of a perpetual spinner. --- Mactrix/Models/MatrixClient.swift | 18 ++++++++++++++- Mactrix/Views/ChatView/MessageImageView.swift | 19 ++++++++++++++++ Mactrix/Views/MatrixImageView.swift | 22 ++++++++++++++++++- MactrixLibrary/Sources/UI/AvatarImage.swift | 13 +++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index be80023..d4f7afa 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -242,7 +242,19 @@ extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { } extension MatrixClient: UI.ImageLoader { + static let imageCache = NSCache() + + func cachedImage(matrixUrl: String) -> Image? { + guard let nsImage = Self.imageCache.object(forKey: NSString(string: matrixUrl)) else { return nil } + return Image(nsImage: nsImage) + } + func loadImage(matrixUrl: String, size: CGSize?) async throws -> Image? { + let cacheKey = NSString(string: matrixUrl) + if let cached = Self.imageCache.object(forKey: cacheKey) { + return Image(nsImage: cached) + } + let imageData: Data if let size { let width = UInt64(size.width) @@ -253,7 +265,11 @@ extension MatrixClient: UI.ImageLoader { } do { - return try imageData.toOrientedImage(contentType: imageData.computeMimeType()) + let image = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) + if let nsImage = NSImage(data: imageData) { + Self.imageCache.setObject(nsImage, forKey: cacheKey) + } + return image } catch { Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") throw error diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index 3b957d0..7279d99 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -14,6 +14,14 @@ struct MessageImageView: View { @State private var image: Image? = nil @State private var errorMessage: String? = nil + init(content: ImageMessageContent) { + self.content = content + if let cached = MatrixClient.imageCache.object(forKey: NSString(string: content.source.url())) { + self._image = State(initialValue: Image(nsImage: cached)) + self._imageData = State(initialValue: cached.tiffRepresentation) + } + } + var aspectRatio: CGFloat? { guard let info = content.info, let height = info.height, @@ -81,15 +89,26 @@ struct MessageImageView: View { .frame(maxHeight: maxHeight) .aspectRatio(aspectRatio, contentMode: .fit) .task(id: content.source.url(), priority: .utility) { + guard image == nil else { return } guard let matrixClient = appState.matrixClient else { errorMessage = "Matrix client not available" return } + let cacheKey = NSString(string: content.source.url()) + if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + imageData = cached.tiffRepresentation + image = Image(nsImage: cached) + return + } + do { let data = try await matrixClient.client.getMediaContent(mediaSource: content.source) imageData = data image = try data.toOrientedImage(contentType: contentType) + if let nsImage = NSImage(data: data) { + MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey) + } } catch { errorMessage = error.localizedDescription } diff --git a/Mactrix/Views/MatrixImageView.swift b/Mactrix/Views/MatrixImageView.swift index 180379b..cf8fe68 100644 --- a/Mactrix/Views/MatrixImageView.swift +++ b/Mactrix/Views/MatrixImageView.swift @@ -10,6 +10,15 @@ struct MatrixImageView: View { @State private var image: Image? = nil @State private var errorMessage: String? = nil + init(mediaSource: MediaSource?, mimeType: String?) { + self.mediaSource = mediaSource + self.mimeType = mimeType + if let url = mediaSource?.url(), + let cached = MatrixClient.imageCache.object(forKey: NSString(string: url)) { + self._image = State(initialValue: Image(nsImage: cached)) + } + } + @ViewBuilder var content: some View { if let image { @@ -19,6 +28,9 @@ struct MatrixImageView: View { ContentUnavailableView("Error loading image", image: "photo.badge.exclamationmark") Text(errorMessage) } + } else if mediaSource == nil { + Rectangle() + .fill(Color.gray.opacity(0.3)) } else { ProgressView { Text("Fetching image") @@ -29,12 +41,17 @@ struct MatrixImageView: View { var body: some View { content .task(id: mediaSource?.url(), priority: .utility) { + guard image == nil else { return } guard let matrixClient = appState.matrixClient else { errorMessage = "Matrix client not available" return } - guard let mediaSource else { + guard let mediaSource else { return } + + let cacheKey = NSString(string: mediaSource.url()) + if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + image = Image(nsImage: cached) return } @@ -42,6 +59,9 @@ struct MatrixImageView: View { let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) let contentType = mimeType.flatMap { UTType(mimeType: $0) } image = try await Image(importing: data, contentType: contentType) + if let nsImage = NSImage(data: data) { + MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey) + } } catch { errorMessage = error.localizedDescription } diff --git a/MactrixLibrary/Sources/UI/AvatarImage.swift b/MactrixLibrary/Sources/UI/AvatarImage.swift index a51a3f8..06fb1c3 100644 --- a/MactrixLibrary/Sources/UI/AvatarImage.swift +++ b/MactrixLibrary/Sources/UI/AvatarImage.swift @@ -5,6 +5,7 @@ import SwiftUI @MainActor public protocol ImageLoader { func loadImage(matrixUrl: String, size: CGSize?) async throws -> Image? + func cachedImage(matrixUrl: String) -> Image? } public struct AvatarImage: View { @@ -22,6 +23,9 @@ public struct AvatarImage: View { self.avatarUrl = avatarUrl self.imageLoader = imageLoader self.placeholder = placeholder + if let avatarUrl, let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { + self._avatar = State(initialValue: cached) + } } public init( @@ -47,12 +51,21 @@ public struct AvatarImage: View { public var body: some View { imageOrPlaceholder .scaledToFill() + .transaction { $0.animation = nil } .task(id: avatarUrl, priority: .utility) { guard let avatarUrl else { avatar = nil return } + // Check cache first (handles cell reuse with stale @State) + if let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { + avatar = cached + return + } + + avatar = nil + do { avatar = try await imageLoader?.loadImage(matrixUrl: avatarUrl, size: nil) } catch { From 04f8f8936bb151d4ebf3eefa6996ff4b63e093e2 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 13 Mar 2026 16:05:34 +0000 Subject: [PATCH 2/6] Add cost parameter and use MediaSource internally - Add cost parameter to NSCache setObject calls for memory management - Convert URL to MediaSource once in MatrixClient.loadImage() - Update .gitignore to exclude build/ directory --- .gitignore | 1 + Mactrix/Models/MatrixClient.swift | 8 +++++--- Mactrix/Views/ChatView/MessageImageView.swift | 2 +- Mactrix/Views/MatrixImageView.swift | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a1711ef..b608a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ xcuserdata/ .DS_Store +build/ diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index d4f7afa..7e84029 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -255,19 +255,21 @@ extension MatrixClient: UI.ImageLoader { return Image(nsImage: cached) } + let mediaSource = try MediaSource.fromUrl(url: matrixUrl) + let imageData: Data if let size { let width = UInt64(size.width) let height = UInt64(size.height) - imageData = try await client.getMediaThumbnail(mediaSource: .fromUrl(url: matrixUrl), width: UInt64(width), height: UInt64(height)) + imageData = try await client.getMediaThumbnail(mediaSource: mediaSource, width: width, height: height) } else { - imageData = try await client.getMediaContent(mediaSource: .fromUrl(url: matrixUrl)) + imageData = try await client.getMediaContent(mediaSource: mediaSource) } do { let image = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) if let nsImage = NSImage(data: imageData) { - Self.imageCache.setObject(nsImage, forKey: cacheKey) + Self.imageCache.setObject(nsImage, forKey: cacheKey, cost: imageData.count) } return image } catch { diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index 7279d99..e41c780 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -107,7 +107,7 @@ struct MessageImageView: View { imageData = data image = try data.toOrientedImage(contentType: contentType) if let nsImage = NSImage(data: data) { - MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey) + MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) } } catch { errorMessage = error.localizedDescription diff --git a/Mactrix/Views/MatrixImageView.swift b/Mactrix/Views/MatrixImageView.swift index cf8fe68..c983f8a 100644 --- a/Mactrix/Views/MatrixImageView.swift +++ b/Mactrix/Views/MatrixImageView.swift @@ -60,7 +60,7 @@ struct MatrixImageView: View { let contentType = mimeType.flatMap { UTType(mimeType: $0) } image = try await Image(importing: data, contentType: contentType) if let nsImage = NSImage(data: data) { - MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey) + MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) } } catch { errorMessage = error.localizedDescription From d5f289e28bf8ad7905100f6093ba0a5cc49744fc Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Sat, 14 Mar 2026 14:07:01 +0000 Subject: [PATCH 3/6] refactor: make toOrientedImage return NSImage instead of SwiftUI Image --- Mactrix/Extensions/Data+Mime.swift | 6 +++--- Mactrix/Models/MatrixClient.swift | 8 +++----- Mactrix/Views/ChatView/MessageImageView.swift | 7 +++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Mactrix/Extensions/Data+Mime.swift b/Mactrix/Extensions/Data+Mime.swift index 94ac9c0..142fb3a 100644 --- a/Mactrix/Extensions/Data+Mime.swift +++ b/Mactrix/Extensions/Data+Mime.swift @@ -28,12 +28,12 @@ extension Data { } } - /// Decode image data into a SwiftUI Image, applying EXIF orientation. + /// Decode image data into an NSImage, applying EXIF orientation. /// `Image(importing:contentType:)` on macOS does not apply EXIF orientation, /// so we route through CIImage which handles it correctly. struct ImageDecodeError: Error {} - func toOrientedImage(contentType: UTType? = nil) throws -> Image { + func toOrientedImage(contentType: UTType? = nil) throws -> NSImage { guard let source = CGImageSourceCreateWithData(self as CFData, nil), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) @@ -50,6 +50,6 @@ extension Data { let rep = NSCIImageRep(ciImage: ciImage) let nsImage = NSImage(size: rep.size) nsImage.addRepresentation(rep) - return Image(nsImage: nsImage) + return nsImage } } diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index 7e84029..725a913 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -267,11 +267,9 @@ extension MatrixClient: UI.ImageLoader { } do { - let image = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) - if let nsImage = NSImage(data: imageData) { - Self.imageCache.setObject(nsImage, forKey: cacheKey, cost: imageData.count) - } - return image + let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) + Self.imageCache.setObject(nsImage, forKey: cacheKey, cost: imageData.count) + return Image(nsImage: nsImage) } catch { Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") throw error diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index e41c780..aadbb66 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -105,10 +105,9 @@ struct MessageImageView: View { do { let data = try await matrixClient.client.getMediaContent(mediaSource: content.source) imageData = data - image = try data.toOrientedImage(contentType: contentType) - if let nsImage = NSImage(data: data) { - MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) - } + let nsImage = try data.toOrientedImage(contentType: contentType) + MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) + image = Image(nsImage: nsImage) } catch { errorMessage = error.localizedDescription } From 695afbae08c82459ddf985356dc31b20c2f5047f Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Sat, 14 Mar 2026 14:13:23 +0000 Subject: [PATCH 4/6] fix: preserve original image bytes for export instead of tiffRepresentation --- Mactrix/Views/ChatView/MessageImageView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index aadbb66..a3c1ba7 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -89,7 +89,6 @@ struct MessageImageView: View { .frame(maxHeight: maxHeight) .aspectRatio(aspectRatio, contentMode: .fit) .task(id: content.source.url(), priority: .utility) { - guard image == nil else { return } guard let matrixClient = appState.matrixClient else { errorMessage = "Matrix client not available" return @@ -97,9 +96,7 @@ struct MessageImageView: View { let cacheKey = NSString(string: content.source.url()) if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { - imageData = cached.tiffRepresentation image = Image(nsImage: cached) - return } do { From 1427292da483923e4e0b222a63313ad119b00170 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Sat, 14 Mar 2026 14:14:23 +0000 Subject: [PATCH 5/6] fix: include size in image cache key to prevent thumbnail/full-size collision --- Mactrix/Models/MatrixClient.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index 725a913..0f0b281 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -250,7 +250,11 @@ extension MatrixClient: UI.ImageLoader { } func loadImage(matrixUrl: String, size: CGSize?) async throws -> Image? { - let cacheKey = NSString(string: matrixUrl) + let cacheKey: NSString = if let size { + NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") + } else { + NSString(string: matrixUrl) + } if let cached = Self.imageCache.object(forKey: cacheKey) { return Image(nsImage: cached) } From 235bd592d93f028727a368fb47875149d23b1fb1 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Sat, 14 Mar 2026 14:38:52 +0000 Subject: [PATCH 6/6] fix: remove remaining tiffRepresentation from init cache path --- Mactrix/Views/ChatView/MessageImageView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index a3c1ba7..965a707 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -18,7 +18,6 @@ struct MessageImageView: View { self.content = content if let cached = MatrixClient.imageCache.object(forKey: NSString(string: content.source.url())) { self._image = State(initialValue: Image(nsImage: cached)) - self._imageData = State(initialValue: cached.tiffRepresentation) } }