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

310 lines
11 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalUI
import SignalRingRTC
import SignalServiceKit
// MARK: - RaisedHandsToastDelegate
protocol RaisedHandsToastDelegate: AnyObject {
func didTapViewRaisedHands()
func raisedHandsToastDidChangeHeight()
}
// MARK: - RaisedHandsToast
class RaisedHandsToast: UIView {
// MARK: Properties
private enum Constants {
static let hMarginCollapsed: CGFloat = 16
static let hMarginExpanded: CGFloat = 12
static let vMargin: CGFloat = 12
}
struct Dependencies {
let db: SDSDatabaseStorage
let contactsManager: any ContactManager
}
private let deps = Dependencies(
db: SSKEnvironment.shared.databaseStorageRef,
contactsManager: SSKEnvironment.shared.contactManagerRef
)
private let outerHStack = UIStackView()
private let iconLabelContainer = UIView()
private let labelContainer = UIView()
private let label = UILabel()
private lazy var viewButton = OWSButton(
title: CommonStrings.viewButton,
tintColor: .ows_white,
dimsWhenHighlighted: true
) { [weak self] in
self?.delegate?.didTapViewRaisedHands()
}
private lazy var lowerHandButton = OWSButton(
title: CallStrings.lowerHandButton,
tintColor: .ows_white,
dimsWhenHighlighted: true
) { [weak self] in
self?.call.ringRtcCall.raiseHand(raise: false)
}
private var isCollapsed = false
private var collapsedText: String = ""
private var expandedText: String = ""
private var call: GroupCall
weak var delegate: RaisedHandsToastDelegate?
var horizontalPinConstraint: NSLayoutConstraint?
var raisedHands: [DemuxId] = [] {
didSet {
self.updateRaisedHands(raisedHands, oldValue: oldValue)
}
}
private var yourHandIsRaised: Bool {
self.call.ringRtcCall.localDeviceState.demuxId.map(raisedHands.contains) ?? false
}
// MARK: Init
init(call: GroupCall) {
self.call = call
super.init(frame: .zero)
self.addSubview(outerHStack)
outerHStack.axis = .horizontal
outerHStack.alignment = .center
outerHStack.autoPinEdgesToSuperviewEdges()
outerHStack.addBackgroundBlurView(blur: .systemMaterialDark, accessibilityFallbackColor: .ows_gray75)
outerHStack.clipsToBounds = true
let raisedHandIcon = UILabel()
raisedHandIcon.attributedText = .with(
image: UIImage(named: "raise_hand")!,
font: .dynamicTypeTitle3,
attributes: [.foregroundColor: UIColor.white]
)
raisedHandIcon.setContentCompressionResistancePriority(.required, for: .horizontal)
raisedHandIcon.contentMode = .scaleAspectFit
raisedHandIcon.tintColor = .white
raisedHandIcon.setContentHuggingHorizontalHigh()
raisedHandIcon.setCompressionResistanceVerticalHigh()
labelContainer.addSubview(label)
labelContainer.heightAnchor.constraint(greaterThanOrEqualTo: label.heightAnchor, multiplier: 1).isActive = true
label.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom)
label.font = .dynamicTypeBody2
label.numberOfLines = 0
label.contentMode = CurrentAppContext().isRTL ? .topRight : .topLeft
label.textColor = .white
label.setContentHuggingHorizontalLow()
label.setCompressionResistanceVerticalHigh()
outerHStack.addArrangedSubview(iconLabelContainer)
iconLabelContainer.addSubview(raisedHandIcon)
iconLabelContainer.addSubview(labelContainer)
let lineHeight = label.font.lineHeight
raisedHandIcon.centerYAnchor.constraint(equalTo: labelContainer.topAnchor, constant: lineHeight / 2).isActive = true
raisedHandIcon.autoPinEdge(toSuperviewMargin: .top)
raisedHandIcon.autoPinEdge(toSuperviewMargin: .leading)
labelContainer.autoPinEdge(toSuperviewMargin: .trailing)
labelContainer.autoVCenterInSuperview()
labelContainer.autoPinEdge(.leading, to: .trailing, of: raisedHandIcon, withOffset: 12)
for button in [self.viewButton, self.lowerHandButton] {
outerHStack.addArrangedSubview(button)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.setContentHuggingHorizontalHigh()
// The button slides to the trailing edge when hiding, but it only goes as
// far as the superview's margins, so if we had
// isLayoutMarginsRelativeArrangement on outerHStack, the button wouldn't
// slide all the way off, so instead set margins on the button itself.
button.ows_contentEdgeInsets = .init(
top: 8,
leading: 8,
bottom: 8,
trailing: Constants.hMarginExpanded
)
button.titleLabel?.font = .dynamicTypeBody2.bold()
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleExpanded))
outerHStack.addGestureRecognizer(tapGesture)
outerHStack.isUserInteractionEnabled = true
updateExpansionState(animated: false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
if isCollapsed {
layer.cornerRadius = height / 2
}
}
// MARK: State
private var autoCollapseTimer: Timer?
/// Called by a parent when a hide animation is completed. Sets
/// `isCollapsed` to `false` so it is expanded for its next presentation.
func wasHidden() {
self.isCollapsed = false
self.updateExpansionState(animated: false)
}
@objc
private func toggleExpanded() {
self.isCollapsed.toggle()
self.updateExpansionState(animated: true)
guard !self.isCollapsed else { return }
self.queueCollapse()
}
private func queueCollapse() {
self.autoCollapseTimer?.invalidate()
self.autoCollapseTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in
guard let self else { return }
self.isCollapsed = true
self.updateExpansionState(animated: true)
self.autoCollapseTimer = nil
}
}
/// Updates the view contents and constraints based on the value of `isCollapsed`.
/// - Parameter animated: Whether the change should be animated.
/// Note that changes in view height will result in a
/// `delegate?.raisedHandsToastDidChangeHeight` call which will animate the
/// superview, regardless of this value.
private func updateExpansionState(animated: Bool) {
let oldHeight = self.height
if isCollapsed {
label.text = self.collapsedText
} else {
label.text = self.expandedText
}
let action: () -> Void = {
self.viewButton.isHiddenInStackView = self.isCollapsed || self.yourHandIsRaised
self.lowerHandButton.isHiddenInStackView = self.isCollapsed || !self.yourHandIsRaised
self.horizontalPinConstraint?.isActive = !self.isCollapsed
self.layoutIfNeeded()
self.outerHStack.layer.cornerRadius = self.isCollapsed ? self.outerHStack.height / 2 : 10
self.iconLabelContainer.layoutMargins = .init(
hMargin: self.isCollapsed ? Constants.hMarginCollapsed : Constants.hMarginExpanded,
vMargin: Constants.vMargin
)
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.3, springDamping: 1, springResponse: 0.3)
animator.addAnimations(action)
animator.startAnimation()
} else {
action()
}
if self.height != oldHeight {
self.delegate?.raisedHandsToastDidChangeHeight()
}
}
private func updateRaisedHands(_ raisedHands: [DemuxId], oldValue: [DemuxId]) {
guard raisedHands != oldValue else { return }
guard let firstRaisedHandDemuxID = raisedHands.first else {
// Parent handles hiding. Don't update state.
// Prevent auto collapse while it's disappearing.
self.autoCollapseTimer?.invalidate()
return
}
self.collapsedText = if self.yourHandIsRaised, raisedHands.count > 1 {
String(
format: OWSLocalizedString(
"RAISED_HANDS_TOAST_YOU_PLUS_OTHERS_COUNT",
comment: "A compact member count on the call view's raised hands toast indicating that you and a number of other users raised a hand. Embeds {{number of other users}}"
),
raisedHands.count - 1
)
} else if self.yourHandIsRaised {
CommonStrings.you
} else {
"\(raisedHands.count)"
}
self.expandedText = {
if self.yourHandIsRaised, raisedHands.count == 1 {
return OWSLocalizedString(
"RAISED_HANDS_TOAST_YOUR_HAND_MESSAGE",
comment: "A message appearing on the call view's raised hands toast indicating that you raised your own hand."
)
}
let firstRaisedHandMemberName: String
if self.yourHandIsRaised {
firstRaisedHandMemberName = CommonStrings.you
} else if let firstRaisedHandRemoteDeviceState = self.call.ringRtcCall.remoteDeviceStates[firstRaisedHandDemuxID] {
firstRaisedHandMemberName = self.deps.db.read { tx -> String in
self.deps.contactsManager.displayName(
for: firstRaisedHandRemoteDeviceState.address,
tx: tx
).resolvedValue(useShortNameIfAvailable: true)
}
} else {
owsFailDebug("Could not find remote device state for demux ID")
firstRaisedHandMemberName = CommonStrings.unknownUser
}
if raisedHands.count > 1 {
let otherMembersCount = raisedHands.count - 1
let format = OWSLocalizedString(
"RAISED_HANDS_TOAST_MULTIPLE_HANDS_MESSAGE",
tableName: "PluralAware",
comment: "A message appearing on the call view's raised hands toast indicating that multiple members have raised their hands. Example: 'Alice +4 raised a hand'. Embeds {{number of other users}}, {{name}}"
)
return String.localizedStringWithFormat(format, otherMembersCount, firstRaisedHandMemberName)
}
return String(
format: OWSLocalizedString(
"RAISED_HANDS_TOAST_SINGLE_HAND_MESSAGE",
comment: "A message appearing on the call view's raised hands toast indicating that another named member has raised their hand."
),
firstRaisedHandMemberName
)
}()
var youJustRaisedYourHand: Bool {
self.call.ringRtcCall.localDeviceState.demuxId.map({ yourDemuxID in
raisedHands.contains(yourDemuxID) && !oldValue.contains(yourDemuxID)
}) ?? false
}
if oldValue.isEmpty || youJustRaisedYourHand {
self.isCollapsed = false
}
self.updateExpansionState(animated: !oldValue.isEmpty)
self.queueCollapse()
}
}