session-ios/Session/Calls/UserInterface/CallHeader.swift

344 lines
13 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalRingRTC
@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 let callTitleLabel = MarqueeLabel()
private let callStatusLabel = UILabel()
private let groupMembersButton = GroupMembersButton()
private let call: SignalCall
private weak var delegate: CallHeaderDelegate!
init(call: SignalCall, delegate: CallHeaderDelegate) {
self.call = call
self.delegate = delegate
super.init(frame: .zero)
call.addObserverAndSyncState(observer: self)
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor.black.withAlphaComponent(0.6).cgColor,
UIColor.black.withAlphaComponent(0).cgColor
]
let gradientView = OWSLayerView(frame: .zero) { view in
gradientLayer.frame = view.bounds
}
gradientView.layer.addSublayer(gradientLayer)
addSubview(gradientView)
gradientView.autoPinEdgesToSuperviewEdges()
let hStack = UIStackView()
hStack.axis = .horizontal
hStack.spacing = 13
hStack.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
hStack.isLayoutMarginsRelativeArrangement = true
addSubview(hStack)
hStack.autoPinWidthToSuperview()
hStack.autoPinEdge(toSuperviewMargin: .top)
hStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: 46)
// Back button
let backButton = UIButton()
let backButtonImage = CurrentAppContext().isRTL ? #imageLiteral(resourceName: "NavBarBackRTL") : #imageLiteral(resourceName: "NavBarBack")
backButton.setTemplateImage(backButtonImage, tintColor: .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)
hStack.addArrangedSubview(backButton)
// vStack
let vStack = UIStackView()
vStack.axis = .vertical
vStack.spacing = 4
hStack.addArrangedSubview(vStack)
// 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.0)
callTitleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
callTitleLabel.textAlignment = .center
callTitleLabel.textColor = UIColor.white
addShadow(to: callTitleLabel)
vStack.addArrangedSubview(callTitleLabel)
// Status label
callStatusLabel.font = UIFont.ows_dynamicTypeFootnoteClamped
callStatusLabel.textAlignment = .center
callStatusLabel.textColor = UIColor.white
addShadow(to: callStatusLabel)
vStack.addArrangedSubview(callStatusLabel)
// Group members button
groupMembersButton.addTarget(
delegate,
action: #selector(CallHeaderDelegate.didTapMembersButton),
for: .touchUpInside
)
addShadow(to: groupMembersButton)
hStack.addArrangedSubview(groupMembersButton)
updateCallTitleLabel()
updateCallStatusLabel()
updateGroupMembersButton()
}
deinit { call.removeObserver(self) }
private func addShadow(to view: UIView) {
view.layer.shadowOffset = .zero
view.layer.shadowOpacity = 0.25
view.layer.shadowRadius = 4
}
private func updateCallStatusLabel() {
let callStatusText: String
switch call.groupCall.localDeviceState.joinState {
case .notJoined, .joining:
callStatusText = ""
case .joined:
let callDuration = call.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[formattedDate.index(formattedDate.startIndex, offsetBy: 3)...])
} else {
// If showing the "hours" portion of the date format, strip any leading
// zeroes.
if formattedDate.hasPrefix("0") {
formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 1)...])
}
}
callStatusText = String(format: CallStrings.callStatusFormat, formattedDate)
}
callStatusLabel.text = callStatusText
callStatusLabel.isHidden = call.groupCall.localDeviceState.joinState != .joined || call.groupCall.remoteDeviceStates.count > 1
}
func updateCallTitleLabel() {
let callTitleText: String
if call.groupCall.localDeviceState.connectionState == .reconnecting {
callTitleText = NSLocalizedString(
"GROUP_CALL_RECONNECTING",
comment: "Text indicating that the user has lost their connection to the call and we are reconnecting."
)
} else {
var isFirstMemberPresenting = false
let memberNames: [String] = databaseStorage.read { transaction in
if self.call.groupCall.localDeviceState.joinState == .joined {
let sortedDeviceStates = self.call.groupCall.remoteDeviceStates.sortedByAddedTime
isFirstMemberPresenting = sortedDeviceStates.first?.presenting == true
return sortedDeviceStates.map { self.contactsManager.displayName(for: $0.address, transaction: transaction) }
} else {
return self.call.groupCall.peekInfo?.joinedMembers
.map { self.contactsManager.displayName(for: SignalServiceAddress(uuid: $0), transaction: transaction) } ?? []
}
}
switch call.groupCall.localDeviceState.joinState {
case .joined:
switch memberNames.count {
case 0:
callTitleText = NSLocalizedString(
"GROUP_CALL_NO_ONE_HERE",
comment: "Text explaining that you are the only person currently in the group call"
)
case 1:
if isFirstMemberPresenting {
let formatString = NSLocalizedString(
"GROUP_CALL_PRESENTING_FORMAT",
comment: "Text explaining that a member is presenting. Embeds {member name}"
)
callTitleText = String(format: formatString, memberNames[0])
} else {
callTitleText = memberNames[0]
}
default:
if isFirstMemberPresenting {
let formatString = NSLocalizedString(
"GROUP_CALL_PRESENTING_FORMAT",
comment: "Text explaining that a member is presenting. Embeds {member name}"
)
callTitleText = String(format: formatString, memberNames[0])
} else {
callTitleText = ""
}
}
default:
switch memberNames.count {
case 0:
callTitleText = ""
case 1:
let formatString = NSLocalizedString(
"GROUP_CALL_ONE_PERSON_HERE_FORMAT",
comment: "Text explaining that there is one person in the group call. Embeds {member name}"
)
callTitleText = String(format: formatString, memberNames[0])
case 2:
let formatString = NSLocalizedString(
"GROUP_CALL_TWO_PEOPLE_HERE_FORMAT",
comment: "Text explaining that there are two people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
)
callTitleText = String(format: formatString, memberNames[0], memberNames[1])
case 3:
let formatString = NSLocalizedString(
"GROUP_CALL_THREE_PEOPLE_HERE_FORMAT",
comment: "Text explaining that there are three people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2 }}"
)
callTitleText = String(format: formatString, memberNames[0], memberNames[1])
default:
let formatString = NSLocalizedString(
"GROUP_CALL_MANY_PEOPLE_HERE_FORMAT",
comment: "Text explaining that there are more than three people in the group call. Embeds {{ %1$@ participant1, %2$@ participant2, %3$@ participantCount-2 }}"
)
callTitleText = String(format: formatString, memberNames[0], memberNames[1], OWSFormat.formatInt(memberNames.count - 2))
}
}
}
callTitleLabel.text = callTitleText
callTitleLabel.isHidden = callTitleText.isEmpty
}
func updateGroupMembersButton() {
let isJoined = call.groupCall.localDeviceState.joinState == .joined
let remoteMemberCount = isJoined ? call.groupCall.remoteDeviceStates.count : Int(call.groupCall.peekInfo?.deviceCount ?? 0)
groupMembersButton.updateMemberCount(remoteMemberCount + (isJoined ? 1 : 0))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension CallHeader: CallObserver {
func groupCallLocalDeviceStateChanged(_ call: SignalCall) {
owsAssertDebug(call.isGroupCall)
if call.groupCall.localDeviceState.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
}
updateCallTitleLabel()
updateCallStatusLabel()
updateGroupMembersButton()
}
func groupCallPeekChanged(_ call: SignalCall) {
updateCallTitleLabel()
updateGroupMembersButton()
}
func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) {
updateCallTitleLabel()
updateGroupMembersButton()
}
func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) {
callDurationTimer?.invalidate()
callDurationTimer = nil
updateCallTitleLabel()
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-solid-24"), tintColor: .ows_white)
addSubview(iconImageView)
iconImageView.autoPinEdge(toSuperviewEdge: .leading)
iconImageView.autoSetDimensions(to: CGSize(square: 22))
iconImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 2)
countLabel.font = .systemFont(ofSize: Values.mediumFontSize)
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))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? 0.5 : 1
}
}
}