From e3e169afcc65fce69a9d8e7316a1bd7384ea57ae Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 7 Nov 2026 16:18:38 -0800 Subject: [PATCH] fix(mobile): keep New DM avatars steady across picker updates Every state update from Dart reloads the native member picker, which built fresh cells showing the initials fallback and fetched each avatar again over the platform channel, so avatars blinked whenever results refreshed, most visibly right after foregrounding. Keep loaded avatars by key and draw them at once when a cell is rebuilt. Co-Authored-By: Claude Opus 5.5 Co-authored-by: studio Signed-off-by: Tom Brow --- .../AddMembersIdentityTests.swift | 6 +++++ mobile/ios/Runner/NativeAddMembersSheet.swift | 22 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift b/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift index dedb06f245..e511592e40 100644 --- a/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift +++ b/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift @@ -37,6 +37,12 @@ struct AddMembersIdentityTests { picker.tableView(picker.tableView, didSelectRowAt: path) } precondition(selected == keys, "Each distinct label must select its own pubkey") + avatarReplies.removeAll() + picker.update(["users": users, "selected": [], "query": "", "loading": false]) + let rebuilt = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 1)) + let rebuiltImage = (rebuilt.contentConfiguration as? UIListContentConfiguration)?.image + precondition(avatarReplies.isEmpty, "A loaded avatar is not fetched again on update") + precondition(rebuiltImage?.pngData() == UIImage(data: avatar!)?.pngData(), "A rebuilt row draws its loaded avatar at once") picker.update(["users": users, "selected": [users[1]], "query": "", "loading": false]) let chosen = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 0)) precondition(chosen.accessibilityLabel == "\(labels[1]), Same owner", "Selected identity retains its qualifier") diff --git a/mobile/ios/Runner/NativeAddMembersSheet.swift b/mobile/ios/Runner/NativeAddMembersSheet.swift index 68f5b63612..02fffc3d42 100644 --- a/mobile/ios/Runner/NativeAddMembersSheet.swift +++ b/mobile/ios/Runner/NativeAddMembersSheet.swift @@ -97,6 +97,10 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul private var state: [String: Any] private let send: (String, [String: Any]) -> Void private let loadAvatar: (String, String, @escaping (Data?) -> Void) -> Void + /// Loaded avatars by key. Every state update rebuilds the cells, so a row + /// draws its known avatar at once instead of blinking to its initial while + /// the image comes back over the channel. + private var avatars: [String: UIImage] = [:] private let search = UISearchController(searchResultsController: nil) private let pageColor: UIColor private let rowColor: UIColor @@ -282,12 +286,12 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ") content.textProperties.numberOfLines = 0 content.secondaryTextProperties.numberOfLines = 1 - content.image = fallbackAvatar(user) - content.imageProperties.maximumSize = CGSize(width: 40, height: 40) - content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40) if let pubkey = user["pubkey"] as? String, let key = user["avatarKey"] as? String { avatarRequest = (pubkey, key) } + content.image = avatarRequest.flatMap { avatars[$0.1] } ?? fallbackAvatar(user) + content.imageProperties.maximumSize = CGSize(width: 40, height: 40) + content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40) cell.accessoryType = indexPath.section == 0 ? .checkmark : .none if unavailable { content.textProperties.color = .secondaryLabel @@ -303,13 +307,15 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul cell.accessibilityLabel = memberLabel cell.contentView.accessibilityElementsHidden = true } - if let (pubkey, key) = avatarRequest { - cell.accessibilityIdentifier = key - loadAvatar(pubkey, key) { [weak cell] data in + if let (_, key) = avatarRequest { cell.accessibilityIdentifier = key } + if let (pubkey, key) = avatarRequest, avatars[key] == nil { + loadAvatar(pubkey, key) { [weak self, weak cell] data in + guard let data, let image = UIImage(data: data) else { return } + let avatar = image.withRenderingMode(.alwaysOriginal) + self?.avatars[key] = avatar guard let cell, cell.accessibilityIdentifier == key, - let data, let image = UIImage(data: data), var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return } - configuration.image = image.withRenderingMode(.alwaysOriginal) + configuration.image = avatar let label = cell.accessibilityLabel cell.contentConfiguration = configuration cell.accessibilityLabel = label