Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/Components/CVComponentDateHeader.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
public import SignalUI

public class CVComponentDateHeader: CVComponentBase, CVRootComponent {

    public var componentKey: CVComponentKey { .dateHeader }

    public var cellReuseIdentifier: CVCellReuseIdentifier {
        CVCellReuseIdentifier.dateHeader
    }

    public let isDedicatedCell = true

    struct State: Equatable {
        let text: String
    }

    private let dateHeaderState: State

    init(itemModel: CVItemModel, dateHeaderState: State) {
        self.dateHeaderState = dateHeaderState

        super.init(itemModel: itemModel)
    }

    public func configureCellRootComponent(
        cellView: UIView,
        cellMeasurement: CVCellMeasurement,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
        componentView: CVComponentView,
    ) {
        Self.configureCellRootComponent(
            rootComponent: self,
            cellView: cellView,
            cellMeasurement: cellMeasurement,
            componentDelegate: componentDelegate,
            componentView: componentView,
        )
    }

    public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
        CVComponentViewDateHeader()
    }

    override public func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
        guard let componentView = componentView as? CVComponentViewDateHeader else {
            owsFailDebug("Unexpected componentView.")
            return nil
        }
        return componentView.plainContentView?.wallpaperBlurView
    }

    override public func apply(
        layoutAttributes: CVCollectionViewLayoutAttributes,
        componentView: CVComponentView,
    ) {
        guard let componentView = componentView as? CVComponentViewDateHeader else {
            owsFailDebug("Unexpected componentView.")
            return
        }
        guard let doubleContentView = componentView.doubleContentView else {
            return
        }
        doubleContentView.normalView.isHidden = layoutAttributes.isStickyHeader
        doubleContentView.stickyView.isHidden = !layoutAttributes.isStickyHeader
    }

    fileprivate struct DoubleContentView {
        let normalView: UIView
        let stickyView: UIView
    }

    public func configureForRendering(
        componentView: CVComponentView,
        cellMeasurement: CVCellMeasurement,
        componentDelegate: CVComponentDelegate,
    ) {
        guard let componentView = componentView as? CVComponentViewDateHeader else {
            owsFailDebug("Unexpected componentView.")
            return
        }

        let themeHasChanged = isDarkThemeEnabled != componentView.isDarkThemeEnabled
        let hasWallpaper = conversationStyle.hasWallpaper
        let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper

        let isReusing = (
            componentView.rootView.superview != nil &&
                !themeHasChanged &&
                !wallpaperModeHasChanged,
        )
        if !isReusing {
            componentView.reset(resetReusableState: true)
        }

        componentView.isDarkThemeEnabled = isDarkThemeEnabled
        componentView.hasWallpaper = hasWallpaper

        let outerStack = componentView.outerStack
        let doubleContentWrapper = componentView.doubleContentWrapper

        let blurBackgroundColor: UIColor = {
            return isDarkThemeEnabled ? UIColor(rgbHex: 0x1B1B1B) : UIColor(rgbHex: 0xFAFAFA)
        }()

        if isReusing {
            outerStack.configureForReuse(
                config: outerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_outerStack,
            )

            let plainContentView = componentView.plainContentView
            plainContentView?.configure(
                componentView: componentView,
                cellMeasurement: cellMeasurement,
                componentDelegate: componentDelegate,
                hasWallpaper: hasWallpaper,
                titleLabelConfig: titleLabelConfig(useProminentTextColor: hasWallpaper),
                innerStackConfig: innerStackConfig,
                isReusing: true,
            )

            let contentViewVisualEffect = componentView.visualEffectContentView
            contentViewVisualEffect?.configure(
                blurBackgroundColor: blurBackgroundColor,
                titleLabelConfig: titleLabelConfig(useProminentTextColor: true),
                innerStackConfig: innerStackConfig,
            )
        } else {
            outerStack.reset()
            doubleContentWrapper.reset()

            let contentView: UIView = {
                func buildPlainContentView() -> UIView {
                    let contentView = componentView.ensurePlainContentView()
                    contentView.configure(
                        componentView: componentView,
                        cellMeasurement: cellMeasurement,
                        componentDelegate: componentDelegate,
                        hasWallpaper: hasWallpaper,
                        titleLabelConfig: titleLabelConfig(useProminentTextColor: hasWallpaper),
                        innerStackConfig: innerStackConfig,
                        isReusing: false,
                    )
                    return contentView.rootView
                }
                func buildVisualEffectContentView() -> UIView {
                    let contentView = componentView.ensureVisualEffectContentView()
                    contentView.configure(
                        blurBackgroundColor: blurBackgroundColor,
                        titleLabelConfig: titleLabelConfig(useProminentTextColor: true),
                        innerStackConfig: innerStackConfig,
                    )
                    return contentView.rootView
                }

                let isStandaloneRenderItem = conversationStyle.isStandaloneRenderItem

                // On iOS 26 always use `visual effect` content view for the sticky header.
                if hasWallpaper, #unavailable(iOS 26) {
                    return buildPlainContentView()
                } else if isStandaloneRenderItem {
                    return buildPlainContentView()
                } else {
                    let plainContentView = buildPlainContentView()
                    let visualEffectContentView = buildVisualEffectContentView()
                    doubleContentWrapper.addSubviewToFillSuperviewEdges(plainContentView)
                    doubleContentWrapper.addSubviewToFillSuperviewEdges(visualEffectContentView)
                    componentView.doubleContentView = DoubleContentView(
                        normalView: plainContentView,
                        stickyView: visualEffectContentView,
                    )
                    return doubleContentWrapper
                }
            }()

            outerStack.configure(
                config: outerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_outerStack,
                subviews: [contentView],
            )
        }

        componentView.rootView.accessibilityLabel = titleLabelConfig().text.accessibilityDescription
        componentView.rootView.isAccessibilityElement = true
        componentView.rootView.accessibilityTraits = .header
    }

    static func buildState(interaction: TSInteraction) -> State {
        let date = Date(millisecondsSince1970: interaction.timestamp)
        let text = DateUtil.formatDateHeaderForCVC(date)
        return State(text: text)
    }

    /// - Parameter useProminentTextColor: Pass `true` to change text color to be more prominent.
    ///
    /// The idea is to make date header text look more promiment when the text is enclosed in a bubble shape.
    /// If the text is placed agains default background (ie no wallpaper, no sticky header) we need to use a more subtle color.
    private func titleLabelConfig(useProminentTextColor: Bool = false) -> CVLabelConfig {
        // More contrasty color if date header has a bubble.
        // Less contrasty color when there's no wallpaper and text is displayed over chat background.
        let textColor = useProminentTextColor ? UIColor.Signal.label : UIColor.Signal.secondaryLabel
        let font = UIFont.dynamicTypeFootnote.medium()
        return CVLabelConfig(
            text: .text(dateHeaderState.text),
            displayConfig: .forUnstyledText(font: font, textColor: textColor),
            font: font,
            textColor: textColor,
            lineBreakMode: .byTruncatingTail,
            textAlignment: .center,
        )
    }

    private var outerStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .center,
            spacing: 0,
            layoutMargins: UIEdgeInsets(
                top: 0,
                leading: conversationStyle.headerGutterLeading,
                bottom: 0,
                trailing: conversationStyle.headerGutterTrailing,
            ),
        )
    }

    private var innerStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .center,
            spacing: 0,
            layoutMargins: UIEdgeInsets(hMargin: 12, vMargin: 3),
        )
    }

    fileprivate static let measurementKey_outerStack = "CVComponentDateHeader.measurementKey_outerStack"
    fileprivate static let measurementKey_innerStack = "CVComponentDateHeader.measurementKey_innerStack"

    public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
        owsAssertDebug(maxWidth > 0)

        let availableWidth = max(
            0,
            maxWidth -
                (
                    innerStackConfig.layoutMargins.totalWidth +
                        outerStackConfig.layoutMargins.totalWidth
                ),
        )
        let labelSize = CVText.measureLabel(config: titleLabelConfig(), maxWidth: availableWidth)

        let labelInfo = labelSize.asManualSubviewInfo
        let innerStackMeasurement = ManualStackView.measure(
            config: innerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_innerStack,
            subviewInfos: [labelInfo],
        )
        let innerStackInfo = innerStackMeasurement.measuredSize.asManualSubviewInfo
        let outerStackMeasurement = ManualStackView.measure(
            config: outerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_outerStack,
            subviewInfos: [innerStackInfo],
            maxWidth: maxWidth,
        )
        return outerStackMeasurement.measuredSize
    }

    // MARK: -

    // Used for rendering some portion of an Conversation View item.
    // It could be the entire item or some part thereof.
    public class CVComponentViewDateHeader: NSObject, CVComponentView {

        fileprivate let outerStack = ManualStackView(name: "dateHeader.outerStackView")
        fileprivate let doubleContentWrapper = ManualLayoutView(name: "dateHeader.doubleContentWrapper")

        fileprivate var plainContentView: ContentViewNoVisualEffect?
        fileprivate func ensurePlainContentView() -> ContentViewNoVisualEffect {
            if let plainContentView {
                return plainContentView
            }
            let plainContentView = ContentViewNoVisualEffect()
            self.plainContentView = plainContentView
            return plainContentView
        }

        fileprivate var visualEffectContentView: VisualEffectContentView?
        fileprivate func ensureVisualEffectContentView() -> VisualEffectContentView {
            if let visualEffectContentView {
                return visualEffectContentView
            }
            let visualEffectContentView: VisualEffectContentView
            if #available(iOS 26.0, *) {
                visualEffectContentView = ContentViewWithGlassEffect()
            } else {
                visualEffectContentView = ContentViewWithBlurEffect()
            }
            self.visualEffectContentView = visualEffectContentView
            return visualEffectContentView
        }

        fileprivate var hasWallpaper = false
        fileprivate var isDarkThemeEnabled = false

        public var isDedicatedCellView = false

        fileprivate var doubleContentView: DoubleContentView?

        public var rootView: UIView {
            outerStack
        }

        // MARK: -

        public func setIsCellVisible(_ isCellVisible: Bool) {}

        public func reset() {
            reset(resetReusableState: false)
        }

        public func reset(resetReusableState: Bool) {
            owsAssertDebug(isDedicatedCellView)

            plainContentView?.reset(resetReusableState: resetReusableState)
            visualEffectContentView?.reset()

            if resetReusableState {
                outerStack.reset()
                doubleContentWrapper.reset()

                hasWallpaper = false
                isDarkThemeEnabled = false
                doubleContentView = nil
            }
        }
    }
}

