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

import Combine
import Foundation
import SignalServiceKit
import SignalUI
import UIKit

final class CallLinkViewController: OWSTableViewController2 {
    override var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
    override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }

    private var db: any DB { DependenciesBridge.shared.db }
    private var callLinkStore: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }

    private let callLink: CallLink

    /// Set if we're the admin for this call link.
    private let adminPasskey: Data?
    private let callLinkAdminManager: CallLinkAdminManager?
    private let canShowDeleteButton: Bool

    /// The ROWID of the corresponding ``CallLinkRecord``. If `nil`, `rootKey`
    /// refers to a just-created call link that hasn't been persisted (it'll be
    /// persisted once it's shared).
    private var callLinkRowId: Int64?

    /// The latest known state for the call link. This usually matches what's on
    /// disk, but it might not be saved to disk yet for just-created call links.
    private var callLinkState: CallLinkState?

    /// Set if we're viewing the details for a call link that we've used.
    private let callRecords: [CallRecord]

    static func forExisting(callLinkRecord: CallLinkRecord, callRecords: [CallRecord]) -> CallLinkViewController {
        return CallLinkViewController(
            title: callRecords.isEmpty ? CallStrings.callLink : CallStrings.callDetails,
            callLink: CallLink(rootKey: callLinkRecord.rootKey),
            adminInfo: callLinkRecord.adminPasskey.map {
                return ($0, CallLinkAdminManager(
                    rootKey: callLinkRecord.rootKey,
                    adminPasskey: $0,
                    callLinkState: callLinkRecord.state,
                ))
            },
            canShowDeleteButton: true,
            callLinkRowId: callLinkRecord.id,
            callLinkState: callLinkRecord.state,
            callRecords: callRecords,
        )
    }

    static func forJustCreated(callLink: CallLink, adminPasskey: Data, callLinkState: CallLinkState) -> CallLinkViewController {
        let adminManager = CallLinkAdminManager(
            rootKey: callLink.rootKey,
            adminPasskey: adminPasskey,
            callLinkState: callLinkState,
        )
        let result = CallLinkViewController(
            title: CallStrings.createCallLinkTitle,
            callLink: callLink,
            adminInfo: (adminPasskey, adminManager),
            canShowDeleteButton: false,
            callLinkRowId: nil,
            callLinkState: callLinkState,
            callRecords: [],
        )
        // We need to learn about updates before `persistIfNeeded` is invoked.
        adminManager.didUpdateCallLinkState = { [weak result] callLinkState in
            result?.callLinkState = callLinkState
            result?.updateContents(shouldReload: true)
        }
        return result
    }

    private init(
        title: String,
        callLink: CallLink,
        adminInfo: (adminPasskey: Data, adminManager: CallLinkAdminManager)?,
        canShowDeleteButton: Bool,
        callLinkRowId: Int64?,
        callLinkState: CallLinkState?,
        callRecords: [CallRecord],
    ) {
        self.callLink = callLink
        self.adminPasskey = adminInfo?.adminPasskey
        self.callLinkAdminManager = adminInfo?.adminManager
        self.canShowDeleteButton = canShowDeleteButton
        self.callLinkRowId = callLinkRowId
        self.callLinkState = callLinkState
        self.callRecords = callRecords

        super.init()
        self.title = title

        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        updateContents(shouldReload: false)
    }

    private func updateContents(shouldReload: Bool) {
        self.setContents(buildTableContents(), shouldReload: shouldReload)
    }

    private func callLinkCardCell() -> UITableViewCell {
        let cell = OWSTableItem.newCell()

        let view = CallLinkCardView(
            callLink: self.callLink,
            callName: self.callLinkState.localizedName,
            joinAction: { [unowned self] in self.joinCall() },
        )
        cell.contentView.addSubview(view)
        view.autoPinLeadingToSuperviewMargin()
        view.autoPinTrailingToSuperviewMargin()
        view.autoPinEdge(.top, to: .top, of: cell.contentView, withOffset: Constants.vMarginCallLinkCard)
        view.autoPinEdge(.bottom, to: .bottom, of: cell.contentView, withOffset: -Constants.vMarginCallLinkCard)

        cell.selectionStyle = .none
        return cell
    }

    private enum Constants {
        static let vMarginCallLinkCard: CGFloat = 12
    }

    private func buildTableContents() -> OWSTableContents {
        let callLinkCardItem = OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else { return UITableViewCell() }
                return self.callLinkCardCell()
            },
        )

        var settingSection: OWSTableSection?
        if let callLinkAdminManager {
            var settingItems = [OWSTableItem]()
            settingItems.append(.item(
                name: callLinkAdminManager.editCallNameButtonTitle,
                accessoryType: .disclosureIndicator,
                actionBlock: { [unowned self] in
                    EditCallLinkNameViewController(
                        oldName: self.callLinkState?.name ?? "",
                        setNewName: callLinkAdminManager.updateName(_:),
                    ).presentInNavController(from: self)
                },
            ))
            settingItems.append(.switch(
                withText: CallStrings.approveAllMembers,
                isOn: { [unowned self] in
                    return (
                        self.callLinkState?.requiresAdminApproval
                            ?? CallLinkState.Constants.defaultRequiresAdminApproval,
                    )
                },
                isEnabled: { [unowned self] in self.callLinkState != nil },
                actionBlock: { [unowned self] sender in
                    callLinkAdminManager.toggleApproveAllMembersWithActivityIndicator(sender, from: self)
                },
            ))
            settingSection = OWSTableSection(items: settingItems)
        }

        let sharingSection = OWSTableSection(items: [
            .item(
                icon: .buttonForward,
                name: CallStrings.shareLinkViaSignal,
                actionBlock: { [unowned self] in self.shareCallLinkViaSignal() },
            ),
            .item(
                icon: .buttonCopy,
                name: CallStrings.copyLinkToClipboard,
                actionBlock: { [unowned self] in self.copyCallLink() },
            ),
            OWSTableItem(
                customCellBlock: {
                    let cell = OWSTableItem.buildCell(
                        icon: .buttonShare,
                        itemName: CallStrings.shareLinkViaSystem,
                    )
                    self.systemShareTableViewCell = cell
                    return cell
                },
                actionBlock: { [unowned self] in self.shareCallLinkViaSystem() },
            ),
        ])
        sharingSection.separatorInsetLeading = OWSTableViewController2.cellHInnerMargin + OWSTableItem.iconSize + OWSTableItem.iconSpacing

        var deleteSection: OWSTableSection?
        if self.canShowDeleteButton, let adminPasskey {
            let rootKey = self.callLink.rootKey
            let deleteItem: OWSTableItem = .item(
                icon: .buttonDelete,
                tintColor: .ows_accentRed,
                name: OWSLocalizedString(
                    "CALL_LINK_DELETE_ACTION",
                    comment: "A button to delete a call link that's shown after tapping the (i) info button on an item in the calls tab.",
                ),
                textColor: .ows_accentRed,
                actionBlock: { [unowned self] in
                    CallLinkDeleter.promptToDelete(fromViewController: self) { [weak self] in
                        do {
                            try await CallLinkDeleter.deleteCallLink(
                                stateUpdater: AppEnvironment.shared.callService.callLinkStateUpdater,
                                storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef,
                                rootKey: rootKey,
                                adminPasskey: adminPasskey,
                            )
                            if let self {
                                let navigationController = self.navigationController!
                                navigationController.popViewController(animated: true) {
                                    navigationController.topViewController?.presentToast(text: CallLinkDeleter.successText)
                                }
                            }
                        } catch {
                            self?.presentToast(text: CallLinkDeleter.failureText)
                        }
                    }
                },
            )
            deleteSection = OWSTableSection(items: [deleteItem])
        }

        return OWSTableContents(
            sections: [
                ConversationSettingsViewController.createCallHistorySection(callRecords: callRecords),
                OWSTableSection(items: [callLinkCardItem]),
                settingSection,
                sharingSection,
                deleteSection,
            ].compacted(),
        )
    }

    private var systemShareTableViewCell: UITableViewCell?

    // MARK: - Actions

    /// Adds the Call Link to the Calls Tab.
    ///
    /// This should be called after the user makes an "escaping" change to the
    /// Call Link (eg sharing it or copying it) or when they explicitly tap
    /// "Done" to confirm it.
    func persistIfNeeded() {
        guard callLinkRowId == nil else {
            return
        }
        callLinkRowId = createCallLinkRecord()
        callLinkAdminManager?.didUpdateCallLinkState = nil
    }

    private func createCallLinkRecord() -> Int64 {
        let rowId = SSKEnvironment.shared.databaseStorageRef.write { tx in
            var callLinkRecord: CallLinkRecord
            do {
                (callLinkRecord, _) = try callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
                callLinkRecord.adminPasskey = adminPasskey!
                callLinkRecord.updateState(callLinkState!)
                try callLinkStore.update(callLinkRecord, tx: tx)
            } catch {
                owsFail("Couldn't create CallLinkRecord: \(error)")
            }

            CallLinkUpdateMessageSender(
                messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
            ).sendCallLinkUpdateMessage(rootKey: callLink.rootKey, adminPasskey: adminPasskey, tx: tx)

            return callLinkRecord.id
        }
        SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(callLinkRootKeys: [callLink.rootKey])
        return rowId
    }

    private func joinCall() {
        persistIfNeeded()
        GroupCallViewController.presentLobby(
            for: callLink,
            callLinkStateRetrievalStrategy: {
                // If the local user is the admin, then we expect to be aware of the latest
                // state. (This is true even on linked devices because we send
                // CallLinkUpdate messages.) In this case, don't issue a redundant fetch.
                if adminPasskey != nil, let callLinkState {
                    return .reuse(callLinkState)
                }
                // In all other cases, the admin may have changed the details, so we should
                // check when joining the call.
                return .fetch
            }(),
        )
    }

    private func copyCallLink() {
        self.persistIfNeeded()
        UIPasteboard.general.url = self.callLink.url()
        self.presentToast(text: CommonStrings.copiedToClipboardToast, image: .copy)
    }

    private func shareCallLinkViaSystem() {
        self.persistIfNeeded()
        let shareViewController = UIActivityViewController(
            activityItems: [self.callLink.url()],
            applicationActivities: nil,
        )
        shareViewController.popoverPresentationController?.sourceView = self.systemShareTableViewCell
        self.present(shareViewController, animated: true)
    }

    private var sendMessageFlow: SendMessageFlow?

    func shareCallLinkViaSignal() {
        let messageBody = MessageBody(text: callLink.url().absoluteString, ranges: .empty)
        guard let unapprovedContent = SendMessageUnapprovedContent(messageBody: messageBody) else {
            owsFailDebug("Missing messageBody.")
            return
        }
        let sendMessageFlow = SendMessageFlow(
            unapprovedContent: unapprovedContent,
            presentationStyle: .presentFrom(self),
            delegate: self,
        )
        // Retain the flow until it is complete.
        self.sendMessageFlow = sendMessageFlow
    }
}

