Collapsing chat events

This commit is contained in:
Elaine 2026-04-15 01:12:00 -04:00 committed by GitHub
parent 86b40cf437
commit 87304b783c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 805 additions and 52 deletions

View File

@ -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 = "<group>"; };
B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallQualitySurvey.swift; sourceTree = "<group>"; };
B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Regular.otf"; sourceTree = "<group>"; };
B96EFB502F7B03B600BD721E /* CollapseSetInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapseSetInteraction.swift; sourceTree = "<group>"; };
B96EFB522F7B041200BD721E /* CVComponentCollapseSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVComponentCollapseSet.swift; sourceTree = "<group>"; };
B96FEE2E2CDC297500836191 /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = "<group>"; };
B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationAvatarView+SwiftUI.swift"; sourceTree = "<group>"; };
B97803002DD65B0A00E9FC82 /* LinearProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearProgressView.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -21,6 +21,7 @@ public enum CVCellReuseIdentifier: String, CaseIterable {
case threadDetails
case systemMessage
case unknownThreadWarning
case collapseSet
}
// MARK: -

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -149,6 +149,10 @@ public class CVViewState: NSObject {
var unwrappedGiftMessageIds = Set<String>()
/// The set of collapse set IDs that have been expanded by the user.
/// Resets to empty when leaving the conversation.
var expandedCollapseSets = Set<String>()
// MARK: - Attachment downloads
var manuallyCanceledDownloadsMessageIds = Set<String>()

View File

@ -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:

View File

@ -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()
}
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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) {

View File

@ -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,
),

View File

@ -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

View File

@ -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
}
}

View File

@ -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.")
}
}

View File

@ -664,7 +664,6 @@ struct CVItemModelBuilder: CVItemBuilding {
prevRenderState: CVRenderState,
updatedInteractionIds: Set<String>,
) {
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 {

View File

@ -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<String>,
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

View File

@ -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)]"
}

View File

@ -206,6 +206,8 @@ extension CVUpdate {
if newIndex + 2 > updateItems.count {
shouldAnimateUpdate = false
}
case .collapseSet:
break
default:
shouldAnimateUpdate = false
}

View File

@ -42,6 +42,8 @@ struct CVViewStateSnapshot {
let hasActiveCall: Bool
let currentGroupThreadCallGroupId: GroupIdentifier?
let expandedCollapseSets: Set<String>
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: [],
)
}
}

View File

@ -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 {

View File

@ -310,6 +310,8 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(

View File

@ -323,6 +323,8 @@ extension MediaGalleryFileCell: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(

View File

@ -558,6 +558,8 @@ extension MemberLabelViewController: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(

View File

@ -1036,6 +1036,8 @@ extension MessageDetailViewController: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
// MARK: - Double-Tap
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}

View File

@ -436,6 +436,8 @@ extension PinnedMessagesDetailsViewController: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(

View File

@ -334,6 +334,8 @@ extension MockConversationView: CVComponentDelegate {
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(

View File

@ -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";

View File

@ -381,6 +381,70 @@
<string>Text + %d days of media</string>
</dict>
</dict>
<key>COLLAPSE_SET_CALL_EVENTS_%d</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@text@</string>
<key>text</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>%ld call</string>
<key>other</key>
<string>%ld calls</string>
</dict>
</dict>
<key>COLLAPSE_SET_CHAT_UPDATES_%d</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@text@</string>
<key>text</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>%ld chat update</string>
<key>other</key>
<string>%ld chat updates</string>
</dict>
</dict>
<key>COLLAPSE_SET_GROUP_UPDATES_%d</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@text@</string>
<key>text</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>%ld group update</string>
<key>other</key>
<string>%ld group updates</string>
</dict>
</dict>
<key>COLLAPSE_SET_TIMER_CHANGES_%d</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@text@</string>
<key>text</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>%ld disappearing message timer change</string>
<key>other</key>
<string>%ld disappearing message timer changes</string>
</dict>
</dict>
<key>CONVERSATION_DELETE_CONFIRMATIONS_ALERT_TITLE_%d</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View File

@ -94,6 +94,8 @@ public enum BuildFlags {
public enum AttachmentBackfill {
public static let handleRequests = true
}
public static let collapsingChatEvents = build <= .internal
}
// MARK: -

View File

@ -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);

View File

@ -35,6 +35,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value)
return @"OWSInteractionType_UnknownThreadWarning";
case OWSInteractionType_DefaultDisappearingMessageTimer:
return @"OWSInteractionType_DefaultDisappearingMessageTimer";
case OWSInteractionType_CollapseSet:
return @"OWSInteractionType_CollapseSet";
}
}