// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import SignalRingRTC class GroupCallNotificationView: UIView { private let call: SignalCall private struct ActiveMember: Hashable { let demuxId: UInt32 let uuid: UUID var address: String { return "" } } private var activeMembers = Set() private var membersPendingJoinNotification = Set() private var membersPendingLeaveNotification = Set() init(call: SignalCall) { self.call = call super.init(frame: .zero) call.addObserverAndSyncState(observer: self) isUserInteractionEnabled = false } deinit { call.removeObserver(self) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var hasJoined = false private func updateActiveMembers() { let newActiveMembers = Set(call.groupCall.remoteDeviceStates.values.map { ActiveMember(demuxId: $0.demuxId, uuid: $0.userId) }) if hasJoined { let joinedMembers = newActiveMembers.subtracting(activeMembers) let leftMembers = activeMembers.subtracting(newActiveMembers) membersPendingJoinNotification.subtract(leftMembers) membersPendingJoinNotification.formUnion(joinedMembers) membersPendingLeaveNotification.subtract(joinedMembers) membersPendingLeaveNotification.formUnion(leftMembers) } else { hasJoined = call.groupCall.localDeviceState.joinState == .joined } activeMembers = newActiveMembers presentNextNotificationIfNecessary() } private var isPresentingNotification = false private func presentNextNotificationIfNecessary() { guard !isPresentingNotification else { return } guard let bannerView: BannerView = { if membersPendingJoinNotification.count > 0 { callService.audioService.playJoinSound() let addresses = membersPendingJoinNotification.map { $0.address } membersPendingJoinNotification.removeAll() return BannerView(addresses: addresses, action: .join) } else if membersPendingLeaveNotification.count > 0 { callService.audioService.playLeaveSound() let addresses = membersPendingLeaveNotification.map { $0.address } membersPendingLeaveNotification.removeAll() return BannerView(addresses: addresses, action: .leave) } else { return nil } }() else { return } isPresentingNotification = true addSubview(bannerView) bannerView.autoHCenterInSuperview() // Prefer to be full width, but don't exceed the maximum width bannerView.autoSetDimension(.width, toSize: 512, relation: .lessThanOrEqual) bannerView.autoMatch( .width, to: .width, of: self, withOffset: -(layoutMargins.left + layoutMargins.right), relation: .lessThanOrEqual ) NSLayoutConstraint.autoSetPriority(.defaultHigh) { bannerView.autoPinWidthToSuperviewMargins() } let onScreenConstraint = bannerView.autoPinEdge(toSuperviewMargin: .top) onScreenConstraint.isActive = false let offScreenConstraint = bannerView.autoPinEdge(.bottom, to: .top, of: self) layoutIfNeeded() firstly(on: .main) { UIView.animate(.promise, duration: 0.35) { offScreenConstraint.isActive = false onScreenConstraint.isActive = true self.layoutIfNeeded() } }.then(on: .main) { _ in UIView.animate(.promise, duration: 0.35, delay: 2, options: .curveEaseInOut) { onScreenConstraint.isActive = false offScreenConstraint.isActive = true self.layoutIfNeeded() } }.done(on: .main) { _ in bannerView.removeFromSuperview() self.isPresentingNotification = false self.presentNextNotificationIfNecessary() } } } extension GroupCallNotificationView: CallObserver { func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { AssertIsOnMainThread() owsAssertDebug(call.isGroupCall) updateActiveMembers() } func groupCallPeekChanged(_ call: SignalCall) { AssertIsOnMainThread() owsAssertDebug(call.isGroupCall) updateActiveMembers() } func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { AssertIsOnMainThread() owsAssertDebug(call.isGroupCall) hasJoined = false activeMembers.removeAll() membersPendingJoinNotification.removeAll() membersPendingLeaveNotification.removeAll() updateActiveMembers() } } private class BannerView: UIView { enum Action: Equatable { case join, leave } init(addresses: [String], action: Action) { super.init(frame: .zero) owsAssertDebug(!addresses.isEmpty) autoSetDimension(.height, toSize: 64, relation: .greaterThanOrEqual) layer.cornerRadius = 8 clipsToBounds = true if UIAccessibility.isReduceTransparencyEnabled { backgroundColor = .black.withAlphaComponent(0.8) } else { let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) addSubview(blurEffectView) blurEffectView.autoPinEdgesToSuperviewEdges() backgroundColor = .black.withAlphaComponent(0.4) } let displayNames = addresses.map { publicKey in Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey }.sorted { $0 < $1 } let actionText: String if displayNames.count > 2 { let formatText = action == .join ? NSLocalizedString( "GROUP_CALL_NOTIFICATION_MANY_JOINED_FORMAT", comment: "Copy explaining that many new users have joined the group call. Embeds {first member name}, {second member name}, {number of additional members}" ) : NSLocalizedString( "GROUP_CALL_NOTIFICATION_MANY_LEFT_FORMAT", comment: "Copy explaining that many users have left the group call. Embeds {first member name}, {second member name}, {number of additional members}" ) actionText = String(format: formatText, displayNames[0], displayNames[1], displayNames.count - 2) } else if displayNames.count > 1 { let formatText = action == .join ? NSLocalizedString( "GROUP_CALL_NOTIFICATION_TWO_JOINED_FORMAT", comment: "Copy explaining that two users have joined the group call. Embeds {first member name}, {second member name}" ) : NSLocalizedString( "GROUP_CALL_NOTIFICATION_TWO_LEFT_FORMAT", comment: "Copy explaining that two users have left the group call. Embeds {first member name}, {second member name}" ) actionText = String(format: formatText, displayNames[0], displayNames[1]) } else { let formatText = action == .join ? NSLocalizedString( "GROUP_CALL_NOTIFICATION_ONE_JOINED_FORMAT", comment: "Copy explaining that a user has joined the group call. Embeds {member name}" ) : NSLocalizedString( "GROUP_CALL_NOTIFICATION_ONE_LEFT_FORMAT", comment: "Copy explaining that a user has left the group call. Embeds {member name}" ) actionText = String(format: formatText, displayNames[0]) } let hStack = UIStackView() hStack.spacing = 12 hStack.axis = .horizontal hStack.isLayoutMarginsRelativeArrangement = true hStack.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) addSubview(hStack) hStack.autoPinEdgesToSuperviewEdges() if addresses.count == 1, let address = addresses.first { let avatarContainer = UIView() hStack.addArrangedSubview(avatarContainer) avatarContainer.autoSetDimension(.width, toSize: 40) let avatarView = UIImageView() avatarView.layer.cornerRadius = 20 avatarView.clipsToBounds = true avatarContainer.addSubview(avatarView) avatarView.autoPinWidthToSuperview() avatarView.autoVCenterInSuperview() avatarView.autoMatch(.height, to: .width, of: avatarView) // TODO: Implement /* if address.isLocalAddress, let avatarImage = profileManager.localProfileAvatarImage() { avatarView.image = avatarImage } else { let avatar = Self.avatarBuilder.avatarImageWithSneakyTransaction(forAddress: address, diameterPoints: 40, localUserDisplayMode: .asUser) avatarView.image = avatar } */ } let label = UILabel() hStack.addArrangedSubview(label) label.setCompressionResistanceHorizontalHigh() label.numberOfLines = 0 label.font = .boldSystemFont(ofSize: Values.mediumFontSize) label.textColor = .ows_white label.text = actionText hStack.addArrangedSubview(.hStretchingSpacer()) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }