Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/Views/ConversationAvatarView.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import GRDB
import LibSignalClient
public import SignalServiceKit
import UIKit

public protocol ConversationAvatarViewDelegate: UIViewController {
    func didTapBadge()

    func presentStoryViewController()
    func presentAvatarViewController()
}

public extension ConversationAvatarViewDelegate {
    func didTapAvatar(_ configuration: ConversationAvatarView.Configuration) {
        if configuration.hasStoriesToDisplay {
            let actionSheet = ActionSheetController()
            actionSheet.addAction(OWSActionSheets.cancelAction)
            actionSheet.addAction(.init(
                title: OWSLocalizedString("VIEW_PHOTO", comment: "View the photo of a group or user"),
                handler: { [weak self] _ in
                    self?.presentAvatarViewController()
                },
            ))
            actionSheet.addAction(.init(
                title: OWSLocalizedString("VIEW_STORY", comment: "View the story of a group or user"),
                handler: { [weak self] _ in
                    self?.presentStoryViewController()
                },
            ))
            presentActionSheet(actionSheet, animated: true)
        } else {
            presentAvatarViewController()
        }
    }
}

public class ConversationAvatarView: UIView, CVView, PrimaryImageView {

    public init(
        sizeClass: Configuration.SizeClass = .customDiameter(0),
        localUserDisplayMode: LocalUserDisplayMode,
        badged: Bool = true,
        shape: Configuration.Shape = .circular,
        useAutolayout: Bool = true,
    ) {
        self.configuration = Configuration(
            sizeClass: sizeClass,
            dataSource: nil,
            localUserDisplayMode: localUserDisplayMode,
            addBadgeIfApplicable: badged,
            shape: shape,
            useAutolayout: useAutolayout,
        )

        super.init(frame: .zero)

        addSubview(storyStateView)
        addSubview(avatarView)
        addSubview(badgeView)
        autoresizesSubviews = false
        isUserInteractionEnabled = false
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Configuration API

    public struct Configuration: Equatable {
        public enum SizeClass: Equatable {
            case twentyFour
            case twentyEight
            case thirtyTwo
            case thirtySix
            case forty
            case fortyFour
            case fortyEight
            case fiftySix
            case sixtyFour
            case seventyFour
            case eighty
            case eightyEight
            case oneHundredTwelve

            // Badge sprites may have artifacts from scaling to custom size classes
            // Stick to explicit size classes when you can
            case customDiameter(UInt)

            public init(avatarDiameter: UInt) {
                switch avatarDiameter {
                case Self.twentyFour.diameter:
                    self = .twentyFour
                case Self.twentyEight.diameter:
                    self = .twentyEight
                case Self.thirtyTwo.diameter:
                    self = .thirtyTwo
                case Self.thirtySix.diameter:
                    self = .thirtySix
                case Self.forty.diameter:
                    self = .forty
                case Self.fortyFour.diameter:
                    self = .fortyFour
                case Self.fortyEight.diameter:
                    self = .fortyEight
                case Self.fiftySix.diameter:
                    self = .fiftySix
                case Self.sixtyFour.diameter:
                    self = .sixtyFour
                case Self.seventyFour.diameter:
                    self = .seventyFour
                case Self.eighty.diameter:
                    self = .eighty
                case Self.eightyEight.diameter:
                    self = .eightyEight
                case Self.oneHundredTwelve.diameter:
                    self = .oneHundredTwelve
                default:
                    self = .customDiameter(avatarDiameter)
                }
            }
        }

        public enum Shape {
            case rectangular
            case circular
        }

        /// The preferred size class of the avatar. Used for avatar generation and autolayout (if enabled).
        ///
        /// If a predefined size class is used, a badge can optionally be placed by
        /// specifying `addBadgeIfApplicable` or `fallbackBadge`.
        public var sizeClass: SizeClass

        fileprivate var avatarSizeClass: SizeClass {
            guard hasStoriesToDisplay else { return sizeClass }
            return .customDiameter(sizeClass.diameter - (sizeClass.storyBorderInsets * 2))
        }

        fileprivate var avatarOffset: CGPoint {
            guard hasStoriesToDisplay else { return .zero }
            return CGPoint(x: Int(sizeClass.storyBorderInsets), y: Int(sizeClass.storyBorderInsets))
        }

        /// The data provider used to fetch an avatar and badge
        public var dataSource: ConversationAvatarDataSource?

        public mutating func setGroupIdWithSneakyTransaction(groupId: Data) {
            if dataSource?.groupId == groupId {
                return
            }
            let databaseStorage = SSKEnvironment.shared.databaseStorageRef
            self.dataSource = databaseStorage.read { tx in
                return TSGroupThread.fetch(groupId: groupId, transaction: tx)
            }.map {
                return .thread($0)
            }
        }

        /// Adjusts how the local user profile avatar is generated (Note to Self or Avatar?)
        public var localUserDisplayMode: LocalUserDisplayMode
        /// Places the user's badge (if they have one) over the avatar. Only supported for predefined size classes
        public var addBadgeIfApplicable: Bool
        /// If the user has no badge, this one will be shown instead.
        /// Useful for previewing badges without them actually being on your profile.
        /// Expects the badge image to already be loaded.
        public var fallbackBadge: ProfileBadge?

        /// Adjusts the mask of the avatar view
        public var shape: Shape
        /// If set `true`, adds constraints to the view to ensure that it's sized for the provided size class
        /// Otherwise, it's the superview's responsibility to ensure this view is sized appropriately
        public var useAutolayout: Bool

        // Adopters that'd like to fetch the image synchronously can set this to perform
        // the next model update synchronously if necessary.
        fileprivate var forceSyncUpdate: Bool = false
        public mutating func applyConfigurationSynchronously() {
            forceSyncUpdate = true
        }

        fileprivate mutating func checkForSyncUpdateAndClear() -> Bool {
            // If we don't have a data source then there's no slow-path asset fetching. We can just take the sync update path always
            var shouldUpdateSync = true
            if let dataSource, !forceSyncUpdate {
                // if we have data and are not forced to perform a sync call
                // we will perform an async call if we shouldn't use the cache (placeholder shall be shown) or the data is not cached
                shouldUpdateSync = useCachedImages && dataSource.isDataImmediatelyAvailable
            }
            forceSyncUpdate = false
            return shouldUpdateSync
        }

        fileprivate var useCachedImages = true
        public mutating func usePlaceholderImages() {
            useCachedImages = false
        }

        public enum StoryConfiguration: Equatable {
            /// Won't show the ring even if there are stories.
            case disabled
            /// Shows the ring based on the provided state; won't observe updates.
            case fixed(StoryContextViewState)
            /// Shows the ring if needed and observes story state changes.
            /// Optional initial value to speed up first load, if not provided state
            /// will be loaded on its own.
            case autoUpdate(state: StoryContextViewState? = nil)
        }

        public var storyConfiguration: StoryConfiguration = .disabled

        public var hasStoriesToDisplay: Bool {
            guard StoryManager.areStoriesEnabled else { return false }
            switch storyConfiguration {
            case .disabled: return false
            case let .autoUpdate(state):
                return state?.hasStoriesToDisplay ?? false
            case let .fixed(state):
                return state.hasStoriesToDisplay
            }
        }
    }

    public private(set) var configuration: Configuration {
        didSet {
            if configuration.dataSource != oldValue.dataSource {
                AssertIsOnMainThread()
                ensureObservers()
            }
        }
    }

    func updateConfigurationAndSetDirtyIfNecessary(_ newValue: Configuration) {
        let oldValue = configuration
        configuration = newValue

        // We may need to update our model, layout, or constraints based on the changes to the configuration
        let sizeClassDidChange = configuration.sizeClass != oldValue.sizeClass
        let avatarSizeClassDidChange = configuration.avatarSizeClass != oldValue.avatarSizeClass
        let dataSourceDidChange = configuration.dataSource != oldValue.dataSource
        let localUserDisplayModeDidChange = configuration.localUserDisplayMode != oldValue.localUserDisplayMode
        let shouldShowBadgeDidChange = configuration.addBadgeIfApplicable != oldValue.addBadgeIfApplicable
        let fallbackBadgeDidChange = configuration.fallbackBadge != oldValue.fallbackBadge
        let shapeDidChange = configuration.shape != oldValue.shape
        let autolayoutDidChange = configuration.useAutolayout != oldValue.useAutolayout
        let storyStateDidChange = configuration.storyConfiguration != oldValue.storyConfiguration

        // Any changes to avatar size or provider will trigger a model update
        let needsModelUpdate: Bool = (
            sizeClassDidChange ||
                avatarSizeClassDidChange ||
                dataSourceDidChange ||
                localUserDisplayModeDidChange ||
                shouldShowBadgeDidChange ||
                fallbackBadgeDidChange,
        )
        if needsModelUpdate {
            setNeedsModelUpdate()
        }

        // If autolayout was toggled, or the size changed while autolayout is enabled we need to update our constraints
        if autolayoutDidChange || (configuration.useAutolayout && (sizeClassDidChange || avatarSizeClassDidChange)) {
            setNeedsUpdateConstraints()
        }

        if sizeClassDidChange || avatarSizeClassDidChange || shouldShowBadgeDidChange || shapeDidChange || storyStateDidChange {
            setNeedsLayout()
        }
    }

    // MARK: Configuration updates

    public func updateWithSneakyTransactionIfNecessary(_ updateBlock: (inout Configuration) -> Void) {
        update(optionalTransaction: nil, updateBlock)
    }

    /// To reduce the occurrence of unnecessary avatar fetches, updates to the view configuration occur in a closure
    /// Configuration updates will be applied all at once
    public func update(_ transaction: DBReadTransaction, _ updateBlock: (inout Configuration) -> Void) {
        update(optionalTransaction: transaction, updateBlock)
    }

    private func update(optionalTransaction transaction: DBReadTransaction?, _ updateBlock: (inout Configuration) -> Void) {
        AssertIsOnMainThread()

        let oldConfiguration = configuration
        var mutableConfig = oldConfiguration
        updateBlock(&mutableConfig)
        updateConfigurationAndSetDirtyIfNecessary(mutableConfig)
        updateModelIfNecessary(transaction: transaction)
    }

    // MARK: Model Updates

    public func reloadDataIfNecessary() {
        updateModel(transaction: nil)
    }

    private func updateModel(transaction readTx: DBReadTransaction?) {
        setNeedsModelUpdate()
        updateModelIfNecessary(transaction: readTx)
    }

    // If the model has been dirtied, performs an update
    // If an async update is requested, the model is updated immediately with any available cached content
    // followed by enqueueing a full model update on a background thread.
    private func updateModelIfNecessary(transaction readTx: DBReadTransaction?) {
        AssertIsOnMainThread()

        guard nextModelGeneration.get() > currentModelGeneration else { return }
        guard let dataSource = configuration.dataSource else {
            updateViewContent(avatarImage: nil, primaryBadgeImage: nil)
            return
        }

        var avatarImage: UIImage?
        var badgeImage: UIImage?
        let updateSynchronously = configuration.checkForSyncUpdateAndClear()
        if updateSynchronously {
            avatarImage = dataSource.buildImage(configuration: configuration, transaction: readTx)
            badgeImage = dataSource.fetchBadge(configuration: configuration, transaction: readTx)
        } else {
            if configuration.useCachedImages {
                avatarImage = dataSource.fetchCachedImage(configuration: configuration, transaction: readTx)
                badgeImage = dataSource.fetchBadge(configuration: configuration, transaction: readTx)
            } else {
                avatarImage = UIImage(named: Theme.iconName(.profilePlaceholder))
            }
        }
        updateViewContent(avatarImage: avatarImage, primaryBadgeImage: badgeImage)
        if !updateSynchronously {
            enqueueAsyncModelUpdate()
        }
    }

    private func updateViewContent(avatarImage: UIImage?, primaryBadgeImage: UIImage?) {
        AssertIsOnMainThread()

        avatarView.image = avatarImage
        badgeView.image = {
            if let primaryBadgeImage {
                return primaryBadgeImage
            } else if let fallbackAssets = configuration.fallbackBadge?.assets {
                return configuration.sizeClass.fetchImageFromBadgeAssets(fallbackAssets)
            } else {
                return nil
            }
        }()

        currentModelGeneration = self.nextModelGeneration.get()
        setNeedsLayout()
    }

    // MARK: - Model Tracking

    // Invoking `setNeedsModelUpdate()` increments the next model generation
    // Any updates to the model copy the `nextModelGeneration` to the currentModelGeneration
    // For synchronous updates, these happen in lockstep on the main thread
    // For async model updates, this helps with detecting parallel model changes on another thread
    // `nextModelGeneration` can be read on a background thread, so it needs to be atomic.
    // All updates are performed on the main thread.
    private var currentModelGeneration: UInt = 0
    private var nextModelGeneration = AtomicUInt(0, lock: .sharedGlobal)
    @discardableResult
    private func setNeedsModelUpdate() -> UInt { nextModelGeneration.increment() }

    // Load avatars in _reverse_ order in which they are enqueued.
    // Avatars are enqueued as the user navigates (and not cancelled),
    // so the most recently enqueued avatars are most likely to be
    // visible. To put it another way, we don't cancel loads so
    // the oldest loads are most likely to be unnecessary.
    private static let serialQueue = ReverseDispatchQueue(
        label: "org.signal.conversation-avatar.loading",
        qos: .userInitiated,
        autoreleaseFrequency: .workItem,
    )

    private func enqueueAsyncModelUpdate() {
        AssertIsOnMainThread()
        let generationAtEnqueue = setNeedsModelUpdate()
        let configurationAtEnqueue = configuration

        Self.serialQueue.async { [weak self] in
            guard let self, self.nextModelGeneration.get() == generationAtEnqueue else {
                return
            }

            let (updatedAvatar, updatedBadge) = SSKEnvironment.shared.databaseStorageRef.read { transaction -> (UIImage?, UIImage?) in
                guard self.nextModelGeneration.get() == generationAtEnqueue else {
                    return (nil, nil)
                }

                let avatarImage = configurationAtEnqueue.dataSource?.buildImage(configuration: configurationAtEnqueue, transaction: transaction)
                let badgeImage = configurationAtEnqueue.dataSource?.fetchBadge(configuration: configurationAtEnqueue, transaction: transaction)
                return (avatarImage, badgeImage)
            }

            DispatchQueue.main.async {
                guard self.nextModelGeneration.get() == generationAtEnqueue else {
                    return
                }
                self.updateViewContent(avatarImage: updatedAvatar, primaryBadgeImage: updatedBadge)
            }
        }
    }

    // MARK: Subviews and Layout

    private var storyStateView: UIView = {
        let view = UIView()
        view.backgroundColor = .clear
        return view
    }()

    private var avatarView: UIImageView = {
        let view = UIImageView()
        view.contentMode = .scaleAspectFill
        view.layer.minificationFilter = .trilinear
        view.layer.magnificationFilter = .trilinear
        return view
    }()

    private var badgeView: UIImageView = {
        let view = UIImageView()
        view.contentMode = .scaleAspectFit
        view.layer.minificationFilter = .trilinear
        view.layer.magnificationFilter = .trilinear
        return view
    }()

    private var sizeConstraints: (width: NSLayoutConstraint, height: NSLayoutConstraint)?
    override public func updateConstraints() {
        let targetSize = configuration.sizeClass.size

        switch (configuration.useAutolayout, sizeConstraints) {
        case (true, let constraints?):
            constraints.width.constant = targetSize.width
            constraints.height.constant = targetSize.height
        case (true, nil):
            sizeConstraints = (
                width: autoSetDimension(.width, toSize: targetSize.width),
                height: autoSetDimension(.height, toSize: targetSize.height),
            )
        case (false, _):
            if let sizeConstraints {
                NSLayoutConstraint.deactivate([sizeConstraints.width, sizeConstraints.height])
            }
            sizeConstraints = nil
        }

        super.updateConstraints()
    }

    override public func layoutSubviews() {
        super.layoutSubviews()

        let storyState: StoryContextViewState?
        switch configuration.storyConfiguration {
        case .disabled:
            storyState = nil
        case .fixed(let state):
            storyState = state
        case .autoUpdate(let state):
            storyState = state
        }
        switch storyState {
        case .none, .noStories:
            storyStateView.isHidden = true
        case .viewed:
            storyStateView.isHidden = false
            storyStateView.layer.borderColor = Theme.isDarkThemeEnabled
                ? UIColor.ows_whiteAlpha25.cgColor : UIColor.ows_blackAlpha25.cgColor
            storyStateView.layer.borderWidth = configuration.sizeClass.storyViewedBorderSize
        case .unviewed:
            storyStateView.isHidden = false
            storyStateView.layer.borderColor = UIColor.ows_accentBlue.cgColor
            storyStateView.layer.borderWidth = configuration.sizeClass.storyUnviewedBorderSize
        }

        storyStateView.frame = CGRect(origin: .zero, size: configuration.sizeClass.size)
        avatarView.frame = CGRect(origin: configuration.avatarOffset, size: configuration.avatarSizeClass.size)
        badgeView.frame = CGRect(origin: configuration.sizeClass.badgeOffset, size: configuration.sizeClass.badgeSize)
        badgeView.isHidden = (badgeView.image == nil)

        switch configuration.shape {
        case .circular:
            storyStateView.layer.cornerRadius = (storyStateView.bounds.height / 2)
            storyStateView.layer.masksToBounds = true
            avatarView.layer.cornerRadius = (avatarView.bounds.height / 2)
            avatarView.layer.masksToBounds = true
        case .rectangular:
            storyStateView.layer.cornerRadius = 0
            storyStateView.layer.masksToBounds = false
            avatarView.layer.cornerRadius = 0
            avatarView.layer.masksToBounds = false
        }
    }

    override public var intrinsicContentSize: CGSize { configuration.sizeClass.size }

    override public func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize }

