TM-SGNL-iOS/Signal/ConversationView/CellViews/CVReactionCountsView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

293 lines
10 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalUI
import UIKit
class CVReactionCountsView: ManualStackView {
enum PillState: Equatable {
case emoji(emoji: String, count: Int, fromLocalUser: Bool)
case moreCount(count: Int, fromLocalUser: Bool)
var fromLocalUser: Bool {
switch self {
case .emoji(_, _, let fromLocalUser):
return fromLocalUser
case .moreCount(_, let fromLocalUser):
return fromLocalUser
}
}
}
struct State: Equatable {
let pill1: PillState?
let pill2: PillState?
let pill3: PillState?
}
public static let height: CGFloat = 24
public static let inset: CGFloat = 7
private static let pillKey1 = "pill1"
private static let pillKey2 = "pill2"
private static let pillKey3 = "pill3"
private let pill1 = PillView(pillKey: CVReactionCountsView.pillKey1)
private let pill2 = PillView(pillKey: CVReactionCountsView.pillKey2)
private let pill3 = PillView(pillKey: CVReactionCountsView.pillKey3)
init() {
super.init(name: "reaction counts")
}
static var stackConfig: CVStackViewConfig {
CVStackViewConfig(axis: .horizontal, alignment: .fill, spacing: 0, layoutMargins: .zero)
}
public static func buildState(with reactionState: InteractionReactionState) -> State {
func buildPillState(emojiCount: InteractionReactionState.EmojiCount) -> PillState {
.emoji(emoji: emojiCount.emoji,
count: emojiCount.count,
fromLocalUser: emojiCount.emoji == reactionState.localUserEmoji)
}
// We display up to 3 reaction bubbles per message in order
// of popularity (`emojiCounts` comes pre-sorted to reflect
// this ordering).
var pill1: PillState?
var pill2: PillState?
var pill3: PillState?
let build = {
State(pill1: pill1, pill2: pill2, pill3: pill3)
}
guard !reactionState.emojiCounts.isEmpty else {
return build()
}
pill1 = buildPillState(emojiCount: reactionState.emojiCounts[0])
guard reactionState.emojiCounts.count >= 2 else {
return build()
}
pill2 = buildPillState(emojiCount: reactionState.emojiCounts[1])
guard reactionState.emojiCounts.count >= 3 else {
return build()
}
// If there are more than 3 unique reactions, the third bubble
// will represent the count of remaining unique reactors *not*
// the count of remaining unique emoji.
if reactionState.emojiCounts.count > 3 {
let renderedEmoji = reactionState.emojiCounts[0...1].map { $0.emoji }
let remainingReactorCount = reactionState.emojiCounts
.lazy
.filter { !renderedEmoji.contains($0.emoji) }
.map { $0.count }
.reduce(0, +)
let remainingReactionsIncludesLocalUserReaction: Bool = {
guard let localEmoji = reactionState.localUserEmoji else { return false }
return !renderedEmoji.contains(localEmoji)
}()
pill3 = .moreCount(count: remainingReactorCount,
fromLocalUser: remainingReactionsIncludesLocalUserReaction)
} else {
pill3 = buildPillState(emojiCount: reactionState.emojiCounts[2])
}
return build()
}
private static let measurementKey = "CVReactionCountsView"
func configure(state: State, cellMeasurement: CVCellMeasurement) {
layer.borderColor = Theme.backgroundColor.cgColor
var subviews = [UIView]()
func configure(pillView: PillView, pillState: PillState?) {
guard let pillState = pillState else {
return
}
pillView.configure(pillState: pillState, cellMeasurement: cellMeasurement)
subviews.append(pillView)
}
configure(pillView: pill1, pillState: state.pill1)
configure(pillView: pill2, pillState: state.pill2)
configure(pillView: pill3, pillState: state.pill3)
self.configure(config: Self.stackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey,
subviews: subviews)
}
static func measure(state: State,
measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
var subviewInfos = [ManualStackSubviewInfo]()
func measurePill(pillState: PillState?, pillKey: String) {
guard let pillState = pillState else {
return
}
let pillSize = PillView.measure(pillState: pillState,
pillKey: pillKey,
measurementBuilder: measurementBuilder)
subviewInfos.append(pillSize.asManualSubviewInfo)
}
measurePill(pillState: state.pill1, pillKey: Self.pillKey1)
measurePill(pillState: state.pill2, pillKey: Self.pillKey2)
measurePill(pillState: state.pill3, pillKey: Self.pillKey3)
let stackMeasurement = ManualStackView.measure(config: Self.stackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey,
subviewInfos: subviewInfos)
return stackMeasurement.measuredSize
}
public override func reset() {
super.reset()
pill1.reset()
pill2.reset()
pill3.reset()
}
// MARK: -
private class PillView: ManualStackViewWithLayer {
private let pillKey: String
private let emojiLabel = CVLabel()
private let countLabel = CVLabel()
private static let pillBorderWidth: CGFloat = 1
init(pillKey: String) {
self.pillKey = pillKey
super.init(name: pillKey)
emojiLabel.clipsToBounds = true
clipsToBounds = true
}
static var stackConfig: CVStackViewConfig {
let layoutMargins = UIEdgeInsets(top: 3, leading: 7, bottom: 3, trailing: 7)
return CVStackViewConfig(axis: .horizontal, alignment: .fill, spacing: 2, layoutMargins: layoutMargins)
}
static func emojiLabelConfig(pillState: PillState) -> CVLabelConfig? {
switch pillState {
case .emoji(let emoji, _, _):
assert(emoji.isSingleEmoji)
// textColor doesn't matter for emoji.
return CVLabelConfig.unstyledText(
emoji,
font: .boldSystemFont(ofSize: 14),
textColor: .black,
textAlignment: .center
)
case .moreCount:
return nil
}
}
static func countLabelConfig(pillState: PillState) -> CVLabelConfig? {
let textColor: UIColor = {
if pillState.fromLocalUser {
return Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_gray90
} else {
return Theme.secondaryTextAndIconColor
}
}()
let text: String
switch pillState {
case .emoji(_, let count, _):
guard count > 1 else {
return nil
}
text = count.abbreviatedString
case .moreCount(let count, _):
text = "+" + count.abbreviatedString
}
return CVLabelConfig.unstyledText(
text,
font: .monospacedDigitSystemFont(ofSize: 12, weight: .bold),
textColor: textColor,
textAlignment: .center
)
}
func configure(pillState: PillState,
cellMeasurement: CVCellMeasurement) {
addLayoutBlock { view in
view.layer.borderWidth = Self.pillBorderWidth
view.layer.cornerRadius = CVReactionCountsView.height / 2
}
layer.borderColor = Theme.backgroundColor.cgColor
if pillState.fromLocalUser {
backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray60 : .ows_gray25
} else {
backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05
}
var subviews = [UIView]()
if let emojiLabelConfig = Self.emojiLabelConfig(pillState: pillState) {
emojiLabelConfig.applyForRendering(label: emojiLabel)
subviews.append(emojiLabel)
}
if let countLabelConfig = Self.countLabelConfig(pillState: pillState) {
countLabelConfig.applyForRendering(label: countLabel)
subviews.append(countLabel)
}
configure(config: Self.stackConfig,
cellMeasurement: cellMeasurement,
measurementKey: pillKey,
subviews: subviews)
}
static func measure(pillState: PillState,
pillKey: String,
measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
var subviewInfos = [ManualStackSubviewInfo]()
if let emojiLabelConfig = Self.emojiLabelConfig(pillState: pillState) {
let labelSize = CVText.measureLabel(config: emojiLabelConfig,
maxWidth: .greatestFiniteMagnitude)
subviewInfos.append(labelSize.asManualSubviewInfo)
}
if let countLabelConfig = Self.countLabelConfig(pillState: pillState) {
let labelSize = CVText.measureLabel(config: countLabelConfig,
maxWidth: .greatestFiniteMagnitude)
subviewInfos.append(labelSize.asManualSubviewInfo)
}
let stackMeasurement = ManualStackView.measure(config: Self.stackConfig,
measurementBuilder: measurementBuilder,
measurementKey: pillKey,
subviewInfos: subviewInfos)
var result = stackMeasurement.measuredSize
// Pin height.
result.height = CVReactionCountsView.height
return result
}
}
}