From 583e62beb951ec83193f299720a19e7d03f227a7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 18 Nov 2025 14:00:35 +1100 Subject: [PATCH 1/4] Added a hack to work around a cell reuse height calculation bug --- .../MessageRequestsViewModel.swift | 1 + .../PhotoCollectionPickerViewModel.swift | 1 + .../Settings/BlockedContactsViewModel.swift | 1 + .../NotificationContentViewModel.swift | 1 + .../Settings/NotificationSoundViewModel.swift | 1 + Session/Shared/SessionListViewModel.swift | 1 + .../Shared/SessionTableViewController.swift | 22 ++++++++++++++++++- Session/Shared/Types/SessionCell+Info.swift | 16 ++++++++++++++ Session/Shared/UserListViewModel.swift | 1 + 9 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index ddece1ddc5..7dcc1604a2 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -322,6 +322,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel threadCanUpload: false // Irrelevant for the MessageRequestsViewModel ), + canReuseCell: true, accessibility: Accessibility( identifier: "Message request" ), diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift index 2b61824528..1ba8a6c3a0 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift @@ -75,6 +75,7 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour return SessionCell.Info( id: TableItem(collection: collection), + canReuseCell: true, leadingAccessory: .iconAsync( size: .extraLarge, source: lastAssetItem?.source, diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 48ef8d91d4..e58c5aa248 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -296,6 +296,7 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo .map { model -> SessionCell.Info in SessionCell.Info( id: model, + canReuseCell: true, leadingAccessory: .profile(id: model.id, profile: model.profile), title: ( model.profile?.displayName() ?? diff --git a/Session/Settings/NotificationContentViewModel.swift b/Session/Settings/NotificationContentViewModel.swift index 94dbc83afa..420e447260 100644 --- a/Session/Settings/NotificationContentViewModel.swift +++ b/Session/Settings/NotificationContentViewModel.swift @@ -97,6 +97,7 @@ class NotificationContentViewModel: SessionTableViewModel, NavigatableStateHolde .map { previewType in SessionCell.Info( id: previewType, + canReuseCell: true, title: previewType.name, trailingAccessory: .radio( isSelected: (state.previewType == previewType) diff --git a/Session/Settings/NotificationSoundViewModel.swift b/Session/Settings/NotificationSoundViewModel.swift index 9b4b30b9dc..bc0c8384b6 100644 --- a/Session/Settings/NotificationSoundViewModel.swift +++ b/Session/Settings/NotificationSoundViewModel.swift @@ -91,6 +91,7 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N .map { sound in SessionCell.Info( id: sound, + canReuseCell: true, title: { guard sound != .note else { return "\(sound.displayName) (default)" diff --git a/Session/Shared/SessionListViewModel.swift b/Session/Shared/SessionListViewModel.swift index d49dfbafbf..9f703de4c4 100644 --- a/Session/Shared/SessionListViewModel.swift +++ b/Session/Shared/SessionListViewModel.swift @@ -144,6 +144,7 @@ class SessionListViewModel: SessionTableViewModel, NavigationItemSo .map { option in SessionCell.Info( id: option, + canReuseCell: true, title: option.title, subtitle: option.subtitle, trailingAccessory: .radio( diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 3e4502aa2e..79242031ba 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -97,6 +97,16 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa result.sectionHeaderTopPadding = 0 result.rowHeight = UITableView.automaticDimension result.estimatedRowHeight = UITableView.automaticDimension + + // FIXME: Refactor this screen to SwiftUI and avoid using this hack + /// There are a bunch of cells which dynamically calculate their heights and when they get reused by other cells the height can + /// incorrectly remain, in order to avoid this we register a bunch of cells with generic identifiers so we can avoid reusing cells in + /// these cases (these screens generally don't have a lot of cells so it shouldn't be an issue) + (0..<50).forEach { index1 in + (0..<50).forEach { index2 in + result.register(SessionCell.self, forCellReuseIdentifier: "\(index1)-\(index2)") + } + } return result }() @@ -443,7 +453,17 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let section: SectionModel = tableData[indexPath.section] let info: SessionCell.Info = section.elements[indexPath.row] - let cell: UITableViewCell = tableView.dequeue(type: viewModel.cellType.viewType.self, for: indexPath) + let cell: UITableViewCell + + // FIXME: Refactor this screen to SwiftUI and avoid using this hack + /// There are a bunch of cells which dynamically calculate their heights and when they get reused by other cells the height can + /// incorrectly remain, in order to avoid this we register a bunch of cells with generic identifiers so we can avoid reusing cells in + /// these cases (these screens generally don't have a lot of cells so it shouldn't be an issue) + switch (viewModel.cellType.viewType.self, info.canReuseCell) { + case (is SessionCell.Type, false): + cell = tableView.dequeueReusableCell(withIdentifier: "\(indexPath.section)-\(indexPath.row)", for: indexPath) + default: cell = tableView.dequeue(type: viewModel.cellType.viewType.self, for: indexPath) + } switch (cell, info) { case (let cell as SessionCell, _): diff --git a/Session/Shared/Types/SessionCell+Info.swift b/Session/Shared/Types/SessionCell+Info.swift index cd32ff0e8a..28b58874cd 100644 --- a/Session/Shared/Types/SessionCell+Info.swift +++ b/Session/Shared/Types/SessionCell+Info.swift @@ -8,6 +8,7 @@ import SessionMessagingKit extension SessionCell { public struct Info: Equatable, Hashable, Differentiable { let id: ID + let canReuseCell: Bool let position: Position let leadingAccessory: SessionCell.Accessory? let title: TextInfo? @@ -32,6 +33,7 @@ extension SessionCell { init( id: ID, + canReuseCell: Bool = false, // FIXME: This shouldn't be needed but is a hack to prevent layout bugs on cell reuse position: Position = .individual, leadingAccessory: SessionCell.Accessory? = nil, title: SessionCell.TextInfo? = nil, @@ -46,6 +48,7 @@ extension SessionCell { onTapView: (@MainActor (UIView?) -> Void)? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = leadingAccessory self.title = title @@ -66,6 +69,7 @@ extension SessionCell { public func hash(into hasher: inout Hasher) { id.hash(into: &hasher) + canReuseCell.hash(into: &hasher) position.hash(into: &hasher) leadingAccessory.hash(into: &hasher) title.hash(into: &hasher) @@ -80,6 +84,7 @@ extension SessionCell { public static func == (lhs: Info, rhs: Info) -> Bool { return ( lhs.id == rhs.id && + lhs.canReuseCell == rhs.canReuseCell && lhs.position == rhs.position && lhs.leadingAccessory == rhs.leadingAccessory && lhs.title == rhs.title && @@ -96,6 +101,7 @@ extension SessionCell { public func updatedPosition(for index: Int, count: Int) -> Info { return Info( id: id, + canReuseCell: canReuseCell, position: Position.with(index, count: count), leadingAccessory: leadingAccessory, title: title, @@ -120,6 +126,7 @@ public extension SessionCell.Info { init( id: ID, + canReuseCell: Bool = false, position: Position = .individual, accessory: SessionCell.Accessory, styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), @@ -129,6 +136,7 @@ public extension SessionCell.Info { onTap: (@MainActor () -> Void)? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = accessory self.title = nil @@ -147,6 +155,7 @@ public extension SessionCell.Info { init( id: ID, + canReuseCell: Bool = false, position: Position = .individual, leadingAccessory: SessionCell.Accessory, trailingAccessory: SessionCell.Accessory, @@ -156,6 +165,7 @@ public extension SessionCell.Info { confirmationInfo: ConfirmationModal.Info? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = leadingAccessory self.title = nil @@ -174,6 +184,7 @@ public extension SessionCell.Info { init( id: ID, + canReuseCell: Bool = false, position: Position = .individual, leadingAccessory: SessionCell.Accessory? = nil, title: String, @@ -185,6 +196,7 @@ public extension SessionCell.Info { onTap: (@MainActor () -> Void)? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = leadingAccessory self.title = SessionCell.TextInfo(title, font: .title) @@ -203,6 +215,7 @@ public extension SessionCell.Info { init( id: ID, + canReuseCell: Bool = false, position: Position = .individual, leadingAccessory: SessionCell.Accessory? = nil, title: SessionCell.TextInfo, @@ -214,6 +227,7 @@ public extension SessionCell.Info { onTap: (@MainActor () -> Void)? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = leadingAccessory self.title = title @@ -232,6 +246,7 @@ public extension SessionCell.Info { init( id: ID, + canReuseCell: Bool = false, position: Position = .individual, leadingAccessory: SessionCell.Accessory? = nil, title: String, @@ -245,6 +260,7 @@ public extension SessionCell.Info { onTapView: (@MainActor (UIView?) -> Void)? = nil ) { self.id = id + self.canReuseCell = canReuseCell self.position = position self.leadingAccessory = leadingAccessory self.title = SessionCell.TextInfo(title, font: .title) diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index ad543a2ea0..56805de11f 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -144,6 +144,7 @@ class UserListViewModel: SessionTableVie return SessionCell.Info( id: .user(userInfo.profileId), + canReuseCell: true, leadingAccessory: .profile( id: userInfo.profileId, profile: userInfo.profile, From 617152978d6c17e3a9dfc02c88d3b5720710dd1f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 18 Nov 2025 14:28:02 +1100 Subject: [PATCH 2/4] Tweak the build script to fix a parsing error --- Scripts/build_ci.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index 0034642f7b..852688a585 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -56,8 +56,10 @@ if [[ "$MODE" == "test" ]]; then xcresultparser --output-format cli --no-test-result --coverage ./build/artifacts/testResults.xcresult parser_output=$(xcresultparser --output-format cli --no-test-result ./build/artifacts/testResults.xcresult) - build_errors_count=$(echo "$parser_output" | grep "Number of errors" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") - failed_tests_count=$(echo "$parser_output" | grep "Number of failed tests" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") + # Strip ANSI color codes before parsing + clean_parser_output=$(echo "$parser_output" | sed 's/\[[0-9;]*m//g') + build_errors_count=$(echo "$clean_parser_output" | grep "Number of errors" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") + failed_tests_count=$(echo "$clean_parser_output" | grep "Number of failed tests" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") if [ "${build_errors_count:-0}" -gt 0 ] || [ "${failed_tests_count:-0}" -gt 0 ]; then echo "" From a2dabefbda4279045f349c55b56e9d5c6bedbd14 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 18 Nov 2025 14:49:29 +1100 Subject: [PATCH 3/4] Made code coverage output smaller, fixed broken unit tests --- Scripts/build_ci.sh | 2 +- .../Settings/NotificationContentViewModelSpec.swift | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index 852688a585..bcfe2913e0 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -53,7 +53,7 @@ if [[ "$MODE" == "test" ]]; then echo "----------------------------------------------------" echo "Checking for test failures in xcresult bundle..." - xcresultparser --output-format cli --no-test-result --coverage ./build/artifacts/testResults.xcresult + xcresultparser --output-format cli --no-test-result --coverage-report-format targets --coverage ./build/artifacts/testResults.xcresult parser_output=$(xcresultparser --output-format cli --no-test-result ./build/artifacts/testResults.xcresult) # Strip ANSI color codes before parsing diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index 2a55c6ffae..ca84de5a9c 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -74,6 +74,7 @@ class NotificationContentViewModelSpec: AsyncSpec { equal([ SessionCell.Info( id: Preferences.NotificationPreviewType.nameAndPreview, + canReuseCell: true, position: .top, title: "notificationsContentShowNameAndContent".localized(), trailingAccessory: .radio( @@ -82,6 +83,7 @@ class NotificationContentViewModelSpec: AsyncSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.nameNoPreview, + canReuseCell: true, position: .middle, title: "notificationsContentShowNameOnly".localized(), trailingAccessory: .radio( @@ -90,6 +92,7 @@ class NotificationContentViewModelSpec: AsyncSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.noNameNoPreview, + canReuseCell: true, position: .bottom, title: "notificationsContentShowNoNameOrContent".localized(), trailingAccessory: .radio( @@ -118,6 +121,7 @@ class NotificationContentViewModelSpec: AsyncSpec { equal([ SessionCell.Info( id: Preferences.NotificationPreviewType.nameAndPreview, + canReuseCell: true, position: .top, title: "notificationsContentShowNameAndContent".localized(), trailingAccessory: .radio( @@ -126,6 +130,7 @@ class NotificationContentViewModelSpec: AsyncSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.nameNoPreview, + canReuseCell: true, position: .middle, title: "notificationsContentShowNameOnly".localized(), trailingAccessory: .radio( @@ -134,6 +139,7 @@ class NotificationContentViewModelSpec: AsyncSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.noNameNoPreview, + canReuseCell: true, position: .bottom, title: "notificationsContentShowNoNameOrContent".localized(), trailingAccessory: .radio( From 8ff3a7d4dd183463350950ab48812ffb2f7dce57 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 24 Nov 2025 12:31:55 +1100 Subject: [PATCH 4/4] Fix issue found during QA --- Session.xcodeproj/project.pbxproj | 16 ++++++++-------- Session/Shared/Views/SessionCell.swift | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 54df4e2ed9..9007b6a666 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8420,7 +8420,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 658; + CURRENT_PROJECT_VERSION = 660; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8460,7 +8460,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.6; + MARKETING_VERSION = 2.14.7; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8501,7 +8501,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 658; + CURRENT_PROJECT_VERSION = 660; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8536,7 +8536,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.6; + MARKETING_VERSION = 2.14.7; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8987,7 +8987,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 658; + CURRENT_PROJECT_VERSION = 660; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9026,7 +9026,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.6; + MARKETING_VERSION = 2.14.7; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9577,7 +9577,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 658; + CURRENT_PROJECT_VERSION = 660; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9610,7 +9610,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.6; + MARKETING_VERSION = 2.14.7; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 0e78a0e704..d84af74adc 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -318,11 +318,14 @@ public class SessionCell: UITableViewCell { titleLabel.text = "" titleLabel.themeTextColor = .textPrimary titleLabel.alpha = 1 + titleLabel.preferredMaxLayoutWidth = 0 subtitleLabel.isUserInteractionEnabled = false subtitleLabel.attributedText = nil subtitleLabel.themeTextColor = .textPrimary + subtitleLabel.preferredMaxLayoutWidth = 0 expandableDescriptionLabel.themeAttributedText = nil expandableDescriptionLabel.themeTextColor = .textPrimary + expandableDescriptionLabel.preferredMaxLayoutWidth = 0 trailingAccessoryView.prepareForReuse() trailingAccessoryView.alpha = 1 trailingAccessoryFillConstraint.isActive = false @@ -521,6 +524,7 @@ public class SessionCell: UITableViewCell { } // Content + let oldTitle: String? = titleLabel.text let contentStackViewHorizontalInset: CGFloat = ( (backgroundLeftConstraint.constant + (-backgroundRightConstraint.constant)) + (contentStackViewLeadingConstraint.constant + (-contentStackViewTrailingConstraint.constant)) @@ -570,6 +574,12 @@ public class SessionCell: UITableViewCell { maxContentWidth: (tableSize.width - contentStackViewHorizontalInset), using: dependencies ) + + /// Need to force a re-layout if the title changes as it might not size the content correctly if we don't + if titleLabel.text != oldTitle { + titleLabel.setNeedsLayout() + titleLabel.layoutIfNeeded() + } } // MARK: - Interaction