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

1268 lines
46 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import Combine
protocol CallDrawerDelegate: AnyObject {
func didPresentViewController(_ viewController: UIViewController)
func didTapDone()
}
// MARK: - GroupCallSheet
class CallDrawerSheet: InteractiveSheetViewController {
private let callControls: CallControls
// MARK: Properties
override var interactiveScrollViews: [UIScrollView] { [tableView] }
override var canBeDismissed: Bool {
return false
}
override var canInteractWithParent: Bool {
return true
}
private lazy var tableViewContainer: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = HeightConstants.titleViewBottomPadding
stackView.addArrangedSubview(self.tableHeaderContainer)
stackView.addArrangedSubview(self.tableView)
return stackView
}()
private lazy var tableHeaderContainer: UIView = {
let container = UIView()
container.layoutMargins = .init(hMargin: 21, vMargin: 0)
container.addSubview(self.sheetTitleLabel)
self.sheetTitleLabel.autoHCenterInSuperview()
self.sheetTitleLabel.autoPinHeightToSuperviewMargins()
let doneButton = UIButton(primaryAction: .init(
title: CommonStrings.doneButton
) { [weak self] _ in
self?.callDrawerDelegate?.didTapDone()
})
container.addSubview(doneButton)
doneButton.setTitleColor(UIColor.Signal.label, for: .normal)
doneButton.titleLabel?.font = .dynamicTypeBody
doneButton.autoAlignAxis(.horizontal, toSameAxisOf: self.sheetTitleLabel)
doneButton.autoPinEdge(toSuperviewMargin: .trailing)
doneButton.autoPinEdge(.leading, to: .trailing, of: sheetTitleLabel, withOffset: 8, relation: .greaterThanOrEqual)
return container
}()
private let sheetTitleLabel: UILabel = {
let label = UILabel()
// "Call Info" for normal group calls. Will be
// overwritten by call link state otherwise.
label.text = OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_TITLE",
comment: "Title for the sheet showing the group call members list"
)
label.font = .dynamicTypeHeadline
label.textColor = UIColor.Signal.label
return label
}()
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let call: SignalCall
private let callSheetDataSource: CallDrawerSheetDataSource
private weak var callDrawerDelegate: CallDrawerDelegate?
private var callLinkDataSource: CallLinkSheetDataSource? {
self.callSheetDataSource as? CallLinkSheetDataSource
}
private lazy var callLinkAdminManager: CallLinkAdminManager? = {
guard
let callLinkDataSource,
let adminPasskey = callLinkDataSource.adminPasskey
else { return nil }
return CallLinkAdminManager(
rootKey: callLinkDataSource.callLink.rootKey,
adminPasskey: adminPasskey,
callLinkState: callLinkDataSource.callLinkState
)
}()
private var callLinkStateSubscription: AnyCancellable?
private var didPresentViewController: ((UIViewController) -> Void)?
override var sheetBackgroundColor: UIColor { UIColor(rgbHex: 0x1C1C1E) }
override var handleBackgroundColor: UIColor { UIColor(rgbHex: 0x787880).withAlphaComponent(0.36) }
init(
call: SignalCall,
callSheetDataSource: CallDrawerSheetDataSource,
callService: CallService,
confirmationToastManager: CallControlsConfirmationToastManager,
callControlsDelegate: CallControlsDelegate,
sheetPanDelegate: (any SheetPanDelegate)?,
callDrawerDelegate: CallDrawerDelegate? = nil
) {
self.call = call
self.callSheetDataSource = callSheetDataSource
self.callControls = CallControls(
call: call,
callService: callService,
confirmationToastManager: confirmationToastManager,
delegate: callControlsDelegate
)
super.init(blurEffect: nil)
self.animationsShouldBeInterruptible = true
self.sheetPanDelegate = sheetPanDelegate
self.callDrawerDelegate = callDrawerDelegate
self.overrideUserInterfaceStyle = .dark
callSheetDataSource.addObserver(self, syncStateImmediately: true)
callControls.addHeightObserver(self)
self.tableViewContainer.alpha = 0
// Don't add a dim visual effect to the call when the sheet is open.
self.backdropColor = .clear
}
override func maximumPreferredHeight() -> CGFloat {
guard let windowHeight = view.window?.frame.height else {
return super.maximumPreferredHeight()
}
let halfHeight = windowHeight / 2
let twoThirdsHeight = 2 * windowHeight / 3
let tableHeight = tableView.contentSize.height
+ tableView.safeAreaInsets.totalHeight
+ Constants.handleHeight
+ tableHeaderContainer.frame.height
+ HeightConstants.titleViewBottomPadding
+ HeightConstants.tableViewTopPadding
if tableHeight >= twoThirdsHeight {
return twoThirdsHeight
} else if tableHeight > halfHeight {
return tableHeight
} else {
return halfHeight
}
}
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
super.present(viewControllerToPresent, animated: flag, completion: completion)
self.callDrawerDelegate?.didPresentViewController(viewControllerToPresent)
}
// MARK: - Table setup
private typealias DiffableDataSource = UITableViewDiffableDataSource<Section, RowID>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowID>
private enum Section: Hashable {
case callLink
case members(MembersSection)
case admin
}
private enum MembersSection: Hashable {
case raisedHands
case inCall
}
private enum RowID: Hashable {
case callLink(CallLinkRow)
case member(section: MembersSection, id: JoinedMember.ID)
case unknownMembers
enum CallLinkRow: Hashable {
case share
case editName
}
}
private lazy var dataSource = DiffableDataSource(
tableView: tableView
) { [weak self] tableView, indexPath, id -> UITableViewCell? in
switch id {
case let .member(section: section, id: memberID):
let cell = tableView.dequeueReusableCell(GroupCallMemberCell.self, for: indexPath)
cell.delegate = self
guard let viewModel = self?.viewModelsByID[memberID] else {
owsFailDebug("missing view model")
cell.hideContent()
return cell
}
let isCallAdmin = self?.callLinkDataSource?.isAdmin ?? false
let canBeRemoved = section == .inCall && !viewModel.isLocalUser && isCallAdmin
let removeUserButtonVisibility: GroupCallMemberCell.Visibility =
if canBeRemoved {
.visible
} else if isCallAdmin && section == .inCall {
.spaceReserved
} else {
.hidden
}
cell.configure(
with: viewModel,
isHandRaised: section == .raisedHands,
removeUserButtonVisibility: removeUserButtonVisibility
)
return cell
case .callLink(.share):
return tableView.dequeueReusableCell(CallLinkURLCell.self, for: indexPath)
case .callLink(.editName):
let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath)
var config = cell.defaultContentConfiguration()
config.text = self?.callLinkAdminManager?.editCallNameButtonTitle
cell.contentConfiguration = config
cell.accessoryType = .disclosureIndicator
cell.contentView.layoutMargins.top = 14
cell.contentView.layoutMargins.bottom = 14
return cell
case .unknownMembers:
let cell = tableView.dequeueReusableCell(UnknownMembersCell.self, for: indexPath)
if let self {
cell.parentViewController = self
cell.unknownMembers = self.unknownMembers
}
return cell
}
}
private class HeaderView: UIView {
private let section: MembersSection
var memberCount: Int = 0 {
didSet {
self.updateText()
}
}
private let label = UILabel()
init(section: MembersSection) {
self.section = section
super.init(frame: .zero)
self.addSubview(self.label)
self.label.autoPinEdgesToSuperviewMargins()
self.updateText()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateText() {
let titleText: String = switch section {
case .raisedHands:
OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_RAISED_HANDS_SECTION_HEADER",
comment: "Title for the section of the group call member list which displays the list of members with their hand raised."
)
case .inCall:
OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_IN_CALL_SECTION_HEADER",
comment: "Title for the section of the group call member list which displays the list of all members in the call."
)
}
label.attributedText = .composed(of: [
titleText.styled(with: .font(.dynamicTypeHeadline)),
" ",
String(
format: OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_SECTION_HEADER_MEMBER_COUNT",
comment: "A count of members in a given group call member list section, displayed after the header."
),
self.memberCount
)
]).styled(
with: .font(.dynamicTypeBody),
.color(Theme.darkThemePrimaryColor)
)
}
}
private let raisedHandsHeader = HeaderView(section: .raisedHands)
private let inCallHeader = HeaderView(section: .inCall)
func setBottomSheetMinimizedHeight() {
minimizedHeight = callControls.currentHeight + self.bottomPadding
}
private func setTableViewTopTranslation(to translation: CGFloat) {
tableViewContainer.transform = .translate(.init(x: 0, y: translation))
}
override public func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.tableHeaderView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: CGFloat.leastNormalMagnitude)))
tableView.backgroundColor = sheetBackgroundColor
contentView.addSubview(tableViewContainer)
tableViewContainer.autoPinEdgesToSuperviewEdges()
if let callLinkAdminManager {
callLinkStateSubscription = callLinkAdminManager.callNamePublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] callLinkName in
self?.callLinkNameDidChange(callLinkName)
}
}
tableView.register(CallLinkURLCell.self)
tableView.register(GroupCallMemberCell.self)
tableView.register(UnknownMembersCell.self)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
tableView.dataSource = self.dataSource
callControls.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(callControls)
NSLayoutConstraint.activate([
callControls.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
callControls.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10)
])
updateMembers()
}
// MARK: - Table contents
struct JoinedMember {
enum ID: Hashable {
case aci(Aci)
case demuxID(DemuxId)
}
let id: ID
let aci: Aci
let displayName: String
let comparableName: DisplayName.ComparableValue
let demuxID: DemuxId?
let isLocalUser: Bool
let isUnknown: Bool
let isAudioMuted: Bool?
let isVideoMuted: Bool?
let isPresenting: Bool?
}
fileprivate struct UnknownMembers {
var members: [JoinedMember]
var count: Int { members.count }
var callHasKnownUsers: Bool
init() {
self.members = []
self.callHasKnownUsers = false
}
}
private var unknownMembers = UnknownMembers()
private var viewModelsByID: [JoinedMember.ID: GroupCallMemberCell.ViewModel] = [:]
private var sortedMembers = [JoinedMember]() {
didSet {
let oldMemberIDs = viewModelsByID.keys
let newMemberIDs = sortedMembers.map(\.id)
let viewModelsToRemove = Set(oldMemberIDs).subtracting(newMemberIDs)
if !viewModelsToRemove.isEmpty {
Logger.info("Removing \(viewModelsToRemove.count) view models")
}
viewModelsToRemove.forEach { viewModelsByID.removeValue(forKey: $0) }
viewModelsByID = sortedMembers.reduce(into: viewModelsByID) { partialResult, member in
if let existingViewModel = partialResult[member.id] {
existingViewModel.update(using: member)
} else {
partialResult[member.id] = .init(member: member)
}
}
}
}
func updateMembers() {
Logger.info("")
let unsortedMembers: [JoinedMember] = SSKEnvironment.shared.databaseStorageRef.read {
callSheetDataSource.unsortedMembers(tx: $0.asV2Read)
}
typealias OrganizedMembers = (joinedMembers: [JoinedMember], unknownMembers: UnknownMembers)
let organizedMembers: OrganizedMembers = unsortedMembers.reduce(
into: ([], UnknownMembers())
) { partialResult, member in
if member.isUnknown {
partialResult.unknownMembers.members.append(member)
} else {
partialResult.joinedMembers.append(member)
partialResult.unknownMembers.callHasKnownUsers = true
}
}
self.sortedMembers = organizedMembers.joinedMembers.sorted {
let nameComparison = $0.comparableName.isLessThanOrNilIfEqual($1.comparableName)
if let nameComparison {
return nameComparison
}
if $0.aci != $1.aci {
return $0.aci < $1.aci
}
return $0.demuxID ?? 0 < $1.demuxID ?? 0
}
self.unknownMembers = organizedMembers.unknownMembers
self.updateSnapshotAndHeaders()
}
private var previousSnapshotItems: [RowID]?
@MainActor
private func updateSnapshotAndHeaders() {
AssertIsOnMainThread()
var snapshot = Snapshot()
let isCallAdmin = callLinkDataSource?.isAdmin ?? false
// Call link info
if callLinkDataSource != nil {
snapshot.appendSections([.callLink])
snapshot.appendItems([.callLink(.share)], toSection: .callLink)
}
// Raised hands
let raiseHandMemberIds = callSheetDataSource.raisedHandMemberIds()
if !raiseHandMemberIds.isEmpty {
snapshot.appendSections([.members(.raisedHands)])
snapshot.appendItems(
raiseHandMemberIds.map {
RowID.member(section: .raisedHands, id: $0)
},
toSection: .members(.raisedHands)
)
raisedHandsHeader.memberCount = raiseHandMemberIds.count
}
// Call members
let shouldHideMembersSection = isCallAdmin && sortedMembers.isEmpty && unknownMembers.count == 0
if !shouldHideMembersSection {
snapshot.appendSections([.members(.inCall)])
snapshot.appendItems(
sortedMembers.map { RowID.member(section: .inCall, id: $0.id) },
toSection: .members(.inCall)
)
if unknownMembers.count > 0 {
snapshot.appendItems([.unknownMembers], toSection: .members(.inCall))
}
}
inCallHeader.memberCount = sortedMembers.count + unknownMembers.count
// Call link admin
if isCallAdmin {
snapshot.appendSections([.admin])
snapshot.appendItems([
.callLink(.editName),
])
}
// Apply snapshot
Logger.info("Applying snapshot")
if self.previousSnapshotItems != snapshot.itemIdentifiers {
self.previousSnapshotItems = snapshot.itemIdentifiers
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
Logger.info("Snapshot applied")
self?.refreshMaxHeight()
}
}
}
private func callLinkNameDidChange(_ callLinkName: String?) {
sheetTitleLabel.text = callLinkName ?? SignalServiceKit.CallLinkState.defaultLocalizedName
var snapshot = dataSource.snapshot()
snapshot.reloadSections([.admin])
dataSource.apply(snapshot, animatingDifferences: false)
}
private func changesForSnapToMax() {
self.tableViewContainer.alpha = 1
self.callControls.alpha = 0
self.setTableViewTopTranslation(to: 0)
self.view.layoutIfNeeded()
}
private func changesForSnapToMin() {
self.tableViewContainer.alpha = 0
self.callControls.alpha = 1
self.setTableViewTopTranslation(to: HeightConstants.initialTableInset)
self.view.layoutIfNeeded()
}
/// The portion of the height of the sheet to pivot the fade transition over
private let pivot: CGFloat = 0.2
private var lastKnownHeight: SheetHeight = .min
override func heightDidChange(to height: InteractiveSheetViewController.SheetHeight) {
switch height {
case .min:
guard UIView.inheritedAnimationDuration > 0 else {
changesForSnapToMin()
break
}
let currentHeight = switch lastKnownHeight {
case .min:
self.minimizedHeight
case .max:
self.maxHeight
case .height(let height):
height
}
let currentHeightProportional = (currentHeight - self.minimizedHeight) / (self.maxHeight - self.minimizedHeight)
let tableFadePortion = max((currentHeightProportional - self.pivot) / currentHeightProportional, 0)
let controlsFadePortion = 1 - tableFadePortion
// Inherit the animation
UIView.animateKeyframes(withDuration: 0, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: tableFadePortion) {
self.tableViewContainer.alpha = 0
self.setTableViewTopTranslation(to: HeightConstants.initialTableInset)
}
UIView.addKeyframe(withRelativeStartTime: tableFadePortion, relativeDuration: controlsFadePortion) {
self.callControls.alpha = 1
}
}
case .height(let height):
let distance = self.maxHeight - self.minimizedHeight
// The "pivot point" is the sheet height where call controls have totally
// faded out and the call info table begins to fade in.
let pivotPoint = minimizedHeight + self.pivot * distance
if height <= self.minimizedHeight {
changesForSnapToMin()
} else if height > self.minimizedHeight && height < pivotPoint {
tableViewContainer.alpha = 0
let denominator = pivotPoint - self.minimizedHeight
if denominator <= 0 {
owsFailBeta("You've changed the conditions of this if-branch such that the denominator could be zero!")
callControls.alpha = 1
} else {
callControls.alpha = max(0.1, 1 - ((height - self.minimizedHeight) / denominator))
}
} else if height >= pivotPoint && height < maxHeight {
callControls.alpha = 0
// Table view fades in as sheet opens and fades out as sheet closes.
let denominator = maxHeight - pivotPoint
if denominator <= 0 {
owsFailBeta("You've changed the conditions of this if-branch such that the denominator could be zero!")
tableViewContainer.alpha = 0
} else {
tableViewContainer.alpha = max(0.1, (height - pivotPoint) / denominator)
}
// Table view slides up via a y-shift to its final position as the sheet opens.
// The distance across which the y-shift will be completed.
let totalTravelableDistanceForSheet = maxHeight - pivotPoint
// The distance traveled in the y-shift range.
let distanceTraveledBySheetSoFar = height - pivotPoint
// Table travel distance per unit sheet travel distance.
let stepSize = HeightConstants.initialTableInset / totalTravelableDistanceForSheet
// How far the table should have traveled.
let tableTravelDistance = stepSize * distanceTraveledBySheetSoFar
self.setTableViewTopTranslation(to: HeightConstants.initialTableInset - tableTravelDistance)
} else if height >= maxHeight {
changesForSnapToMax()
}
case .max:
guard UIView.inheritedAnimationDuration > 0 else {
changesForSnapToMax()
break
}
let currentHeight = switch lastKnownHeight {
case .min:
self.minimizedHeight
case .max:
self.maxHeight
case .height(let height):
height
}
// Basically the same as the .min case, but flipped upside-down
// - Height of 1 = bottom
// - Treat pivot as 1 - its value
let currentHeightProportional = 1 - ((currentHeight - self.minimizedHeight) / (self.maxHeight - self.minimizedHeight))
let controlsFadePortion = max((currentHeightProportional - (1 - self.pivot)) / currentHeightProportional, 0)
let tableFadePortion = 1 - controlsFadePortion
UIView.animateKeyframes(withDuration: 0, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: controlsFadePortion) {
self.callControls.alpha = 0
}
UIView.addKeyframe(withRelativeStartTime: controlsFadePortion, relativeDuration: tableFadePortion) {
self.tableViewContainer.alpha = 1
self.setTableViewTopTranslation(to: 0)
}
}
}
self.lastKnownHeight = height
}
override func themeDidChange() {
// The call drawer always uses dark styling regardless of the
// system setting, so ignore.
}
}
// MARK: UITableViewDelegate
extension CallDrawerSheet: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section = dataSource.snapshot().sectionIdentifiers[section]
switch section {
case .callLink, .admin:
return nil
case .members(.raisedHands):
return raisedHandsHeader
case .members(.inCall):
return inCallHeader
}
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section = dataSource.snapshot().sectionIdentifiers[section]
switch section {
case .callLink:
return HeightConstants.tableViewTopPadding
case .members, .admin:
return UITableView.automaticDimension
}
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return .leastNormalMagnitude
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let section = dataSource.snapshot().sectionIdentifiers[indexPath.section]
let row = dataSource.snapshot().itemIdentifiers(inSection: section)[indexPath.row]
switch row {
case .callLink(.share):
tableView.deselectRow(at: indexPath, animated: true)
self.shareCallLink()
case .callLink(.editName):
tableView.deselectRow(at: indexPath, animated: true)
self.editCallName()
case .member, .unknownMembers:
tableView.deselectRow(at: indexPath, animated: false)
}
}
private func shareCallLink() {
AssertIsOnMainThread()
guard let callLinkDataSource else {
owsFailDebug("Contains call link section without a call link data source")
return
}
let shareSheet = UIActivityViewController(
activityItems: [callLinkDataSource.url()],
applicationActivities: nil
)
present(shareSheet, animated: true)
}
private func editCallName() {
guard let callLinkAdminManager else {
owsFailDebug("Contains call admin section without a call link admin manager")
return
}
EditCallLinkNameViewController(
oldName: callLinkAdminManager.callLinkState?.name ?? "",
setNewName: { name in
try await callLinkAdminManager.updateName(name)
}
).presentInNavController(from: self, forceDarkMode: true)
}
}
// MARK: GroupCallMemberCellDelegate
extension CallDrawerSheet: GroupCallMemberCellDelegate {
func raiseHand(raise: Bool) {
callSheetDataSource.raiseHand(raise: raise)
}
func removeMember(demuxId: DemuxId) {
guard let callLinkDataSource else {
return owsFailDebug("Missing call link data source")
}
guard let name = viewModelsByID[.demuxID(demuxId)]?.name else {
return owsFailDebug("Missing view model for demux ID")
}
let actionSheet = ActionSheetController(
title: String(
format: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_TITLE",
comment: "Title for action sheet confirming removal of a member from a group call. embeds {{ name }}"
),
name
),
theme: .translucentDark
)
actionSheet.addAction(.init(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_REMOVE_ACTION",
comment: "Label for the button to confirm removing a member from a group call."
)
) { [callLinkDataSource] _ in
callLinkDataSource.removeMember(demuxId: demuxId)
})
actionSheet.addAction(.init(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_BLOCK_ACTION",
comment: "Label for a button to block a member from a group call."
)
) { [callLinkDataSource] _ in
callLinkDataSource.blockMember(demuxId: demuxId)
})
actionSheet.addAction(.cancel)
self.presentActionSheet(actionSheet)
}
}
// MARK: CallObserver
extension CallDrawerSheet: CallDrawerSheetDataSourceObserver {
func callSheetMembershipDidChange(_ dataSource: CallDrawerSheetDataSource) {
AssertIsOnMainThread()
updateMembers()
}
func callSheetRaisedHandsDidChange(_ dataSource: CallDrawerSheetDataSource) {
AssertIsOnMainThread()
updateSnapshotAndHeaders()
}
}
extension CallDrawerSheet: EmojiPickerSheetPresenter {
func present(sheet: EmojiPickerSheet, animated: Bool) {
self.present(sheet, animated: animated)
}
}
extension CallDrawerSheet {
func isPresentingCallControls() -> Bool {
return self.presentingViewController != nil && callControls.alpha == 1
}
func isPresentingCallInfo() -> Bool {
return self.presentingViewController != nil && tableViewContainer.alpha == 1
}
func isCrossFading() -> Bool {
return self.presentingViewController != nil && callControls.alpha < 1 && tableView.alpha < 1
}
}
// MARK: - CallLinkURLCell
private class CallLinkURLCell: UITableViewCell, ReusableTableViewCell {
static var reuseIdentifier = "CallLinkURLCell"
static let iconBackgroundSize: CGFloat = 36
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let hStack = UIStackView()
hStack.axis = .horizontal
hStack.spacing = 12
let iconBackground = UIView()
hStack.addArrangedSubview(iconBackground)
iconBackground.backgroundColor = .ows_gray60
iconBackground.autoSetDimensions(to: .square(Self.iconBackgroundSize))
iconBackground.layer.cornerRadius = Self.iconBackgroundSize / 2
let icon = UIImageView(image: Theme.iconImage(.buttonLink))
iconBackground.addSubview(icon)
icon.tintColor = .white
icon.autoCenterInSuperview()
let titleLabel = UILabel()
hStack.addArrangedSubview(titleLabel)
titleLabel.font = .dynamicTypeBody
titleLabel.textColor = .white
titleLabel.text = OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_SHARE_CALL_LINK_BUTTON",
comment: "Title for a button on the group members sheet for sharing that call's link."
)
self.contentView.addSubview(hStack)
hStack.autoPinWidthToSuperviewMargins()
hStack.autoPinHeightToSuperview(withMargin: 7)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - GroupCallMemberCell
protocol GroupCallMemberCellDelegate: AnyObject {
func raiseHand(raise: Bool)
func removeMember(demuxId: DemuxId)
}
private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
// MARK: ViewModel
class ViewModel {
typealias Member = CallDrawerSheet.JoinedMember
let aci: Aci
let name: String
let isLocalUser: Bool
let demuxId: DemuxId?
@Published var shouldShowAudioMutedIcon = false
@Published var shouldShowVideoMutedIcon = false
@Published var shouldShowPresentingIcon = false
init(member: Member) {
self.aci = member.aci
self.name = member.displayName
self.isLocalUser = member.isLocalUser
self.demuxId = member.demuxID
self.update(using: member)
}
func update(using member: Member) {
owsAssertDebug(aci == member.aci)
self.shouldShowAudioMutedIcon = member.isAudioMuted ?? false
self.shouldShowVideoMutedIcon = member.isVideoMuted == true && member.isPresenting != true
self.shouldShowPresentingIcon = member.isPresenting ?? false
}
}
// MARK: Properties
static let reuseIdentifier = "GroupCallMemberCell"
weak var delegate: GroupCallMemberCellDelegate?
private let avatarView = ConversationAvatarView(
sizeClass: .thirtySix,
localUserDisplayMode: .asUser,
badged: false
)
private let nameLabel = UILabel()
private lazy var lowerHandButton = OWSButton(
title: CallStrings.lowerHandButton,
tintColor: .ows_white,
dimsWhenHighlighted: true
) { [weak self] in
self?.delegate?.raiseHand(raise: false)
}
private var demuxId: DemuxId?
private lazy var removeUserButton: OWSButton = {
let button = OWSButton { [weak self] in
guard let self, let demuxId else { return }
self.delegate?.removeMember(demuxId: demuxId)
}
button.setAttributedTitle(
SignalSymbol.minusCircle.attributedString(
dynamicTypeBaseSize: 24,
weight: .light,
attributes: [.foregroundColor: UIColor.Signal.label]
),
for: .normal
)
button.dimsWhenHighlighted = true
return button
}()
private let leadingWrapper = UIView()
private let videoMutedIndicator = UIImageView()
private let presentingIndicator = UIImageView()
private let audioMutedIndicator = UIImageView()
private let raisedHandIndicator = UIImageView()
private var subscriptions = Set<AnyCancellable>()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
nameLabel.textColor = Theme.darkThemePrimaryColor
nameLabel.font = .dynamicTypeBody
lowerHandButton.titleLabel?.font = .dynamicTypeBody
func setup(iconView: UIImageView, withImageNamed imageName: String, in wrapper: UIView) {
iconView.setTemplateImageName(imageName, tintColor: Theme.darkThemeSecondaryTextAndIconColor)
wrapper.addSubview(iconView)
iconView.autoPinEdgesToSuperviewEdges()
iconView.setCompressionResistanceHorizontalHigh()
iconView.setContentHuggingHorizontalHigh()
}
let trailingWrapper = UIView()
setup(iconView: audioMutedIndicator, withImageNamed: "mic-slash", in: trailingWrapper)
setup(iconView: raisedHandIndicator, withImageNamed: Theme.iconName(.raiseHand), in: trailingWrapper)
setup(iconView: videoMutedIndicator, withImageNamed: "video-slash", in: leadingWrapper)
setup(iconView: presentingIndicator, withImageNamed: "share_screen", in: leadingWrapper)
let stackView = UIStackView(arrangedSubviews: [
avatarView,
nameLabel,
lowerHandButton,
leadingWrapper,
trailingWrapper,
removeUserButton,
])
stackView.axis = .horizontal
stackView.alignment = .center
contentView.addSubview(stackView)
stackView.autoPinWidthToSuperviewMargins()
stackView.autoPinHeightToSuperview(withMargin: 7)
stackView.spacing = 16
stackView.setCustomSpacing(12, after: avatarView)
stackView.setCustomSpacing(8, after: nameLabel)
nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
nameLabel.setContentHuggingHorizontalLow()
nameLabel.setCompressionResistanceHorizontalLow()
[leadingWrapper, trailingWrapper, removeUserButton, lowerHandButton]
.forEach {
$0.setContentHuggingHorizontalHigh()
$0.setCompressionResistanceHorizontalHigh()
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Configuration
enum Visibility {
case visible, spaceReserved, hidden
}
// isHandRaised isn't part of ViewModel because the same view model is used
// for any given member in both the members and raised hand sections.
func configure(
with viewModel: ViewModel,
isHandRaised: Bool,
removeUserButtonVisibility: Visibility
) {
self.subscriptions.removeAll()
if isHandRaised {
self.raisedHandIndicator.isHidden = false
self.lowerHandButton.isHiddenInStackView = !viewModel.isLocalUser
self.audioMutedIndicator.isHidden = true
self.leadingWrapper.isHiddenInStackView = true
} else {
self.raisedHandIndicator.isHidden = true
self.lowerHandButton.isHiddenInStackView = true
self.leadingWrapper.isHiddenInStackView = false
self.subscribe(to: viewModel.$shouldShowAudioMutedIcon, showing: self.audioMutedIndicator)
self.subscribe(to: viewModel.$shouldShowVideoMutedIcon, showing: self.videoMutedIndicator)
self.subscribe(to: viewModel.$shouldShowPresentingIcon, showing: self.presentingIndicator)
}
self.nameLabel.text = viewModel.name
self.avatarView.updateWithSneakyTransactionIfNecessary { config in
config.dataSource = .address(SignalServiceAddress(viewModel.aci))
}
self.demuxId = viewModel.demuxId
switch removeUserButtonVisibility {
case .visible:
self.removeUserButton.isHiddenInStackView = false
self.removeUserButton.layer.opacity = 1
case .spaceReserved:
self.removeUserButton.isHiddenInStackView = false
self.removeUserButton.layer.opacity = 0
case .hidden:
self.removeUserButton.isHiddenInStackView = true
}
}
func hideContent() {
self.raisedHandIndicator.isHidden = true
self.lowerHandButton.isHiddenInStackView = true
self.audioMutedIndicator.isHidden = true
self.leadingWrapper.isHiddenInStackView = true
self.removeUserButton.isHiddenInStackView = true
}
private func subscribe(to publisher: Published<Bool>.Publisher, showing view: UIView) {
publisher
.removeDuplicates()
.sink { [weak view] shouldShow in
view?.isHidden = !shouldShow
}
.store(in: &self.subscriptions)
}
}
// MARK: - UnknownMembersCell
private class UnknownMembersCell: UITableViewCell, ReusableTableViewCell {
static let reuseIdentifier: String = "UnknownMembersCell"
typealias UnknownMembers = CallDrawerSheet.UnknownMembers
private enum Constants {
static let borderWidth: CGFloat = 1.75
static let singleAvatarSize: UInt = 36
static let twoAvatarsSize: UInt = 31
static let threeAvatarsSize: UInt = 27
}
weak var parentViewController: UIViewController?
var unknownMembers = UnknownMembers() {
didSet {
updateContents()
}
}
private let bodyLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeBody
label.textColor = UIColor.Signal.label
return label
}()
private let avatarContainer = UIView()
private let frontAvatar: Avatar
private let middleAvatar: Avatar
private let backAvatar: Avatar
private struct Avatar {
let avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false)
let borderView = CircleView()
let borderConstraints: [NSLayoutConstraint]
enum Position {
case front, middle, back
}
init(in containerView: UIView, position: Position) {
containerView.addSubview(borderView)
borderView.backgroundColor = .secondarySystemGroupedBackground
borderConstraints = borderView.autoSetDimensions(to: .square(Self.borderSize(for: Constants.singleAvatarSize)))
containerView.addSubview(avatarView)
avatarView.autoVCenterInSuperview()
switch position {
case .front:
avatarView.autoPinEdge(toSuperviewEdge: .trailing)
avatarView.autoPinEdge(toSuperviewEdge: .leading, relation: .greaterThanOrEqual)
case .middle:
avatarView.autoHCenterInSuperview()
case .back:
avatarView.autoPinEdge(toSuperviewEdge: .leading)
avatarView.autoPinEdge(toSuperviewEdge: .trailing, relation: .greaterThanOrEqual)
}
avatarView.updateWithSneakyTransactionIfNecessary { configuration in
configuration.sizeClass = .customDiameter(Constants.singleAvatarSize)
configuration.dataSource = nil
}
borderView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView)
borderView.autoAlignAxis(.vertical, toSameAxisOf: avatarView)
borderView.isHiddenInStackView = true
avatarView.isHiddenInStackView = true
}
func configure(with aci: Aci?, totalAvatars: Int) {
guard let aci else {
borderView.isHiddenInStackView = true
avatarView.isHiddenInStackView = true
return
}
borderView.isHiddenInStackView = false
avatarView.isHiddenInStackView = false
avatarView.updateWithSneakyTransactionIfNecessary { configuration in
configuration.dataSource = .address(SignalServiceAddress(aci))
let avatarSize = Self.avatarSize(for: totalAvatars)
configuration.sizeClass = .customDiameter(avatarSize)
let borderSize = Self.borderSize(for: avatarSize)
self.borderConstraints.forEach { $0.constant = borderSize }
}
}
func hide() {
borderView.isHiddenInStackView = true
avatarView.isHiddenInStackView = true
}
private static func avatarSize(for totalAvatars: Int) -> UInt {
if totalAvatars == 1 {
Constants.singleAvatarSize
} else if totalAvatars == 2 {
Constants.twoAvatarsSize
} else {
Constants.threeAvatarsSize
}
}
private static func borderSize(for avatarSize: UInt) -> CGFloat {
CGFloat(avatarSize) + Constants.borderWidth * 2
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
backAvatar = Avatar(in: avatarContainer, position: .back)
middleAvatar = Avatar(in: avatarContainer, position: .middle)
frontAvatar = Avatar(in: avatarContainer, position: .front)
super.init(style: style, reuseIdentifier: reuseIdentifier)
let hStack = UIStackView()
self.contentView.addSubview(hStack)
hStack.autoPinWidthToSuperviewMargins()
hStack.autoPinHeightToSuperview(withMargin: 7)
hStack.axis = .horizontal
hStack.spacing = 12
hStack.addArrangedSubview(avatarContainer)
avatarContainer.autoSetDimensions(to: .square(CGFloat(Constants.singleAvatarSize)))
hStack.addArrangedSubview(bodyLabel)
let infoButton = OWSButton(
imageName: "info",
tintColor: .white,
dimsWhenHighlighted: true
) { [weak self] in
let actionSheet = ActionSheetController(
message: OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_UNKNOWN_MEMBERS_INFO_SHEET",
comment: "Message on an action sheet when tapping an info button next to unknown members in the group call member list."
),
theme: .translucentDark
)
actionSheet.addAction(.acknowledge)
self?.parentViewController?.presentActionSheet(actionSheet)
}
hStack.addArrangedSubview(infoButton)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateContents() {
if unknownMembers.count == 1, !unknownMembers.callHasKnownUsers {
bodyLabel.text = OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_SINGLE_UNKNOWN_MEMBER_ROW",
comment: "Label for an unknown member in the group call member list when they are the only member of the call."
)
} else {
bodyLabel.text = String(
format: OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_UNKNOWN_MEMBERS_ROW_%ld",
tableName: "PluralAware",
comment: "Label for one or more unknown members in the group call member list when there is at least one known member in the call. Embeds {{ count }}"
),
unknownMembers.count
)
}
frontAvatar.configure(
with: unknownMembers.members.first?.aci,
totalAvatars: unknownMembers.members.count
)
if unknownMembers.members.count == 2 {
middleAvatar.hide()
backAvatar.configure(
with: unknownMembers.members.last?.aci,
totalAvatars: unknownMembers.members.count
)
} else {
middleAvatar.configure(
with: unknownMembers.members[safe: 1]?.aci,
totalAvatars: unknownMembers.members.count
)
backAvatar.configure(
with: unknownMembers.members[safe: 2]?.aci,
totalAvatars: unknownMembers.members.count
)
}
}
}
// MARK: - CallControlsHeightObserver
extension CallDrawerSheet: CallControlsHeightObserver {
func callControlsHeightDidChange(newHeight: CGFloat) {
self.cancelAnimationAndUpdateConstraints()
self.animate {
self.setBottomSheetMinimizedHeight()
self.view.layoutIfNeeded()
}
}
open override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
self.setBottomSheetMinimizedHeight()
}
private var bottomPadding: CGFloat {
max(self.view.safeAreaInsets.bottom + HeightConstants.bottomPadding, HeightConstants.minimumBottomPaddingIncludingSafeArea)
}
private enum HeightConstants {
static let bottomPadding: CGFloat = 14
static let minimumBottomPaddingIncludingSafeArea: CGFloat = 30
static let initialTableInset: CGFloat = 25
static let titleViewBottomPadding: CGFloat = 16
static let tableViewTopPadding: CGFloat = 8
}
}