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

import SignalServiceKit
public import SignalUI

public class CVComponentUnreadIndicator: CVComponentBase, CVRootComponent {

    public var componentKey: CVComponentKey { .unreadIndicator }

    public var cellReuseIdentifier: CVCellReuseIdentifier {
        CVCellReuseIdentifier.unreadIndicator
    }

    public let isDedicatedCell = true

    override init(itemModel: CVItemModel) {
        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 {
        CVComponentViewUnreadIndicator()
    }

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

        let themeHasChanged = conversationStyle.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 = conversationStyle.isDarkThemeEnabled
        componentView.hasWallpaper = hasWallpaper

        let outerStack = componentView.outerStack
        let innerStack = componentView.innerStack
        let titleLabel = componentView.titleLabel
        let strokeViewLeading = componentView.strokeViewLeading
        let strokeViewTrailing = componentView.strokeViewTrailing

        titleLabelConfig.applyForRendering(label: titleLabel)

        if isReusing {
            innerStack.configureForReuse(
                config: innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_innerStack,
            )
            outerStack.configureForReuse(
                config: outerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_outerStack,
            )
        } else {
            outerStack.reset()

            let visualEffectView: UIVisualEffectView
            if #available(iOS 26, *) {
                let glassEffectView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
                glassEffectView.contentView.addSubview(titleLabel)
                glassEffectView.cornerConfiguration = .capsule()
                visualEffectView = glassEffectView
            } else {
                let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
                blurEffectView.contentView.addSubview(titleLabel)
                blurEffectView.layer.masksToBounds = true
                visualEffectView = blurEffectView
            }
            titleLabel.autoPinEdgesToSuperviewEdges(with: Self.titleLabelMargins)

            let wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(visualEffectView)
            if #unavailable(iOS 26) {
                wrapper.addLayoutBlock { view in
                    visualEffectView.layer.cornerRadius = view.bounds.size.smallerAxis / 2
                }
            }

            innerStack.reset()
            innerStack.configure(
                config: innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_innerStack,
                subviews: [wrapper],
            )

            let strokeViewStyle: StrokeView.Style = hasWallpaper ? .double : .single
            strokeViewLeading.style = strokeViewStyle
            strokeViewTrailing.style = strokeViewStyle

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

    private var titleLabelConfig: CVLabelConfig {
        CVLabelConfig.unstyledText(
            OWSLocalizedString(
                "MESSAGES_VIEW_UNREAD_INDICATOR",
                comment: "Indicator that separates read from unread messages.",
            ),
            font: UIFont.dynamicTypeFootnote.medium(),
            textColor: Theme.primaryTextColor,
            numberOfLines: 0,
            lineBreakMode: .byTruncatingTail,
            textAlignment: .center,
        )
    }

    private static var titleLabelMargins = UIEdgeInsets(hMargin: 12, vMargin: 3)

    private var outerStackConfig: CVStackViewConfig {
        let cellLayoutMargins = UIEdgeInsets(
            top: 8,
            leading: conversationStyle.fullWidthGutterLeading,
            bottom: 8,
            trailing: conversationStyle.fullWidthGutterTrailing,
        )
        return CVStackViewConfig(
            axis: .horizontal,
            alignment: .center,
            spacing: 6,
            layoutMargins: cellLayoutMargins,
        )
    }

    private var innerStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .center,
            spacing: 0,
            layoutMargins: .zero,
        )
    }

    private static let measurementKey_outerStack = "CVComponentUnreadIndicator.measurementKey_outerStack"
    private static let measurementKey_innerStack = "CVComponentUnreadIndicator.measurementKey_innerStack"

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

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

        let strokeSize = CGSize(width: 0, height: 2)

        let labelInfo = labelSize.asManualSubviewInfo
        let innerStackMeasurement = ManualStackView.measure(
            config: innerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_innerStack,
            subviewInfos: [labelInfo],
        )

        let strokeInfo = strokeSize.asManualSubviewInfo(hasFixedHeight: true)
        let innerStackInfo = innerStackMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)
        let hStackSubviewInfos = [
            strokeInfo,
            innerStackInfo,
            strokeInfo,
        ]
        let hStackMeasurement = ManualStackView.measure(
            config: outerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_outerStack,
            subviewInfos: hStackSubviewInfos,
            maxWidth: maxWidth,
        )
        return hStackMeasurement.measuredSize
    }

    // MARK: -

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

        fileprivate let outerStack = ManualStackView(name: "unreadIndicator.outerStack")
        fileprivate let innerStack = ManualStackView(name: "unreadIndicator.innerStack")

        fileprivate let titleLabel = CVLabel()

        fileprivate var hasWallpaper = false
        fileprivate var isDarkThemeEnabled = false

        fileprivate let strokeViewLeading = StrokeView()
        fileprivate let strokeViewTrailing = StrokeView()

        public var isDedicatedCellView = false

        public var rootView: UIView {
            outerStack
        }

        // MARK: -

        public func setIsCellVisible(_ isCellVisible: Bool) {}

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

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

            titleLabel.text = nil

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

                hasWallpaper = false
                isDarkThemeEnabled = false
            }
        }
    }

    fileprivate class StrokeView: ManualLayoutView {
        enum Style {
            case single
            case double
        }

        var style: Style = .single {
            didSet {
                updateStokeStyle()
            }
        }

        private let topStrokeView = UIView()
        private let middleStrokeView = UIView()
        private let bottomStrokeView = UIView()

        init() {
            super.init(name: "StrokeView")

            clipsToBounds = true

            topStrokeView.backgroundColor = UIColor(white: 0, alpha: 0.32)
            middleStrokeView.backgroundColor = UIColor.Signal.quaternaryLabel
            bottomStrokeView.backgroundColor = UIColor(white: 1, alpha: 0.16)

            addSubview(topStrokeView)
            addSubview(middleStrokeView)
            addSubview(bottomStrokeView)

            addDefaultLayoutBlock()
            updateStokeStyle()
        }

        private func addDefaultLayoutBlock() {
            addLayoutBlock { [weak self] _ in
                guard let self else { return }

                let strokeViewSize = CGSize(width: self.bounds.width, height: 1)

                self.topStrokeView.frame = CGRect(
                    origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY - strokeViewSize.height),
                    size: strokeViewSize,
                )
                self.middleStrokeView.frame = CGRect(
                    origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY - strokeViewSize.height / 2),
                    size: strokeViewSize,
                )
                self.bottomStrokeView.frame = CGRect(
                    origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY),
                    size: strokeViewSize,
                )
            }
        }

        private func updateStokeStyle() {
            topStrokeView.isHidden = style == .single
            bottomStrokeView.isHidden = style == .single
            middleStrokeView.isHidden = style == .double
        }
    }
}