    // MARK: - Controls

    private lazy var avatarTapGestureRecognizer: UITapGestureRecognizer = {
        let tapGestureRecognizer = UITapGestureRecognizer()
        tapGestureRecognizer.addTarget(self, action: #selector(didTapAvatar(_:)))
        tapGestureRecognizer.numberOfTapsRequired = 1
        return tapGestureRecognizer
    }()

    private lazy var badgeTapGestureRecognizer: UITapGestureRecognizer = {
        let tapGestureRecognizer = UITapGestureRecognizer()
        tapGestureRecognizer.addTarget(self, action: #selector(didTapBadge(_:)))
        tapGestureRecognizer.numberOfTapsRequired = 1
        return tapGestureRecognizer
    }()

    public weak var interactionDelegate: (any ConversationAvatarViewDelegate)? {
        didSet {
            if interactionDelegate != nil, oldValue == nil {
                avatarView.addGestureRecognizer(avatarTapGestureRecognizer)
                badgeView.addGestureRecognizer(badgeTapGestureRecognizer)
                avatarView.isUserInteractionEnabled = true
                badgeView.isUserInteractionEnabled = true
                isUserInteractionEnabled = true
            } else if interactionDelegate == nil, oldValue != nil {
                avatarView.removeGestureRecognizer(avatarTapGestureRecognizer)
                badgeView.removeGestureRecognizer(badgeTapGestureRecognizer)
                avatarView.isUserInteractionEnabled = false
                badgeView.isUserInteractionEnabled = false
                isUserInteractionEnabled = false
            }
        }
    }

    @objc
    private func didTapAvatar(_ sender: UITapGestureRecognizer) {
        guard avatarView.image != nil else { return }
        interactionDelegate?.didTapAvatar(configuration)
    }

    @objc
    private func didTapBadge(_ sender: UITapGestureRecognizer) {
        guard badgeView.image != nil else { return }
        interactionDelegate?.didTapBadge()
    }

    // MARK: Notifications

    private func ensureObservers() {
        // TODO: Badges — Notify on an updated badge asset?

        NotificationCenter.default.removeObserver(self)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(themeDidChange),
            name: .themeDidChange,
            object: nil,
        )

        guard let dataSource = configuration.dataSource else { return }

        if dataSource.isContactAvatar {
            if dataSource.contactAddress?.isLocalAddress == true {
                NotificationCenter.default.addObserver(
                    self,
                    selector: #selector(localUsersProfileDidChange(notification:)),
                    name: UserProfileNotifications.localProfileDidChange,
                    object: nil,
                )
            } else {
                NotificationCenter.default.addObserver(
                    self,
                    selector: #selector(otherUsersProfileDidChange(notification:)),
                    name: UserProfileNotifications.otherUsersProfileDidChange,
                    object: nil,
                )
            }

            NotificationCenter.default.addObserver(
                self,
                selector: #selector(handleSignalAccountsChanged(notification:)),
                name: .OWSContactsManagerSignalAccountsDidChange,
                object: nil,
            )
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(skipContactAvatarBlurDidChange(notification:)),
                name: OWSContactsManager.skipContactAvatarBlurDidChange,
                object: nil,
            )
        } else if dataSource.isGroupAvatar {
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(handleGroupAvatarChanged(notification:)),
                name: .TSGroupThreadAvatarChanged,
                object: nil,
            )
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(skipGroupAvatarBlurDidChange(notification:)),
                name: OWSContactsManager.skipGroupAvatarBlurDidChange,
                object: nil,
            )
        }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(startObservingStoryChangesIfNeeded),
            name: .storiesEnabledStateDidChange,
            object: nil,
        )

        startObservingStoryChangesIfNeeded()
    }

    private var storyContextAssociatedDataObservation: DatabaseCancellable?

    @objc
    private func startObservingStoryChangesIfNeeded() {
        switch configuration.storyConfiguration {
        case .disabled, .fixed:
            stopObservingStoryChanges()
            return
        case .autoUpdate:
            break
        }
        guard let dataSource = configuration.dataSource, StoryManager.areStoriesEnabled else {
            stopObservingStoryChanges()
            return
        }

        let storyContextAssociatedDataFilterPredicate: SQLSpecificExpressible?
        if
            let contactAddress = dataSource.contactAddress,
            !contactAddress.isLocalAddress,
            let contactAci = contactAddress.serviceId as? Aci
        {
            storyContextAssociatedDataFilterPredicate = Column(StoryContextAssociatedData.columnName(.contactAci)) == contactAci.serviceIdUppercaseString
        } else if let groupId = dataSource.groupId {
            // == not available for data, use single item set contains which is the same thing.
            storyContextAssociatedDataFilterPredicate = Set([groupId]).contains(Column(StoryContextAssociatedData.columnName(.groupId)))
        } else {
            storyContextAssociatedDataFilterPredicate = nil
        }

        stopObservingStoryChanges()
        if let predicate = storyContextAssociatedDataFilterPredicate {
            storyContextAssociatedDataObservation = ValueObservation.tracking { db -> StoryContextAssociatedData? in
                try StoryContextAssociatedData
                    .filter(predicate)
                    .fetchOne(db)
            }.start(
                in: SSKEnvironment.shared.databaseStorageRef.grdbStorage.pool,
                onError: { error in
                    owsFailDebug("Failed to observe story hidden state: \(error))")
                },
                onChange: { [weak self] (associatedData: StoryContextAssociatedData?) in
                    guard self?.configuration.storyConfiguration != .disabled else {
                        self?.stopObservingStoryChanges()
                        return
                    }
                    let newStoryState: StoryContextViewState = {
                        guard
                            StoryManager.areStoriesEnabled,
                            associatedData?.hasUnexpiredStories ?? false
                        else {
                            return .noStories
                        }
                        return (associatedData?.hasUnviewedStories ?? false) ? .unviewed : .viewed
                    }()
                    switch self?.configuration.storyConfiguration {
                    case .none, .disabled, .fixed:
                        self?.stopObservingStoryChanges()
                        return
                    case .autoUpdate(let currentState):
                        if newStoryState != currentState {
                            self?.configuration.storyConfiguration = .autoUpdate(state: newStoryState)
                            self?.setNeedsLayout()
                        }
                    }
                },
            )
        }
    }

    private func stopObservingStoryChanges() {
        storyContextAssociatedDataObservation?.cancel()
        storyContextAssociatedDataObservation = nil
    }

    @objc
    private func themeDidChange() {
        AssertIsOnMainThread()
        updateModel(transaction: nil)
    }

    @objc
    private func handleSignalAccountsChanged(notification: Notification) {
        AssertIsOnMainThread()

        // PERF: It would be nice if we could do this only if *this* user's SignalAccount changed,
        // but currently this is only a course grained notification.
        updateModel(transaction: nil)
    }

    @objc
    private func localUsersProfileDidChange(notification: Notification) {
        AssertIsOnMainThread()
        if let sourceAddress = configuration.dataSource?.contactAddress, sourceAddress.isLocalAddress {
            handleUpdatedAddressNotification(address: sourceAddress)
        }
    }

    @objc
    private func otherUsersProfileDidChange(notification: Notification) {
        AssertIsOnMainThread()
        guard let changedAddress = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress, changedAddress.isValid else {
            owsFailDebug("changedAddress was unexpectedly nil")
            return
        }
        handleUpdatedAddressNotification(address: changedAddress)
    }

    @objc
    private func skipContactAvatarBlurDidChange(notification: Notification) {
        AssertIsOnMainThread()
        guard let address = notification.userInfo?[OWSContactsManager.skipContactAvatarBlurAddressKey] as? SignalServiceAddress, address.isValid else {
            owsFailDebug("Missing address.")
            return
        }
        handleUpdatedAddressNotification(address: address)
    }

    private func handleUpdatedAddressNotification(address: SignalServiceAddress) {
        AssertIsOnMainThread()
        guard let dataSource = configuration.dataSource else { return }
        guard let providerAddress = dataSource.contactAddress else {
            // Should always be set for non-group thread avatar providers
            owsFailDebug("contactAddress was unexpectedly nil")
            return
        }

        if providerAddress == address {
            updateModel(transaction: nil)
        }
    }

    @objc
    private func handleGroupAvatarChanged(notification: Notification) {
        AssertIsOnMainThread()
        guard let changedGroupThreadId = notification.userInfo?[TSGroupThread_NotificationKey_UniqueId] as? String else {
            owsFailDebug("groupThreadId was unexpectedly nil")
            return
        }
        handleUpdatedGroupThreadNotification(changedThreadId: changedGroupThreadId)
    }

    @objc
    private func skipGroupAvatarBlurDidChange(notification: Notification) {
        AssertIsOnMainThread()
        guard let groupThreadId = notification.userInfo?[OWSContactsManager.skipGroupAvatarBlurGroupUniqueIdKey] as? String else {
            owsFailDebug("Missing groupId.")
            return
        }
        handleUpdatedGroupThreadNotification(changedThreadId: groupThreadId)
    }

    private func handleUpdatedGroupThreadNotification(changedThreadId: String) {
        AssertIsOnMainThread()
        guard let dataSource = configuration.dataSource, dataSource.isGroupAvatar else { return }
        guard let contentThreadId = dataSource.threadId else {
            // Should always be set for non-group thread avatar providers
            owsFailDebug("contactAddress was unexpectedly nil")
            return
        }

        if contentThreadId == changedThreadId {
            SSKEnvironment.shared.databaseStorageRef.read {
                updateModel(transaction: $0)
            }
        }
    }

    // MARK: - <CVView>

    public func reset() {
        configuration.dataSource = nil
        reloadDataIfNecessary()
    }

    // MARK: - <PrimaryImageView>

    public var primaryImage: UIImage? { avatarView.image }
}

