Collapsing chat events
This commit is contained in:
parent
86b40cf437
commit
87304b783c
@ -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 */,
|
||||
|
||||
@ -21,6 +21,7 @@ public enum CVCellReuseIdentifier: String, CaseIterable {
|
||||
case threadDetails
|
||||
case systemMessage
|
||||
case unknownThreadWarning
|
||||
case collapseSet
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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:
|
||||
|
||||
244
Signal/ConversationView/Components/CVComponentCollapseSet.swift
Normal file
244
Signal/ConversationView/Components/CVComponentCollapseSet.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]"
|
||||
}
|
||||
|
||||
@ -206,6 +206,8 @@ extension CVUpdate {
|
||||
if newIndex + 2 > updateItems.count {
|
||||
shouldAnimateUpdate = false
|
||||
}
|
||||
case .collapseSet:
|
||||
break
|
||||
default:
|
||||
shouldAnimateUpdate = false
|
||||
}
|
||||
|
||||
@ -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: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -310,6 +310,8 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
func didLongPressTextViewItem(
|
||||
|
||||
@ -323,6 +323,8 @@ extension MediaGalleryFileCell: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
func didLongPressTextViewItem(
|
||||
|
||||
@ -558,6 +558,8 @@ extension MemberLabelViewController: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
func didLongPressTextViewItem(
|
||||
|
||||
@ -1036,6 +1036,8 @@ extension MessageDetailViewController: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
// MARK: - Double-Tap
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
@ -436,6 +436,8 @@ extension PinnedMessagesDetailsViewController: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
func didLongPressTextViewItem(
|
||||
|
||||
@ -334,6 +334,8 @@ extension MockConversationView: CVComponentDelegate {
|
||||
|
||||
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
|
||||
|
||||
func didTapCollapseSet(collapseSetId: String) {}
|
||||
|
||||
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
|
||||
|
||||
func didLongPressTextViewItem(
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -94,6 +94,8 @@ public enum BuildFlags {
|
||||
public enum AttachmentBackfill {
|
||||
public static let handleRequests = true
|
||||
}
|
||||
|
||||
public static let collapsingChatEvents = build <= .internal
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -35,6 +35,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value)
|
||||
return @"OWSInteractionType_UnknownThreadWarning";
|
||||
case OWSInteractionType_DefaultDisappearingMessageTimer:
|
||||
return @"OWSInteractionType_DefaultDisappearingMessageTimer";
|
||||
case OWSInteractionType_CollapseSet:
|
||||
return @"OWSInteractionType_CollapseSet";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user