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

public import SignalServiceKit

// An immutable snapshot of the core styling
// state used by CVC for a given load/render cycle.
public struct ConversationStyle {

    public enum `Type`: UInt {
        // The style used from initialization until presentation begins.
        // It does not have valid values and should not be rendered.
        case initial
        // The style used until the presentation has configured the view(s).
        // It has values inferred from the navigationController.
        case placeholder
        // The style once presentation has configured the view(s).
        // It has values from the CVC view state.
        case `default`
        // The style used in the message detail view
        case messageDetails

        fileprivate var isValid: Bool { self != .initial }
    }

    public let type: `Type`

    public var isValidStyle: Bool { type.isValid }

    // The width of the collection view.
    public let viewWidth: CGFloat

    public let isDarkThemeEnabled: Bool

    public let hasWallpaper: Bool
    // Determines blur effect for incoming message bubbles.
    public let shouldDimWallpaperInDarkMode: Bool
    public let isWallpaperPhoto: Bool

    public let isStandaloneRenderItem: Bool

    private let dynamicBodyTypePointSize: CGFloat
    private let primaryTextColor: UIColor

    public let contentMarginTop: CGFloat = 24
    public let contentMarginBottom: CGFloat = if #available(iOS 26, *) { 8 } else { 24 }

    public let gutterLeading: CGFloat
    public let gutterTrailing: CGFloat

    public let headerGutterLeading: CGFloat = 28
    public let headerGutterTrailing: CGFloat = 28

    // These are the gutters used by "full width" views
    // like "contact offer" and "info message".
    public let fullWidthGutterLeading: CGFloat
    public let fullWidthGutterTrailing: CGFloat

    public static let groupMessageAvatarSizeClass = ConversationAvatarView.Configuration.SizeClass.twentyEight
    public static let selectionViewWidth: CGFloat = 24
    public static let messageStackSpacing: CGFloat = 8
    public static let defaultMessageSpacing: CGFloat = 12
    public static let compactMessageSpacing: CGFloat = 2
    public static let systemMessageSpacing: CGFloat = 20

    public let contentWidth: CGFloat

    public var headerViewContentWidth: CGFloat {
        viewWidth - (headerGutterLeading + headerGutterTrailing)
    }

    public let maxMessageWidth: CGFloat
    public let maxMediaMessageWidth: CGFloat
    public let maxAudioMessageWidth: CGFloat

    public let textInsetTop: CGFloat
    public let textInsetBottom: CGFloat
    public let textInsetHorizontal: CGFloat
    public var textInsets: UIEdgeInsets {
        UIEdgeInsets(
            top: textInsetTop,
            leading: textInsetHorizontal,
            bottom: textInsetBottom,
            trailing: textInsetHorizontal,
        )
    }

    // We want to align "group sender" avatars with the v-center of the
    // "last line" of the message body text - or where it would be for
    // non-text content.
    //
    // This is the distance from that v-center to the bottom of the
    // message bubble.
    public let lastTextLineAxis: CGFloat

    // Incoming and outgoing messages are visually distinguished
    // by leading and trailing alignment, respectively.
    //
    // Reserve a space so that in the most compressed layouts
    // (small form factor, group avatar, multi-select, etc.)
    // there is space for them to align in these directions.
    public static let messageDirectionSpacing: CGFloat = 12

    // ColorOrGradientSetting is used for persistence, logging and comparison.
    public let chatColorSetting: ColorOrGradientSetting
    // ColorOrGradientValue is used for rendering.
    public let chatColorValue: ColorOrGradientValue

