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

import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI

// MARK: - RemoteMuteToast

class RemoteMuteToast: UIView {

    // MARK: Properties

    struct Dependencies {
        let db: SDSDatabaseStorage
        let contactsManager: any ContactManager
        let tsAccountManager: any TSAccountManager
    }

    private let deps = Dependencies(
        db: SSKEnvironment.shared.databaseStorageRef,
        contactsManager: SSKEnvironment.shared.contactManagerRef,
        tsAccountManager: DependenciesBridge.shared.tsAccountManager,
    )

    private var call: GroupCall
    private var pendingNotifications = [String]()

    // MARK: Init

    init(call: GroupCall) {
        self.call = call
        super.init(frame: .zero)

        isUserInteractionEnabled = false

    }

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

    // MARK: State

    func displaySelfMuted(muteSource: Aci) {
        let muteSourceName = self.deps.db.read { tx -> String in
            self.deps.contactsManager.displayName(
                for: SignalServiceAddress(muteSource),
                tx: tx,
            ).resolvedValue(useShortNameIfAvailable: true)
        }
        let toastText: String
        let localAci = self.deps.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!.aci
        if muteSource == localAci {
            toastText = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "REMOTE_MUTE_TOAST_YOU_MUTED_YOURSELF",
                    comment: "A message that displays when you joined a call on two devices and mute one from the other.",
                ),
            )
        } else {
            toastText = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "REMOTE_MUTE_TOAST_SOMEONE_MUTED_YOU",
                    comment:
                    "A message that displays when your microphone is remotely muted by another call participant. Embeds {{name}}",
                ),
                muteSourceName,
            )
        }

        pendingNotifications.append(toastText)
        presentNextNotificationIfNecessary()
    }

    func displayOtherMuted(source: Aci, target: Aci) {
        let toastText: String
        let localAci = self.deps.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!.aci
        if source == localAci {
            if target == localAci {
                // Don't display a toast if you muted your other device.
                return
            }
            let muteTargetName = self.deps.db.read { tx -> String in
                self.deps.contactsManager.displayName(
                    for: SignalServiceAddress(target),
                    tx: tx,
                ).resolvedValue(useShortNameIfAvailable: true)
            }

            toastText = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "REMOTE_MUTE_TOAST_YOU_MUTED_SOMEONE",
                    comment:
                    "A message that displays when you remotely muted another call participant's microphone. Embeds {{name}}",
                ),
                muteTargetName,
            )
        } else {
            if source == target {
                // Don't display toast for self-mutes.
                return
            }
            let (muteSourceName, muteTargetName) = self.deps.db.read { tx -> (String, String) in
                (
                    self.deps.contactsManager.displayName(
                        for: SignalServiceAddress(source),
                        tx: tx,
                    ).resolvedValue(useShortNameIfAvailable: true),
                    self.deps.contactsManager.displayName(
                        for: SignalServiceAddress(target),
                        tx: tx,
                    ).resolvedValue(useShortNameIfAvailable: true),
                )
            }

            toastText = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "REMOTE_MUTE_TOAST_A_MUTED_B",
                    comment:
                    "A message that displays when one person in a call remotely muted another participant. Embeds {{name}} {{name}}",
                ),
                muteSourceName,
                muteTargetName,
            )
        }

        pendingNotifications.append(toastText)
        presentNextNotificationIfNecessary()
    }

    private var isPresentingNotification = false
    private func presentNextNotificationIfNecessary() {
        guard !isPresentingNotification else { return }
        guard let text = pendingNotifications.popLast() else { return }

        let bannerView = RemoteMuteBannerView(text: text)

        isPresentingNotification = true

        addSubview(bannerView)
        bannerView.autoHCenterInSuperview()

        // Prefer to be full width, but don't exceed the maximum width
        bannerView.autoSetDimension(.width, toSize: 512, relation: .lessThanOrEqual)
        bannerView.autoMatch(
            .width,
            to: .width,
            of: self,
            withOffset: -(layoutMargins.left + layoutMargins.right),
            relation: .lessThanOrEqual,
        )
        NSLayoutConstraint.autoSetPriority(.defaultHigh) {
            bannerView.autoPinWidthToSuperviewMargins()
        }

        let onScreenConstraint = bannerView.autoPinEdge(toSuperviewMargin: .top)
        onScreenConstraint.isActive = false

        let offScreenConstraint = bannerView.autoPinEdge(.bottom, to: .top, of: self)

        layoutIfNeeded()

        UIView.animate(withDuration: 0.35, delay: 0) {
            offScreenConstraint.isActive = false
            onScreenConstraint.isActive = true

            self.layoutIfNeeded()
        } completion: { _ in
            UIView.animate(withDuration: 0.35, delay: 4, options: .curveEaseInOut) {
                onScreenConstraint.isActive = false
                offScreenConstraint.isActive = true

                self.layoutIfNeeded()
            } completion: { _ in
                bannerView.removeFromSuperview()
                self.isPresentingNotification = false
                self.presentNextNotificationIfNecessary()
            }
        }
    }
}

private class RemoteMuteBannerView: UIView {

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

        autoSetDimension(.height, toSize: 64, relation: .greaterThanOrEqual)
        layer.cornerRadius = 8
        clipsToBounds = true

        let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
        addSubview(blurEffectView)
        blurEffectView.autoPinEdgesToSuperviewEdges()
        backgroundColor = .ows_blackAlpha40

        let hStack = UIStackView()
        hStack.spacing = 12
        hStack.axis = .horizontal
        hStack.isLayoutMarginsRelativeArrangement = true
        hStack.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)

        addSubview(hStack)
        hStack.autoPinEdgesToSuperviewEdges()

        let mutedIcon = UILabel()
        mutedIcon.attributedText = .with(
            image: UIImage(named: "mic-slash")!,
            font: .dynamicTypeTitle3,
            attributes: [.foregroundColor: UIColor.ows_white],
        )
        mutedIcon.setContentCompressionResistancePriority(
            .required,
            for: .horizontal,
        )
        mutedIcon.contentMode = .scaleAspectFit
        mutedIcon.tintColor = .ows_white
        mutedIcon.setContentHuggingHorizontalHigh()
        mutedIcon.setCompressionResistanceVerticalHigh()

        hStack.addArrangedSubview(mutedIcon)

        let label = UILabel()
        hStack.addArrangedSubview(label)
        label.setCompressionResistanceHorizontalHigh()
        label.numberOfLines = 0
        label.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
        label.textColor = .ows_white
        label.text = text

        hStack.addArrangedSubview(.hStretchingSpacer())
    }

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