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

327 lines
10 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
// MARK: - UI
class ReactionsBurstView: UIView, ReactionBurstDelegate {
private let burstAligner: ReactionBurstAligner
private var burstManager: ReactionBurstManager?
init(burstAligner: ReactionBurstAligner) {
self.burstAligner = burstAligner
super.init(frame: .zero)
self.burstManager = ReactionBurstManager(burstDelegate: self)
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: ReactionBurstDelegate
func burst(reactions: [Reaction]) {
guard
reactions.count == ReactionBurstManager.Constants.reactionCountNeededForBurst,
let reaction0 = reactions[safe: 0]?.emoji,
let reaction1 = reactions[safe: 1]?.emoji,
let reaction2 = reactions[safe: 2]?.emoji
else {
owsAssertBeta(false, "Call reaction burst requires \(ReactionBurstManager.Constants.reactionCountNeededForBurst) reactions as input!")
return
}
guard !AppEnvironment.shared.windowManagerRef.isCallInPip else {
// Don't burst when call is minimized.
return
}
owsAssertDebug(reaction0.isSingleEmoji)
owsAssertDebug(reaction1.isSingleEmoji)
owsAssertDebug(reaction2.isSingleEmoji)
let labels = [
self.emojiLabel(reaction: reaction0),
self.emojiLabel(reaction: reaction0),
self.emojiLabel(reaction: reaction1),
self.emojiLabel(reaction: reaction1),
self.emojiLabel(reaction: reaction2),
self.emojiLabel(reaction: reaction2),
]
let emoji0Animation = prepareAnimation(
label: labels[0],
relativeDuration: 0.4,
scaleFactor: 2,
rotation: -27...27
)
let emoji1Animation = prepareAnimation(
label: labels[1],
relativeDuration: 0.54,
scaleFactor: 2.5,
rotation: -30...0
)
let emoji2Animation = prepareAnimation(
label: labels[2],
relativeDuration: 0.6,
scaleFactor: 3,
rotation: -8...8
)
let emoji3Animation = prepareAnimation(
label: labels[3],
relativeDuration: 0.85,
scaleFactor: 3,
rotation: -12...12
)
let emoji4Animation = prepareAnimation(
label: labels[4],
relativeDuration: 0.65,
scaleFactor: 2.5,
rotation: -12...12
)
let emoji5Animation = prepareAnimation(
label: labels[5],
relativeDuration: 0.7,
scaleFactor: 2
)
UIView.animateKeyframes(withDuration: 2.5, delay: 0) {
emoji0Animation()
emoji1Animation()
emoji2Animation()
emoji3Animation()
emoji4Animation()
emoji5Animation()
} completion: { _ in
labels.forEach {
$0.removeFromSuperview()
}
}
}
private func emojiLabel(reaction: String) -> UILabel {
let font = UIFont.systemFont(ofSize: burstAligner.emojiStartingSize())
let label = UILabel()
label.text = reaction
label.textAlignment = .center
label.font = font
return label
}
private func prepareAnimation(
label: UILabel,
relativeDuration: Double,
scaleFactor: CGFloat,
rotation: ClosedRange<CGFloat>? = nil
) -> () -> Void {
let reactionSize = label.intrinsicContentSize
let container = OWSLayerView(frame: CGRect(origin: .zero, size: reactionSize * 4)) { view in
label.frame = view.bounds
}
container.addSubview(label)
if let rotation = rotation {
container.transform = .init(rotationAngle: rotation.lowerBound.toRadians)
}
let position = burstAligner.burstStartingPoint(in: self)
container.frame.origin = CGPoint(
x: position.x - container.width/4 - reactionSize.width/2,
y: position.y - container.height/4-reactionSize.height/2
)
addSubview(container)
return {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: relativeDuration) {
container.frame.origin.y = -container.height
if let rotation = rotation {
container.transform = .init(rotationAngle: rotation.upperBound.toRadians)
}
container.transform = container.transform.scaledBy(x: scaleFactor, y: scaleFactor)
}
}
}
}
private extension CGFloat {
var toRadians: CGFloat {
self * (.pi / 180)
}
}
// MARK: - ReactionReceiver
extension ReactionsBurstView: ReactionReceiver {
func addReactions(reactions: [Reaction]) {
reactions.forEach {
burstManager?.add(reaction: $0)
}
}
}
// MARK: - ReactionBurstAligner
protocol ReactionBurstAligner {
func burstStartingPoint(in view: UIView) -> CGPoint
func emojiStartingSize() -> CGFloat
}
// MARK: - RotatingArray data structure
private class RotatingArray<T: Timestamped> {
private let capacity: Int
private let timespan: TimeInterval
private var array = [T]()
init(capacity: Int, timespan: TimeInterval) {
self.capacity = capacity
self.timespan = timespan
}
func toArray() -> [T] {
return array
}
func append(_ item: T) {
AssertIsOnMainThread()
if array.count == capacity {
array.removeFirst()
}
array.append(item)
}
func removeAll() {
array.removeAll()
}
func removeAll(where shouldFilter: (T) -> Bool) {
array.removeAll(where: {
shouldFilter($0)
})
}
func atCapacityAndWithinTimespan(compareToNow: Bool = false) -> Bool {
guard array.count == capacity, let first = array[safe: 0] else {
return false
}
let timeElapsed: TimeInterval
if compareToNow {
timeElapsed = Date.timeIntervalSinceReferenceDate - first.timestamp
} else {
if let last = array[safe: array.count - 1] {
timeElapsed = last.timestamp - first.timestamp
} else {
return false
}
}
return timeElapsed <= self.timespan
}
}
// MARK: Timestamped
private protocol Timestamped {
var timestamp: TimeInterval { get }
}
extension Reaction: Timestamped {}
// MARK: - ReactionBurstDelegate
protocol ReactionBurstDelegate: AnyObject {
func burst(reactions: [Reaction])
}
// MARK: - ReactionBurstManager
class ReactionBurstManager {
private var incomingEmojiDictionary = [String: RotatingArray<Reaction>]()
private var cooloffDictionary = [String: TimeInterval]()
private weak var burstDelegate: ReactionBurstDelegate?
private var recentBursts = RotatingArray<Burst>(
capacity: Constants.maxBurstsInTimespan,
timespan: Constants.timespanForMaxBursts
)
private struct Burst: Timestamped {
var timestamp: TimeInterval
}
enum Constants {
// Three different people must react the same emoji in a span of 4 seconds in order
// for a burst to occur. The same emoji in different skintones counts as the same
// emoji, though we will burst proportionate to the variations.
static let reactionCountNeededForBurst = 3
static let timespanDuringWhichReactionsMustOccur: TimeInterval = 4
// After a burst, the amount of time the emoji must wait to be burst-eligible again.
static let perEmojiCooloffTime: TimeInterval = 2
// We only want to burst overall 3 times within 4 seconds. This rule is NOT on a
// per emoji basis. It applies across all bursts for all emojis.
static let maxBurstsInTimespan = 3
static let timespanForMaxBursts: TimeInterval = 4
}
init(burstDelegate: ReactionBurstDelegate) {
self.burstDelegate = burstDelegate
}
func add(reaction: Reaction) {
AssertIsOnMainThread()
let skintonedEmoji = reaction.emoji
// Different skintones of the same emoji should all go in the array of the base emoji, as
// they all contribute to the same burst. But if we can't get a base emoji, fallback to
// the emoji itself.
let key = EmojiWithSkinTones(rawValue: skintonedEmoji)?.baseEmoji.rawValue ?? skintonedEmoji
let array: RotatingArray<Reaction>
if let rxnArray = incomingEmojiDictionary[key] {
array = rxnArray
} else {
let rxnArray = RotatingArray<Reaction>(
capacity: Constants.reactionCountNeededForBurst,
timespan: Constants.timespanDuringWhichReactionsMustOccur
)
incomingEmojiDictionary[key] = rxnArray
array = rxnArray
}
// Maintain invariant: the per-emoji rotating array only ever has up to one
// reaction from a given ACI - the latest one. This helps us to ensure that
// reactions are only triggered when the reaction threshold is fulfilled by
// `Constants.reactionCountNeededForBurst` _distinct_ ACIs.
array.removeAll(where: { rxn in
return rxn.aci == reaction.aci
})
array.append(reaction)
let thresholdMet = array.atCapacityAndWithinTimespan()
var isEmojiCoolingOff = false
if
let lastOccurrenceTime = self.cooloffDictionary[key],
Date.timeIntervalSinceReferenceDate - lastOccurrenceTime < Constants.perEmojiCooloffTime
{
isEmojiCoolingOff = true
}
let areBurstsShutOff = self.recentBursts.atCapacityAndWithinTimespan(compareToNow: true)
if thresholdMet && !isEmojiCoolingOff && !areBurstsShutOff {
self.burstDelegate?.burst(reactions: array.toArray())
array.removeAll()
cooloffDictionary[key] = Date.timeIntervalSinceReferenceDate
self.recentBursts.append(Burst(timestamp: Date.timeIntervalSinceReferenceDate))
}
}
}