// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalServiceKit import SignalRingRTC import SignalUI import UIKit @objc protocol CallHeaderDelegate: AnyObject { func didTapBackButton() func didTapMembersButton() } class CallHeader: UIView { // MARK: - Views private lazy var dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" dateFormatter.timeZone = TimeZone(identifier: "UTC")! dateFormatter.locale = Locale(identifier: "en_US") return dateFormatter }() private var callDurationTimer: Timer? private lazy var gradientView: UIView = { let gradientLayer = CAGradientLayer() gradientLayer.colors = [ UIColor.ows_blackAlpha60.cgColor, UIColor.black.withAlphaComponent(0).cgColor ] let view = OWSLayerView(frame: .zero) { view in gradientLayer.frame = view.bounds } view.layer.addSublayer(gradientLayer) return view }() private var avatarView: UIView? private let callTitleLabel = MarqueeLabel() private let callStatusLabel = UILabel() private lazy var groupMembersButton = GroupMembersButton() private let groupCall: GroupCall private let ringRtcCall: SignalRingRTC.GroupCall private weak var delegate: CallHeaderDelegate! init(groupCall: GroupCall, delegate: CallHeaderDelegate) { self.groupCall = groupCall self.ringRtcCall = groupCall.ringRtcCall self.delegate = delegate super.init(frame: .zero) addSubview(gradientView) gradientView.autoPinEdgesToSuperviewEdges() // Back button let backButton = UIButton() backButton.setTemplateImage(UIImage(imageLiteralResourceName: "NavBarBack"), tintColor: .ows_white) backButton.autoSetDimensions(to: CGSize(square: 40)) backButton.imageEdgeInsets = UIEdgeInsets(top: -12, leading: -18, bottom: 0, trailing: 0) backButton.addTarget(delegate, action: #selector(CallHeaderDelegate.didTapBackButton), for: .touchUpInside) addShadow(to: backButton) addSubview(backButton) backButton.autoPinLeadingToSuperviewMargin(withInset: 8) backButton.autoPinTopToSuperviewMargin() // Group members button let topRightButton: UIButton switch groupCall.concreteType { case .groupThread: groupMembersButton.addTarget( delegate, action: #selector(CallHeaderDelegate.didTapMembersButton), for: .touchUpInside ) topRightButton = groupMembersButton case .callLink: // [CallLink] TODO: Add support for the Info button. topRightButton = UIButton(type: .infoLight) } addShadow(to: topRightButton) addSubview(topRightButton) topRightButton.autoPinTrailingToSuperviewMargin(withInset: 8) topRightButton.autoPinTopToSuperviewMargin() // vStack let vStack = UIStackView() vStack.axis = .vertical vStack.alignment = .center vStack.spacing = 8 vStack.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0) // This view doesn't contain any interactable content, put it as low as possible. insertSubview(vStack, aboveSubview: gradientView) vStack.autoPinEdgesToSuperviewMargins() // Avatar switch groupCall.concreteType { case .groupThread(let groupThreadCall): let avatarView = ConversationAvatarView( sizeClass: .customDiameter(96), localUserDisplayMode: .asLocalUser, badged: false ) avatarView.updateWithSneakyTransactionIfNecessary { $0.dataSource = .thread(groupThreadCall.groupThread) } let avatarPaddingView = UIView() avatarPaddingView.addSubview(avatarView) avatarView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top) vStack.addArrangedSubview(avatarPaddingView) vStack.setCustomSpacing(16, after: avatarPaddingView) self.avatarView = avatarView case .callLink: break } // Name Label callTitleLabel.type = .continuous // This feels pretty slow when you're initially waiting for it, but when you're overlaying video calls, anything faster is distracting. callTitleLabel.speed = .duration(30.0) callTitleLabel.animationCurve = .linear callTitleLabel.fadeLength = 10.0 callTitleLabel.animationDelay = 5 // Add trailing space after the name scrolls before it wraps around and scrolls back in. callTitleLabel.trailingBuffer = .scaleFromIPhone5(80) callTitleLabel.font = UIFont.dynamicTypeHeadline.semibold() callTitleLabel.textAlignment = .center callTitleLabel.textColor = UIColor.white addShadow(to: callTitleLabel) #if TESTABLE_BUILD // For debugging purposes, make it easy to force the call to disconnect. callTitleLabel.addGestureRecognizer( UILongPressGestureRecognizer(target: self, action: #selector(injectDisconnect)) ) callTitleLabel.isUserInteractionEnabled = true #endif vStack.addArrangedSubview(callTitleLabel) // Make the title view as wide as possible, but don't overlap either button. // This gets combined with the vStack's centered alignment, so we won't ever get an unbalanced title label. NSLayoutConstraint.autoSetPriority(.defaultHigh) { callTitleLabel.autoPinWidthToSuperview() } callTitleLabel.autoPinEdge(.leading, to: .trailing, of: backButton, withOffset: 13, relation: .greaterThanOrEqual) callTitleLabel.autoPinEdge(.trailing, to: .leading, of: topRightButton, withOffset: -13, relation: .lessThanOrEqual) // Status label callStatusLabel.font = UIFont.dynamicTypeFootnote.monospaced() callStatusLabel.textAlignment = .center callStatusLabel.textColor = UIColor.white callStatusLabel.numberOfLines = 0 // Cut off the status lines before cutting off anything else. callStatusLabel.setContentCompressionResistancePriority(.defaultLow + 1, for: .vertical) // But always fit at least one line, with moderate descenders. callStatusLabel.autoSetDimension(.height, toSize: callStatusLabel.font.lineHeight * 1.2, relation: .greaterThanOrEqual) addShadow(to: callStatusLabel) vStack.addArrangedSubview(callStatusLabel) groupCall.addObserverAndSyncState(self) } override func didMoveToSuperview() { guard let superview = self.superview else { return } // The bottom of the avatar must be no more than 25% down the screen. // This constraint is on the avatar view container rather than the full view // because the label may change its number of lines, // and we don't want that to affect the vertical position. avatarView?.autoMatch(.height, to: .height, of: superview, withMultiplier: 0.25) } private func addShadow(to view: UIView) { view.layer.shadowOffset = .zero view.layer.shadowOpacity = 0.25 view.layer.shadowRadius = 4 } private func describeMembers( count: Int, names: [String], zeroMemberString: @autoclosure () -> String, oneMemberFormat: @autoclosure () -> String, twoMemberFormat: @autoclosure () -> String, manyMemberFormat: @autoclosure () -> String ) -> String { switch count { case 0: return zeroMemberString() case 1: return String(format: oneMemberFormat(), names[0]) case 2: return String(format: twoMemberFormat(), names[0], names[1]) default: return String.localizedStringWithFormat(manyMemberFormat(), count - 2, names[0], names[1]) } } private func fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: GroupThreadCall) -> (Int, [String]) { let groupThread = groupThreadCall.groupThread return databaseStorage.read { transaction in // FIXME: Register for notifications so we can update if someone leaves the group while the screen is up? let firstTwoNames = groupThread.sortedMemberNames( includingBlocked: false, limit: 2, useShortNameIfAvailable: true, transaction: transaction ) if firstTwoNames.count < 2 { return (firstTwoNames.count, firstTwoNames) } let count = groupThread.groupMembership.fullMembers.lazy.filter { !$0.isLocalAddress && !self.blockingManager.isAddressBlocked($0, transaction: transaction) }.count return (count, firstTwoNames) } } private func updateCallStatusLabel() { callStatusLabel.text = self.callStatusLabelText() } private func callStatusLabelText() -> String { switch ringRtcCall.localDeviceState.joinState { case .notJoined, .joining: switch groupCall.concreteType { case .groupThread(let groupThreadCall): if case .incomingRing(let caller, _) = groupThreadCall.groupCallRingState { return incomingRingText(caller: caller) } if let joinedMembers = ringRtcCall.peekInfo?.joinedMembers, !joinedMembers.isEmpty { return whoIsHereText(joinedMembers: joinedMembers) } if ringRtcCall.peekInfo == nil, groupThreadCall.ringRestrictions.contains(.callInProgress) { // If we think there might already be a call, don't show anything until we have proper peek info. return "" } if groupThreadCall.ringRestrictions.isEmpty, case .shouldRing = groupThreadCall.groupCallRingState { return willRingOthersText(groupThreadCall: groupThreadCall) } else { return willNotifyOthersText(groupThreadCall: groupThreadCall) } case .callLink: return whoIsHereText(joinedMembers: ringRtcCall.peekInfo?.joinedMembers ?? []) } case .pending: return OWSLocalizedString( "CALL_WAITING_TO_BE_LET_IN", comment: "Shown in the header below the name of the call while waiting for the host to allow you to enter the call." ) case .joined: if ringRtcCall.localDeviceState.connectionState == .reconnecting { return OWSLocalizedString( "GROUP_CALL_RECONNECTING", comment: "Text indicating that the user has lost their connection to the call and we are reconnecting." ) } switch groupCall.concreteType { case .groupThread(let groupThreadCall): if !ringRtcCall.remoteDeviceStates.isEmpty { return callDurationText() } if case .ringing = groupThreadCall.groupCallRingState { return ringingOthersText(groupThreadCall: groupThreadCall) } case .callLink: guard let peekInfo = ringRtcCall.peekInfo else { return "" } if !peekInfo.pendingUsers.isEmpty { return howManyAreWaitingText(count: peekInfo.pendingUsers.count) } if peekInfo.deviceCountExcludingPendingDevices > 0 { return howManyAreHereText(count: Int(peekInfo.deviceCountExcludingPendingDevices)) } } return noOneElseIsHereText() } } private func incomingRingText(caller: SignalServiceAddress) -> String { let callerName = databaseStorage.read { transaction in contactsManager.displayName(for: caller, tx: transaction).resolvedValue(useShortNameIfAvailable: true) } let formatString = OWSLocalizedString( "GROUP_CALL_INCOMING_RING_FORMAT", comment: "Text explaining that someone has sent a ring to the group. Embeds {ring sender name}" ) return String(format: formatString, callerName) } private func whoIsHereText(joinedMembers: [UUID]) -> String { if joinedMembers.isEmpty { return noOneElseIsHereText() } let upToTwoKnownMemberNames: [String] = databaseStorage.read { tx -> [String] in joinedMembers .lazy .map { [contactsManager] in contactsManager.displayName(for: SignalServiceAddress(Aci(fromUUID: $0)), tx: tx) } .filter { $0.hasKnownValue } .prefix(2) .map { $0.resolvedValue(useShortNameIfAvailable: true) } } let noneOneOrBothKnownMemberNames: (String, String?)? = ( upToTwoKnownMemberNames.first.map { ($0, upToTwoKnownMemberNames.dropFirst().first) } ) let otherOrUnknownMemberCount = joinedMembers.count - upToTwoKnownMemberNames.count switch (noneOneOrBothKnownMemberNames, otherOrUnknownMemberCount) { case ((let someMember, nil)?, 0): // exactly one member, known let format = OWSLocalizedString( "GROUP_CALL_ONE_PERSON_HERE_FORMAT", comment: "Text explaining that there is one person in the group call. Embeds {member name}" ) return String(format: format, someMember) case ((let someMember, let someOtherMember?)?, 0): // exactly two members, both known let format = OWSLocalizedString( "GROUP_CALL_TWO_PEOPLE_HERE_FORMAT", comment: "Text explaining that there are two people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}" ) return String(format: format, someMember, someOtherMember) case ((let someMember, let someOtherMember?)?, let otherCount): // two or more members, at least two known let format = OWSLocalizedString( "GROUP_CALL_MANY_PEOPLE_HERE_%d", tableName: "PluralAware", comment: "Text explaining that there are three or more people in the group call. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}" ) return String.localizedStringWithFormat(format, otherCount, someMember, someOtherMember) case ((let someMember, nil)?, let unknownCount): // two or more members, only one known let format = OWSLocalizedString( "GROUP_CALL_ONE_KNOWN_AND_MANY_OTHERS_HERE", tableName: "PluralAware", comment: "Text explaining that there is at least one person whose name is known in the call as well as others whose names may or may not be known." ) return String.localizedStringWithFormat(format, unknownCount, someMember) case (nil, let unknownCount): // no known members let format = OWSLocalizedString( "GROUP_CALL_MANY_OTHERS_HERE", tableName: "PluralAware", comment: "Text explaining that there are people in the call whose names we don't know. The argument is how many people are in the call." ) return String.localizedStringWithFormat(format, unknownCount) } } private func willRingOthersText(groupThreadCall: GroupThreadCall) -> String { let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall) return describeMembers( count: memberCount, names: firstTwoNames, zeroMemberString: "", oneMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_RING_ONE_PERSON_FORMAT", comment: "Text shown before the user starts a group call if the user has enabled ringing and there is one other person in the group. Embeds {member name}" ), twoMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_RING_TWO_PEOPLE_FORMAT", comment: "Text shown before the user starts a group call if the user has enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}" ), manyMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_RING_MANY_PEOPLE_%d", tableName: "PluralAware", comment: "Text shown before the user starts a group call if the user has enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}" ) ) } private func willNotifyOthersText(groupThreadCall: GroupThreadCall) -> String { let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall) return describeMembers( count: memberCount, names: firstTwoNames, zeroMemberString: "", oneMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_NOTIFY_ONE_PERSON_FORMAT", comment: "Text shown before the user starts a group call if the user has not enabled ringing and there is one other person in the group. Embeds {member name}" ), twoMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_NOTIFY_TWO_PEOPLE_FORMAT", comment: "Text shown before the user starts a group call if the user has not enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}" ), manyMemberFormat: OWSLocalizedString( "GROUP_CALL_WILL_NOTIFY_MANY_PEOPLE_%d", tableName: "PluralAware", comment: "Text shown before the user starts a group call if the user has not enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}" ) ) } private func ringingOthersText(groupThreadCall: GroupThreadCall) -> String { let (memberCount, firstTwoNames) = fetchGroupSizeAndMemberNamesWithSneakyTransaction(groupThreadCall: groupThreadCall) return describeMembers( count: memberCount, names: firstTwoNames, zeroMemberString: "", oneMemberFormat: OWSLocalizedString( "GROUP_CALL_IS_RINGING_ONE_PERSON_FORMAT", comment: "Text shown before the user starts a group call if the user has enabled ringing and there is one other person in the group. Embeds {member name}" ), twoMemberFormat: OWSLocalizedString( "GROUP_CALL_IS_RINGING_TWO_PEOPLE_FORMAT", comment: "Text shown before the user starts a group call if the user has enabled ringing and there are two other people in the group. Embeds {{ %1$@ participant1, %2$@ participant2 }}" ), manyMemberFormat: OWSLocalizedString( "GROUP_CALL_IS_RINGING_MANY_PEOPLE_%d", tableName: "PluralAware", comment: "Text shown before the user starts a group call if the user has enabled ringing and there are three or more other people in the group. Embeds {{ %1$@ participantCount-2, %2$@ participant1, %3$@ participant2 }}" ) ) } private func callDurationText() -> String { let callDuration = groupCall.commonState.connectionDuration() let callDurationDate = Date(timeIntervalSinceReferenceDate: callDuration) var formattedDate = dateFormatter.string(from: callDurationDate) if formattedDate.hasPrefix("00:") { // Don't show the "hours" portion of the date format unless the // call duration is at least 1 hour. formattedDate = String(formattedDate.dropFirst(3)) } else if formattedDate.hasPrefix("0") { // If showing the "hours" portion of the date format, strip any leading // zeroes. formattedDate = String(formattedDate.dropFirst(1)) } return formattedDate } private func noOneElseIsHereText() -> String { return OWSLocalizedString( "GROUP_CALL_NO_ONE_HERE", comment: "Text explaining that you are the only person currently in the group call" ) } private func howManyAreWaitingText(count: Int) -> String { let format = OWSLocalizedString( "CALL_PEOPLE_WAITING", tableName: "PluralAware", comment: "Text shown in the header of a call to indicate how many people have requested to join and need to be approved." ) return String.localizedStringWithFormat(format, count) } private func howManyAreHereText(count: Int) -> String { let format = OWSLocalizedString( "CALL_PEOPLE_HERE", tableName: "PluralAware", comment: "Text shown in the header of a call to indicate how many people are present." ) return String.localizedStringWithFormat(format, count) } private func updateCallTitleLabel() { callTitleLabel.text = self.callTitleLabelText() } private func callTitleLabelText() -> String { if ringRtcCall.localDeviceState.joinState == .joined, let firstMember = ringRtcCall.remoteDeviceStates.sortedBySpeakerTime.first, firstMember.presenting == true { let presentingName = databaseStorage.read { tx in contactsManager.displayName(for: SignalServiceAddress(Aci(fromUUID: firstMember.userId)), tx: tx).resolvedValue(useShortNameIfAvailable: true) } let formatString = OWSLocalizedString( "GROUP_CALL_PRESENTING_FORMAT", comment: "Text explaining that a member is presenting. Embeds {member name}" ) return String(format: formatString, presentingName) } switch groupCall.concreteType { case .groupThread(let groupThreadCall): // FIXME: This should auto-update if the group name changes. return databaseStorage.read { transaction in contactsManager.displayName(for: groupThreadCall.groupThread, transaction: transaction) } case .callLink(let call): return call.localizedName } } private func updateGroupMembersButton() { guard case .groupThread = groupCall.concreteType else { return } let isJoined = ringRtcCall.localDeviceState.joinState == .joined let remoteMemberCount = isJoined ? ringRtcCall.remoteDeviceStates.count : Int(ringRtcCall.peekInfo?.deviceCountExcludingPendingDevices ?? 0) groupMembersButton.updateMemberCount(remoteMemberCount + (isJoined ? 1 : 0)) } // For testing abnormal scenarios. @objc private func injectDisconnect() { ringRtcCall.disconnect() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension CallHeader: GroupCallObserver { func groupCallLocalDeviceStateChanged(_ call: GroupCall) { if case .groupThread = call.concreteType, call.joinState == .joined { if callDurationTimer == nil { let kDurationUpdateFrequencySeconds = 1 / 20.0 callDurationTimer = WeakTimer.scheduledTimer( timeInterval: TimeInterval(kDurationUpdateFrequencySeconds), target: self, userInfo: nil, repeats: true ) {[weak self] _ in self?.updateCallStatusLabel() } } } else { callDurationTimer?.invalidate() callDurationTimer = nil } if call.hasJoinedOrIsWaitingForAdminApproval { gradientView.isHidden = false avatarView?.isHidden = true // hide the container } else { gradientView.isHidden = true avatarView?.isHidden = false } updateCallStatusLabel() updateGroupMembersButton() } func groupCallPeekChanged(_ call: GroupCall) { updateCallStatusLabel() updateGroupMembersButton() } func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) { updateCallTitleLabel() updateCallStatusLabel() updateGroupMembersButton() } func groupCallEnded(_ call: GroupCall, reason: GroupCallEndReason) { callDurationTimer?.invalidate() callDurationTimer = nil updateCallStatusLabel() updateGroupMembersButton() } } private class GroupMembersButton: UIButton { private let iconImageView = UIImageView() private let countLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) autoSetDimension(.height, toSize: 40) iconImageView.contentMode = .scaleAspectFit iconImageView.setTemplateImage(#imageLiteral(resourceName: "group-fill"), tintColor: .ows_white) addSubview(iconImageView) iconImageView.autoPinEdge(toSuperviewEdge: .leading) iconImageView.autoSetDimensions(to: CGSize(square: 22)) iconImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 2) countLabel.font = UIFont.dynamicTypeFootnoteClamped.monospaced() countLabel.textColor = .ows_white addSubview(countLabel) countLabel.autoPinEdge(.leading, to: .trailing, of: iconImageView, withOffset: 5) countLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5) countLabel.autoAlignAxis(.horizontal, toSameAxisOf: iconImageView) countLabel.setContentHuggingHorizontalHigh() countLabel.setCompressionResistanceHorizontalHigh() } func updateMemberCount(_ count: Int) { countLabel.text = String(OWSFormat.formatInt(count)) self.isHidden = count == 0 } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var isHighlighted: Bool { didSet { alpha = isHighlighted ? 0.5 : 1 } } }