// MARK: -

public enum ConversationAvatarDataSource: Equatable, CustomStringConvertible {
    case thread(TSThread)
    case address(SignalServiceAddress)
    case asset(avatar: UIImage?, badge: UIImage?)

    var isContactAvatar: Bool { contactAddress != nil }
    var isGroupAvatar: Bool {
        if case .thread(_ as TSGroupThread) = self {
            return true
        } else {
            return false
        }
    }

    var contactAddress: SignalServiceAddress? {
        switch self {
        case .address(let address): return address
        case .thread(let thread as TSContactThread): return thread.contactAddress
        case .thread: return nil
        case .asset: return nil
        }
    }

    fileprivate var threadId: String? {
        switch self {
        case .thread(let thread): return thread.uniqueId
        case .address, .asset: return nil
        }
    }

    fileprivate var groupId: Data? {
        switch self {
        case .thread(let thread): return (thread as? TSGroupThread)?.groupId
        case .address, .asset: return nil
        }
    }

    fileprivate var isDataImmediatelyAvailable: Bool {
        switch self {
        case .thread, .address: return false
        case .asset: return true
        }
    }

    private func performWithTransaction<T>(_ existingTx: DBReadTransaction?, _ block: (DBReadTransaction) -> T) -> T {
        if let transaction = existingTx {
            return block(transaction)
        } else {
            return SSKEnvironment.shared.databaseStorageRef.read { readTx in
                block(readTx)
            }
        }
    }