    public init(
        type: `Type`,
        thread: TSThread,
        viewWidth: CGFloat,
        hasWallpaper: Bool,
        shouldDimWallpaperInDarkMode: Bool,
        isWallpaperPhoto: Bool,
        chatColor: ColorOrGradientSetting,
        isStandaloneRenderItem: Bool = false,
    ) {
        self.type = type
        self.viewWidth = viewWidth
        self.isDarkThemeEnabled = Theme.isDarkThemeEnabled
        self.primaryTextColor = Theme.primaryTextColor
        self.hasWallpaper = hasWallpaper
        self.shouldDimWallpaperInDarkMode = shouldDimWallpaperInDarkMode
        self.isWallpaperPhoto = isWallpaperPhoto
        self.chatColorSetting = chatColor
        self.chatColorValue = chatColor.asValue

        if type == .messageDetails {
            gutterLeading = 0
            gutterTrailing = 0
            fullWidthGutterLeading = 0
            fullWidthGutterTrailing = 0
        } else {
            gutterLeading = thread.isGroupThread ? 12 : 16
            gutterTrailing = 16

            fullWidthGutterLeading = thread.isGroupThread ? 12 : 16
            fullWidthGutterTrailing = thread.isGroupThread ? 12 : 16
        }

        let messageTextFont = UIFont.dynamicTypeBody

        dynamicBodyTypePointSize = messageTextFont.pointSize

        let baseFontOffset: CGFloat = 11

        // Don't include the distance from the "cap height" to the top of the UILabel
        // in the top margin.
        textInsetTop = max(0, round(baseFontOffset - (messageTextFont.ascender - messageTextFont.capHeight)))
        // Don't include the distance from the "baseline" to the bottom of the UILabel
        // (e.g. the descender) in the top margin. Note that UIFont.descender is a
        // negative value.
        textInsetBottom = max(0, round(baseFontOffset - abs(messageTextFont.descender)))

        textInsetHorizontal = 12

        lastTextLineAxis = CGFloat(round(baseFontOffset + messageTextFont.capHeight * 0.5))

        contentWidth = viewWidth - (gutterLeading + gutterTrailing)

        var maxMessageWidth = contentWidth - (Self.selectionViewWidth + Self.messageStackSpacing)

        maxMessageWidth -= Self.messageDirectionSpacing

        if thread.isGroupThread {
            maxMessageWidth -= (CGFloat(ConversationStyle.groupMessageAvatarSizeClass.size.width) + Self.messageStackSpacing)
        }
        self.maxMessageWidth = maxMessageWidth

        // This upper bound should have no effect in portrait orientation.
        // It limits body media size in landscape.
        let kMaxBodyMediaSize: CGFloat = 350
        maxMediaMessageWidth = floor(min(maxMessageWidth, kMaxBodyMediaSize))

        let kMaxAudioMessageWidth: CGFloat = 244
        maxAudioMessageWidth = floor(min(maxMessageWidth, kMaxAudioMessageWidth))

        self.isStandaloneRenderItem = isStandaloneRenderItem
    }

    // MARK: Colors

    /// - Returns: Bubble background color to be used for regular text messages and such.
    public func bubbleChatColor(isIncoming: Bool) -> ColorOrGradientValue {
        if isIncoming {
            bubbleChatColorIncoming
        } else {
            bubbleChatColorOutgoing
        }
    }

    private static func bubbleBackgroundBlurEffect(
        hasWallpaper: Bool,
        isDarkThemeEnabled: Bool,
        shouldDimWallpaperInDarkMode: Bool,
    ) -> UIVisualEffect? {
        guard hasWallpaper else { return nil }
        guard UIAccessibility.isReduceTransparencyEnabled == false else { return nil }
        if isDarkThemeEnabled, shouldDimWallpaperInDarkMode {
            return UIBlurEffect(style: .systemUltraThinMaterial)
        }
        return UIBlurEffect(style: .systemThinMaterial)
    }

    /// - Returns: Background effect to be used for bubbles in chat if current chat environment requires one.
    /// If current chat does not require a blur effect `nil` will be returned.
    ///
    /// Defines the visual effect to be used in current theme and with the current chat wallpaper.
    /// Messages that could use this effect include, but are not limited to, incoming messages and system events.
    public var bubbleBackgroundBlurEffect: UIVisualEffect? {
        return Self.bubbleBackgroundBlurEffect(
            hasWallpaper: hasWallpaper,
            isDarkThemeEnabled: isDarkThemeEnabled,
            shouldDimWallpaperInDarkMode: shouldDimWallpaperInDarkMode,
        )
    }

    /// Contains logic for choosing background style to be used for incoming message bubbles.
    ///
    /// - Returns: Background style for incoming message bubbles when chat has provided parameters.
    /// - Parameter hasWallpaper: Pass `true` if chat has a wallpaper. Otherwise, pass `false`.
    /// - Parameter shouldDimWallpaperInDarkMode: Is only relevant if `hasWallpaper` is `true`.
    /// - Parameter isDarkThemeEnabled: Pass `true` to return style to be used in dark theme.
    public static func bubbleChatColorIncoming(
        hasWallpaper: Bool,
        shouldDimWallpaperInDarkMode: Bool,
        isDarkThemeEnabled: Bool,
    ) -> ColorOrGradientValue {
        if UIAccessibility.isReduceTransparencyEnabled, hasWallpaper {
            return .solidColor(color: Theme.backgroundColor)
        }
        if
            let blurEffect = bubbleBackgroundBlurEffect(
                hasWallpaper: hasWallpaper,
                isDarkThemeEnabled: isDarkThemeEnabled,
                shouldDimWallpaperInDarkMode: shouldDimWallpaperInDarkMode,
            )
        {
            return .blur(blurEffect: blurEffect)
        }
        let color = isDarkThemeEnabled ? UIColor(rgbHex: 0x2C2C2E) : UIColor(rgbHex: 0xE9E9E9)
        return .solidColor(color: color)
    }

