Signal-iOS/Signal/Calls/UserInterface/CreateCallLinkViewController.swift
2024-09-26 16:57:38 -04:00

576 lines
21 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
import SignalUI
class CreateCallLinkViewController: InteractiveSheetViewController {
private let callLink: CallLink
private let adminPasskey: Data
private var callLinkState: CallLinkState
private lazy var _navigationController = OWSNavigationController()
private lazy var _tableViewController = _CreateCallLinkViewController()
override var interactiveScrollViews: [UIScrollView] { [self._tableViewController.tableView] }
override var sheetBackgroundColor: UIColor { Theme.tableView2PresentedBackgroundColor }
// MARK: -
init(callLink: CallLink, adminPasskey: Data, callLinkState: CallLinkState) {
self.callLink = callLink
self.adminPasskey = adminPasskey
self.callLinkState = callLinkState
super.init()
self.allowsExpansion = false
}
override func viewDidLoad() {
super.viewDidLoad()
self._navigationController.viewControllers = [ self._tableViewController ]
self.addChild(self._navigationController)
self._navigationController.didMove(toParent: self)
self.contentView.addSubview(self._navigationController.view)
self._navigationController.view.autoPinEdgesToSuperviewEdges()
updateContents(shouldReload: false)
self._tableViewController.navigationItem.rightBarButtonItem = .doneButton(
action: { [unowned self] in
self.persistIfNeeded()
self.dismiss(animated: true)
}
)
}
override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
self.view.layoutIfNeeded()
// InteractiveSheetViewController doesn't work with adjustedContentInset.
self._tableViewController.tableView.contentInsetAdjustmentBehavior = .never
self._tableViewController.tableView.contentInset = UIEdgeInsets(
top: self._navigationController.navigationBar.bounds.size.height,
left: 0,
bottom: self.view.safeAreaInsets.bottom,
right: 0
)
self.minimizedHeight = (
self._tableViewController.tableView.contentSize.height
+ self._tableViewController.tableView.contentInset.totalHeight
+ InteractiveSheetViewController.Constants.handleHeight
)
}
// MARK: - Contents
private func updateContents(shouldReload: Bool) {
self._tableViewController.setContents(buildTableContents(), shouldReload: shouldReload)
}
private func callLinkCardCell() -> UITableViewCell {
let cell = OWSTableItem.newCell()
let view = CallLinkCardView(
callLink: self.callLink,
callLinkState: self.callLinkState,
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 = self else { return UITableViewCell() }
return self.callLinkCardCell()
}
)
var settingItems = [OWSTableItem]()
settingItems.append(.item(
name: callLinkState.name != nil ? CallStrings.editCallName : CallStrings.addCallName,
accessoryType: .disclosureIndicator,
actionBlock: { [unowned self] in self.editName() }
))
settingItems.append(.switch(
withText: CallStrings.approveAllMembers,
isOn: { [unowned self] in self.callLinkState.requiresAdminApproval },
target: self,
selector: #selector(toggleApproveAllMembers(_:))
))
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
return OWSTableContents(
title: CallStrings.createCallLinkTitle,
sections: [
OWSTableSection(items: [callLinkCardItem]),
OWSTableSection(items: settingItems),
sharingSection,
]
)
}
private var systemShareTableViewCell: UITableViewCell?
// MARK: - Actions
private var didPersist = false
/// 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.
private func persistIfNeeded() {
if didPersist {
return
}
didPersist = true
createCallLinkRecord()
}
private func createCallLinkRecord() {
// [CallLink] TODO: Make this asynchronous if needed.
let callLinkStore = DependenciesBridge.shared.callLinkStore
databaseStorage.write { tx in
if FeatureFlags.callLinkRecordTable {
do {
var callLinkRecord = try callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx.asV2Write)
callLinkRecord.adminPasskey = adminPasskey
callLinkRecord.updateState(callLinkState)
try callLinkStore.update(callLinkRecord, tx: tx.asV2Write)
} catch {
owsFailDebug("Couldn't create CallLinkRecord: \(error)")
}
}
// [CallLink] TODO: Move this into a -Manager object.
let localThread = TSContactThread.getOrCreateLocalThread(transaction: tx)!
let callLinkUpdate = OutgoingCallLinkUpdateMessage(
localThread: localThread,
rootKey: callLink.rootKey,
adminPasskey: adminPasskey,
tx: tx
)
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueueRef
messageSenderJobQueue.add(message: .preprepared(transientMessageWithoutAttachments: callLinkUpdate), transaction: tx)
}
storageServiceManager.recordPendingUpdates(callLinkRootKeys: [callLink.rootKey])
}
private func joinCall() {
persistIfNeeded()
GroupCallViewController.presentLobby(
for: callLink,
adminPasskey: adminPasskey,
// Because the local user is the admin and all the changes
// they are making to the state are being updated in the
// local model, we don't need to re-fetch the state from
// the server. In fact, it feels strange to block the UI with
// an activity indicator when waiting for this, as we
// already have all the info necessary to show the call UI.
callLinkStateRetrievalStrategy: .reuse(callLinkState)
)
}
private func editName() {
let editNameViewController = EditCallLinkNameViewController(
oldCallName: self.callLinkState.name ?? "",
setNewCallName: self.updateName(_:)
)
self.presentFormSheet(
OWSNavigationController(rootViewController: editNameViewController),
animated: true
)
}
private func updateName(_ name: String) async throws {
try await updateCallLink(
{ callLinkManager, authCredential in
return try await callLinkManager.updateCallLinkName(
name,
rootKey: self.callLink.rootKey,
adminPasskey: self.adminPasskey,
authCredential: authCredential
)
}
)
}
@objc
private func toggleApproveAllMembers(_ sender: UISwitch) {
let isOn = sender.isOn
ModalActivityIndicatorViewController.present(
fromViewController: self,
presentationDelay: 0.25,
asyncBlock: { [weak self] modal in
guard let self else { return }
let updateResult = await Result { [weak self] in
guard let self else { return }
try await self.updateCallLink { callLinkManager, authCredential in
return try await callLinkManager.updateCallLinkRestrictions(
requiresAdminApproval: isOn,
rootKey: self.callLink.rootKey,
adminPasskey: self.adminPasskey,
authCredential: authCredential
)
}
}
modal.dismissIfNotCanceled {
do {
_ = try updateResult.get()
} catch {
if error.isNetworkFailureOrTimeout {
// [CallLink] TODO: Refresh switch UI, as we don't know whether the operation succeeded or failed.
} else {
Logger.warn("Call link approve members switch update failed with error \(error)")
// The operation definitely failed. Revert switch state.
sender.isOn = !isOn
OWSActionSheets.showActionSheet(
title: CallStrings.callLinkErrorSheetTitle,
message: CallStrings.callLinkUpdateErrorSheetDescription
)
}
}
}
}
)
}
private func copyCallLink() {
self.persistIfNeeded()
UIPasteboard.general.url = self.callLink.url()
self.presentToast(text: OWSLocalizedString(
"COPIED_TO_CLIPBOARD",
comment: "Indicator that a value has been copied to the clipboard."
))
}
private func shareCallLinkViaSystem() {
self.persistIfNeeded()
let shareViewController = UIActivityViewController(
activityItems: [self.callLink.url()],
applicationActivities: nil
)
shareViewController.popoverPresentationController?.sourceView = self.systemShareTableViewCell
self.present(shareViewController, animated: true)
}
// MARK: - Create & Present
static func createCallLinkOnServerAndPresent(from viewController: UIViewController) {
ModalActivityIndicatorViewController.present(
fromViewController: viewController,
presentationDelay: 0.25,
asyncBlock: { modal in
do {
let callLink = CallLink.generate()
let callService = AppEnvironment.shared.callService!
let createResult = try await callService.callLinkManager.createCallLink(rootKey: callLink.rootKey)
modal.dismissIfNotCanceled {
viewController.present(CreateCallLinkViewController(
callLink: callLink,
adminPasskey: createResult.adminPasskey,
callLinkState: createResult.callLinkState
), animated: true)
}
} catch {
Logger.warn("Call link creation failed with error \(error)")
modal.dismissIfNotCanceled {
OWSActionSheets.showActionSheet(
title: CallStrings.callLinkErrorSheetTitle,
message: OWSLocalizedString(
"CALL_LINK_CREATION_FAILURE_SHEET_DESCRIPTION",
comment: "Description of sheet presented when call link creation fails."
)
)
}
}
}
)
}
// MARK: - Update Call Link
private var priorTask: Task<Void, any Error>?
private func updateCallLink(
_ performUpdate: @escaping (_ callLinkManager: CallLinkManager, _ authCredential: SignalServiceKit.CallLinkAuthCredential) async throws -> CallLinkState
) async throws {
let priorTask = self.priorTask
let newTask = Task {
try? await priorTask?.value
return try await self._updateCallLink(performUpdate)
}
self.priorTask = newTask
return try await newTask.value
}
private func _updateCallLink(
_ performUpdate: (CallLinkManager, SignalServiceKit.CallLinkAuthCredential) async throws -> CallLinkState
) async throws {
let authCredentialManager = AppEnvironment.shared.callService.authCredentialManager
let callLinkManager = AppEnvironment.shared.callService.callLinkManager
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers)
self.callLinkState = try await performUpdate(callLinkManager, authCredential)
updateContents(shouldReload: true)
}
// MARK: - Share Via Signal
private var sendMessageFlow: SendMessageFlow?
func shareCallLinkViaSignal() {
let messageBody = MessageBody(text: callLink.url().absoluteString, ranges: .empty)
let unapprovedContent = SendMessageUnapprovedContent.text(messageBody: messageBody)
let sendMessageFlow = SendMessageFlow(
flowType: .`default`,
unapprovedContent: unapprovedContent,
useConversationComposeForSingleRecipient: true,
presentationStyle: .presentFrom(self),
delegate: self
)
// Retain the flow until it is complete.
self.sendMessageFlow = sendMessageFlow
}
}
extension CreateCallLinkViewController: SendMessageDelegate {
func sendMessageFlowDidComplete(threads: [TSThread]) {
AssertIsOnMainThread()
sendMessageFlow?.dismissNavigationController(animated: true)
sendMessageFlow = nil
}
func sendMessageFlowDidCancel() {
AssertIsOnMainThread()
sendMessageFlow?.dismissNavigationController(animated: true)
sendMessageFlow = nil
}
}
// MARK: -
private class _CreateCallLinkViewController: OWSTableViewController2 {
override var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }
}
// MARK: - CallLinkCardView
private class CallLinkCardView: UIView {
private lazy var iconView: UIImageView = {
let image = CommonCallLinksUI.callLinkIcon()
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 = callLinkState.localizedName
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 = Theme.snippetColor
linkLabel.font = .dynamicTypeBody2
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.accentBlueColor
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 public 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 callLinkState: CallLinkState
init(
callLink: CallLink,
callLinkState: CallLinkState,
joinAction: @escaping () -> Void
) {
self.callLink = callLink
self.callLinkState = callLinkState
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
}
}
public class CommonCallLinksUI {
public static func callLinkIcon() -> UIImage? {
guard let image = UIImage(named: "video-compact") else { return nil }
let newSize = CGSize(square: Constants.circleViewDimension)
let renderer = UIGraphicsImageRenderer(size: newSize)
let finalImage = renderer.image { context in
let rect = CGRect(origin: .zero, size: newSize)
let circlePath = UIBezierPath(ovalIn: rect)
Constants.iconBackgroundColor.setFill()
circlePath.fill()
context.cgContext.addPath(circlePath.cgPath)
context.cgContext.clip()
Constants.iconTintColor.set()
let centerOffset = Constants.circleViewDimension/2 - Constants.iconDimension/2
let imageRect = CGRect(
x: centerOffset,
y: centerOffset,
width: Constants.iconDimension,
height: Constants.iconDimension
)
image.withRenderingMode(.alwaysTemplate).draw(in: imageRect)
}
return finalImage
}
enum Constants {
static let circleViewDimension: CGFloat = 64
fileprivate static let iconDimension: CGFloat = 36
fileprivate static let iconBackgroundColor = UIColor(rgbHex: 0xE4E4FD)
fileprivate static let iconTintColor = UIColor(rgbHex: 0x5151F6)
}
}