    // TODO: Badges — Should this be async?
    fileprivate func fetchBadge(configuration: ConversationAvatarView.Configuration, transaction: DBReadTransaction?) -> UIImage? {
        guard configuration.addBadgeIfApplicable else { return nil }
        guard configuration.sizeClass.badgeDiameter >= 16 else {
            // We never want to show a badge <= 16pts
            Logger.warn("Skipping badge request for badge with diameter of \(configuration.sizeClass.badgeDiameter)")
            return nil
        }

        let targetAddress: SignalServiceAddress
        switch self {
        case .address(let address):
            targetAddress = address
        case .thread(let contactThread as TSContactThread):
            targetAddress = contactThread.contactAddress
        case .thread:
            return nil
        case .asset(avatar: _, badge: let badge):
            return badge
        }

        if targetAddress.isLocalAddress, configuration.localUserDisplayMode == .noteToSelf {
            // We never want to show badges on Note To Self
            return nil
        }

        let primaryBadge: ProfileBadge? = performWithTransaction(transaction) { tx in
            let userProfile: OWSUserProfile? = SSKEnvironment.shared.profileManagerRef.userProfile(for: targetAddress, tx: tx)
            return userProfile?.primaryBadge?.fetchBadgeContent(transaction: tx)
        }
        guard let badgeAssets = primaryBadge?.assets else { return nil }
        return configuration.sizeClass.fetchImageFromBadgeAssets(badgeAssets)
    }

