459 lines
17 KiB
Swift
459 lines
17 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
|
|
override func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
|
|
guard let componentView = componentView as? CVComponentViewCollapseSet else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return nil
|
|
}
|
|
return componentView.wallpaperBlurView
|
|
}
|
|
|
|
func configureForRendering(
|
|
componentView: CVComponentView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
) {
|
|
guard let componentView = componentView as? CVComponentViewCollapseSet else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
componentView.reset()
|
|
return
|
|
}
|
|
|
|
let hasWallpaper = conversationStyle.hasWallpaper
|
|
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
|
|
let isReusing = componentView.rootView.superview != nil
|
|
&& componentView.innerStack.superview != nil
|
|
&& !wallpaperModeHasChanged
|
|
|
|
if !isReusing {
|
|
componentView.reset()
|
|
}
|
|
|
|
componentView.hasWallpaper = hasWallpaper
|
|
|
|
labelConfig.applyForRendering(label: componentView.label)
|
|
chevronConfig.applyForRendering(label: componentView.chevronLabel)
|
|
|
|
if isReusing {
|
|
componentView.innerStack.configureForReuse(
|
|
config: innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_innerStack,
|
|
)
|
|
componentView.outerStack.configureForReuse(
|
|
config: outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
)
|
|
} else {
|
|
componentView.innerStack.configure(
|
|
config: innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_innerStack,
|
|
subviews: [componentView.label, componentView.chevronContainer],
|
|
)
|
|
|
|
componentView.outerStack.configure(
|
|
config: outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviews: [componentView.innerStack],
|
|
)
|
|
|
|
let bubbleView: UIView
|
|
if hasWallpaper {
|
|
let wallpaperBlurView = componentView.ensureWallpaperBlurView()
|
|
configureWallpaperBlurView(
|
|
wallpaperBlurView: wallpaperBlurView,
|
|
componentDelegate: componentDelegate,
|
|
bubbleConfig: BubbleConfiguration(
|
|
corners: .capsule(maxRadius: 16),
|
|
stroke: ConversationStyle.bubbleStroke(isDarkThemeEnabled: isDarkThemeEnabled),
|
|
),
|
|
)
|
|
bubbleView = wallpaperBlurView
|
|
} else {
|
|
let solidBackgroundView = componentView.solidBackgroundView
|
|
solidBackgroundView.layer.cornerRadius = 16
|
|
solidBackgroundView.backgroundColor = .Signal.secondaryFill
|
|
bubbleView = solidBackgroundView
|
|
}
|
|
componentView.outerStack.addSubview(bubbleView)
|
|
componentView.outerStack.sendSubviewToBack(bubbleView)
|
|
componentView.outerStack.addLayoutBlock { [innerStack = componentView.innerStack] _ in
|
|
bubbleView.frame = innerStack.frame.inset(by: Self.backgroundLayoutInsets)
|
|
}
|
|
componentView.innerStack.addLayoutBlock { [chevronContainer = componentView.chevronContainer, chevronLabel = componentView.chevronLabel] _ in
|
|
chevronLabel.bounds.size = chevronContainer.bounds.size
|
|
chevronLabel.center = CGPoint(x: chevronContainer.bounds.midX, y: chevronContainer.bounds.midY)
|
|
}
|
|
}
|
|
|
|
componentView.isShowingExpanded = collapseSet.isExpanded
|
|
componentView.chevronLabel.transform = collapseSet.isExpanded
|
|
? CGAffineTransform(rotationAngle: -.pi)
|
|
: .identity
|
|
|
|
if
|
|
hasWallpaper,
|
|
let wallpaperBlurView = componentView.wallpaperBlurView
|
|
{
|
|
wallpaperBlurView.applyLayout()
|
|
wallpaperBlurView.updateIfNecessary()
|
|
}
|
|
|
|
componentView.outerStack.isAccessibilityElement = true
|
|
componentView.outerStack.accessibilityLabel = titleString
|
|
componentView.outerStack.accessibilityTraits = .button
|
|
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
override func handleTap(
|
|
sender: UIGestureRecognizer,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
) -> Bool {
|
|
if let componentView = componentView as? CVComponentViewCollapseSet {
|
|
let wasExpanded = componentView.isShowingExpanded
|
|
let willBeExpanded = !wasExpanded
|
|
let expandedRotation: CGFloat = -.pi
|
|
let isRTL = componentView.chevronLabel.effectiveUserInterfaceLayoutDirection == .rightToLeft
|
|
|
|
let fromAngle: CGFloat
|
|
let toAngle: CGFloat
|
|
if willBeExpanded {
|
|
fromAngle = 0
|
|
toAngle = isRTL ? CGFloat.pi : -CGFloat.pi
|
|
} else {
|
|
fromAngle = expandedRotation
|
|
toAngle = isRTL ? -2 * CGFloat.pi : 0
|
|
}
|
|
|
|
componentView.isShowingExpanded = willBeExpanded
|
|
componentView.chevronLabel.transform = willBeExpanded
|
|
? CGAffineTransform(rotationAngle: expandedRotation)
|
|
: .identity
|
|
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
|
|
|
|
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
animation.fromValue = fromAngle
|
|
animation.toValue = toAngle
|
|
animation.duration = 0.2
|
|
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
componentView.chevronLabel.layer.add(animation, forKey: "chevronRotation")
|
|
}
|
|
componentDelegate.didTapCollapseSet(collapseSetId: interaction.uniqueId)
|
|
return true
|
|
}
|
|
|
|
// MARK: - Measurement
|
|
|
|
fileprivate static let measurementKey_outerStack = "CVComponentCollapseSet.outerStack"
|
|
fileprivate static let measurementKey_innerStack = "CVComponentCollapseSet.innerStack"
|
|
|
|
func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
owsAssertDebug(maxWidth > 0)
|
|
let availableWidth = max(
|
|
0,
|
|
maxWidth - outerStackConfig.layoutMargins.totalWidth,
|
|
)
|
|
let chevronSize = CVText.measureLabel(config: chevronConfig, maxWidth: availableWidth)
|
|
let labelMaxWidth = max(0, availableWidth - chevronSize.width - innerStackConfig.spacing)
|
|
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: labelMaxWidth)
|
|
let innerMeasurement = ManualStackView.measure(
|
|
config: innerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_innerStack,
|
|
subviewInfos: [
|
|
labelSize.asManualSubviewInfo(hasFixedWidth: true),
|
|
chevronSize.asManualSubviewInfo(hasFixedSize: true),
|
|
],
|
|
)
|
|
let outerMeasurement = ManualStackView.measure(
|
|
config: outerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviewInfos: [innerMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)],
|
|
)
|
|
return outerMeasurement.measuredSize
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
private var innerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(
|
|
axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 4,
|
|
layoutMargins: .zero,
|
|
)
|
|
}
|
|
|
|
private var outerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(
|
|
axis: .vertical,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: UIEdgeInsets(
|
|
top: 4 + Self.labelContentInsets.top,
|
|
leading: conversationStyle.fullWidthGutterLeading + Self.labelContentInsets.leading,
|
|
bottom: 4 + Self.labelContentInsets.bottom,
|
|
trailing: conversationStyle.fullWidthGutterTrailing + Self.labelContentInsets.trailing,
|
|
),
|
|
)
|
|
}
|
|
|
|
private static var backgroundLayoutInsets: UIEdgeInsets {
|
|
UIEdgeInsets(
|
|
top: -labelContentInsets.top,
|
|
leading: -labelContentInsets.leading,
|
|
bottom: -labelContentInsets.bottom,
|
|
trailing: -labelContentInsets.trailing,
|
|
)
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private var labelFont: UIFont { .dynamicTypeFootnote.medium() }
|
|
|
|
private static var labelContentInsets: NSDirectionalEdgeInsets {
|
|
NSDirectionalEdgeInsets(hMargin: 14, vMargin: 5)
|
|
}
|
|
|
|
private var leadingIcon: SignalSymbol {
|
|
switch collapseSet.collapseSetType {
|
|
case .groupUpdates: return .group
|
|
case .chatUpdates: return .thread
|
|
case .callEvents: return .phone
|
|
case .timerChanges: return .timer
|
|
}
|
|
}
|
|
|
|
private var labelConfig: CVLabelConfig {
|
|
CVLabelConfig(
|
|
text: .attributedText(titleAttributedString),
|
|
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
|
|
font: labelFont,
|
|
textColor: .Signal.label,
|
|
numberOfLines: 0,
|
|
lineBreakMode: .byWordWrapping,
|
|
textAlignment: .center,
|
|
)
|
|
}
|
|
|
|
private var titleAttributedString: NSAttributedString {
|
|
let labelText = summaryLabel(
|
|
count: collapseSet.collapsedInteractions.count,
|
|
type: collapseSet.collapseSetType,
|
|
finalTimerDescription: collapseSet.finalTimerDescription,
|
|
)
|
|
|
|
let nbsp = SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue
|
|
|
|
let result = NSMutableAttributedString()
|
|
result.append(leadingIcon.attributedString(
|
|
for: .footnote,
|
|
clamped: false,
|
|
attributes: [.foregroundColor: UIColor.Signal.label],
|
|
))
|
|
result.append(NSAttributedString(
|
|
string: "\(nbsp)\(labelText)",
|
|
attributes: [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.Signal.label,
|
|
],
|
|
))
|
|
return result
|
|
}
|
|
|
|
private var chevronConfig: CVLabelConfig {
|
|
CVLabelConfig(
|
|
text: .attributedText(chevronAttributedString),
|
|
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
|
|
font: labelFont,
|
|
textColor: .Signal.label,
|
|
numberOfLines: 1,
|
|
lineBreakMode: .byClipping,
|
|
textAlignment: .center,
|
|
)
|
|
}
|
|
|
|
private var chevronAttributedString: NSAttributedString {
|
|
SignalSymbol.chevronDown.attributedString(
|
|
for: .footnote,
|
|
clamped: false,
|
|
attributes: [.foregroundColor: UIColor.Signal.label],
|
|
)
|
|
}
|
|
|
|
private var titleString: String {
|
|
summaryLabel(
|
|
count: collapseSet.collapsedInteractions.count,
|
|
type: collapseSet.collapseSetType,
|
|
finalTimerDescription: collapseSet.finalTimerDescription,
|
|
)
|
|
}
|
|
|
|
private func accessibilityHint(isExpanded: Bool) -> String {
|
|
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.",
|
|
)
|
|
}
|
|
|
|
private func summaryLabel(
|
|
count: Int,
|
|
type: CollapseSetInteraction.MessagesType,
|
|
finalTimerDescription: String? = nil,
|
|
) -> 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:
|
|
let finalTimer: String
|
|
if let finalTimerDescription {
|
|
finalTimer = finalTimerDescription
|
|
} else {
|
|
owsFailDebug("disappearing message timer collapse set does not have final timer description")
|
|
finalTimer = ""
|
|
}
|
|
return String(
|
|
format: OWSLocalizedString(
|
|
"COLLAPSE_SET_TIMER_CHANGES_WITH_FINAL_TIMER_%d",
|
|
tableName: "PluralAware",
|
|
comment: "Label for collapsed disappearing message timer changes showing the final timer value. Embeds {{number of events}} and {{timer description}}.",
|
|
),
|
|
count,
|
|
finalTimer,
|
|
)
|
|
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 innerStack = ManualStackView(name: "collapseSet.innerStack")
|
|
fileprivate let label = CVLabel()
|
|
fileprivate let chevronContainer = UIView()
|
|
fileprivate let chevronLabel = CVLabel()
|
|
fileprivate let solidBackgroundView = UIView()
|
|
|
|
fileprivate var isShowingExpanded = false
|
|
|
|
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
|
|
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
|
|
if let wallpaperBlurView = self.wallpaperBlurView {
|
|
return wallpaperBlurView
|
|
}
|
|
let wallpaperBlurView = CVWallpaperBlurView()
|
|
self.wallpaperBlurView = wallpaperBlurView
|
|
return wallpaperBlurView
|
|
}
|
|
|
|
fileprivate var hasWallpaper = false
|
|
|
|
var isDedicatedCellView = false
|
|
|
|
var rootView: UIView { outerStack }
|
|
|
|
override init() {
|
|
super.init()
|
|
chevronContainer.addSubview(chevronLabel)
|
|
}
|
|
|
|
func setIsCellVisible(_ isCellVisible: Bool) {}
|
|
|
|
func reset() {
|
|
label.reset()
|
|
chevronLabel.reset()
|
|
chevronLabel.transform = .identity
|
|
chevronLabel.layer.removeAnimation(forKey: "chevronRotation")
|
|
isShowingExpanded = false
|
|
solidBackgroundView.backgroundColor = nil
|
|
wallpaperBlurView?.removeFromSuperview()
|
|
wallpaperBlurView = nil
|
|
hasWallpaper = false
|
|
innerStack.reset()
|
|
outerStack.reset()
|
|
}
|
|
}
|
|
}
|