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

import Foundation
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI

class CallMemberCameraOffView: UIView, CallMemberComposableView {
    private let blurredAvatarBackgroundView = BlurredAvatarBackgroundView()

    // One of these three is shown depending on the circumstances.
    private var avatarView: ConversationAvatarView?
    private var detailedNoVideoIndicatorView: UIStackView?
    private var noVideoIndicatorImageView: UIImageView?

    private let type: CallMemberView.MemberType

    init(type: CallMemberView.MemberType) {
        self.type = type
        super.init(frame: .zero)
        self.isUserInteractionEnabled = false

        let overlayView = UIView()
        overlayView.backgroundColor = .ows_blackAlpha40
        self.addSubview(overlayView)
        overlayView.autoPinEdgesToSuperviewEdges()

        self.addSubview(self.blurredAvatarBackgroundView)
        self.blurredAvatarBackgroundView.autoPinEdgesToSuperviewEdges()
    }

    /// This method initializes the views that have the potential to be shown
    /// based on whether the call is individual/group and whether the member
    /// is local/remote.
    ///
    /// Spec when camera is off:
    /// - Local Member:
    ///   - Individual call:
    ///     - PIP: N/A because PIP disappears when camera is off.
    ///     - Fullscreen: Circular avatar.
    ///   - Group call:
    ///     - PIP: Camera-off image.
    ///     - Fullscreen: Camera-off image and message.
    /// - Remote Member: Circular avatar.
    private func createOptionalViews(type: CallMemberView.MemberType, call: SignalCall) {
        switch type {
        case .local:
            switch call.mode {
            case .individual:
                self.avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false)
            case .groupThread, .callLink:
                self.detailedNoVideoIndicatorView = self.createDetailedVideoOffIndicatorView()
                self.noVideoIndicatorImageView = self.createVideoOffIndicatorImageView()
            }
        case .remoteInGroup, .remoteInIndividual:
            self.avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false)
        }
    }

    private var hasConfiguredOnce = false
    func configure(
        call: SignalCall,
        isFullScreen: Bool = false,
        remoteGroupMemberDeviceState: RemoteDeviceState?,
    ) {
        if !hasConfiguredOnce {
            self.createOptionalViews(type: type, call: call)
            if let avatarView {
                self.addSubview(avatarView)
                avatarView.autoCenterInSuperview()
            }
            if let detailedNoVideoIndicatorView {
                self.addSubview(detailedNoVideoIndicatorView)
                detailedNoVideoIndicatorView.autoCenterInSuperview()
                detailedNoVideoIndicatorView.isHidden = true
            }
            if let noVideoIndicatorImageView {
                self.addSubview(noVideoIndicatorImageView)
                noVideoIndicatorImageView.isHidden = true
                noVideoIndicatorImageView.autoMatch(.height, to: .width, of: noVideoIndicatorImageView)
                noVideoIndicatorImageView.autoCenterInSuperview()
                let constraint = noVideoIndicatorImageView.autoSetDimension(.width, toSize: videoOffImageIndicatorWidth)
                self.videoOffIndicatorImageWidthConstraint = constraint
                NSLayoutConstraint.activate([constraint])
            }
            hasConfiguredOnce = true
        }

        switch type {
        case .local:
            self.isHidden = !call.isOutgoingVideoMuted
        case .remoteInIndividual(let individualCall):
            self.isHidden = individualCall.isRemoteVideoEnabled
        case .remoteInGroup:
            if let videoMuted = remoteGroupMemberDeviceState?.videoMuted {
                self.isHidden = !videoMuted
            } else {
                self.isHidden = false
            }
        }
        if self.isHidden {
            return
        }

        if let detailedNoVideoIndicatorView {
            detailedNoVideoIndicatorView.isHidden = !isFullScreen
        }
        if let noVideoIndicatorImageView {
            noVideoIndicatorImageView.isHidden = isFullScreen
        }

        // Update blurred avatar background
        self.blurredAvatarBackgroundView.update(
            type: self.type,
            remoteGroupMemberDeviceState: remoteGroupMemberDeviceState,
        )

        // Update circular avatar
        switch type {
        case .local:
            SSKEnvironment.shared.databaseStorageRef.read { tx in
                guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aciAddress else {
                    owsFailDebug("missing local address")
                    return
                }
                updateCircularAvatarIfNecessary(
                    address: localAddress,
                    tx: tx,
                )
            }
        case .remoteInGroup:
            guard let remoteGroupMemberDeviceState else { return }
            SSKEnvironment.shared.databaseStorageRef.read { tx in
                updateCircularAvatarIfNecessary(
                    address: remoteGroupMemberDeviceState.address,
                    tx: tx,
                )
            }
        case .remoteInIndividual(let individualCall):
            SSKEnvironment.shared.databaseStorageRef.read { tx in
                updateCircularAvatarIfNecessary(
                    address: individualCall.remoteAddress,
                    tx: tx,
                )
            }
        }
    }

    func updateDimensions() {
        self.videoOffIndicatorImageWidthConstraint?.constant = videoOffImageIndicatorWidth
        avatarView?.updateWithSneakyTransactionIfNecessary { config in
            config.sizeClass = .customDiameter(avatarDiameter)
        }
    }

    func rotateForPhoneOrientation(_ rotationAngle: CGFloat) {
        /// TODO: Add support for rotating other elements too.
        self.avatarView?.transform = CGAffineTransform(rotationAngle: rotationAngle)
    }

    func clearConfiguration() {
        self.backgroundColor = .ows_black
        self.blurredAvatarBackgroundView.clear()
        avatarView?.reset()
    }

    // MARK: - Avatar

    private var avatarDiameter: UInt {
        if width > 180 {
            return 112
        } else if width > 102 {
            return 96
        } else if width > 48 {
            return UInt(width) - 36
        } else {
            return 16
        }
    }

    private func updateCircularAvatarIfNecessary(
        address: SignalServiceAddress,
        tx: DBReadTransaction,
    ) {
        guard let avatarView else {
            Logger.info("Skipping refresh of avatar view in call member view.")
            return
        }
        let updatedSize = avatarDiameter
        avatarView.update(tx) { config in
            config.dataSource = .address(address)
            config.sizeClass = .customDiameter(updatedSize)
        }
    }

    // MARK: - Detailed Video Off Indicator View

    private func createDetailedVideoOffIndicatorView() -> UIStackView {
        let icon = UIImageView()
        icon.contentMode = .scaleAspectFit
        icon.setTemplateImageName("video-slash-fill-28", tintColor: .ows_white)

        let label = UILabel()
        label.font = .dynamicTypeCaption1
        label.text = OWSLocalizedString(
            "CALLING_MEMBER_VIEW_YOUR_CAMERA_IS_OFF",
            comment: "Indicates to the user that their camera is currently off.",
        )
        label.textAlignment = .center
        label.textColor = Theme.darkThemePrimaryColor

        let container = UIStackView(arrangedSubviews: [icon, label])
        if UIDevice.current.isIPhone5OrShorter {
            // Use a horizontal layout to save on vertical space.
            // Allow the icon to shrink below its natural size of 28pt...
            icon.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
            container.axis = .horizontal
            container.spacing = 4
            // ...by always matching the label's height.
            container.alignment = .fill
        } else {
            // Use a simple vertical layout.
            icon.autoSetDimensions(to: CGSize(square: 28))
            container.axis = .vertical
            container.spacing = 10
            container.alignment = .center
            label.autoPinWidthToSuperview()
        }

        return container
    }

    // MARK: - Video Off Indicator Image

    private var videoOffIndicatorImageWidthConstraint: NSLayoutConstraint?

    private var videoOffImageIndicatorWidth: CGFloat {
        width > 102 ? 28 : 16
    }

    private func createVideoOffIndicatorImageView() -> UIImageView {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.setTemplateImageName("video-slash-fill-28", tintColor: .ows_white)
        return imageView
    }

    // MARK: - Required

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