// MARK: -

private protocol VisualEffectContentView {
    var rootView: UIView { get }
    func configure(blurBackgroundColor: UIColor, titleLabelConfig: CVLabelConfig, innerStackConfig: CVStackViewConfig)
    func reset()
}

// MARK: -

private class ContentViewWithBlurEffect: VisualEffectContentView {
    private let titleLabel = CVLabel()
    private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
    private let blurOverlay = UIView()
    private let wrapper: UIView

    var rootView: UIView { wrapper }

    init() {
        blurView.clipsToBounds = true
        blurView.contentView.addSubview(blurOverlay)
        blurOverlay.autoPinEdgesToSuperviewEdges()

        let wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(blurView)
        let blurView = self.blurView
        wrapper.addLayoutBlock { view in
            blurView.layer.cornerRadius = view.frame.size.smallerAxis * 0.5
        }
        self.wrapper = wrapper
    }

    func configure(blurBackgroundColor: UIColor, titleLabelConfig: CVLabelConfig, innerStackConfig: CVStackViewConfig) {
        titleLabelConfig.applyForRendering(label: titleLabel)
        blurOverlay.backgroundColor = blurBackgroundColor

        if titleLabel.superview == nil {
            titleLabel.setContentHuggingLow()
            blurView.contentView.addSubview(titleLabel)
            titleLabel.autoPinEdgesToSuperviewEdges(with: innerStackConfig.layoutMargins)
        }
    }

