TM-SGNL-iOS/Signal/Calls/UserInterface/IncomingReactionsView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

418 lines
14 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import UIKit
class IncomingReactionsView: UIView, ReactionReceiver {
enum Constants {
static let viewWidth: CGFloat = 217
fileprivate static let maxReactionsToDisplay = ReactionsModel.Constants.maxReactionsToDisplay
fileprivate static let reactionSpacing: CGFloat = 12
fileprivate static let reactionViewHeight: CGFloat = ReactionView.Constants.nameViewDimension
fileprivate static let displayTime: TimeInterval = 4
fileprivate static let animationDuration: TimeInterval = 0.2
}
// MARK: - Model
private var reactionsModel = ReactionsModel()
private func removeReactions(uuids: [UUID]) {
self.reactionsModel.remove(uuids: uuids)
self.applyLatestSnapshot()
}
func addReactions(reactions: [Reaction]) {
self.reactionsModel.add(reactions: reactions)
applyLatestSnapshot()
}
// MARK: - Updates
private func applyLatestSnapshot(animated: Bool = true) {
guard !isAnimationInProgress else {
return
}
guard let changes = reactionsModel.changesSinceLastDiff() else {
// Nil => No changes to apply.
return
}
self.isAnimationInProgress = true
var reactionViewsToAdd = [ReactionView]()
for rxn in changes.reactionsToAdd {
if let view = reactionViewReusePool?.retrieve(for: rxn) {
reactionViewsToAdd.append(view)
}
}
self.setReactionFrames(
reactionViewsToLayout: reactionViewsToAdd,
// Start the views one slot down so that they can animate up
bottommostOriginY: self.bounds.maxY
)
let uuidsToRemove = changes.reactionsToRemove.map { $0.uuid }
let reactionViewsToRemove = reactionViews.filter { reactionView in
return uuidsToRemove.contains(where: { reactionView.reaction?.uuid == $0 })
}
let uuidsToMove = changes.reactionsToMove.map { $0.uuid }
let reactionViewsToMove = reactionViews.filter { reactionView in
return uuidsToMove.contains(where: { reactionView.reaction?.uuid == $0 })
}
let finalReactionViewsDisplayed = reactionViewsToMove + reactionViewsToAdd
UIView.animate(withDuration: Constants.animationDuration, delay: 0, options: .curveEaseOut, animations: {
// Adding reactions
self.setReactionFrames(
reactionViewsToLayout: reactionViewsToAdd,
bottommostOriginY: self.bounds.maxY - Constants.reactionSpacing - Constants.reactionViewHeight
)
// Moving reactions
let numSlotsToMove = CGFloat(changes.slotsToMove)
if let last = reactionViewsToMove.last {
self.setReactionFrames(
reactionViewsToLayout: reactionViewsToMove,
bottommostOriginY: last.frame.origin.y - numSlotsToMove*(Constants.reactionSpacing + Constants.reactionViewHeight)
)
}
// Removing reactions
for viewToRemove in reactionViewsToRemove {
viewToRemove.alpha = 0
}
}, completion: { [weak self] _ in
reactionViewsToRemove.forEach {
self?.reactionViewReusePool?.relinquish(reactionView: $0)
}
self?.reactionViews = finalReactionViewsDisplayed
let addedUuids = changes.reactionsToAdd.map { $0.uuid }
if !addedUuids.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.displayTime) { [weak self] in
self?.removeReactions(uuids: addedUuids)
}
}
self?.isAnimationInProgress = false
})
}
private var isAnimationInProgress = false {
didSet {
if oldValue && !isAnimationInProgress {
applyLatestSnapshot()
}
}
}
// MARK: - View
static var viewHeight: CGFloat {
let sumReactionViewHeights = CGFloat(Constants.maxReactionsToDisplay) * Constants.reactionViewHeight
let sumSpacingHeights = (CGFloat(Constants.maxReactionsToDisplay) - 1) * Constants.reactionSpacing
return sumReactionViewHeights + sumSpacingHeights
}
private var reactionViewReusePool: ReactionViewReusePool?
private var reactionViews = [ReactionView]()
/// Sets reaction frames.
///
/// - Parameter reactionViewsToLayout: The `ReactionViews`, ordered from oldest
/// to newest, ie, top to bottom.
/// - Parameter bottommostOriginY: The target origin y-value of the bottommost `ReactionView`.
private func setReactionFrames(
reactionViewsToLayout: [ReactionView],
bottommostOriginY: CGFloat
) {
var origin = CGPoint(x: 0, y: bottommostOriginY)
for view in reactionViewsToLayout.reversed() {
view.frame = CGRect(
origin: origin,
size: CGSize(
width: Constants.viewWidth,
height: Constants.reactionViewHeight
)
)
origin = CGPoint(
x: 0,
y: origin.y - Constants.reactionSpacing - Constants.reactionViewHeight
)
}
}
private class ReactionViewReusePool {
var pool = [ReactionView]()
weak var superview: UIView?
init(superview: UIView) {
self.superview = superview
}
func retrieve(for reaction: Reaction) -> ReactionView {
let retrievedView: ReactionView
if let view = pool.popLast() {
view.reaction = reaction
retrievedView = view
} else {
retrievedView = ReactionView(reaction: reaction)
}
if retrievedView.superview == nil {
self.superview?.addSubview(retrievedView)
}
retrievedView.alpha = 1
retrievedView.isHidden = false
return retrievedView
}
func relinquish(reactionView: ReactionView) {
reactionView.reaction = nil
reactionView.alpha = 0
reactionView.isHidden = true
pool.append(reactionView)
}
}
private class ReactionView: UIView {
enum Constants {
static let emojiDimension: CGFloat = 28
static let nameViewDimension: CGFloat = 36
static let nameLabelDimension: CGFloat = 20
static let spacingBetweenEmojiAndName: CGFloat = 18
static let nameCornerRadius: CGFloat = 18
static let nameViewHInset: CGFloat = 16
static let nameViewVInset: CGFloat = 8
}
private lazy var emojiLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = label.font.withSize(Constants.emojiDimension)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.heightAnchor.constraint(equalToConstant: Constants.emojiDimension)
])
return label
}()
private lazy var nameLabel: UILabel = {
let label = UILabel()
label.textColor = .ows_white
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
NSLayoutConstraint.activate([
label.heightAnchor.constraint(equalToConstant: Constants.nameLabelDimension)
])
return label
}()
private lazy var nameView: UIView = {
let view = UIView()
view.clipsToBounds = true
view.layer.cornerRadius = Constants.nameCornerRadius
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
if UIAccessibility.isReduceTransparencyEnabled {
view.backgroundColor = .ows_gray75
} else {
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
view.addSubview(blurView)
blurView.autoPinEdgesToSuperviewEdges()
}
view.addSubview(nameLabel)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor, constant: -Constants.nameViewHInset),
view.trailingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: Constants.nameViewHInset),
view.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: Constants.nameViewVInset),
view.topAnchor.constraint(equalTo: nameLabel.topAnchor, constant: -Constants.nameViewVInset),
view.heightAnchor.constraint(equalToConstant: Constants.nameViewDimension)
])
return view
}()
fileprivate var reaction: Reaction? {
didSet {
guard let reaction else { return }
applyModel(reaction: reaction)
}
}
private func applyModel(reaction: Reaction) {
self.emojiLabel.text = reaction.emoji
self.nameLabel.text = reaction.name
}
convenience init(reaction: Reaction) {
self.init(frame: .zero)
self.reaction = reaction
applyModel(reaction: reaction)
}
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.emojiLabel)
self.addSubview(self.nameView)
NSLayoutConstraint.activate([
self.leadingAnchor.constraint(equalTo: self.emojiLabel.leadingAnchor),
self.emojiLabel.trailingAnchor.constraint(equalTo: self.nameView.leadingAnchor, constant: -Constants.spacingBetweenEmojiAndName),
self.emojiLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
self.nameView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
self.nameView.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Required
override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = false
self.reactionViewReusePool = ReactionViewReusePool(superview: self)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - ReactionBurstAligner
extension IncomingReactionsView: ReactionBurstAligner {
func burstStartingPoint(in view: UIView) -> CGPoint {
let y = self.bounds.maxY - Constants.reactionViewHeight
let originalPosition = CGPoint(x: 0, y: y)
return self.convert(
originalPosition,
to: view
)
}
func emojiStartingSize() -> CGFloat {
return ReactionView.Constants.emojiDimension
}
}
// MARK: - Model classes/structs
class ReactionsModel {
fileprivate enum Constants {
static let maxReactionsToDisplay = 5
}
private var reactions = [Reaction]()
private var prevSnapshot = [Reaction]()
func add(reactions: [Reaction]) {
AssertIsOnMainThread()
self.reactions.append(contentsOf: reactions)
}
func remove(uuids: [UUID]) {
AssertIsOnMainThread()
self.reactions = self.reactions.filter { originalReaction in
!uuids.contains(where: { $0 == originalReaction.uuid })
}
}
func snapshot() -> [Reaction]? {
AssertIsOnMainThread()
return self.reactions
}
struct ReactionChangeSet {
let reactionsToAdd: [Reaction]
let reactionsToMove: [Reaction]
let slotsToMove: Int
let reactionsToRemove: [Reaction]
}
func changesSinceLastDiff() -> ReactionChangeSet? {
AssertIsOnMainThread()
guard let snapshot = snapshot() else { return nil }
var trimmedSnapshot = snapshot
// Trim the snapshot since we can never show more than `Constants.maxReactionsToDisplay` anyway.
let excess = trimmedSnapshot.count - Constants.maxReactionsToDisplay
if excess > 0 {
trimmedSnapshot = Array(trimmedSnapshot.dropFirst(excess))
}
let currSnapshot = trimmedSnapshot
var reactionsToAdd = [Reaction]()
for newRxn in currSnapshot {
if !self.prevSnapshot.contains(where: { $0.uuid == newRxn.uuid }) {
reactionsToAdd.append(newRxn)
}
}
var reactionsToMove = [Reaction]()
for rxn in currSnapshot {
if self.prevSnapshot.contains(where: { $0.uuid == rxn.uuid }) {
reactionsToMove.append(rxn)
}
}
var reactionsToRemove = [Reaction]()
for oldRxn in self.prevSnapshot {
if !currSnapshot.contains(where: { $0.uuid == oldRxn.uuid }) {
reactionsToRemove.append(oldRxn)
}
}
self.prevSnapshot = currSnapshot
self.reactions = currSnapshot
var slotsToMove = 0
if !reactionsToMove.isEmpty {
slotsToMove = reactionsToAdd.count
}
if reactionsToAdd.isEmpty && (reactionsToMove.isEmpty || slotsToMove == 0) && reactionsToRemove.isEmpty {
// No changes
return nil
}
return ReactionChangeSet(
reactionsToAdd: reactionsToAdd,
reactionsToMove: reactionsToMove,
slotsToMove: slotsToMove,
reactionsToRemove: reactionsToRemove
)
}
}
struct Reaction {
let emoji: String
let name: String
let aci: Aci
let timestamp: TimeInterval
let uuid = UUID()
init(
emoji: String,
name: String,
aci: Aci,
timestamp: TimeInterval
) {
self.emoji = emoji
self.name = name
self.aci = aci
self.timestamp = timestamp
}
}