class BlurredAvatarBackgroundView: UIView {
    private let backgroundAvatarView = UIImageView()
    private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))

    init() {
        super.init(frame: .zero)

        self.overrideUserInterfaceStyle = .dark

        backgroundAvatarView.contentMode = .scaleAspectFill
        self.addSubview(backgroundAvatarView)
        backgroundAvatarView.autoPinEdgesToSuperviewEdges()

        self.addSubview(blurView)
        blurView.autoPinEdgesToSuperviewEdges()
    }

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

    func update(
        type: CallMemberView.MemberType,
        remoteGroupMemberDeviceState: RemoteDeviceState?,
    ) {
        let address: SignalServiceAddress
        switch type {
        case .local:
            let tsAccountManager = DependenciesBridge.shared.tsAccountManager
            guard let localAddress = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress else {
                owsFailDebug("missing local address")
                return
            }
            address = localAddress
        case .remoteInGroup:
            guard let remoteGroupMemberDeviceState else { return }
            address = remoteGroupMemberDeviceState.address
        case .remoteInIndividual(let individualCall):
            address = individualCall.remoteAddress
        }
        let (image, color) = self.avatarImageAndBackgroundColorWithSneakyTransaction(for: address)
        backgroundAvatarView.image = image
        self.backgroundColor = color
    }

    private func avatarImageAndBackgroundColorWithSneakyTransaction(
        for address: SignalServiceAddress,
    ) -> (UIImage?, UIColor) {
        let avatarDefaultColorManager = DependenciesBridge.shared.avatarDefaultColorManager
        let contactManagerImpl = SSKEnvironment.shared.contactManagerImplRef
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable

        let (
            profileImage,
            defaultAvatarColor,
        ): (UIImage?, AvatarTheme) = databaseStorage.read { tx in
            let avatarImage = contactManagerImpl.avatarImage(forAddress: address, transaction: tx)

            if let recipient = recipientDatabaseTable.fetchRecipient(address: address, tx: tx) {
                return (
                    avatarImage,
                    avatarDefaultColorManager.defaultColor(useCase: .contact(recipient: recipient), tx: tx),
                )
            } else {
                return (
                    avatarImage,
                    avatarDefaultColorManager.defaultColor(useCase: .contactWithoutRecipient(address: address), tx: tx),
                )
            }
        }

        return (profileImage, defaultAvatarColor.backgroundColor)
    }

    func clear() {
        backgroundAvatarView.image = nil
    }
}

// MARK: -

extension RemoteDeviceState {
    var aci: Aci { Aci(fromUUID: userId) }
    var address: SignalServiceAddress { SignalServiceAddress(aci) }
}