    func reset() {
        titleLabel.text = nil
    }
}

// MARK: -

@available(iOS 26.0, *)
private class ContentViewWithGlassEffect: VisualEffectContentView {
    private let titleLabel = CVLabel()
    private let glassView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
    private let wrapper: UIView

    private var layoutConstraints = [NSLayoutConstraint]()

    var rootView: UIView { wrapper }

    init() {
        glassView.cornerConfiguration = .capsule()

        /// WARNING: Must wrap into a UIView or `titleLabel` won't be properly sized.
        self.wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(glassView)
    }

    func configure(
        blurBackgroundColor: UIColor,
        titleLabelConfig: CVLabelConfig,
        innerStackConfig: CVStackViewConfig,
    ) {

        titleLabelConfig.applyForRendering(label: titleLabel)

        if titleLabel.superview == nil {
            titleLabel.setContentHuggingLow()
            glassView.contentView.addSubview(titleLabel)
            titleLabel.autoPinEdgesToSuperviewEdges(with: innerStackConfig.layoutMargins)
        }
    }

    func reset() {
        titleLabel.text = nil
    }
}

// MARK: -

private class ContentViewNoVisualEffect {
    private let titleLabel = CVLabel()
    private let innerStack = ManualStackView(name: "dateHeader.innerStackView")