extension CallLinkViewController: DatabaseChangeDelegate {
    private func loadStateAndReloadViewIfNeeded(callLinkRowId: Int64) {
        let didChangeVisibleProperty: Bool
        do {
            let oldState = self.callLinkState
            let newState = try self.db.read { tx in try callLinkStore.fetch(rowId: callLinkRowId, tx: tx)?.state }
            didChangeVisibleProperty = (
                (oldState == nil) != (newState == nil)
                    || (oldState?.name != newState?.name)
                    || (oldState?.requiresAdminApproval != newState?.requiresAdminApproval),
            )
            self.callLinkState = newState
        } catch {
            owsFailDebug("Couldn't fetch CallLink: \(error)")
            return
        }
        if didChangeVisibleProperty, self.isViewLoaded {
            updateContents(shouldReload: true)
        }
    }

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard let callLinkRowId else {
            return
        }
        guard databaseChanges.tableRowIds[CallLinkRecord.databaseTableName]?.contains(callLinkRowId) == true else {
            return
        }
        loadStateAndReloadViewIfNeeded(callLinkRowId: callLinkRowId)
    }

    func databaseChangesDidUpdateExternally() {
        guard let callLinkRowId else {
            return
        }
        loadStateAndReloadViewIfNeeded(callLinkRowId: callLinkRowId)
    }

    func databaseChangesDidReset() {
    }
}