    fileprivate func buildImage(configuration: ConversationAvatarView.Configuration, transaction: DBReadTransaction?) -> UIImage? {
        switch self {
        case .thread(let contactThread as TSContactThread):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                    forAddress: contactThread.contactAddress,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    localUserDisplayMode: configuration.localUserDisplayMode,
                    transaction: $0,
                )
            }

        case .address(let address):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                    forAddress: address,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    localUserDisplayMode: configuration.localUserDisplayMode,
                    transaction: $0,
                )
            }

        case .thread(let groupThread as TSGroupThread):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                    forGroupThread: groupThread,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    transaction: $0,
                )
            }

        case .asset(let avatar, _):
            return avatar

        case .thread:
            owsFailDebug("Unrecognized thread subclass: \(self)")
            return nil
        }
    }

    fileprivate func fetchCachedImage(configuration: ConversationAvatarView.Configuration, transaction: DBReadTransaction?) -> UIImage? {
        switch self {
        case .thread(let contactThread as TSContactThread):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
                    forAddress: contactThread.contactAddress,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    localUserDisplayMode: configuration.localUserDisplayMode,
                    transaction: $0,
                )
            }

        case .address(let address):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
                    forAddress: address,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    localUserDisplayMode: configuration.localUserDisplayMode,
                    transaction: $0,
                )
            }

        case .thread(let groupThread as TSGroupThread):
            return performWithTransaction(transaction) {
                SSKEnvironment.shared.avatarBuilderRef.precachedAvatarImage(
                    forGroupThread: groupThread,
                    diameterPoints: UInt(configuration.avatarSizeClass.diameter),
                    transaction: $0,
                )
            }

        case .asset(let avatar, _):
            return avatar

        case .thread:
            owsFailDebug("Unrecognized thread subclass: \(self)")
            return nil
        }
    }

    public var description: String {
        switch self {
        case .address(let address): return "[Address: \(address)]"
        case .thread(let thread): return "[Thread \(type(of: thread)):\(thread.logString)]"
        case .asset(let avatar, let badge): return "[AvatarImage: \(String(describing: avatar)), BadgeImage: \(String(describing: badge))]"
        }
    }
}