    var rootView: UIView { innerStack }

    fileprivate var wallpaperBlurView: CVWallpaperBlurView?
    private func ensureWallpaperBlurView() -> CVWallpaperBlurView {
        if let wallpaperBlurView {
            return wallpaperBlurView
        }
        let wallpaperBlurView = CVWallpaperBlurView()
        self.wallpaperBlurView = wallpaperBlurView
        return wallpaperBlurView
    }

    func configure(
        componentView: CVComponentDateHeader.CVComponentViewDateHeader,
        cellMeasurement: CVCellMeasurement,
        componentDelegate: CVComponentDelegate,
        hasWallpaper: Bool,
        titleLabelConfig: CVLabelConfig,
        innerStackConfig: CVStackViewConfig,
        isReusing: Bool,
    ) {

        if !isReusing {
            reset(resetReusableState: true)
        }

        titleLabelConfig.applyForRendering(label: titleLabel)

        if isReusing {
            innerStack.configureForReuse(
                config: innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: CVComponentDateHeader.measurementKey_innerStack,
            )
        } else {
            if hasWallpaper {
                let wallpaperBlurView = ensureWallpaperBlurView()
                CVComponentBase.configureWallpaperBlurView(
                    wallpaperBlurView: wallpaperBlurView,
                    componentDelegate: componentDelegate,
                    bubbleConfig: BubbleConfiguration(
                        corners: .capsule(),
                        stroke: ConversationStyle.bubbleStroke(
                            isDarkThemeEnabled: Theme.isDarkThemeEnabled,
                        ),
                    ),
                )
                innerStack.addSubviewToFillSuperviewEdges(wallpaperBlurView)
            }
            innerStack.configure(
                config: innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: CVComponentDateHeader.measurementKey_innerStack,
                subviews: [titleLabel],
            )
        }
    }

    func reset(resetReusableState: Bool) {
        if resetReusableState {
            innerStack.reset()

            titleLabel.removeFromSuperview()

            wallpaperBlurView?.removeFromSuperview()
        }

        titleLabel.text = nil
    }
}