Signal-iOS/SignalUI/Appearance/ChatColors.swift
Ehren Kret 30424e19ae remove OWSNavigationControllerBase
This appears to have been a hack for something that may have been
necessary in older versions of iOS. Current iOS seems to just return
true with no side-effects from this super call so there's no reason to
retain this hack apparent.
2024-04-28 23:19:43 -05:00

517 lines
22 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
import UIKit
/// Represents the chat color chosen for a particular scope.
///
/// Possible scopes (ordered by priority):
/// (1) Thread: Applies to a specific conversation.
/// (2) Global: Applies to all other conversations.
///
/// On its own, a `ChatColorSetting` may not contain enough information to
/// compute the color to use for a particular conversation. To compute the
/// color to use, you generally need the `ChatColorSetting` for both scopes,
/// the `Wallpaper` for both scopes (which may have their own preferred
/// color), and the default fallback.
public enum ChatColorSetting: Equatable {
case auto
case builtIn(PaletteChatColor)
case custom(CustomChatColor.Key, CustomChatColor)
public var constantColor: ColorOrGradientSetting? {
switch self {
case .auto:
return nil
case .builtIn(let value):
return value.colorSetting
case .custom(_, let value):
return value.colorSetting
}
}
public static func == (lhs: ChatColorSetting, rhs: ChatColorSetting) -> Bool {
switch (lhs, rhs) {
case (.auto, .auto):
return true
case (.builtIn(let lhs), .builtIn(let rhs)) where lhs.rawValue == rhs.rawValue:
return true
case (.custom(let lhs, _), .custom(let rhs, _)) where lhs.rawValue == rhs.rawValue:
return true
default:
return false
}
}
}
public struct CustomChatColor: Codable {
public struct Key {
public let rawValue: String
public static func generateRandom() -> Self {
return Key(rawValue: UUID().uuidString)
}
}
public let colorSetting: ColorOrGradientSetting
public let creationTimestamp: UInt64
public init(colorSetting: ColorOrGradientSetting, creationTimestamp: UInt64) {
self.colorSetting = colorSetting
self.creationTimestamp = creationTimestamp
}
private enum CodingKeys: String, CodingKey {
case colorSetting = "setting"
case creationTimestamp
// Deprecated keys that may still exist in values stored in the database:
// - "id"
// - "isBuiltIn"
// - "updateTimestamp"
}
}
// MARK: -
public class ChatColors: Dependencies {
public enum Constants {
fileprivate static let globalKey = "defaultKey"
public static let defaultColor: PaletteChatColor = .ultramarine
}
public init() {
SwiftSingletons.register(self)
}
/// The keys in this store are `CustomChatColor.Key`. The values are
/// `CustomChatColor`s.
private static let customColorsStore = SDSKeyValueStore(collection: "customColorsStore.3")
public func fetchCustomValues(tx: SDSAnyReadTransaction) -> [(key: CustomChatColor.Key, value: CustomChatColor)] {
var customChatColors = [(key: CustomChatColor.Key, value: CustomChatColor)]()
for key in Self.customColorsStore.allKeys(transaction: tx) {
let colorKey = CustomChatColor.Key(rawValue: key)
guard let colorValue = Self.fetchCustomValue(for: colorKey, tx: tx) else { continue }
customChatColors.append((colorKey, colorValue))
}
return customChatColors.sorted(by: { $0.value.creationTimestamp < $1.value.creationTimestamp })
}
public static func fetchCustomValue(for key: CustomChatColor.Key, tx: SDSAnyReadTransaction) -> CustomChatColor? {
do {
return try customColorsStore.getCodableValue(forKey: key.rawValue, transaction: tx)
} catch {
owsFailDebug("Couldn't decode custom color: \(error)")
return nil
}
}
public func upsertCustomValue(_ value: CustomChatColor, for key: CustomChatColor.Key, tx: SDSAnyWriteTransaction) {
do {
try Self.customColorsStore.setCodable(value, key: key.rawValue, transaction: tx)
} catch {
owsFailDebug("Couldn't save custom color: \(error)")
}
Self.postChatColorsDidChangeNotification(for: nil, tx: tx)
}
public func deleteCustomValue(for key: CustomChatColor.Key, tx: SDSAnyWriteTransaction) {
Self.customColorsStore.removeValue(forKey: key.rawValue, transaction: tx)
Self.postChatColorsDidChangeNotification(for: nil, tx: tx)
}
/// Returns the number of conversations that use a given value.
public static func usageCount(of colorKey: CustomChatColor.Key, tx: SDSAnyReadTransaction) -> Int {
let chatColorSettingStore = DependenciesBridge.shared.chatColorSettingStore
var count: Int = 0
for scopeKey in chatColorSettingStore.fetchAllScopeKeys(tx: tx.asV2Read) {
if colorKey.rawValue == chatColorSettingStore.fetchRawSetting(for: scopeKey, tx: tx.asV2Read) {
count += 1
}
}
return count
}
/// The color that should actually be used when rendering messages.
///
/// - Parameters:
/// - previewWallpaper: If provided, use this `Wallpaper` rather than the
/// one that's currently assigned. This is useful if you want to preview
/// the rendered color when selecting `Wallpaper`. (The logic is more
/// complicated than checking the color for the `Wallpaper` since it may
/// be overridden by an explicit color that takes precedence.)
///
/// - Returns: The color to use for outgoing message bubbles.
public static func resolvedChatColor(
for thread: TSThread?,
previewWallpaper: Wallpaper? = nil,
tx: SDSAnyReadTransaction
) -> ColorOrGradientSetting {
if let threadColor = chatColorSetting(for: thread, tx: tx).constantColor {
return threadColor
}
return autoChatColor(for: thread, previewWallpaper: previewWallpaper, tx: tx)
}
/// The color that should be rendered in the "auto" bubble in the chat color editor.
///
/// For the global scope, this will either be the wallpaper color or the
/// default fallback.
///
/// For the thread scope, this might be the global color, global wallpaper,
/// thread wallpaper, or default fallback.
public static func autoChatColor(for thread: TSThread?, tx: SDSAnyReadTransaction) -> ColorOrGradientSetting {
return autoChatColor(for: thread, previewWallpaper: nil, tx: tx)
}
private static func autoChatColor(
for thread: TSThread?,
previewWallpaper: Wallpaper?,
tx: SDSAnyReadTransaction
) -> ColorOrGradientSetting {
// If we're editing the color for a specific thread, then we'll prefer the
// globally-selected value instead of both the thread-specific and global
// wallpaper values.
if thread != nil, let globalColor = chatColorSetting(for: nil, tx: tx).constantColor {
return globalColor
}
let resolvedWallpaper = previewWallpaper ?? Wallpaper.wallpaperForRendering(for: thread, transaction: tx)
if let wallpaperColor = resolvedWallpaper?.defaultChatColor {
return wallpaperColor.colorSetting
}
return Constants.defaultColor.colorSetting
}
/// The currently-chosen setting for a particular scope.
///
/// This doesn't always contain enough information to render a color on the
/// screen. For example, a user may choose `.auto`, in which case you need
/// to run additional logic to determine which color corresponds to `.auto`.
public static func chatColorSetting(for thread: TSThread?, tx: SDSAnyReadTransaction) -> ChatColorSetting {
let chatColorSettingStore = DependenciesBridge.shared.chatColorSettingStore
let persistenceKey: String = thread?.uniqueId ?? Constants.globalKey
guard let valueId = chatColorSettingStore.fetchRawSetting(for: persistenceKey, tx: tx.asV2Read) else {
return .auto
}
if let paletteChatColor = PaletteChatColor(rawValue: valueId) {
return .builtIn(paletteChatColor)
}
let customColorKey = CustomChatColor.Key(rawValue: valueId)
if let customChatColor = Self.fetchCustomValue(for: customColorKey, tx: tx) {
return .custom(customColorKey, customChatColor)
}
// This isn't necessarily an error. A user might apply a custom chat color
// value to a conversation (or the global default), then delete the custom
// chat color value. In that case, all references to the value should
// behave as "auto" (the default).
return .auto
}
public static let chatColorsDidChangeNotification = NSNotification.Name("chatColorsDidChange")
public static func setChatColorSetting(_ value: ChatColorSetting, for thread: TSThread?, tx: SDSAnyWriteTransaction) {
let chatColorSettingStore = DependenciesBridge.shared.chatColorSettingStore
chatColorSettingStore.setRawSetting({ () -> String? in
switch value {
case .auto:
return nil
case .builtIn(let paletteChatColor):
return paletteChatColor.rawValue
case .custom(let colorKey, _):
return colorKey.rawValue
}
}(), for: thread?.uniqueId ?? Constants.globalKey, tx: tx.asV2Write)
postChatColorsDidChangeNotification(for: thread, tx: tx)
}
public static func resetAllSettings(transaction tx: SDSAnyWriteTransaction) {
DependenciesBridge.shared.chatColorSettingStore.resetAllSettings(tx: tx.asV2Write)
postChatColorsDidChangeNotification(for: nil, tx: tx)
}
private static func postChatColorsDidChangeNotification(for thread: TSThread?, tx: SDSAnyWriteTransaction) {
let threadUniqueId = thread?.uniqueId
tx.addAsyncCompletionOnMain {
NotificationCenter.default.post(name: chatColorsDidChangeNotification, object: threadUniqueId)
}
}
// MARK: -
}
public enum PaletteChatColor: String, CaseIterable {
// Default
case ultramarine = "Ultramarine"
// Solid Colors
case crimson = "Crimson"
case vermilion = "Vermilion"
case burlap = "Burlap"
case forest = "Forest"
case wintergreen = "Wintergreen"
case teal = "Teal"
case blue = "Blue"
case indigo = "Indigo"
case violet = "Violet"
case plum = "Plum"
case taupe = "Taupe"
case steel = "Steel"
// Gradients
case ember = "Ember"
case midnight = "Midnight"
case infrared = "Infrared"
case lagoon = "Lagoon"
case fluorescent = "Fluorescent"
case basil = "Basil"
case sublime = "Sublime"
case sea = "Sea"
case tangerine = "Tangerine"
private static func parseAngleDegreesFromSpec(_ angleDegreesFromSpec: CGFloat) -> CGFloat {
// In our models:
// If angleRadians = 0, gradientColor1 is N.
// If angleRadians = PI / 2, gradientColor1 is E.
// etc.
//
// In the spec:
// If angleDegrees = 180, gradientColor1 is N.
// If angleDegrees = 270, gradientColor1 is E.
// etc.
return ((angleDegreesFromSpec - 180) / 180) * CGFloat.pi
}
public var colorSetting: ColorOrGradientSetting {
switch self {
case .ultramarine:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x0552F0).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x2C6BED).asOWSColor,
angleRadians: CGFloat.pi * 0
)
case .crimson:
return .solidColor(color: UIColor(rgbHex: 0xCF163E).asOWSColor)
case .vermilion:
return .solidColor(color: UIColor(rgbHex: 0xC73F0A).asOWSColor)
case .burlap:
return .solidColor(color: UIColor(rgbHex: 0x6F6A58).asOWSColor)
case .forest:
return .solidColor(color: UIColor(rgbHex: 0x3B7845).asOWSColor)
case .wintergreen:
return .solidColor(color: UIColor(rgbHex: 0x1D8663).asOWSColor)
case .teal:
return .solidColor(color: UIColor(rgbHex: 0x077D92).asOWSColor)
case .blue:
return .solidColor(color: UIColor(rgbHex: 0x336BA3).asOWSColor)
case .indigo:
return .solidColor(color: UIColor(rgbHex: 0x6058CA).asOWSColor)
case .violet:
return .solidColor(color: UIColor(rgbHex: 0x9932C8).asOWSColor)
case .plum:
return .solidColor(color: UIColor(rgbHex: 0xAA377A).asOWSColor)
case .taupe:
return .solidColor(color: UIColor(rgbHex: 0x8F616A).asOWSColor)
case .steel:
return .solidColor(color: UIColor(rgbHex: 0x71717F).asOWSColor)
case .ember:
return .gradient(
gradientColor1: UIColor(rgbHex: 0xE57C00).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x5E0000).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(168)
)
case .midnight:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x2C2C3A).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x787891).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(180)
)
case .infrared:
return .gradient(
gradientColor1: UIColor(rgbHex: 0xF65560).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x442CED).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(192)
)
case .lagoon:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x004066).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x32867D).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(180)
)
case .fluorescent:
return .gradient(
gradientColor1: UIColor(rgbHex: 0xEC13DD).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x1B36C6).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(192)
)
case .basil:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x2F9373).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x077343).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(180)
)
case .sublime:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x6281D5).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x974460).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(180)
)
case .sea:
return .gradient(
gradientColor1: UIColor(rgbHex: 0x498FD4).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x2C66A0).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(180)
)
case .tangerine:
return .gradient(
gradientColor1: UIColor(rgbHex: 0xDB7133).asOWSColor,
gradientColor2: UIColor(rgbHex: 0x911231).asOWSColor,
angleRadians: Self.parseAngleDegreesFromSpec(192)
)
}
}
}
// MARK: -
public extension ChatColors {
// Represents the "message sender" to "group name color" mapping
// for a given CVC load.
struct GroupNameColors {
fileprivate let colorMap: [SignalServiceAddress: UIColor]
// TODO: Pending design.
fileprivate let defaultColor: UIColor
public func color(for address: SignalServiceAddress) -> UIColor {
colorMap[address] ?? defaultColor
}
fileprivate static var defaultColors: GroupNameColors {
GroupNameColors(colorMap: [:], defaultColor: Theme.primaryTextColor)
}
}
static func groupNameColors(forThread thread: TSThread) -> GroupNameColors {
guard let groupThread = thread as? TSGroupThread else {
return .defaultColors
}
let groupMembership = groupThread.groupMembership
let values = Self.groupNameColorValues
let isDarkThemeEnabled = Theme.isDarkThemeEnabled
var lastIndex: Int = 0
var colorMap = [SignalServiceAddress: UIColor]()
let addresses = Array(groupMembership.fullMembers).stableSort()
for (index, address) in addresses.enumerated() {
let valueIndex = index % values.count
guard let value = values[safe: valueIndex] else {
owsFailDebug("Invalid values.")
return .defaultColors
}
colorMap[address] = value.color(isDarkThemeEnabled: isDarkThemeEnabled)
lastIndex = index
}
let defaultValueIndex = (lastIndex + 1) % values.count
guard let defaultValue = values[safe: defaultValueIndex] else {
owsFailDebug("Invalid values.")
return .defaultColors
}
let defaultColor = defaultValue.color(isDarkThemeEnabled: isDarkThemeEnabled)
return GroupNameColors(colorMap: colorMap, defaultColor: defaultColor)
}
private static var defaultGroupNameColor: UIColor {
let isDarkThemeEnabled = Theme.isDarkThemeEnabled
return Self.groupNameColorValues.first!.color(isDarkThemeEnabled: isDarkThemeEnabled)
}
fileprivate struct GroupNameColorValue {
let lightTheme: UIColor
let darkTheme: UIColor
func color(isDarkThemeEnabled: Bool) -> UIColor {
isDarkThemeEnabled ? darkTheme : lightTheme
}
}
// In descending order of contrast with the other values.
fileprivate static let groupNameColorValues: [GroupNameColorValue] = [
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x006DA3),
darkTheme: UIColor(rgbHex: 0x00A7FA)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x007A3D),
darkTheme: UIColor(rgbHex: 0x00B85C)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xC13215),
darkTheme: UIColor(rgbHex: 0xFF6F52)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xB814B8),
darkTheme: UIColor(rgbHex: 0xF65AF6)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x5B6976),
darkTheme: UIColor(rgbHex: 0x8BA1B6)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x3D7406),
darkTheme: UIColor(rgbHex: 0x5EB309)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xCC0066),
darkTheme: UIColor(rgbHex: 0xF76EB2)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x2E51FF),
darkTheme: UIColor(rgbHex: 0x8599FF)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x9C5711),
darkTheme: UIColor(rgbHex: 0xD5920B)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x007575),
darkTheme: UIColor(rgbHex: 0x00B2B2)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xD00B4D),
darkTheme: UIColor(rgbHex: 0xFF6B9C)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x8F2AF4),
darkTheme: UIColor(rgbHex: 0xBF80FF)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xD00B0B),
darkTheme: UIColor(rgbHex: 0xFF7070)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x067906),
darkTheme: UIColor(rgbHex: 0x0AB80A)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x5151F6),
darkTheme: UIColor(rgbHex: 0x9494FF)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x866118),
darkTheme: UIColor(rgbHex: 0xD68F00)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x067953),
darkTheme: UIColor(rgbHex: 0x00B87A)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xA20CED),
darkTheme: UIColor(rgbHex: 0xCF7CF8)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x4B7000),
darkTheme: UIColor(rgbHex: 0x74AD00)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xC70A88),
darkTheme: UIColor(rgbHex: 0xF76EC9)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xB34209),
darkTheme: UIColor(rgbHex: 0xF57A3D)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x06792D),
darkTheme: UIColor(rgbHex: 0x0AB844)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x7A3DF5),
darkTheme: UIColor(rgbHex: 0xAF8AF9)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x6B6B24),
darkTheme: UIColor(rgbHex: 0xA4A437)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xD00B2C),
darkTheme: UIColor(rgbHex: 0xF77389)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x2D7906),
darkTheme: UIColor(rgbHex: 0x42B309)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xAF0BD0),
darkTheme: UIColor(rgbHex: 0xE06EF7)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x32763E),
darkTheme: UIColor(rgbHex: 0x4BAF5C)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x2662D9),
darkTheme: UIColor(rgbHex: 0x7DA1E8)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x76681E),
darkTheme: UIColor(rgbHex: 0xB89B0A)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x067462),
darkTheme: UIColor(rgbHex: 0x09B397)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x6447F5),
darkTheme: UIColor(rgbHex: 0xA18FF9)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x5E6E0C),
darkTheme: UIColor(rgbHex: 0x8FAA09)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x077288),
darkTheme: UIColor(rgbHex: 0x00AED1)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0xC20AA3),
darkTheme: UIColor(rgbHex: 0xF75FDD)),
GroupNameColorValue(lightTheme: UIColor(rgbHex: 0x2D761E),
darkTheme: UIColor(rgbHex: 0x43B42D))
]
}