extension ConversationAvatarView.Configuration.SizeClass {
    // Badge layout is hardcoded. There's no simple rule to precisely place a badge on
    // an arbitrarily sized avatar. Design has provided us with these pre-defined sizes.
    // An avatar outside of these sizes will not support badging

    public var diameter: UInt {
        switch self {
        case .twentyFour: return 24
        case .twentyEight: return 28
        case .thirtyTwo: return 32
        case .thirtySix: return 36
        case .forty: return 40
        case .fortyFour: return 44
        case .fortyEight: return 48
        case .fiftySix: return 56
        case .sixtyFour: return 64
        case .seventyFour: return 74
        case .eighty: return 80
        case .eightyEight: return 88
        case .oneHundredTwelve: return 112
        case .customDiameter(let diameter): return diameter
        }
    }

    var badgeDiameter: CGFloat {
        // Originally, we were only using badge sprites for explicit size classes
        // But it turns out we need these badges displayed in more places than originally thought
        // Now we lerp between the explicit size classes that design has provided for us
        switch diameter {
        case ..<24: return 0
        case 24...36: return 16
        case 36..<40: return CGFloat(diameter).inverseLerp(36, 40).lerp(16, 24)
        case 40...74: return 24
        case 64..<80: return CGFloat(diameter).inverseLerp(74, 80).lerp(24, 36)
        case 80...112: return 36
        case 112...: return (CGFloat(diameter) / 112) * 36
        default: return 0
        }
    }

