342 lines
12 KiB
Swift
342 lines
12 KiB
Swift
//
|
|
// 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 class ConversationStyle: NSObject {
|
|
|
|
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
|
|
|
|
public let isWallpaperPhoto: Bool
|
|
|
|
private let dynamicBodyTypePointSize: CGFloat
|
|
private let primaryTextColor: UIColor
|
|
|
|
public let contentMarginTop: CGFloat = 24
|
|
public let contentMarginBottom: CGFloat = 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
|
|
|
|
static public let groupMessageAvatarSizeClass = ConversationAvatarView.Configuration.SizeClass.twentyEight
|
|
static public let selectionViewWidth: CGFloat = 24
|
|
static public let messageStackSpacing: CGFloat = 8
|
|
static public let defaultMessageSpacing: CGFloat = 12
|
|
static public let compactMessageSpacing: CGFloat = 2
|
|
static public 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,
|
|
isWallpaperPhoto: Bool,
|
|
chatColor: ColorOrGradientSetting
|
|
) {
|
|
self.type = type
|
|
self.viewWidth = viewWidth
|
|
self.isDarkThemeEnabled = Theme.isDarkThemeEnabled
|
|
self.primaryTextColor = Theme.primaryTextColor
|
|
self.hasWallpaper = hasWallpaper
|
|
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))
|
|
|
|
super.init()
|
|
}
|
|
|
|
// MARK: Colors
|
|
|
|
public static func bubbleColorIncoming(hasWallpaper: Bool,
|
|
isDarkThemeEnabled: Bool) -> UIColor {
|
|
if hasWallpaper {
|
|
return isDarkThemeEnabled ? .ows_gray95 : .white
|
|
} else {
|
|
return isDarkThemeEnabled ? UIColor.ows_gray80 : UIColor.ows_gray05
|
|
}
|
|
}
|
|
public var bubbleColorIncoming: UIColor {
|
|
Self.bubbleColorIncoming(hasWallpaper: hasWallpaper,
|
|
isDarkThemeEnabled: isDarkThemeEnabled)
|
|
}
|
|
|
|
public let dateBreakTextColor = UIColor.ows_gray60
|
|
|
|
public func bubbleChatColor(isIncoming: Bool) -> ColorOrGradientValue {
|
|
if isIncoming {
|
|
return .solidColor(color: bubbleColorIncoming)
|
|
} else {
|
|
return bubbleChatColorOutgoing
|
|
}
|
|
}
|
|
|
|
public var bubbleChatColorOutgoing: ColorOrGradientValue {
|
|
chatColorValue
|
|
}
|
|
|
|
public static var bubbleTextColorIncoming: UIColor {
|
|
return bubbleTextColorIncomingThemed.forCurrentTheme
|
|
}
|
|
|
|
public static var bubbleTextColorIncomingThemed: ThemedColor {
|
|
ThemedColor(light: UIColor.ows_gray90, dark: UIColor.ows_gray05)
|
|
}
|
|
|
|
public static var bubbleTextColorOutgoing: UIColor {
|
|
return bubbleTextColorOutgoingThemed.forCurrentTheme
|
|
}
|
|
|
|
public static var bubbleTextColorOutgoingThemed: ThemedColor {
|
|
ThemedColor(light: UIColor.ows_white, dark: UIColor.ows_gray05)
|
|
}
|
|
|
|
public var bubbleTextColorIncoming: UIColor {
|
|
Self.bubbleTextColorIncomingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
|
|
}
|
|
|
|
public var bubbleSecondaryTextColorIncoming: UIColor {
|
|
isDarkThemeEnabled ? .ows_gray25 : .ows_gray60
|
|
}
|
|
|
|
public var bubbleTextColorOutgoing: UIColor {
|
|
Self.bubbleTextColorOutgoingThemed.color(isDarkThemeEnabled: isDarkThemeEnabled)
|
|
}
|
|
|
|
public var bubbleSecondaryTextColorOutgoing: UIColor {
|
|
isDarkThemeEnabled ? .ows_whiteAlpha60 : .ows_whiteAlpha80
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
public func bubbleTextColor(isIncoming: Bool) -> UIColor {
|
|
if isIncoming {
|
|
return bubbleTextColorIncoming
|
|
} else {
|
|
return bubbleTextColorOutgoing
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
public func bubbleSecondaryTextColor(isIncoming: Bool) -> UIColor {
|
|
if isIncoming {
|
|
return bubbleSecondaryTextColorIncoming
|
|
} else {
|
|
return bubbleSecondaryTextColorOutgoing
|
|
}
|
|
}
|
|
|
|
// Same across all themes
|
|
public static var searchMatchHighlightColor: UIColor {
|
|
return UIColor.yellow
|
|
}
|
|
|
|
public func isEqualForCellRendering(_ other: ConversationStyle) -> Bool {
|
|
// We need to compare any state that could affect
|
|
// how we render view appearance.
|
|
(type.isValid == other.type.isValid &&
|
|
viewWidth == other.viewWidth &&
|
|
dynamicBodyTypePointSize == other.dynamicBodyTypePointSize &&
|
|
isDarkThemeEnabled == other.isDarkThemeEnabled &&
|
|
hasWallpaper == other.hasWallpaper &&
|
|
isWallpaperPhoto == other.isWallpaperPhoto &&
|
|
maxMessageWidth == other.maxMessageWidth &&
|
|
maxMediaMessageWidth == other.maxMediaMessageWidth &&
|
|
textInsets == other.textInsets &&
|
|
gutterLeading == other.gutterLeading &&
|
|
gutterTrailing == other.gutterTrailing &&
|
|
fullWidthGutterLeading == other.fullWidthGutterLeading &&
|
|
fullWidthGutterTrailing == other.fullWidthGutterTrailing &&
|
|
textInsets == other.textInsets &&
|
|
lastTextLineAxis == other.lastTextLineAxis &&
|
|
chatColorSetting == other.chatColorSetting)
|
|
}
|
|
|
|
public override 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), " +
|
|
"]"
|
|
}
|
|
}
|
|
|
|
extension ConversationStyle {
|
|
|
|
public func quotedReplyHighlightColor() -> UIColor {
|
|
UIColor.init(rgbHex: 0xB5B5B5)
|
|
}
|
|
|
|
public func quotedReplyAuthorColor() -> UIColor {
|
|
quotedReplyTextColor()
|
|
}
|
|
|
|
public func quotedReplyTextColor() -> UIColor {
|
|
isDarkThemeEnabled ? .ows_gray05 : .ows_gray90
|
|
}
|
|
|
|
public func quotedReplyAttachmentColor() -> UIColor {
|
|
isDarkThemeEnabled ? .ows_gray05 : UIColor.ows_gray90
|
|
}
|
|
}
|