extension CallLinkViewController: SendMessageDelegate {
    func sendMessageFlowDidComplete(threads: [TSThread]) {
        AssertIsOnMainThread()

        persistIfNeeded()

        sendMessageFlow?.dismissNavigationController(animated: true)

        sendMessageFlow = nil
    }

    func sendMessageFlowWillShowConversation() {
        AssertIsOnMainThread()

        persistIfNeeded()

        sendMessageFlow = nil
    }

    func sendMessageFlowDidCancel() {
        AssertIsOnMainThread()

        sendMessageFlow?.dismissNavigationController(animated: true)

        sendMessageFlow = nil
    }
}

// MARK: - CallLinkCardView

private class CallLinkCardView: UIView {
    private lazy var iconView: UIImageView = {
        let image = CommonCallLinksUI.callLinkIcon(rootKey: callLink.rootKey)
        let imageView = UIImageView(image: image)
        imageView.autoSetDimensions(to: CGSize(
            width: Constants.circleViewDimension,
            height: Constants.circleViewDimension,
        ))
        return imageView
    }()

    private lazy var textStack: UIStackView = {
        let stackView = UIStackView()

        let nameLabel = UILabel()
        nameLabel.text = callName
        nameLabel.lineBreakMode = .byWordWrapping
        nameLabel.numberOfLines = 0
        nameLabel.textColor = Theme.primaryTextColor
        nameLabel.font = .dynamicTypeHeadline

        let linkLabel = UILabel()
        linkLabel.text = callLink.url().absoluteString
        linkLabel.lineBreakMode = .byTruncatingTail
        linkLabel.numberOfLines = 2

        linkLabel.textColor = .Signal.secondaryLabel
        linkLabel.font = .dynamicTypeSubheadline

        stackView.addArrangedSubviews([nameLabel, linkLabel])
        stackView.axis = .vertical
        stackView.spacing = Constants.textStackSpacing
        stackView.alignment = .leading

        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

    private let joinButton: UIButton

    private class JoinButton: UIButton {
        init(joinAction: @escaping () -> Void) {
            super.init(frame: .zero)

            let view = UIView()
            view.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray05
            view.isUserInteractionEnabled = false
            view.layer.cornerRadius = bounds.size.height / 2

            let label = UILabel()
            label.setCompressionResistanceHigh()
            label.text = CallStrings.joinCallPillButtonTitle
            label.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
            label.textColor = Theme.joinButtonTextColor
            view.isUserInteractionEnabled = false

            self.clipsToBounds = true
            self.addAction(UIAction(handler: { _ in joinAction() }), for: .touchUpInside)

            view.addSubview(label)
            label.autoPinEdge(.top, to: .top, of: view, withOffset: Constants.vMargin)
            label.autoPinEdge(.bottom, to: .bottom, of: view, withOffset: -Constants.vMargin)
            label.autoPinEdge(.leading, to: .leading, of: view, withOffset: Constants.hMargin)
            label.autoPinEdge(.trailing, to: .trailing, of: view, withOffset: -Constants.hMargin)

            self.addSubview(view)
            view.autoPinEdgesToSuperviewEdges()

            self.accessibilityLabel = CallStrings.joinCallPillButtonTitle
        }

        override var bounds: CGRect {
            didSet {
                updateRadius()
            }
        }

        private func updateRadius() {
            layer.cornerRadius = bounds.size.height / 2
        }

        private enum Constants {
            static let vMargin: CGFloat = 4
            static let hMargin: CGFloat = 12
        }

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

    private let callLink: CallLink
    private let callName: String

    init(
        callLink: CallLink,
        callName: String,
        joinAction: @escaping () -> Void,
    ) {
        self.callLink = callLink
        self.callName = callName
        self.joinButton = JoinButton(joinAction: joinAction)

        super.init(frame: .zero)

        let stackView = UIStackView()
        stackView.addArrangedSubviews([iconView, textStack, joinButton])
        stackView.axis = .horizontal
        stackView.distribution = .fillProportionally
        stackView.alignment = .center
        stackView.spacing = Constants.spacingIconToText
        stackView.setCustomSpacing(Constants.spacingTextToButton, after: textStack)

        self.addSubview(stackView)
        stackView.autoPinEdgesToSuperviewEdges()
    }

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

    private enum Constants {
        static let spacingTextToButton: CGFloat = 16
        static let spacingIconToText: CGFloat = 12
        static let textStackSpacing: CGFloat = 2

        static let circleViewDimension: CGFloat = CommonCallLinksUI.Constants.circleViewDimension
    }
}

private extension Theme {
    class var joinButtonTextColor: UIColor {
        // When we're in dark theme, we actually want the color corresponding
        // with the _high contrast_ dark theme, due to the background color
        // of the button. Design requested this exception.
        let darkThemeTraits = UITraitCollection(traitsFrom: [
            UITraitCollection(userInterfaceStyle: .dark),
            UITraitCollection(accessibilityContrast: .high),
        ])
        let darkThemeColor = UIColor.Signal.ultramarine.resolvedColor(with: darkThemeTraits)
        let lightThemeColor = UIColor.Signal.ultramarine.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
        return isDarkThemeEnabled ? darkThemeColor : lightThemeColor
    }
}