    /// The badge offset from its frame origin. Design has specified these points so the badge sits right alongside the circular avatar edge
    var badgeOffset: CGPoint {
        switch self {
        case .twentyFour: return CGPoint(x: 10, y: 12)
        case .twentyEight: return CGPoint(x: 14, y: 16)
        case .thirtyTwo: return CGPoint(x: 18, y: 20)
        case .thirtySix: return CGPoint(x: 20, y: 23)
        case .forty: return CGPoint(x: 20, y: 22)
        case .fortyFour: return CGPoint(x: 24, y: 26)
        case .fortyEight: return CGPoint(x: 28, y: 30)
        case .fiftySix: return CGPoint(x: 32, y: 38)
        case .sixtyFour: return CGPoint(x: 40, y: 46)
        case .seventyFour: return CGPoint(x: 48, y: 54)
        case .eighty: return CGPoint(x: 44, y: 52)
        case .eightyEight: return CGPoint(x: 49, y: 56)
        case .oneHundredTwelve: return CGPoint(x: 74, y: 80)
        case .customDiameter:
            // Design hand-selected the above offsets for each size class
            // For anything in between, let's just stick the badge at the bottom left of the frame for now
            let avatarFrame = CGRect(origin: .zero, size: size)
            let offsetVector = CGVector(dx: -badgeSize.width, dy: -badgeSize.height)
            return avatarFrame.bottomRight.offsetBy(offsetVector)
        }
    }

