157 lines
5.3 KiB
Swift
157 lines
5.3 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
final class MessageTimerView: ManualLayoutView {
|
|
|
|
private enum Constants {
|
|
static let layoutSize: CGFloat = 12
|
|
// 0 == about to expire, 12 == just started countdown.
|
|
static let quantizationLevelCount: UInt64 = 12
|
|
}
|
|
|
|
private let imageView = CVImageView()
|
|
private var animationTimer: Timer?
|
|
|
|
init() {
|
|
super.init(name: "OWSMessageTimerView")
|
|
|
|
addSubviewToFillSuperviewEdges(imageView)
|
|
}
|
|
|
|
deinit {
|
|
clearAnimation()
|
|
}
|
|
|
|
func configure(
|
|
expirationTimestampMs: UInt64,
|
|
disappearingMessageInterval: UInt32,
|
|
tintColor: UIColor
|
|
) {
|
|
let expirationProgress = self.expirationProgress(
|
|
expirationTimestampMs: expirationTimestampMs,
|
|
disappearingMessageInterval: disappearingMessageInterval,
|
|
nowMs: Date.ows_millisecondTimestamp()
|
|
)
|
|
updateIcon(quantizedValue: expirationProgress.quantizedValue, tintColor: tintColor)
|
|
startAnimation(expirationProgress: expirationProgress, tintColor: tintColor)
|
|
}
|
|
|
|
private struct ExpirationProgress {
|
|
var quantizedValue: UInt64
|
|
var timerConfiguration: TimerConfiguration?
|
|
}
|
|
|
|
private struct TimerConfiguration {
|
|
let nextRefreshMs: UInt64
|
|
let refreshIntervalMs: UInt64
|
|
}
|
|
|
|
private func expirationProgress(
|
|
expirationTimestampMs: UInt64,
|
|
disappearingMessageInterval: UInt32,
|
|
nowMs: UInt64
|
|
) -> ExpirationProgress {
|
|
// Every N milliseconds we move to the next progress level.
|
|
let refreshIntervalMs = UInt64(disappearingMessageInterval) * 1000 / Constants.quantizationLevelCount
|
|
|
|
// It will never expire because the timer hasn't started yet.
|
|
guard expirationTimestampMs > 0, refreshIntervalMs > 0 else {
|
|
return ExpirationProgress(quantizedValue: Constants.quantizationLevelCount)
|
|
}
|
|
|
|
let remainingMs = expirationTimestampMs.subtractingReportingOverflow(nowMs)
|
|
// It already expired because the expiration date is in the past.
|
|
guard !remainingMs.overflow else {
|
|
return ExpirationProgress(quantizedValue: 0)
|
|
}
|
|
|
|
let intermediateValue = remainingMs.partialValue.addingReportingOverflow(refreshIntervalMs/2)
|
|
// The disappearing interval is way too large -- something is wrong.
|
|
guard !intermediateValue.overflow else {
|
|
return ExpirationProgress(quantizedValue: Constants.quantizationLevelCount)
|
|
}
|
|
|
|
let quantizedValue = intermediateValue.partialValue / refreshIntervalMs
|
|
return ExpirationProgress(
|
|
quantizedValue: quantizedValue,
|
|
timerConfiguration: {
|
|
guard quantizedValue > 0 else {
|
|
return nil
|
|
}
|
|
return TimerConfiguration(
|
|
nextRefreshMs: expirationTimestampMs - (quantizedValue - 1) * refreshIntervalMs - refreshIntervalMs/2,
|
|
refreshIntervalMs: refreshIntervalMs
|
|
)
|
|
}()
|
|
)
|
|
}
|
|
|
|
private func updateIcon(quantizedValue: UInt64, tintColor: UIColor) {
|
|
let progressIcon = self.progressIcon(quantizedValue: quantizedValue, tintColor: tintColor)
|
|
imageView.image = progressIcon?.withRenderingMode(.alwaysTemplate)
|
|
imageView.tintColor = tintColor
|
|
}
|
|
|
|
private func progressIcon(quantizedValue: UInt64, tintColor: UIColor) -> UIImage? {
|
|
owsAssertDebug(quantizedValue <= Constants.quantizationLevelCount)
|
|
let imageName = String(format: "messagetimer-%02ld", quantizedValue * 5)
|
|
guard let image = UIImage(named: imageName) else {
|
|
owsFailDebug("Missing icon.")
|
|
return nil
|
|
}
|
|
owsAssertDebug(image.size.width == Constants.layoutSize)
|
|
owsAssertDebug(image.size.height == Constants.layoutSize)
|
|
return image
|
|
}
|
|
|
|
private func startAnimation(expirationProgress: ExpirationProgress, tintColor: UIColor) {
|
|
AssertIsOnMainThread()
|
|
|
|
clearAnimation()
|
|
guard let timerConfiguration = expirationProgress.timerConfiguration else {
|
|
return
|
|
}
|
|
|
|
var quantizedValue = expirationProgress.quantizedValue
|
|
let animationTimer = Timer(
|
|
fire: Date(millisecondsSince1970: timerConfiguration.nextRefreshMs),
|
|
interval: TimeInterval(timerConfiguration.refreshIntervalMs)/1000,
|
|
repeats: true,
|
|
block: { [weak self] timer in
|
|
guard let self else {
|
|
timer.invalidate()
|
|
return
|
|
}
|
|
quantizedValue -= 1
|
|
self.updateIcon(quantizedValue: quantizedValue, tintColor: tintColor)
|
|
guard quantizedValue > 0 else {
|
|
timer.invalidate()
|
|
return
|
|
}
|
|
}
|
|
)
|
|
self.animationTimer = animationTimer
|
|
RunLoop.main.add(animationTimer, forMode: .common)
|
|
}
|
|
|
|
private func clearAnimation() {
|
|
AssertIsOnMainThread()
|
|
|
|
animationTimer?.invalidate()
|
|
animationTimer = nil
|
|
}
|
|
|
|
func prepareForReuse() {
|
|
clearAnimation()
|
|
imageView.image = nil
|
|
}
|
|
|
|
static var measureSize: CGSize {
|
|
.square(Constants.layoutSize)
|
|
}
|
|
}
|