diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index cb742aa7c2..380d07c7cb 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1777,6 +1777,8 @@ B95A765E2B76E93500AA7E97 /* FindByUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */; }; B965716E2F0DC91900690CE9 /* CallQualitySurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */; }; B96D6D792B9F83270039EB99 /* SignalSymbols-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */; }; + B96EFB512F7B03B600BD721E /* CollapseSetInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96EFB502F7B03B600BD721E /* CollapseSetInteraction.swift */; }; + B96EFB532F7B041200BD721E /* CVComponentCollapseSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96EFB522F7B041200BD721E /* CVComponentCollapseSet.swift */; }; B9754F542C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */; }; B97803012DD65B0A00E9FC82 /* LinearProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97803002DD65B0A00E9FC82 /* LinearProgressView.swift */; }; B982ACFF2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = B982ACFE2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf */; }; @@ -6058,6 +6060,8 @@ B95BBAC12BB36025009EFB4A /* ProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileName.swift; sourceTree = ""; }; B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallQualitySurvey.swift; sourceTree = ""; }; B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Regular.otf"; sourceTree = ""; }; + B96EFB502F7B03B600BD721E /* CollapseSetInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapseSetInteraction.swift; sourceTree = ""; }; + B96EFB522F7B041200BD721E /* CVComponentCollapseSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVComponentCollapseSet.swift; sourceTree = ""; }; B96FEE2E2CDC297500836191 /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = ""; }; B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationAvatarView+SwiftUI.swift"; sourceTree = ""; }; B97803002DD65B0A00E9FC82 /* LinearProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearProgressView.swift; sourceTree = ""; }; @@ -8906,6 +8910,7 @@ 347C383C252CE6C900F3D941 /* CVComponentBodyText.swift */, 3426A3792563F0EA0036407F /* CVComponentBottomButtons.swift */, 04FAAD0D2E82EEA900E4AAC3 /* CVComponentBottomLabel.swift */, + B96EFB522F7B041200BD721E /* CVComponentCollapseSet.swift */, 3470C8792555DE5F00F5847C /* CVComponentContactShare.swift */, 347C3848252D004C00F3D941 /* CVComponentDateHeader.swift */, 348815C3255343FC00D4F4C4 /* CVComponentDelegate.swift */, @@ -9950,6 +9955,7 @@ 50E7E1CF2BACC1A500A94861 /* DynamicInteractions */ = { isa = PBXGroup; children = ( + B96EFB502F7B03B600BD721E /* CollapseSetInteraction.swift */, 88535063240829950011D318 /* DateHeaderInteraction.swift */, 88DBDFB8263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift */, 88D1D40322EF8A9700F472C5 /* ThreadDetailsInteraction.swift */, @@ -18251,6 +18257,7 @@ 34E95C25269F4F4F004807EC /* CLVTableDataSource.swift in Sources */, 0550A5E22C4035170072CC02 /* CLVViewInfo.swift in Sources */, 34E95C24269F4F4F004807EC /* CLVViewState.swift in Sources */, + B96EFB512F7B03B600BD721E /* CollapseSetInteraction.swift in Sources */, 886BB3D425BA0CA900079781 /* ColorAndWallpaperSettingsViewController.swift in Sources */, D9E43C232CC194140001536E /* CommonCallState.swift in Sources */, 32B3286524C6957B00E4F974 /* ComposeSupportEmailOperation.swift in Sources */, @@ -18333,6 +18340,7 @@ 347C3843252CE6C900F3D941 /* CVComponentBodyText.swift in Sources */, 3426A37A2563F0EA0036407F /* CVComponentBottomButtons.swift in Sources */, 04FAAD0E2E82EEAE00E4AAC3 /* CVComponentBottomLabel.swift in Sources */, + B96EFB532F7B041200BD721E /* CVComponentCollapseSet.swift in Sources */, 3470C87A2555DE5F00F5847C /* CVComponentContactShare.swift in Sources */, 347C3849252D004C00F3D941 /* CVComponentDateHeader.swift in Sources */, 348815C4255343FC00D4F4C4 /* CVComponentDelegate.swift in Sources */, diff --git a/Signal/ConversationView/CVCell.swift b/Signal/ConversationView/CVCell.swift index b6a4408b99..3e4cf92b32 100644 --- a/Signal/ConversationView/CVCell.swift +++ b/Signal/ConversationView/CVCell.swift @@ -21,6 +21,7 @@ public enum CVCellReuseIdentifier: String, CaseIterable { case threadDetails case systemMessage case unknownThreadWarning + case collapseSet } // MARK: - diff --git a/Signal/ConversationView/CVItemViewModelImpl.swift b/Signal/ConversationView/CVItemViewModelImpl.swift index 5cfc7b77bf..24ecc44027 100644 --- a/Signal/ConversationView/CVItemViewModelImpl.swift +++ b/Signal/ConversationView/CVItemViewModelImpl.swift @@ -263,7 +263,7 @@ extension CVItemViewModelImpl { } switch messageCellType { - case .unknown, .dateHeader, .typingIndicator, .unreadIndicator, .threadDetails, .systemMessage, .unknownThreadWarning, .defaultDisappearingMessageTimer: + case .unknown, .dateHeader, .typingIndicator, .unreadIndicator, .threadDetails, .systemMessage, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet: return false case .giftBadge: return false diff --git a/Signal/ConversationView/CVNode.swift b/Signal/ConversationView/CVNode.swift index 9010746486..97c9f80eec 100644 --- a/Signal/ConversationView/CVNode.swift +++ b/Signal/ConversationView/CVNode.swift @@ -32,6 +32,7 @@ public enum CVMessageCellType: Int, CustomStringConvertible, Equatable { case systemMessage case unknownThreadWarning case defaultDisappearingMessageTimer + case collapseSet // MARK: - CustomStringConvertible @@ -58,6 +59,7 @@ public enum CVMessageCellType: Int, CustomStringConvertible, Equatable { case .unknownThreadWarning: return "unknownThreadWarning" case .defaultDisappearingMessageTimer: return "defaultDisappearingMessageTimer" case .poll: return "poll" + case .collapseSet: return "collapseSet" } } } diff --git a/Signal/ConversationView/CVViewState.swift b/Signal/ConversationView/CVViewState.swift index 77397db6a8..e05cab1b89 100644 --- a/Signal/ConversationView/CVViewState.swift +++ b/Signal/ConversationView/CVViewState.swift @@ -149,6 +149,10 @@ public class CVViewState: NSObject { var unwrappedGiftMessageIds = Set() + /// The set of collapse set IDs that have been expanded by the user. + /// Resets to empty when leaving the conversation. + var expandedCollapseSets = Set() + // MARK: - Attachment downloads var manuallyCanceledDownloadsMessageIds = Set() diff --git a/Signal/ConversationView/Components/CVComponent.swift b/Signal/ConversationView/Components/CVComponent.swift index 590a59b33d..129030afb3 100644 --- a/Signal/ConversationView/Components/CVComponent.swift +++ b/Signal/ConversationView/Components/CVComponent.swift @@ -393,6 +393,7 @@ public enum CVComponentKey: CustomStringConvertible, CaseIterable { case skippedDownloads case unknownThreadWarning case defaultDisappearingMessageTimer + case collapseSet case messageRoot public var description: String { @@ -451,6 +452,8 @@ public enum CVComponentKey: CustomStringConvertible, CaseIterable { return ".sendFailureBadge" case .defaultDisappearingMessageTimer: return ".defaultDisappearingMessageTimer" + case .collapseSet: + return ".collapseSet" case .messageRoot: return ".messageRoot" case .poll: diff --git a/Signal/ConversationView/Components/CVComponentCollapseSet.swift b/Signal/ConversationView/Components/CVComponentCollapseSet.swift new file mode 100644 index 0000000000..6c313a6e8d --- /dev/null +++ b/Signal/ConversationView/Components/CVComponentCollapseSet.swift @@ -0,0 +1,244 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +class CVComponentCollapseSet: CVComponentBase, CVRootComponent { + + var componentKey: CVComponentKey { .collapseSet } + + var cellReuseIdentifier: CVCellReuseIdentifier { .collapseSet } + + let isDedicatedCell = true + + private let collapseSet: CVComponentState.CollapseSet + + init(itemModel: CVItemModel, collapseSet: CVComponentState.CollapseSet) { + self.collapseSet = collapseSet + super.init(itemModel: itemModel) + } + + // MARK: - CVRootComponent + + func configureCellRootComponent( + cellView: UIView, + cellMeasurement: CVCellMeasurement, + componentDelegate: CVComponentDelegate, + messageSwipeActionState: CVMessageSwipeActionState, + componentView: CVComponentView, + ) { + Self.configureCellRootComponent( + rootComponent: self, + cellView: cellView, + cellMeasurement: cellMeasurement, + componentDelegate: componentDelegate, + componentView: componentView, + ) + } + + func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView { + CVComponentViewCollapseSet() + } + + func configureForRendering( + componentView: CVComponentView, + cellMeasurement: CVCellMeasurement, + componentDelegate: CVComponentDelegate, + ) { + guard let componentView = componentView as? CVComponentViewCollapseSet else { + owsFailDebug("Unexpected componentView.") + return + } + + let isReusing = componentView.rootView.superview != nil + + if !isReusing { + componentView.reset() + } + + // TODO: Add icons + + var config = UIButton.Configuration.gray() + config.title = buttonTitleString + config.baseForegroundColor = .Signal.label + config.baseBackgroundColor = conversationStyle.hasWallpaper + ? .Signal.MaterialBase.button + : .Signal.secondaryFill + config.contentInsets = buttonContentInsets + config.titleTextAttributesTransformer = .defaultFont(buttonFont) + componentView.button.configuration = config + componentView.button.isUserInteractionEnabled = false + + if let buttonSize = cellMeasurement.size(key: Self.measurementKey_button) { + componentView.button.layer.cornerRadius = buttonSize.height / 2 + } + + componentView.outerStack.configure( + config: outerStackConfig, + cellMeasurement: cellMeasurement, + measurementKey: Self.measurementKey_outerStack, + subviews: [componentView.button], + ) + + componentView.outerStack.isAccessibilityElement = true + componentView.outerStack.accessibilityLabel = buttonTitleString + componentView.outerStack.accessibilityTraits = .button + componentView.outerStack.accessibilityHint = collapseSet.isExpanded + ? OWSLocalizedString( + "COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE", + comment: "VoiceOver hint for an expanded collapse set button.", + ) + : OWSLocalizedString( + "COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND", + comment: "VoiceOver hint for a collapsed collapse set button.", + ) + } + + // MARK: - Events + + override func handleTap( + sender: UIGestureRecognizer, + componentDelegate: CVComponentDelegate, + componentView: CVComponentView, + renderItem: CVRenderItem, + ) -> Bool { + componentDelegate.didTapCollapseSet(collapseSetId: interaction.uniqueId) + return true + } + + // MARK: - Measurement + + fileprivate static let measurementKey_outerStack = "CVComponentCollapseSet.outerStack" + fileprivate static let measurementKey_button = "CVComponentCollapseSet.button" + + func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { + owsAssertDebug(maxWidth > 0) + let availableWidth = max( + 0, + maxWidth - outerStackConfig.layoutMargins.totalWidth, + ) + let labelSize = CVText.measureLabel(config: buttonLabelConfig, maxWidth: availableWidth) + let buttonSize = labelSize + buttonContentInsets.asSize + measurementBuilder.setSize(key: Self.measurementKey_button, size: buttonSize) + let outerMeasurement = ManualStackView.measure( + config: outerStackConfig, + measurementBuilder: measurementBuilder, + measurementKey: Self.measurementKey_outerStack, + subviewInfos: [buttonSize.asManualSubviewInfo(hasFixedSize: true)], + ) + return outerMeasurement.measuredSize + } + + // MARK: - Layout + + private var outerStackConfig: CVStackViewConfig { + CVStackViewConfig( + axis: .vertical, + alignment: .center, + spacing: 0, + layoutMargins: UIEdgeInsets( + top: 4, + leading: conversationStyle.fullWidthGutterLeading, + bottom: 4, + trailing: conversationStyle.fullWidthGutterTrailing, + ), + ) + } + + // MARK: - Content + + private var buttonFont: UIFont { .dynamicTypeFootnote.medium() } + + private var buttonContentInsets: NSDirectionalEdgeInsets { + NSDirectionalEdgeInsets(hMargin: 10, vMargin: 5) + } + + private var buttonLabelConfig: CVLabelConfig { + CVLabelConfig.unstyledText( + buttonTitleString, + font: buttonFont, + textColor: .Signal.label, + textAlignment: .center, + ) + } + + private var buttonTitleString: String { + var label = summaryLabel( + count: collapseSet.collapsedInteractions.count, + type: collapseSet.collapseSetType, + ) + // TODO: Localize this more rigorously + if let timerDesc = collapseSet.finalTimerDescription { + label += " · " + timerDesc + } + // TODO: Use proper symbols + let chevron = collapseSet.isExpanded ? "\u{25B4}" : "\u{25BE}" // ▴ or ▾ + return label + " " + chevron + } + + private func summaryLabel( + count: Int, + type: CollapseSetInteraction.MessagesType, + ) -> String { + switch type { + case .groupUpdates: + return String( + format: OWSLocalizedString( + "COLLAPSE_SET_GROUP_UPDATES_%d", + tableName: "PluralAware", + comment: "Label for a collapsed group of group update events. Embeds {{number of events}}.", + ), + count, + ) + case .chatUpdates: + return String( + format: OWSLocalizedString( + "COLLAPSE_SET_CHAT_UPDATES_%d", + tableName: "PluralAware", + comment: "Label for a collapsed group of chat update events. Embeds {{number of events}}.", + ), + count, + ) + case .timerChanges: + return String( + format: OWSLocalizedString( + "COLLAPSE_SET_TIMER_CHANGES_%d", + tableName: "PluralAware", + comment: "Label for a collapsed group of disappearing message timer changes. Embeds {{number of events}}.", + ), + count, + ) + case .callEvents: + return String( + format: OWSLocalizedString( + "COLLAPSE_SET_CALL_EVENTS_%d", + tableName: "PluralAware", + comment: "Label for a collapsed group of call events. Embeds {{number of events}}.", + ), + count, + ) + } + } + + // MARK: - CVComponentViewCollapseSet + + class CVComponentViewCollapseSet: NSObject, CVComponentView { + + fileprivate let outerStack = ManualStackView(name: "collapseSet.outerStack") + fileprivate let button = UIButton(configuration: .gray()) + + var isDedicatedCellView = false + + var rootView: UIView { outerStack } + + func setIsCellVisible(_ isCellVisible: Bool) {} + + func reset() { + button.configuration?.title = nil + outerStack.reset() + } + } +} diff --git a/Signal/ConversationView/Components/CVComponentDelegate.swift b/Signal/ConversationView/Components/CVComponentDelegate.swift index 746026630a..83672689ef 100644 --- a/Signal/ConversationView/Components/CVComponentDelegate.swift +++ b/Signal/ConversationView/Components/CVComponentDelegate.swift @@ -34,6 +34,10 @@ public protocol CVComponentDelegate: AnyObject, AudioMessageViewDelegate, CVPoll func didTapSystemMessageItem(_ item: CVTextLabel.Item) + // MARK: - Collapse Sets + + func didTapCollapseSet(collapseSetId: String) + // MARK: - Double-Tap func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) diff --git a/Signal/ConversationView/Components/CVComponentMessage.swift b/Signal/ConversationView/Components/CVComponentMessage.swift index 8fa9f7ab00..0e5f085285 100644 --- a/Signal/ConversationView/Components/CVComponentMessage.swift +++ b/Signal/ConversationView/Components/CVComponentMessage.swift @@ -155,7 +155,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { // We don't render sender avatars with a subcomponent. case .senderAvatar: return nil - case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot: + case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: return nil } } @@ -1345,7 +1345,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { return true case .senderName: return false - case .senderAvatar, .reactions, .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .unknownThreadWarning, .skippedDownloads, .sendFailureBadge, .defaultDisappearingMessageTimer, .messageRoot: + case .senderAvatar, .reactions, .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .unknownThreadWarning, .skippedDownloads, .sendFailureBadge, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: owsFailDebug("Unexpected component.") return false case .footer: @@ -2380,7 +2380,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { case .senderAvatar: owsFailDebug("Invalid component key: \(key)") return nil - case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot: + case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: owsFailDebug("Invalid component key: \(key)") return nil } @@ -2429,7 +2429,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { // We don't render sender avatars with a subcomponent. case .senderAvatar: owsAssertDebug(subcomponentView == nil) - case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot: + case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: owsAssertDebug(subcomponentView == nil) } } diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index cbb2e0a0bf..130177003b 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -501,6 +501,22 @@ public struct CVComponentState: Equatable { typealias DefaultDisappearingMessageTimer = CVComponentState.SystemMessage let defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer? + struct CollapseSet: Equatable { + let collapsedInteractions: [TSInteraction] + let collapseSetType: CollapseSetInteraction.MessagesType + let isExpanded: Bool + let finalTimerDescription: String? + + static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool { + return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId) + && lhs.collapseSetType == rhs.collapseSetType + && lhs.isExpanded == rhs.isExpanded + && lhs.finalTimerDescription == rhs.finalTimerDescription + } + } + + let collapseSet: CollapseSet? + struct BottomButtons: Equatable { let actions: [CVMessageAction] } @@ -552,6 +568,7 @@ public struct CVComponentState: Equatable { threadDetails: ThreadDetails?, unknownThreadWarning: UnknownThreadWarning?, defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?, + collapseSet: CollapseSet?, bottomButtons: BottomButtons?, bottomLabel: String?, skippedDownloads: SkippedDownloads?, @@ -584,6 +601,7 @@ public struct CVComponentState: Equatable { self.threadDetails = threadDetails self.unknownThreadWarning = unknownThreadWarning self.defaultDisappearingMessageTimer = defaultDisappearingMessageTimer + self.collapseSet = collapseSet self.bottomButtons = bottomButtons self.bottomLabel = bottomLabel self.skippedDownloads = skippedDownloads @@ -620,6 +638,7 @@ public struct CVComponentState: Equatable { lhs.threadDetails == rhs.threadDetails && lhs.unknownThreadWarning == rhs.unknownThreadWarning && lhs.defaultDisappearingMessageTimer == rhs.defaultDisappearingMessageTimer && + lhs.collapseSet == rhs.collapseSet && lhs.bottomButtons == rhs.bottomButtons && lhs.bottomLabel == rhs.bottomLabel && lhs.skippedDownloads == rhs.skippedDownloads && @@ -686,6 +705,7 @@ public struct CVComponentState: Equatable { var threadDetails: ThreadDetails? var unknownThreadWarning: UnknownThreadWarning? var defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer? + var collapseSet: CollapseSet? var reactions: Reactions? var skippedDownloads: SkippedDownloads? var sendFailureBadge: SendFailureBadge? @@ -734,6 +754,7 @@ public struct CVComponentState: Equatable { threadDetails: threadDetails, unknownThreadWarning: unknownThreadWarning, defaultDisappearingMessageTimer: defaultDisappearingMessageTimer, + collapseSet: collapseSet, bottomButtons: bottomButtons, bottomLabel: bottomLabel, skippedDownloads: skippedDownloads, @@ -773,6 +794,9 @@ public struct CVComponentState: Equatable { if defaultDisappearingMessageTimer != nil { return .defaultDisappearingMessageTimer } + if collapseSet != nil { + return .collapseSet + } if systemMessage != nil { return .systemMessage } @@ -895,6 +919,9 @@ public struct CVComponentState: Equatable { if defaultDisappearingMessageTimer != nil { result.insert(.defaultDisappearingMessageTimer) } + if collapseSet != nil { + result.insert(.collapseSet) + } if bottomButtons != nil { result.insert(.bottomButtons) } @@ -980,6 +1007,23 @@ public struct CVComponentState: Equatable { return builder.build() } + static func buildCollapseSet( + interaction: CollapseSetInteraction, + itemBuildingContext: CVItemBuildingContext, + ) -> CVComponentState { + var builder = CVComponentState.Builder( + interaction: interaction, + itemBuildingContext: itemBuildingContext, + ) + builder.collapseSet = CollapseSet( + collapsedInteractions: interaction.collapsedInteractions, + collapseSetType: interaction.collapseSetType, + isExpanded: interaction.isExpanded, + finalTimerDescription: interaction.finalTimerDescription, + ) + return builder.build() + } + static func build( interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext, @@ -1005,7 +1049,7 @@ public struct CVComponentState: Equatable { break case .bodyMedia, .sticker, .audioAttachment, .genericAttachment, .contactShare: hasPrimaryContent = true - case .senderName, .senderAvatar, .footer, .reactions, .bottomButtons, .bottomLabel, .sendFailureBadge, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot: + case .senderName, .senderAvatar, .footer, .reactions, .bottomButtons, .bottomLabel, .sendFailureBadge, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: // "Primary" content is not just metadata / UI. break case .giftBadge: @@ -1164,6 +1208,18 @@ private extension CVComponentState.Builder { transaction: transaction, ) return build() + case .collapseSet: + guard let collapseSetInteraction = interaction as? CollapseSetInteraction else { + owsFailDebug("Invalid collapseSet interaction.") + return build() + } + self.collapseSet = CVComponentState.CollapseSet( + collapsedInteractions: collapseSetInteraction.collapsedInteractions, + collapseSetType: collapseSetInteraction.collapseSetType, + isExpanded: collapseSetInteraction.isExpanded, + finalTimerDescription: collapseSetInteraction.finalTimerDescription, + ) + return build() case .unreadIndicator: unreadIndicator = CVComponentState.UnreadIndicator() return build() diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index d7e43d5341..edca612561 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -23,6 +23,18 @@ extension ConversationViewController: CVComponentDelegate { self.loadCoordinator.enqueueReloadWithoutCaches() } + // MARK: - Collapse Sets + + public func didTapCollapseSet(collapseSetId: String) { + AssertIsOnMainThread() + if viewState.expandedCollapseSets.contains(collapseSetId) { + viewState.expandedCollapseSets.remove(collapseSetId) + } else { + viewState.expandedCollapseSets.insert(collapseSetId) + } + loadCoordinator.enqueueReload() + } + // MARK: - Double-Tap public func didDoubleTapTextViewItem(_ viewModel: CVItemViewModelImpl) { diff --git a/Signal/ConversationView/ConversationViewController+Scroll.swift b/Signal/ConversationView/ConversationViewController+Scroll.swift index ca34f78faf..3feabf5f23 100644 --- a/Signal/ConversationView/ConversationViewController+Scroll.swift +++ b/Signal/ConversationView/ConversationViewController+Scroll.swift @@ -186,7 +186,10 @@ extension ConversationViewController { return scrollToBottomOfConversation(animated: animated) } - guard let indexPath = indexPath(forInteractionUniqueId: lastVisibleInteraction.uniqueId) else { + guard + let renderedId = safeUniqueIdForScrolling(interactionUniqueId: lastVisibleInteraction.uniqueId), + let indexPath = indexPath(forInteractionUniqueId: renderedId) + else { owsFailDebug("No index path for interaction, scrolling to bottom") scrollToBottomOfConversation(animated: animated) return @@ -194,7 +197,7 @@ extension ConversationViewController { scrollToInteraction( indexPath: indexPath, - interactionUniqueId: lastVisibleInteraction.uniqueId, + interactionUniqueId: renderedId, onScreenPercentage: CGFloat(lastVisibleInteraction.onScreenPercentage), alignment: .bottom, animated: animated, @@ -373,6 +376,7 @@ extension ConversationViewController { animated: isAnimated, ) } else { + expandCollapseSetContaining(interactionId: interactionId) loadCoordinator.enqueueLoadAndScrollToInteraction( interactionId: interactionId, onScreenPercentage: onScreenPercentage, @@ -382,6 +386,21 @@ extension ConversationViewController { } } + /// Finds the uniqueId of the rendered item representing the given interaction. + /// If the interaction is inside a CollapseSetInteraction, returns the set's uniqueId. + private func safeUniqueIdForScrolling(interactionUniqueId: String) -> String? { + if indexPath(forInteractionUniqueId: interactionUniqueId) != nil { + return interactionUniqueId + } + return renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionUniqueId) + } + + private func expandCollapseSetContaining(interactionId: String) { + guard let parentUniqueId = renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionId) else { return } + viewState.expandedCollapseSets.insert(parentUniqueId) + loadCoordinator.enqueueReload() + } + func setScrollActionForSizeTransition() { AssertIsOnMainThread() @@ -409,9 +428,12 @@ extension ConversationViewController { return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) } + let renderedId = safeUniqueIdForScrolling( + interactionUniqueId: lastVisibleInteraction.uniqueId, + ) ?? lastVisibleInteraction.uniqueId return CVScrollAction( action: .scrollTo( - interactionId: lastVisibleInteraction.uniqueId, + interactionId: renderedId, onScreenPercentage: lastVisibleInteraction.onScreenPercentage, alignment: .bottom, ), diff --git a/Signal/ConversationView/ConversationViewController+Selection.swift b/Signal/ConversationView/ConversationViewController+Selection.swift index c1dcb69947..4e20f63eb9 100644 --- a/Signal/ConversationView/ConversationViewController+Selection.swift +++ b/Signal/ConversationView/ConversationViewController+Selection.swift @@ -223,7 +223,7 @@ extension CVSelectionState { } for item in itemMap.values { switch item.interactionType { - case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader: + case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader, .collapseSet: return false case .outgoingMessage where item.selectionType != .allContent: return false @@ -253,7 +253,7 @@ extension CVSelectionState { } switch item.interactionType { - case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader: + case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader, .collapseSet: return false case .info, .error, .call: return false diff --git a/Signal/ConversationView/ConversationViewLayout.swift b/Signal/ConversationView/ConversationViewLayout.swift index f504e10234..cf434a8b01 100644 --- a/Signal/ConversationView/ConversationViewLayout.swift +++ b/Signal/ConversationView/ConversationViewLayout.swift @@ -998,14 +998,10 @@ public class ConversationViewLayout: UICollectionViewLayout { // Some interactions shift around and cannot be reliably used as // references for scroll continuity. public static func canInteractionBeUsedForScrollContinuity(_ interaction: TSInteraction) -> Bool { - guard !interaction.isDynamicInteraction else { - return false - } - switch interaction.interactionType { - case .unknown, .unreadIndicator, .dateHeader, .typingIndicator: + case .unknown, .unreadIndicator, .dateHeader, .typingIndicator, .threadDetails, .defaultDisappearingMessageTimer: return false - case .incomingMessage, .outgoingMessage, .error, .call, .info, .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer: + case .incomingMessage, .outgoingMessage, .error, .call, .info, .unknownThreadWarning, .collapseSet: return true } } diff --git a/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift b/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift new file mode 100644 index 0000000000..a04727cb8a --- /dev/null +++ b/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift @@ -0,0 +1,88 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit + +class CollapseSetInteraction: TSInteraction { + + enum MessagesType: Equatable { + case groupUpdates + case chatUpdates + case timerChanges + case callEvents + } + + let collapsedInteractions: [TSInteraction] + + let collapseSetType: MessagesType + + let isExpanded: Bool + + let finalTimerDescription: String? + + override var isDynamicInteraction: Bool { true } + + override var interactionType: OWSInteractionType { .collapseSet } + + override var shouldBeSaved: Bool { false } + + init( + thread: TSThread, + collapsedInteractions: [TSInteraction], + collapseSetType: MessagesType, + isExpanded: Bool = false, + ) { + owsPrecondition(!collapsedInteractions.isEmpty) + self.collapsedInteractions = collapsedInteractions + self.collapseSetType = collapseSetType + self.isExpanded = isExpanded + self.finalTimerDescription = Self.disappearingTimerDescription( + for: collapsedInteractions, + type: collapseSetType, + ) + + let firstInteraction = collapsedInteractions[0] + super.init( + customUniqueId: "CollapseSet_\(firstInteraction.timestamp)", + timestamp: firstInteraction.timestamp, + receivedAtTimestamp: firstInteraction.receivedAtTimestamp, + thread: thread, + ) + } + + private static func disappearingTimerDescription( + for interactions: [TSInteraction], + type: MessagesType, + ) -> String? { + guard + type == .timerChanges, + let last = interactions.last as? TSInfoMessage, + let wrapper = last.infoMessageUserInfo?[.groupUpdateItems] as? TSInfoMessage.PersistableGroupUpdateItemsWrapper, + let item = wrapper.updateItems.last + else { + return nil + } + + switch item { + case .disappearingMessagesEnabledByLocalUser(let durationMs), + .disappearingMessagesEnabledByUnknownUser(let durationMs), + .disappearingMessagesEnabledByOtherUser(_, let durationMs): + return String.formatDurationLossless(durationSeconds: UInt32(durationMs / 1000)) + case .disappearingMessagesDisabledByLocalUser, + .disappearingMessagesDisabledByOtherUser, + .disappearingMessagesDisabledByUnknownUser: + return OWSLocalizedString( + "COLLAPSE_SET_TIMER_DISABLED", + comment: "Short label shown in a collapsed timer-changes set indicating the timer is now disabled.", + ) + default: + return nil + } + } + + override func anyWillInsert(with transaction: DBWriteTransaction) { + owsFailDebug("CollapseSetInteraction should not be saved to the database.") + } +} diff --git a/Signal/ConversationView/Loading/CVItemViewState.swift b/Signal/ConversationView/Loading/CVItemViewState.swift index 2dd8c259c1..94bbe86e5a 100644 --- a/Signal/ConversationView/Loading/CVItemViewState.swift +++ b/Signal/ConversationView/Loading/CVItemViewState.swift @@ -664,7 +664,6 @@ struct CVItemModelBuilder: CVItemBuilding { prevRenderState: CVRenderState, updatedInteractionIds: Set, ) { - for renderItem in prevRenderState.items { guard !updatedInteractionIds.contains(renderItem.interactionUniqueId) else { continue @@ -676,6 +675,17 @@ struct CVItemModelBuilder: CVItemBuilding { } } + mutating func reuseComponentStates(from itemModels: [CVItemModel]) { + // Dynamic interactions like collapse sets could have their + // ID change as more are loaded, so don't cache them. + for model in itemModels where !model.interaction.isDynamicInteraction { + componentStateCache.add( + interactionId: model.interaction.uniqueId, + componentState: model.componentState, + ) + } + } + private static func buildComponentState( interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext, @@ -883,7 +893,7 @@ private class ItemBuilder { var canShowDate: Bool { switch interaction.interactionType { - case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer: + case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet: return false case .info: guard let infoMessage = interaction as? TSInfoMessage else { diff --git a/Signal/ConversationView/Loading/CVLoader.swift b/Signal/ConversationView/Loading/CVLoader.swift index 8d9cf5a925..20f7eb15ba 100644 --- a/Signal/ConversationView/Loading/CVLoader.swift +++ b/Signal/ConversationView/Loading/CVLoader.swift @@ -171,14 +171,57 @@ public class CVLoader: NSObject { throw error } + var ungroupedItemModels = self.buildUngroupedItemModels( + loadContext: loadContext, + updatedInteractionIds: updatedInteractionIds, + localAci: localAci, + cachedModels: nil, + ) + var groupedModels = Self.applyCollapseGroups( + to: ungroupedItemModels, + loadContext: loadContext, + ) + + // MessageLoader doesn't know the type of messages, so we + // collapse them here and ask for more if it's not enough. + if case .loadInitialMapping = loadRequest.loadType { + let maxExtraLoads = 5 + var extraLoads = 0 + while + groupedModels.count < messageLoader.initialLoadCount, + messageLoader.canLoadOlder, + extraLoads < maxExtraLoads + { + try messageLoader.loadOlderMessagePage( + reusableInteractions: reusableInteractions, + deletedInteractionIds: deletedInteractionIds, + tx: transaction, + ) + ungroupedItemModels = self.buildUngroupedItemModels( + loadContext: loadContext, + updatedInteractionIds: updatedInteractionIds, + localAci: localAci, + cachedModels: ungroupedItemModels, + ) + groupedModels = Self.applyCollapseGroups( + to: ungroupedItemModels, + loadContext: loadContext, + ) + extraLoads += 1 + } + } + + let items = groupedModels.compactMap { item in + Self.buildRenderItem( + itemBuildingContext: loadContext, + itemModel: item, + ) + } + return LoadState( threadViewModel: threadViewModel, conversationViewModel: conversationViewModel, - items: self.buildRenderItems( - loadContext: loadContext, - updatedInteractionIds: updatedInteractionIds, - localAci: localAci, - ), + items: items, ) } @@ -205,12 +248,12 @@ public class CVLoader: NSObject { // MARK: - - private func buildRenderItems( + private func buildUngroupedItemModels( loadContext: CVLoadContext, updatedInteractionIds: Set, localAci: Aci, - ) -> [CVRenderItem] { - + cachedModels: [CVItemModel]?, + ) -> [CVItemModel] { let conversationStyle = loadContext.conversationStyle // Don't cache in the reset() case. @@ -222,40 +265,180 @@ public class CVLoader: NSObject { // can be expensive. Therefore we want to reuse them _unless_: // // * The corresponding interaction was updated. - // * We're do a "reset" reload where we deliberately reload everything, e.g. - // in response to an error or a cross-process write, etc. + // * We're doing a "reset" reload where we deliberately reload + // everything, e.g. in response to an error or a cross-process write. if canReuseState { itemModelBuilder.reuseComponentStates( prevRenderState: prevRenderState, updatedInteractionIds: updatedInteractionIds, ) } - let itemModels: [CVItemModel] = itemModelBuilder.buildItems(localAci: localAci) - var renderItems = [CVRenderItem]() - for itemModel in itemModels { - guard - let renderItem = buildRenderItem( - itemBuildingContext: loadContext, - itemModel: itemModel, - ) - else { - continue - } - renderItems.append(renderItem) + if let cachedModels = cachedModels?.nilIfEmpty { + itemModelBuilder.reuseComponentStates(from: cachedModels) } - return renderItems + return itemModelBuilder.buildItems(localAci: localAci) } - private func buildRenderItem( - itemBuildingContext: CVItemBuildingContext, - itemModel: CVItemModel, - ) -> CVRenderItem? { - Self.buildRenderItem( - itemBuildingContext: itemBuildingContext, - itemModel: itemModel, - ) + // MARK: - Collapse Set Grouping + + private static let maxCollapseSetSize = 50 + + /// Takes ungrouped item models and returns a list of item models with info + /// messages merged into collapse sets + private static func applyCollapseGroups( + to itemModels: [CVItemModel], + loadContext: CVLoadContext, + ) -> [CVItemModel] { + guard BuildFlags.collapsingChatEvents else { + return itemModels + } + + let thread = loadContext.thread + let threadAssociatedData = loadContext.threadViewModel.associatedData + let isGroupThread = thread.isGroupThread + let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets + let coreState = loadContext.viewStateSnapshot.coreState + + var result = [CVItemModel]() + var currentRun = [CVItemModel]() + var currentRunType: CollapseSetInteraction.MessagesType? + var pastUnreadIndicator = false + + func finalizeSet() { + defer { + currentRun.removeAll() + currentRunType = nil + } + guard currentRun.count >= 2, let runType = currentRunType else { + // Nothing to collapse + result.append(contentsOf: currentRun) + return + } + let collapseId = "CollapseSet_\(currentRun[0].interaction.timestamp)" + + let isExpanded = expandedCollapseSets.contains(collapseId) + let collapseSetInteraction = CollapseSetInteraction( + thread: thread, + collapsedInteractions: currentRun.map(\.interaction), + collapseSetType: runType, + isExpanded: isExpanded, + ) + let componentState = CVComponentState.buildCollapseSet( + interaction: collapseSetInteraction, + itemBuildingContext: loadContext, + ) + let itemModel = CVItemModel( + interaction: collapseSetInteraction, + thread: thread, + threadAssociatedData: threadAssociatedData, + componentState: componentState, + itemViewState: CVItemViewState.Builder().build(), + coreState: coreState, + ) + result.append(itemModel) + if isExpanded { + result.append(contentsOf: currentRun) + } + } + + for item in itemModels { + // Don't collapse unread items + if pastUnreadIndicator { + result.append(item) + continue + } + + switch item.interaction.interactionType { + case .dateHeader: + finalizeSet() + result.append(item) + case .unreadIndicator: + finalizeSet() + pastUnreadIndicator = true + result.append(item) + default: + let collapseSetType = collapseSetType(for: item.interaction, isGroupThread: isGroupThread) + if let collapseSetType { + let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseSetType + let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize + if isDifferentSetThanCurrentRun || exceededCurrentRunLimit { + finalizeSet() + } + currentRun.append(item) + currentRunType = collapseSetType + } else { + finalizeSet() + result.append(item) + } + } + } + finalizeSet() + return result + } + + private static func collapseSetType( + for interaction: TSInteraction, + isGroupThread: Bool, + ) -> CollapseSetInteraction.MessagesType? { + switch interaction.interactionType { + case .info: + guard let infoMessage = interaction as? TSInfoMessage else { + owsFailDebug("info interaction is not TSInfoMessage") + return nil + } + switch infoMessage.messageType { + case .typeDisappearingMessagesUpdate: + return .timerChanges + case .typeGroupUpdate: + if + let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems] + as? TSInfoMessage.PersistableGroupUpdateItemsWrapper + { + for event in wrapper.updateItems { + switch event { + case + .groupTerminatedByLocalUser, + .groupTerminatedByOtherUser, + .groupTerminatedByUnknownUser: + return nil + case + .disappearingMessagesEnabledByLocalUser, + .disappearingMessagesEnabledByOtherUser, + .disappearingMessagesEnabledByUnknownUser, + .disappearingMessagesDisabledByLocalUser, + .disappearingMessagesDisabledByOtherUser, + .disappearingMessagesDisabledByUnknownUser: + return .timerChanges + default: + break + } + } + } + + return isGroupThread ? .groupUpdates : .chatUpdates + case .verificationStateChange, + .profileUpdate, + .phoneNumberChange, + .typeEndPoll, + .typePinnedMessage: + return isGroupThread ? .groupUpdates : .chatUpdates + default: + return nil + } + case .call: + // Don't collapse an active group call. + if + let groupCallMessage = interaction as? OWSGroupCallMessage, + !groupCallMessage.hasEnded + { + return nil + } + return .callEvents + default: + return nil + } } #if USE_DEBUG_UI @@ -523,6 +706,12 @@ public class CVLoader: NSObject { return nil } rootComponent = CVComponentSystemMessage(itemModel: itemModel, systemMessage: systemMessage) + case .collapseSet: + guard let collapseSet = itemModel.componentState.collapseSet else { + owsFailDebug("Missing collapseSet.") + return nil + } + rootComponent = CVComponentCollapseSet(itemModel: itemModel, collapseSet: collapseSet) case .unknown: Logger.warn("Discarding item: \(itemModel.messageCellType).") return nil diff --git a/Signal/ConversationView/Loading/CVRenderState.swift b/Signal/ConversationView/Loading/CVRenderState.swift index 9ba2e79153..16602daf81 100644 --- a/Signal/ConversationView/Loading/CVRenderState.swift +++ b/Signal/ConversationView/Loading/CVRenderState.swift @@ -62,6 +62,8 @@ class CVRenderState { let indexPathOfUnreadIndicator: IndexPath? private let interactionIdToIndexPathMap: [String: IndexPath] + /// Maps each collapsed interaction's uniqueId to the uniqueId of its parent CollapseSetInteraction. + private let collapsedInteractionToParentUniqueIdMap: [String: String] let allIndexPaths: [IndexPath] @@ -89,6 +91,7 @@ class CVRenderState { let messageSection = CVLoadCoordinator.messageSection var indexPathOfUnreadIndicator: IndexPath? var interactionIdToIndexPathMap = [String: IndexPath]() + var collapsedInteractionToParentUniqueIdMap = [String: String]() var allIndexPaths = [IndexPath]() for (index, item) in items.enumerated() { let indexPath = IndexPath(row: index, section: messageSection) @@ -98,9 +101,18 @@ class CVRenderState { if item.interactionType == .unreadIndicator { indexPathOfUnreadIndicator = indexPath } + if + item.interactionType == .collapseSet, + let collapseSet = item.interaction as? CollapseSetInteraction + { + for collapsed in collapseSet.collapsedInteractions { + collapsedInteractionToParentUniqueIdMap[collapsed.uniqueId] = collapseSet.uniqueId + } + } } self.indexPathOfUnreadIndicator = indexPathOfUnreadIndicator self.interactionIdToIndexPathMap = interactionIdToIndexPathMap + self.collapsedInteractionToParentUniqueIdMap = collapsedInteractionToParentUniqueIdMap self.allIndexPaths = allIndexPaths } @@ -125,6 +137,12 @@ class CVRenderState { interactionIdToIndexPathMap[interactionUniqueId] } + /// Returns the uniqueId of the CollapseSetInteraction that contains + /// the given interaction if it is inside a collapse set. + func collapseSetUniqueId(forCollapsedInteractionId interactionId: String) -> String? { + collapsedInteractionToParentUniqueIdMap[interactionId] + } + var debugDescription: String { isEmptyInitialState ? "empty" : "[items: \(items.count)]" } diff --git a/Signal/ConversationView/Loading/CVUpdate.swift b/Signal/ConversationView/Loading/CVUpdate.swift index 20578739c2..c892199dcc 100644 --- a/Signal/ConversationView/Loading/CVUpdate.swift +++ b/Signal/ConversationView/Loading/CVUpdate.swift @@ -206,6 +206,8 @@ extension CVUpdate { if newIndex + 2 > updateItems.count { shouldAnimateUpdate = false } + case .collapseSet: + break default: shouldAnimateUpdate = false } diff --git a/Signal/ConversationView/Loading/CVViewStateSnapshot.swift b/Signal/ConversationView/Loading/CVViewStateSnapshot.swift index 1b0751fbbf..fc5007f599 100644 --- a/Signal/ConversationView/Loading/CVViewStateSnapshot.swift +++ b/Signal/ConversationView/Loading/CVViewStateSnapshot.swift @@ -42,6 +42,8 @@ struct CVViewStateSnapshot { let hasActiveCall: Bool let currentGroupThreadCallGroupId: GroupIdentifier? + let expandedCollapseSets: Set + private static var currentCallProvider: any CurrentCallProvider { DependenciesBridge.shared.currentCallProvider } static func snapshot( @@ -62,6 +64,7 @@ struct CVViewStateSnapshot { oldestUnreadMessageSortId: oldestUnreadMessageSortId, hasActiveCall: currentCallProvider.hasCurrentCall, currentGroupThreadCallGroupId: currentCallProvider.currentGroupThreadCallGroupId, + expandedCollapseSets: viewState.expandedCollapseSets, ) } @@ -81,6 +84,7 @@ struct CVViewStateSnapshot { oldestUnreadMessageSortId: nil, hasActiveCall: false, currentGroupThreadCallGroupId: nil, + expandedCollapseSets: [], ) } } diff --git a/Signal/ConversationView/Loading/MessageLoader.swift b/Signal/ConversationView/Loading/MessageLoader.swift index 3b6be985b2..14de54c8df 100644 --- a/Signal/ConversationView/Loading/MessageLoader.swift +++ b/Signal/ConversationView/Loading/MessageLoader.swift @@ -70,7 +70,7 @@ class MessageLoader { // fast for most conversations, at the expense of a second fetch for // conversations with pathologically small messages (e.g. a bunch of 1-line // texts in a row from the same sender and timestamp) - private lazy var initialLoadCount: Int = { + lazy var initialLoadCount: Int = { let avgMessageHeight: CGFloat = 35 var deviceFrame = CGRect.zero DispatchSyncMainThreadSafe { diff --git a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift index acf73811a2..c77900489e 100644 --- a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift +++ b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift @@ -310,6 +310,8 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} func didLongPressTextViewItem( diff --git a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift index 19fe9df67b..90c6b0bd54 100644 --- a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift +++ b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift @@ -323,6 +323,8 @@ extension MediaGalleryFileCell: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} func didLongPressTextViewItem( diff --git a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift index d35e4d04f5..7434c7ebe0 100644 --- a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift +++ b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift @@ -558,6 +558,8 @@ extension MemberLabelViewController: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} func didLongPressTextViewItem( diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 8a5d8d5daa..7a25702e03 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -1036,6 +1036,8 @@ extension MessageDetailViewController: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + // MARK: - Double-Tap func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} diff --git a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift index 99deff299b..e8c6e56fb7 100644 --- a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift +++ b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift @@ -436,6 +436,8 @@ extension PinnedMessagesDetailsViewController: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} func didLongPressTextViewItem( diff --git a/Signal/src/views/MockConversationView.swift b/Signal/src/views/MockConversationView.swift index d202ba2562..36a62027a8 100644 --- a/Signal/src/views/MockConversationView.swift +++ b/Signal/src/views/MockConversationView.swift @@ -334,6 +334,8 @@ extension MockConversationView: CVComponentDelegate { func didTapSystemMessageItem(_ item: CVTextLabel.Item) {} + func didTapCollapseSet(collapseSetId: String) {} + func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} func didLongPressTextViewItem( diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 7c12049892..6816c232e8 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1957,6 +1957,15 @@ /* Title for a view allowing users to choose a Backup plan. */ "CHOOSE_BACKUP_PLAN_TITLE" = "Choose Backup Plan"; +/* VoiceOver hint for an expanded collapse set button. */ +"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE" = "Hide updates"; + +/* VoiceOver hint for a collapsed collapse set button. */ +"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND" = "Show updates"; + +/* Short label shown in a collapsed timer-changes set indicating the timer is now disabled. */ +"COLLAPSE_SET_TIMER_DISABLED" = "Disabled"; + /* Title for the color & wallpaper settings view. */ "COLOR_AND_WALLPAPER_SETTINGS_TITLE" = "Chat Color & Wallpaper"; diff --git a/Signal/translations/en.lproj/PluralAware.stringsdict b/Signal/translations/en.lproj/PluralAware.stringsdict index 4eb01c24c5..72e821907a 100644 --- a/Signal/translations/en.lproj/PluralAware.stringsdict +++ b/Signal/translations/en.lproj/PluralAware.stringsdict @@ -381,6 +381,70 @@ Text + %d days of media + COLLAPSE_SET_CALL_EVENTS_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld call + other + %ld calls + + + COLLAPSE_SET_CHAT_UPDATES_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld chat update + other + %ld chat updates + + + COLLAPSE_SET_GROUP_UPDATES_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld group update + other + %ld group updates + + + COLLAPSE_SET_TIMER_CHANGES_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld disappearing message timer change + other + %ld disappearing message timer changes + + CONVERSATION_DELETE_CONFIRMATIONS_ALERT_TITLE_%d NSStringLocalizedFormatKey diff --git a/SignalServiceKit/Environment/BuildFlags.swift b/SignalServiceKit/Environment/BuildFlags.swift index c50ed18efe..14bd0e16e1 100644 --- a/SignalServiceKit/Environment/BuildFlags.swift +++ b/SignalServiceKit/Environment/BuildFlags.swift @@ -94,6 +94,8 @@ public enum BuildFlags { public enum AttachmentBackfill { public static let handleRequests = true } + + public static let collapsingChatEvents = build <= .internal } // MARK: - diff --git a/SignalServiceKit/Messages/Interactions/TSInteraction.h b/SignalServiceKit/Messages/Interactions/TSInteraction.h index 7fa3aef141..fdcaa1828d 100644 --- a/SignalServiceKit/Messages/Interactions/TSInteraction.h +++ b/SignalServiceKit/Messages/Interactions/TSInteraction.h @@ -22,7 +22,8 @@ typedef NS_CLOSED_ENUM(NSInteger, OWSInteractionType) { OWSInteractionType_UnreadIndicator, OWSInteractionType_DateHeader, OWSInteractionType_UnknownThreadWarning, - OWSInteractionType_DefaultDisappearingMessageTimer + OWSInteractionType_DefaultDisappearingMessageTimer, + OWSInteractionType_CollapseSet, }; NSString *NSStringFromOWSInteractionType(OWSInteractionType value); diff --git a/SignalServiceKit/Messages/Interactions/TSInteraction.m b/SignalServiceKit/Messages/Interactions/TSInteraction.m index 1c18eb07ce..69fd3430e4 100644 --- a/SignalServiceKit/Messages/Interactions/TSInteraction.m +++ b/SignalServiceKit/Messages/Interactions/TSInteraction.m @@ -35,6 +35,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) return @"OWSInteractionType_UnknownThreadWarning"; case OWSInteractionType_DefaultDisappearingMessageTimer: return @"OWSInteractionType_DefaultDisappearingMessageTimer"; + case OWSInteractionType_CollapseSet: + return @"OWSInteractionType_CollapseSet"; } }