    public func fetchImageFromBadgeAssets(_ badgeAssets: BadgeAssets) -> UIImage? {
        // Never show a badge less than 16 points
        // Otherwise, err on the side of downscaling over upscaling
        switch badgeDiameter {
        case ..<16: return nil
        case 16: return Theme.isDarkThemeEnabled ? badgeAssets.dark16 : badgeAssets.light16
        case 16...24: return Theme.isDarkThemeEnabled ? badgeAssets.dark24 : badgeAssets.light24
        case 24...: return Theme.isDarkThemeEnabled ? badgeAssets.dark36 : badgeAssets.light36
        default: return nil
        }
    }

    public var size: CGSize { .init(square: CGFloat(diameter)) }
    public var badgeSize: CGSize { .init(square: CGFloat(badgeDiameter)) }

    public var storyBorderInsets: UInt {
        switch self {
        case .twentyFour: return 4
        case .twentyEight: return 4
        case .thirtyTwo: return 4
        case .thirtySix: return 4
        case .forty: return 4
        case .fortyFour: return 4
        case .fortyEight: return 5
        case .fiftySix: return 5
        case .sixtyFour: return 5
        case .seventyFour: return 5
        case .eighty: return 5
        case .eightyEight: return 6
        case .oneHundredTwelve: return 6
        case .customDiameter: return UInt(CGFloat(diameter) * 0.09)
        }
    }

    public var storyUnviewedBorderSize: CGFloat {
        switch self {
        case .twentyFour: return 2
        case .twentyEight: return 2
        case .thirtyTwo: return 2
        case .thirtySix: return 2
        case .forty: return 2
        case .fortyFour: return 2
        case .fortyEight: return 2
        case .fiftySix: return 2
        case .sixtyFour: return 2
        case .seventyFour: return 3
        case .eighty: return 3
        case .eightyEight: return 3
        case .oneHundredTwelve: return 3
        case .customDiameter: return CGFloat(diameter) * 0.04
        }
    }

    public var storyViewedBorderSize: CGFloat { storyUnviewedBorderSize / 2 }
}