Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/CellViews/MessageTimerView.swift
1 views
//
// 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)
    }
}