    /// - Returns: Background style for incoming message bubble in the current chat.
    public var bubbleChatColorIncoming: ColorOrGradientValue {
        Self.bubbleChatColorIncoming(
            hasWallpaper: hasWallpaper,
            shouldDimWallpaperInDarkMode: shouldDimWallpaperInDarkMode,
            isDarkThemeEnabled: isDarkThemeEnabled,
        )
    }

    /// - Returns: Background style for outgoing message bubble in the current chat.
    public var bubbleChatColorOutgoing: ColorOrGradientValue {
        chatColorValue
    }

    // MARK: - Primary text color

    public static var bubbleTextColorIncomingThemed: ThemedColor {
        ThemedColor(light: Theme.lightThemePrimaryColor, dark: Theme.darkThemePrimaryColor)
    }

    public static var bubbleTextColorOutgoingThemed: ThemedColor {
        ThemedColor(light: UIColor.ows_white, dark: UIColor.ows_gray05)
    }

    public static var bubbleTextColorIncoming: UIColor {
        bubbleTextColorIncomingThemed.forCurrentTheme
    }

    public static var bubbleTextColorOutgoing: UIColor {
        bubbleTextColorOutgoingThemed.forCurrentTheme
    }

    public static func bubbleTextColor(isIncoming: Bool) -> UIColor {
        isIncoming ? bubbleTextColorIncoming : bubbleTextColorOutgoing
    }

    public var bubbleTextColorIncoming: UIColor {
        Self.bubbleTextColorIncomingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
    }

    public var bubbleTextColorOutgoing: UIColor {
        Self.bubbleTextColorOutgoingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
    }

    public func bubbleTextColor(isIncoming: Bool) -> UIColor {
        isIncoming ? bubbleTextColorIncoming : bubbleTextColorOutgoing
    }

    public func bubbleTextColor(message: TSMessage) -> UIColor {
        if message.wasRemotelyDeleted, !hasWallpaper {
            return primaryTextColor
        } else if message is TSIncomingMessage {
            return bubbleTextColorIncoming
        } else if message is TSOutgoingMessage {
            return bubbleTextColorOutgoing
        } else {
            owsFailDebug("Unexpected message type: \(message)")
            return bubbleTextColorOutgoing
        }
    }

    // MARK: - Secondary text color

    private static var bubbleSecondaryTextColorIncomingThemed: ThemedColor {
        ThemedColor(
            light: Theme.lightThemeSecondaryTextAndIconColor,
            dark: Theme.darkThemeSecondaryTextAndIconColor,
        )
    }

    private static var bubbleSecondaryTextColorOutgoingThemed: ThemedColor {
        ThemedColor(
            light: UIColor.ows_whiteAlpha80,
            dark: UIColor.ows_whiteAlpha60,
        )
    }

    public static var bubbleSecondaryTextColorIncoming: UIColor {
        bubbleSecondaryTextColorIncomingThemed.forCurrentTheme
    }

    public static var bubbleSecondaryTextColorOutgoing: UIColor {
        bubbleSecondaryTextColorOutgoingThemed.forCurrentTheme
    }

    public static func bubbleSecondaryTextColor(isIncoming: Bool) -> UIColor {
        isIncoming ? bubbleSecondaryTextColorIncoming : bubbleSecondaryTextColorOutgoing
    }

    public var bubbleSecondaryTextColorIncoming: UIColor {
        Self.bubbleSecondaryTextColorIncomingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
    }

    public var bubbleSecondaryTextColorOutgoing: UIColor {
        Self.bubbleSecondaryTextColorOutgoingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
    }

    public func bubbleSecondaryTextColor(isIncoming: Bool) -> UIColor {
        isIncoming ? bubbleSecondaryTextColorIncoming : bubbleSecondaryTextColorOutgoing
    }

    // MARK: - Misc colors

