TM-SGNL-iOS/Signal/Calls/UserInterface/CallLinkBulkApprovalSheet.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

555 lines
19 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import UIKit
import Combine
import SignalUI
import LibSignalClient
import SignalServiceKit
class CallLinkBulkApprovalSheet: InteractiveSheetViewController {
fileprivate enum Constants {
static let backgroundColor = UIColor.Signal.secondaryBackground
static let sheetHeaderBottomPadding: CGFloat = 8
static let avatarSizeClass = ConversationAvatarView.Configuration.SizeClass.thirtySix
static var avatarWidth: CGFloat { avatarSizeClass.size.width }
static let avatarSpacing: CGFloat = 12
static let buttonSpacing: CGFloat = 16
static var buttonSize: CGFloat {
max(UIFont.dynamicTypeTitle1Clamped.pointSize, 28)
}
static let denyButtonColor = UIColor.Signal.red
static let approveButtonColor = UIColor.Signal.green
static let selectedButtonColor = UIColor.Signal.primaryFill
static let denyAllButtonTitle = OWSLocalizedString(
"CALL_LINK_REQUEST_SHEET_DENY_ALL_BUTTON",
comment: "Title for button to deny all requests to join a call."
)
static let approveAllButtonTitle = OWSLocalizedString(
"CALL_LINK_REQUEST_SHEET_APPROVE_ALL_BUTTON",
comment: "Title for button to approve all requests to join a call."
)
}
override var interactiveScrollViews: [UIScrollView] { [tableView] }
override var sheetBackgroundColor: UIColor {
Constants.backgroundColor
}
override var handleBackgroundColor: UIColor {
UIColor.Signal.transparentSeparator
}
typealias Request = CallLinkApprovalRequest
private var viewModel: CallLinkApprovalViewModel
private var subscriptions = Set<AnyCancellable>()
// MARK: Views
private let sheetHeader: UILabel = {
let label = UILabel()
label.font = .dynamicTypeHeadline
label.textColor = .Signal.label
label.textAlignment = .center
label.text = OWSLocalizedString(
"CALL_LINK_REQUEST_SHEET_HEADER",
comment: "Header for the sheet displaying a list of requests to join a call."
)
return label
}()
private let sheetFooter: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = .init(
top: 8,
leading: 16,
bottom: 16,
trailing: 16
)
return stackView
}()
private lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.delegate = self
tableView.backgroundColor = Constants.backgroundColor
tableView.separatorInset.leading += Constants.avatarWidth + Constants.avatarSpacing
return tableView
}()
private lazy var tableHeader = TableHeader()
private class TableHeader: UIView {
var requestCount: Int = 0 {
didSet { self.updateText() }
}
private lazy var label: UILabel = {
let label = UILabel()
self.addSubview(label)
self.layoutMargins.top = 36
label.autoPinEdgesToSuperviewMargins()
label.font = .dynamicTypeHeadline
label.textColor = .Signal.label
return label
}()
private func updateText() {
self.label.text = String(
format: OWSLocalizedString(
"CALL_LINK_REQUEST_HEADER_COUNT_%d",
tableName: "PluralAware",
comment: "Header for a table section which lists users requesting to join a call. Embeds {{ number of requests }}"
),
self.requestCount
)
}
}
// MARK: init
init(viewModel: CallLinkApprovalViewModel) {
self.viewModel = viewModel
super.init()
self.overrideUserInterfaceStyle = .dark
self.contentView.addSubview(sheetHeader)
sheetHeader.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)
self.contentView.addSubview(tableView)
tableView.autoPinEdge(.top, to: .bottom, of: sheetHeader, withOffset: Constants.sheetHeaderBottomPadding)
tableView.autoPinWidthToSuperview()
let denyAllButton = bulkActionButton(
title: Constants.denyAllButtonTitle,
color: .clear
) { [weak self] in
self?.didTapDenyAll()
}
let approveAllButton = bulkActionButton(
title: Constants.approveAllButtonTitle,
color: .Signal.tertiaryFill
) { [weak self] in
self?.didTapApproveAll()
}
self.contentView.addSubview(sheetFooter)
sheetFooter.autoPinEdge(.top, to: .bottom, of: tableView)
sheetFooter.autoPinEdge(toSuperviewMargin: .bottom)
sheetFooter.autoPinWidthToSuperview()
sheetFooter.addArrangedSubview(denyAllButton)
sheetFooter.addArrangedSubview(.hStretchingSpacer())
sheetFooter.addArrangedSubview(approveAllButton)
tableView.register(RequestCell.self)
viewModel.$requests
.removeDuplicates()
.sink { [weak self] requests in
self?.updateSnapshot(requests: requests)
}
.store(in: &subscriptions)
tableView
.publisher(for: \.contentSize)
.removeDuplicates()
.sink { [weak self] _ in
self?.updateSheetSize()
}
.store(in: &subscriptions)
}
private func bulkActionButton(
title: String,
color: UIColor,
action: @escaping () -> Void
) -> UIButton {
var configuration = UIButton.Configuration.gray()
configuration.background.cornerRadius = 12
configuration.baseBackgroundColor = color
configuration.baseForegroundColor = .Signal.label
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.contentInsets = .init(hMargin: 16, vMargin: 14)
return UIButton(
configuration: configuration,
primaryAction: .init(title: title) { _ in action() }
)
}
// MARK: Presentation
private weak var fromViewController: UIViewController?
func present(
from viewController: UIViewController,
dismissalDelegate: (any SheetDismissalDelegate)? = nil
) {
self.fromViewController = viewController
self.dismissalDelegate = dismissalDelegate
viewController.present(self, animated: true)
}
// MARK: Sheet sizing
/// `minimizedHeight` doesn't animate, so don't update it after the sheet is presented.
private var shouldUpdateMinimizedHeight = true
private var viewHasAppeared = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.viewHasAppeared = true
}
private var desiredSheetHeight: CGFloat {
InteractiveSheetViewController.Constants.handleHeight
+ contentView.layoutMargins.totalHeight
+ sheetHeader.height
+ Constants.sheetHeaderBottomPadding
+ tableView.contentSize.height
+ tableView.contentInset.totalHeight
+ sheetFooter.height
}
private func updateSheetSize() {
let desiredHeight = desiredSheetHeight
self.preferredSize = desiredHeight
if shouldUpdateMinimizedHeight {
self.minimizedHeight = desiredHeight
}
self.tableView.isScrollEnabled = self.maxHeight < desiredHeight
}
private lazy var preferredSize: CGFloat = self.maximumAllowedHeight()
override func maximumPreferredHeight() -> CGFloat {
min(preferredSize, maximumAllowedHeight())
}
// MARK: Actions
private func didTapDenyAll() {
let requestsAtTimeOfPrompt = self.viewModel.requests
self.performWithActionSheetConfirmation(
title: String(
format: OWSLocalizedString(
"CALL_LINK_DENY_ALL_REQUESTS_CONFIRMATION_TITLE_%ld",
tableName: "PluralAware",
comment: "Title for confirmation sheet when denying all requests to join a call."
),
requestsAtTimeOfPrompt.count
),
message: String(
format: OWSLocalizedString(
"CALL_LINK_DENY_ALL_REQUESTS_CONFIRMATION_BODY_%ld",
tableName: "PluralAware",
comment: "Body for confirmation sheet when denying all requests to join a call."
),
requestsAtTimeOfPrompt.count
),
confirmButtonTitle: Constants.denyAllButtonTitle
) { [viewModel] in
viewModel.bulkDeny(requests: requestsAtTimeOfPrompt)
}
}
private func didTapApproveAll() {
let requestsAtTimeOfPrompt = self.viewModel.requests
guard !requestsAtTimeOfPrompt.isEmpty else {
return self.dismiss(animated: true)
}
self.performWithActionSheetConfirmation(
title: String(
format: OWSLocalizedString(
"CALL_LINK_APPROVE_ALL_REQUESTS_CONFIRMATION_TITLE_%ld",
tableName: "PluralAware",
comment: "Title for confirmation sheet when approving all requests to join a call."
),
requestsAtTimeOfPrompt.count
),
message: String(
format: OWSLocalizedString(
"CALL_LINK_APPROVE_ALL_REQUESTS_CONFIRMATION_BODY_%ld",
tableName: "PluralAware",
comment: "Body for confirmation sheet when approving all requests to join a call."
),
requestsAtTimeOfPrompt.count
),
confirmButtonTitle: Constants.approveAllButtonTitle
) { [viewModel] in
viewModel.bulkApprove(requests: requestsAtTimeOfPrompt)
}
}
private func performWithActionSheetConfirmation(
title: String,
message: String,
confirmButtonTitle: String,
action: @escaping () -> Void
) {
guard let fromViewController else {
return owsFailDebug("Missing parent view controller")
}
let actionSheet = ActionSheetController(
title: title,
message: message,
theme: .translucentDark
)
actionSheet.addAction(.init(title: confirmButtonTitle) { _ in action() })
actionSheet.addAction(.cancel)
self.dismiss(animated: true) {
fromViewController.presentActionSheet(actionSheet)
}
}
// MARK: Table contents
private typealias DiffableDataSource = UITableViewDiffableDataSource<Section, Aci>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Aci>
enum Section: Hashable {
case requests
}
private lazy var dataSource = DiffableDataSource(
tableView: self.tableView
) { [weak self] tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(RequestCell.self)
cell?.approvalViewModel = self?.viewModel
cell?.rowModel = self?.rowModelsByID[itemIdentifier]
return cell
}
fileprivate class RowModel: ObservableObject {
let request: Request
@Published var status: Status
init(request: Request, status: Status) {
self.request = request
self.status = status
}
enum Status {
case pending, approved, denied
}
}
private var rowModels: [RowModel] = [] {
didSet {
rowModels.forEach { requestStatus in
rowModelsByID[requestStatus.request.aci] = requestStatus
}
}
}
private var rowModelsByID: [Aci: RowModel] = [:]
private func updateSnapshot(requests: [Request]) {
let pendingRequests = requests.filter { request in
(rowModelsByID[request.aci]?.status ?? .pending) == .pending
}
if self.tableHeader.requestCount != pendingRequests.count, self.viewHasAppeared {
self.shouldUpdateMinimizedHeight = false
}
self.tableHeader.requestCount = pendingRequests.count
// We want to keep users in this list after they're approved or denied,
// so remove only pending users who are no longer in the peek info.
let newRequests = Set(requests.map(\.aci))
self.rowModels.removeAll { requestStatus in
requestStatus.status == .pending && !newRequests.contains(requestStatus.request.aci)
}
let existingRequests = Set(self.rowModels.map(\.request.aci))
self.rowModels += requests
.filter { !existingRequests.contains($0.aci) }
.map { RowModel(request: $0, status: .pending) }
let requests = self.rowModels.map(\.request.aci)
var snapshot = Snapshot()
snapshot.appendSections([.requests])
snapshot.appendItems(requests, toSection: .requests)
dataSource.apply(snapshot)
}
}
// MARK: UITableViewDelegate
extension CallLinkBulkApprovalSheet: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
self.tableHeader
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard
let aci = dataSource.itemIdentifier(for: indexPath),
let rowModel = rowModelsByID[aci]
else {
return owsFailDebug("Missing request object")
}
viewModel.performRequestAction.send((.viewDetails, rowModel.request))
}
}
// MARK: - RequestCell
private class RequestCell: UITableViewCell, ReusableTableViewCell {
static var reuseIdentifier: String = "RequestCell"
private typealias Constants = CallLinkBulkApprovalSheet.Constants
typealias RowModel = CallLinkBulkApprovalSheet.RowModel
var rowModel: RowModel? {
didSet {
loadRequestContents()
}
}
var approvalViewModel: CallLinkApprovalViewModel?
private var requestStatusSubscription: AnyCancellable?
private var avatarView = ConversationAvatarView(
sizeClass: Constants.avatarSizeClass,
localUserDisplayMode: .asUser,
badged: true
)
private lazy var nameLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeBodyClamped
label.textColor = .Signal.label
return label
}()
private lazy var denyButton = ActionButton(icon: .xBold) { [weak self] in
if let request = self?.rowModel?.request {
self?.approvalViewModel?.performRequestAction.send((.deny, request))
self?.rowModel?.status = .denied
}
}
private lazy var approveButton = ActionButton(icon: .checkmarkBold) { [weak self] in
if let request = self?.rowModel?.request {
self?.approvalViewModel?.performRequestAction.send((.approve, request))
self?.rowModel?.status = .approved
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let hStack = UIStackView()
hStack.alignment = .center
hStack.axis = .horizontal
hStack.spacing = 8
hStack.addArrangedSubview(avatarView)
hStack.setCustomSpacing(Constants.avatarSpacing, after: avatarView)
hStack.addArrangedSubview(nameLabel)
hStack.addArrangedSubview(denyButton)
hStack.setCustomSpacing(Constants.buttonSpacing, after: denyButton)
hStack.addArrangedSubview(approveButton)
contentView.addSubview(hStack)
hStack.autoPinEdgesToSuperviewMargins()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func loadRequestContents() {
guard let rowModel else { return }
avatarView.updateWithSneakyTransactionIfNecessary { configuration in
configuration.dataSource = .address(rowModel.request.address)
}
nameLabel.text = rowModel.request.name
requestStatusSubscription = rowModel.$status
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
guard let self else { return }
UIView.animate(withDuration: 0.1) {
self.statusDidChange(to: status)
}
}
}
private func statusDidChange(to status: RowModel.Status) {
switch status {
case .pending:
self.denyButton.layer.opacity = 1
self.approveButton.layer.opacity = 1
self.denyButton.backgroundColor = Constants.denyButtonColor
self.approveButton.backgroundColor = Constants.approveButtonColor
case .approved:
self.denyButton.layer.opacity = 0
self.approveButton.layer.opacity = 1
self.approveButton.backgroundColor = Constants.selectedButtonColor
case .denied:
self.denyButton.layer.opacity = 1
self.denyButton.backgroundColor = Constants.selectedButtonColor
self.approveButton.layer.opacity = 0
}
}
// MARK: ActionButton
private class ActionButton: UIButton {
private var sizeConstraints: [NSLayoutConstraint] = []
convenience init(icon: ThemeIcon, action: @escaping () -> Void) {
self.init(primaryAction: .init(image: Theme.iconImage(icon)) { _ in
action()
})
self.titleLabel?.font = .dynamicTypeBody.bold()
self.ows_imageEdgeInsets = .init(margin: 6)
self.tintColor = .Signal.label
self.sizeConstraints = self.autoSetDimensions(to: .square(Constants.buttonSize))
self.layer.cornerRadius = Constants.buttonSize / 2
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
self.sizeConstraints.forEach { $0.constant = Constants.buttonSize }
self.layer.cornerRadius = Constants.buttonSize / 2
}
}
}
// MARK: - Previews
#if DEBUG
@available(iOS 17.0, *)
#Preview {
SheetPreviewViewController {
let viewModel = CallLinkApprovalViewModel()
viewModel.requests = [
.init(aci: .init(fromUUID: UUID()), name: "Candice"),
.init(aci: .init(fromUUID: UUID()), name: "Sam"),
.init(aci: .init(fromUUID: UUID()), name: "Kai"),
]
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
viewModel.requests.append(.init(aci: .init(fromUUID: UUID()), name: "Gerte"))
}
return CallLinkBulkApprovalSheet(viewModel: viewModel)
}
}
#endif