    public var systemMessageTextColor: UIColor {
        hasWallpaper ? Theme.primaryTextColor : Theme.secondaryTextAndIconColor
    }

    public func bubbleReadMoreTextColor(message: TSMessage) -> UIColor {
        if message is TSIncomingMessage {
            return isDarkThemeEnabled ? .ows_whiteAlpha90 : .ows_accentBlue
        } else if message is TSOutgoingMessage {
            return isDarkThemeEnabled ? .ows_whiteAlpha90 : .white
        } else {
            owsFailDebug("Unexpected message type: \(message)")
            return bubbleTextColorOutgoing
        }
    }

    /// - Returns: Stroke configuration to use on regular bubbles in chat for the theme provided.
    ///
    /// The purpose of this method is to provide stroke configuration to be used with non-message bubbles (eg date headers).
    public static func bubbleStroke(isDarkThemeEnabled: Bool) -> BubbleConfiguration.Stroke {
        let strokeColor = isDarkThemeEnabled ? UIColor(white: 1, alpha: 0.25) : UIColor(white: 0, alpha: 0.35)
        return BubbleConfiguration.Stroke(color: strokeColor, width: 2 * CGFloat.hairlineWidth)
    }

    /// - Returns: Stroke configuration to use for incoming or outgoing message bubbles in chat.
    ///
    /// Unlike static method above this function will only return stroke configuration if bubbles must have one.
    public func bubbleStroke(isIncoming: Bool) -> BubbleConfiguration.Stroke? {
        // Only use stroke for incoming messages and if there's a wallpaper.
        guard hasWallpaper, isIncoming else { return nil }

        return ConversationStyle.bubbleStroke(isDarkThemeEnabled: isDarkThemeEnabled)
    }

    // Same across all themes
    public static var searchMatchHighlightColor: UIColor {
        return UIColor.yellow
    }
}

extension ConversationStyle: Equatable {
    public static func ==(lhs: ConversationStyle, rhs: ConversationStyle) -> Bool {
        // We need to compare any state that could affect
        // how we render view appearance.
        lhs.type.isValid == rhs.type.isValid &&
            lhs.viewWidth == rhs.viewWidth &&
            lhs.dynamicBodyTypePointSize == rhs.dynamicBodyTypePointSize &&
            lhs.isDarkThemeEnabled == rhs.isDarkThemeEnabled &&
            lhs.hasWallpaper == rhs.hasWallpaper &&
            lhs.shouldDimWallpaperInDarkMode == rhs.shouldDimWallpaperInDarkMode &&
            lhs.isWallpaperPhoto == rhs.isWallpaperPhoto &&
            lhs.maxMessageWidth == rhs.maxMessageWidth &&
            lhs.maxMediaMessageWidth == rhs.maxMediaMessageWidth &&
            lhs.textInsets == rhs.textInsets &&
            lhs.gutterLeading == rhs.gutterLeading &&
            lhs.gutterTrailing == rhs.gutterTrailing &&
            lhs.fullWidthGutterLeading == rhs.fullWidthGutterLeading &&
            lhs.fullWidthGutterTrailing == rhs.fullWidthGutterTrailing &&
            lhs.textInsets == rhs.textInsets &&
            lhs.lastTextLineAxis == rhs.lastTextLineAxis &&
            lhs.chatColorSetting == rhs.chatColorSetting
    }
}

extension ConversationStyle: CustomDebugStringConvertible {
    public var debugDescription: String {
        "[" +
            "type.isValid: \(type.isValid), " +
            "viewWidth: \(viewWidth), " +
            "dynamicBodyTypePointSize: \(dynamicBodyTypePointSize), " +
            "isDarkThemeEnabled: \(isDarkThemeEnabled), " +
            "hasWallpaper: \(hasWallpaper), " +
            "isWallpaperPhoto: \(isWallpaperPhoto), " +
            "maxMessageWidth: \(maxMessageWidth), " +
            "maxMediaMessageWidth: \(maxMediaMessageWidth), " +
            "textInsets: \(textInsets), " +
            "gutterLeading: \(gutterLeading), " +
            "gutterTrailing: \(gutterTrailing), " +
            "fullWidthGutterLeading: \(fullWidthGutterLeading), " +
            "fullWidthGutterTrailing: \(fullWidthGutterTrailing), " +
            "textInsets: \(textInsets), " +
            "lastTextLineAxis: \(lastTextLineAxis), " +
            "chatColor: \(chatColorSetting), " +
            "]"
    }
}