diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index f8e8305c16..0a74b6e388 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1203,6 +1203,8 @@ public protocol SharedAccountContext: AnyObject { func makeDebugSettingsController(context: AccountContext?) -> ViewController? + func openCreateGroupCallUI(context: AccountContext, peerIds: [EnginePeer.Id], parentController: ViewController) + func navigateToCurrentCall() var hasOngoingCall: ValuePromise { get } var immediateHasOngoingCall: Bool { get } diff --git a/submodules/AccountContext/Sources/ContactMultiselectionController.swift b/submodules/AccountContext/Sources/ContactMultiselectionController.swift index fc3ea9691c..43f815e7d0 100644 --- a/submodules/AccountContext/Sources/ContactMultiselectionController.swift +++ b/submodules/AccountContext/Sources/ContactMultiselectionController.swift @@ -77,7 +77,7 @@ public enum ContactMultiselectionControllerMode { } } - case groupCreation + case groupCreation(isCall: Bool) case peerSelection(searchChatList: Bool, searchGroups: Bool, searchChannels: Bool) case channelCreation case chatSelection(ChatSelection) @@ -109,8 +109,26 @@ public final class ContactMultiselectionControllerParams { public let reachedLimit: ((Int32) -> Void)? public let openProfile: ((EnginePeer) -> Void)? public let sendMessage: ((EnginePeer) -> Void)? + public let initialSelectedPeers: [EnginePeer] - public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, title: String? = nil, mode: ContactMultiselectionControllerMode, options: Signal<[ContactListAdditionalOption], NoError> = .single([]), filters: [ContactListFilter] = [.excludeSelf], onlyWriteable: Bool = false, isGroupInvitation: Bool = false, isPeerEnabled: ((EnginePeer) -> Bool)? = nil, attemptDisabledItemSelection: ((EnginePeer, ChatListDisabledPeerReason) -> Void)? = nil, alwaysEnabled: Bool = false, limit: Int32? = nil, reachedLimit: ((Int32) -> Void)? = nil, openProfile: ((EnginePeer) -> Void)? = nil, sendMessage: ((EnginePeer) -> Void)? = nil) { + public init( + context: AccountContext, + updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, + title: String? = nil, + mode: ContactMultiselectionControllerMode, + options: Signal<[ContactListAdditionalOption], NoError> = .single([]), + filters: [ContactListFilter] = [.excludeSelf], + onlyWriteable: Bool = false, + isGroupInvitation: Bool = false, + isPeerEnabled: ((EnginePeer) -> Bool)? = nil, + attemptDisabledItemSelection: ((EnginePeer, ChatListDisabledPeerReason) -> Void)? = nil, + alwaysEnabled: Bool = false, + limit: Int32? = nil, + reachedLimit: ((Int32) -> Void)? = nil, + openProfile: ((EnginePeer) -> Void)? = nil, + sendMessage: ((EnginePeer) -> Void)? = nil, + initialSelectedPeers: [EnginePeer] = [] + ) { self.context = context self.updatedPresentationData = updatedPresentationData self.title = title @@ -126,6 +144,7 @@ public final class ContactMultiselectionControllerParams { self.reachedLimit = reachedLimit self.openProfile = openProfile self.sendMessage = sendMessage + self.initialSelectedPeers = initialSelectedPeers } } diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index e47d704bbf..be259bce31 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -224,6 +224,7 @@ public struct PresentationGroupCallState: Equatable { public var subscribedToScheduled: Bool public var isVideoEnabled: Bool public var isVideoWatchersLimitReached: Bool + public var isMyVideoActive: Bool public init( myPeerId: EnginePeer.Id, @@ -238,7 +239,8 @@ public struct PresentationGroupCallState: Equatable { scheduleTimestamp: Int32?, subscribedToScheduled: Bool, isVideoEnabled: Bool, - isVideoWatchersLimitReached: Bool + isVideoWatchersLimitReached: Bool, + isMyVideoActive: Bool ) { self.myPeerId = myPeerId self.networkState = networkState @@ -253,6 +255,7 @@ public struct PresentationGroupCallState: Equatable { self.subscribedToScheduled = subscribedToScheduled self.isVideoEnabled = isVideoEnabled self.isVideoWatchersLimitReached = isVideoWatchersLimitReached + self.isMyVideoActive = isMyVideoActive } } @@ -569,6 +572,7 @@ public protocol PresentationCallManager: AnyObject { accountContext: AccountContext, initialCall: EngineGroupCallDescription, reference: InternalGroupCallReference, - beginWithVideo: Bool + beginWithVideo: Bool, + invitePeerIds: [EnginePeer.Id] ) } diff --git a/submodules/CallListUI/Sources/CallListController.swift b/submodules/CallListUI/Sources/CallListController.swift index d8df9577a9..0607e99d09 100644 --- a/submodules/CallListUI/Sources/CallListController.swift +++ b/submodules/CallListUI/Sources/CallListController.swift @@ -208,7 +208,7 @@ public final class CallListController: TelegramBaseController { } } - private func createGroupCall() { + private func createGroupCall(peerIds: [EnginePeer.Id], completion: (() -> Void)? = nil) { self.view.endEditing(true) guard !self.presentAccountFrozenInfoIfNeeded() else { @@ -274,38 +274,44 @@ public final class CallListController: TelegramBaseController { isStream: false ), reference: .id(id: call.callInfo.id, accessHash: call.callInfo.accessHash), - beginWithVideo: false + beginWithVideo: false, + invitePeerIds: peerIds ) + completion?() } - let controller = InviteLinkInviteController( - context: self.context, - updatedPresentationData: nil, - mode: .groupCall(InviteLinkInviteController.Mode.GroupCall(callId: call.callInfo.id, accessHash: call.callInfo.accessHash, isRecentlyCreated: true, canRevoke: true)), - initialInvite: .link(link: call.link, title: nil, isPermanent: true, requestApproval: false, isRevoked: false, adminId: self.context.account.peerId, date: 0, startDate: nil, expireDate: nil, usageLimit: nil, count: nil, requestedCount: nil, pricing: nil), - parentNavigationController: self.navigationController as? NavigationController, - completed: { [weak self] result in - guard let self else { - return - } - if let result { - switch result { - case .linkCopied: - //TODO:localize - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - self.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_linkcopied", scale: 0.08, colors: ["info1.info1.stroke": UIColor.clear, "info2.info2.Fill": UIColor.clear], title: nil, text: "Call link copied.", customUndoText: "View Call", timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in - if case .undo = action { - openCall() - } - return false - }), in: .window(.root)) - case .openCall: - openCall() + if !peerIds.isEmpty { + openCall() + } else { + let controller = InviteLinkInviteController( + context: self.context, + updatedPresentationData: nil, + mode: .groupCall(InviteLinkInviteController.Mode.GroupCall(callId: call.callInfo.id, accessHash: call.callInfo.accessHash, isRecentlyCreated: true, canRevoke: true)), + initialInvite: .link(link: call.link, title: nil, isPermanent: true, requestApproval: false, isRevoked: false, adminId: self.context.account.peerId, date: 0, startDate: nil, expireDate: nil, usageLimit: nil, count: nil, requestedCount: nil, pricing: nil), + parentNavigationController: self.navigationController as? NavigationController, + completed: { [weak self] result in + guard let self else { + return + } + if let result { + switch result { + case .linkCopied: + //TODO:localize + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_linkcopied", scale: 0.08, colors: ["info1.info1.stroke": UIColor.clear, "info2.info2.Fill": UIColor.clear], title: nil, text: "Call link copied.", customUndoText: "View Call", timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in + if case .undo = action { + openCall() + } + return false + }), in: .window(.root)) + case .openCall: + openCall() + } } } - } - ) - self.present(controller, in: .window(.root), with: nil) + ) + self.present(controller, in: .window(.root), with: nil) + } }) } @@ -395,7 +401,7 @@ public final class CallListController: TelegramBaseController { } }, createGroupCall: { [weak self] in if let strongSelf = self { - strongSelf.createGroupCall() + strongSelf.createGroupCall(peerIds: []) } }) @@ -508,21 +514,69 @@ public final class CallListController: TelegramBaseController { guard !self.presentAccountFrozenInfoIfNeeded() else { return } - let controller = self.context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams(context: self.context, title: { $0.Calls_NewCall }, displayCallIcons: true)) + + //TODO:localize + let options = [ContactListAdditionalOption(title: "New Call Link", icon: .generic(PresentationResourcesItemList.linkIcon(presentationData.theme)!), action: { [weak self] in + guard let self else { + return + } + self.createGroupCall(peerIds: []) + }, clearHighlightAutomatically: true)] + + let controller = self.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams( + context: self.context, + title: self.presentationData.strings.Calls_NewCall, + mode: .groupCreation(isCall: true), + options: .single(options), + filters: [.excludeSelf], + onlyWriteable: true, + isGroupInvitation: false, + isPeerEnabled: nil, + attemptDisabledItemSelection: nil, + alwaysEnabled: false, + limit: nil, + reachedLimit: nil, + openProfile: nil, + sendMessage: nil + )) controller.navigationPresentation = .modal - self.createActionDisposable.set((controller.result + if let navigationController = self.context.sharedContext.mainWindow?.viewController as? NavigationController { + navigationController.pushViewController(controller) + } + + let _ = (controller.result |> take(1) - |> deliverOnMainQueue).startStrict(next: { [weak controller, weak self] result in - controller?.dismissSearch() - if let strongSelf = self, let (contactPeers, action, _, _, _, _) = result, let contactPeer = contactPeers.first, case let .peer(peer, _, _) = contactPeer { - strongSelf.call(peer.id, isVideo: action == .videoCall, began: { + |> deliverOnMainQueue).startStandalone(next: { [weak controller, weak self] result in + guard let self else { + controller?.dismiss() + return + } + guard case let .result(rawPeerIds, _) = result else { + controller?.dismiss() + return + } + let peerIds = rawPeerIds.compactMap { id -> EnginePeer.Id? in + if case let .peer(id) = id { + return id + } + return nil + } + if peerIds.isEmpty { + controller?.dismiss() + return + } + + if peerIds.count == 1 { + //TODO:release isVideo + controller?.dismiss() + self.call(peerIds[0], isVideo: false, began: { [weak self] in if let strongSelf = self { let _ = (strongSelf.context.sharedContext.hasOngoingCall.get() |> filter { $0 } |> timeout(1.0, queue: Queue.mainQueue(), alternate: .single(true)) |> delay(0.5, queue: Queue.mainQueue()) |> take(1) - |> deliverOnMainQueue).startStandalone(next: { _ in + |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in if let _ = self, let controller = controller, let navigationController = controller.navigationController as? NavigationController { if navigationController.viewControllers.last === controller { let _ = navigationController.popViewController(animated: true) @@ -531,11 +585,12 @@ public final class CallListController: TelegramBaseController { }) } }) + } else { + self.createGroupCall(peerIds: peerIds, completion: { + controller?.dismiss() + }) } - })) - if let navigationController = self.context.sharedContext.mainWindow?.viewController as? NavigationController { - navigationController.pushViewController(controller) - } + }) } private func presentAccountFrozenInfoIfNeeded(delay: Bool = false) -> Bool { @@ -612,8 +667,8 @@ public final class CallListController: TelegramBaseController { return } self.peerViewDisposable.set((self.context.account.viewTracker.peerView(peerId) - |> take(1) - |> deliverOnMainQueue).startStrict(next: { [weak self] view in + |> take(1) + |> deliverOnMainQueue).startStrict(next: { [weak self] view in if let strongSelf = self { guard let peer = peerViewMainPeer(view) else { return @@ -621,7 +676,6 @@ public final class CallListController: TelegramBaseController { if let cachedUserData = view.cachedData as? CachedUserData, cachedUserData.callsPrivate { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.present(textAlertController(context: strongSelf.context, title: presentationData.strings.Call_ConnectionErrorTitle, text: presentationData.strings.Call_PrivacyErrorMessage(EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return } @@ -645,6 +699,7 @@ public final class CallListController: TelegramBaseController { return } if conferenceCall.duration != nil { + self.context.sharedContext.openCreateGroupCallUI(context: self.context, peerIds: conferenceCall.otherParticipants, parentController: self) return } @@ -670,8 +725,21 @@ public final class CallListController: TelegramBaseController { isStream: false ), reference: .message(id: message.id), - beginWithVideo: conferenceCall.flags.contains(.isVideo) + beginWithVideo: conferenceCall.flags.contains(.isVideo), + invitePeerIds: [] ) + }, error: { [weak self] error in + guard let self else { + return + } + switch error { + case .doesNotExist: + self.context.sharedContext.openCreateGroupCallUI(context: self.context, peerIds: conferenceCall.otherParticipants, parentController: self) + default: + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + //TODO:localize + self.present(textAlertController(context: self.context, title: nil, text: "An error occurred", actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + } }) } diff --git a/submodules/CounterControllerTitleView/Sources/CounterControllerTitleView.swift b/submodules/CounterControllerTitleView/Sources/CounterControllerTitleView.swift index 117417dc7e..5685a37fcf 100644 --- a/submodules/CounterControllerTitleView/Sources/CounterControllerTitleView.swift +++ b/submodules/CounterControllerTitleView/Sources/CounterControllerTitleView.swift @@ -6,9 +6,9 @@ import TelegramPresentationData public struct CounterControllerTitle: Equatable { public var title: String - public var counter: String + public var counter: String? - public init(title: String, counter: String) { + public init(title: String, counter: String?) { self.title = title self.counter = counter } @@ -18,7 +18,7 @@ public final class CounterControllerTitleView: UIView { private let titleNode: ImmediateTextNode private let subtitleNode: ImmediateTextNode - public var title: CounterControllerTitle = CounterControllerTitle(title: "", counter: "") { + public var title: CounterControllerTitle = CounterControllerTitle(title: "", counter: nil) { didSet { if self.title != oldValue { self.update() @@ -59,7 +59,7 @@ public final class CounterControllerTitleView: UIView { let primaryTextColor = self.primaryTextColor ?? self.theme.rootController.navigationBar.primaryTextColor let secondaryTextColor = self.secondaryTextColor ?? self.theme.rootController.navigationBar.secondaryTextColor self.titleNode.attributedText = NSAttributedString(string: self.title.title, font: Font.semibold(17.0), textColor: primaryTextColor) - self.subtitleNode.attributedText = NSAttributedString(string: self.title.counter, font: Font.with(size: 13.0, traits: .monospacedNumbers), textColor: secondaryTextColor) + self.subtitleNode.attributedText = NSAttributedString(string: self.title.counter ?? "", font: Font.with(size: 13.0, traits: .monospacedNumbers), textColor: secondaryTextColor) self.accessibilityLabel = self.title.title self.accessibilityValue = self.title.counter @@ -103,7 +103,13 @@ public final class CounterControllerTitleView: UIView { let titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, size.width), height: size.height)) let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: max(1.0, size.width), height: size.height)) - let combinedHeight = titleSize.height + subtitleSize.height + spacing + + let combinedHeight: CGFloat + if self.title.counter != nil { + combinedHeight = titleSize.height + subtitleSize.height + spacing + } else { + combinedHeight = titleSize.height + } let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) self.titleNode.frame = titleFrame diff --git a/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift b/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift index b881c0d803..cf901db89a 100644 --- a/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift +++ b/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift @@ -427,7 +427,7 @@ public final class InviteLinkInviteController: ViewController { strongSelf.controller?.present(controller, in: .window(.root)) }) } else if case .groupCall = self.mode { - let controller = QrCodeScreen(context: context, updatedPresentationData: (self.presentationData, self.presentationDataPromise.get()), subject: .invite(invite: invite, type: .channel)) + let controller = QrCodeScreen(context: context, updatedPresentationData: (self.presentationData, self.presentationDataPromise.get()), subject: .invite(invite: invite, type: .groupCall)) self.controller?.present(controller, in: .window(.root)) } } diff --git a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift index c4c989cceb..bd4a4d56b3 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift @@ -1048,14 +1048,10 @@ private func selectivePrivacySettingsControllerEntries(presentationData: Present entries.append(.settingInfo(presentationData.theme, settingInfoText, settingInfoLink)) } - if case .phoneNumber = kind, state.setting == .nobody { - if state.phoneDiscoveryEnabled == false || phoneNumber.hasPrefix("888") { - entries.append(.phoneDiscoveryHeader(presentationData.theme, presentationData.strings.PrivacyPhoneNumberSettings_DiscoveryHeader)) - entries.append(.phoneDiscoveryEverybody(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenEverybody, state.phoneDiscoveryEnabled != false)) - entries.append(.phoneDiscoveryMyContacts(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenContacts, state.phoneDiscoveryEnabled == false)) - entries.append(.phoneDiscoveryInfo(presentationData.theme, state.phoneDiscoveryEnabled != false ? presentationData.strings.PrivacyPhoneNumberSettings_CustomPublicLink("+\(phoneNumber)").string : presentationData.strings.PrivacyPhoneNumberSettings_CustomDisabledHelp, phoneLink)) - } - } + entries.append(.phoneDiscoveryHeader(presentationData.theme, presentationData.strings.PrivacyPhoneNumberSettings_DiscoveryHeader)) + entries.append(.phoneDiscoveryEverybody(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenEverybody, state.phoneDiscoveryEnabled != false)) + entries.append(.phoneDiscoveryMyContacts(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenContacts, state.phoneDiscoveryEnabled == false)) + entries.append(.phoneDiscoveryInfo(presentationData.theme, state.phoneDiscoveryEnabled != false ? presentationData.strings.PrivacyPhoneNumberSettings_CustomPublicLink("+\(phoneNumber)").string : presentationData.strings.PrivacyPhoneNumberSettings_CustomDisabledHelp, phoneLink)) if case .voiceMessages = kind, !isPremium { diff --git a/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift b/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift index e7b6433db6..57b06878a5 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift @@ -1083,7 +1083,8 @@ public final class PresentationCallManagerImpl: PresentationCallManager { accountContext: AccountContext, initialCall: EngineGroupCallDescription, reference: InternalGroupCallReference, - beginWithVideo: Bool + beginWithVideo: Bool, + invitePeerIds: [EnginePeer.Id] ) { let keyPair: TelegramKeyPair guard let keyPairValue = TelegramE2EEncryptionProviderImpl.shared.generateKeyPair() else { @@ -1109,6 +1110,9 @@ public final class PresentationCallManagerImpl: PresentationCallManager { beginWithVideo: beginWithVideo, sharedAudioContext: nil ) + for peerId in invitePeerIds { + let _ = call.invitePeer(peerId, isVideo: beginWithVideo) + } self.updateCurrentGroupCall(.group(call)) } } diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index f7fe16141b..4caf389cbc 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -34,7 +34,8 @@ private extension PresentationGroupCallState { scheduleTimestamp: scheduleTimestamp, subscribedToScheduled: subscribedToScheduled, isVideoEnabled: false, - isVideoWatchersLimitReached: false + isVideoWatchersLimitReached: false, + isMyVideoActive: false ) } } @@ -1708,6 +1709,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } var prioritizeVP8 = false + #if DEBUG + prioritizeVP8 = "".isEmpty + #endif if let data = self.accountContext.currentAppConfiguration.with({ $0 }).data, let value = data["ios_calls_prioritize_vp8"] as? Double { prioritizeVP8 = value != 0.0 } @@ -3130,6 +3134,10 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { if let videoCapturer = self.videoCapturer { self.requestVideo(capturer: videoCapturer) + + var stateValue = self.stateValue + stateValue.isMyVideoActive = true + self.stateValue = stateValue } } @@ -3152,6 +3160,10 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { })) self.updateLocalVideoState() + + var stateValue = self.stateValue + stateValue.isMyVideoActive = true + self.stateValue = stateValue } } @@ -3165,6 +3177,10 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.isVideoMuted = true self.updateLocalVideoState() + + var stateValue = self.stateValue + stateValue.isMyVideoActive = false + self.stateValue = stateValue } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index beef446dc2..5a68054ee1 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -33,11 +33,13 @@ final class VideoChatActionButtonComponent: Component { case audio(audio: Audio) case video + case rotateCamera case leave } case audio(audio: Audio, isEnabled: Bool) case video(isActive: Bool) + case rotateCamera case leave fileprivate var iconType: IconType { @@ -55,6 +57,8 @@ final class VideoChatActionButtonComponent: Component { return .audio(audio: mappedAudio) case .video: return .video + case .rotateCamera: + return .rotateCamera case .leave: return .leave } @@ -176,6 +180,19 @@ final class VideoChatActionButtonComponent: Component { backgroundColor = UIColor(rgb: 0x3252EF) } iconDiameter = 60.0 + case .rotateCamera: + titleText = "" + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = UIColor(rgb: 0x34C659) + case .raiseHand, .scheduled: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 60.0 case .leave: titleText = component.strings.VoiceChat_Leave backgroundColor = UIColor(rgb: 0x47191E) @@ -206,6 +223,8 @@ final class VideoChatActionButtonComponent: Component { self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate) case .video: self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) + case .rotateCamera: + self.contentImage = UIImage(bundleImageName: "Call/CallSwitchCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) case .leave: self.contentImage = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) @@ -277,8 +296,10 @@ final class VideoChatActionButtonComponent: Component { if iconView.superview == nil { self.addSubview(iconView) } - transition.setFrame(view: iconView, frame: iconFrame) + transition.setPosition(view: iconView, position: iconFrame.center) + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) transition.setAlpha(view: iconView, alpha: isEnabled ? 1.0 : 0.6) + transition.setScale(view: iconView, scale: availableSize.height / 56.0) } self.isEnabled = isEnabled diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index 4fedb48475..787ec7d39c 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -227,6 +227,7 @@ final class VideoChatScreenComponent: Component { var isEncryptionKeyExpanded: Bool = false let videoButton = ComponentView() + let videoControlButton = ComponentView() let leaveButton = ComponentView() let microphoneButton = ComponentView() @@ -1130,7 +1131,8 @@ final class VideoChatScreenComponent: Component { scheduleTimestamp: nil, subscribedToScheduled: false, isVideoEnabled: true, - isVideoWatchersLimitReached: false + isVideoWatchersLimitReached: false, + isMyVideoActive: false ) return .single((callState, invitedPeers.compactMap({ peer -> VideoChatScreenComponent.InvitedPeer? in @@ -2657,38 +2659,84 @@ final class VideoChatScreenComponent: Component { } let videoButtonContent: VideoChatActionButtonComponent.Content - if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { - var buttonAudio: VideoChatActionButtonComponent.Content.Audio = .speaker - var buttonIsEnabled = false - if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { - buttonIsEnabled = availableOutputs.count > 1 - switch currentOutput { - case .builtin: - buttonAudio = .builtin - case .speaker: - buttonAudio = .speaker - case .headphones: - buttonAudio = .headphones - case let .port(port): - var type: VideoChatActionButtonComponent.Content.BluetoothType = .generic - let portName = port.name.lowercased() - if portName.contains("airpods max") { - type = .airpodsMax - } else if portName.contains("airpods pro") { - type = .airpodsPro - } else if portName.contains("airpods") { - type = .airpods - } - buttonAudio = .bluetooth(type) - } - if availableOutputs.count <= 1 { - buttonAudio = .none + let videoControlButtonContent: VideoChatActionButtonComponent.Content + + var buttonAudio: VideoChatActionButtonComponent.Content.Audio = .speaker + var buttonIsEnabled = false + if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { + buttonIsEnabled = availableOutputs.count > 1 + switch currentOutput { + case .builtin: + buttonAudio = .builtin + case .speaker: + buttonAudio = .speaker + case .headphones: + buttonAudio = .headphones + case let .port(port): + var type: VideoChatActionButtonComponent.Content.BluetoothType = .generic + let portName = port.name.lowercased() + if portName.contains("airpods max") { + type = .airpodsMax + } else if portName.contains("airpods pro") { + type = .airpodsPro + } else if portName.contains("airpods") { + type = .airpods } + buttonAudio = .bluetooth(type) + } + if availableOutputs.count <= 1 { + buttonAudio = .none } - videoButtonContent = .audio(audio: buttonAudio, isEnabled: buttonIsEnabled) - } else { - videoButtonContent = .video(isActive: false) } + + if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { + videoButtonContent = .audio(audio: buttonAudio, isEnabled: buttonIsEnabled) + videoControlButtonContent = .audio(audio: buttonAudio, isEnabled: buttonIsEnabled) + } else { + let isVideoActive = self.callState?.isMyVideoActive ?? false + videoButtonContent = .video(isActive: isVideoActive) + if isVideoActive { + videoControlButtonContent = .rotateCamera + } else { + videoControlButtonContent = .audio(audio: buttonAudio, isEnabled: buttonIsEnabled) + } + } + + var displayVideoControlButton = true + if areButtonsCollapsed { + displayVideoControlButton = false + } else if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, !expandedParticipantsVideoState.isUIHidden { + displayVideoControlButton = false + } + + let videoControlButtonSize = self.videoControlButton.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, + content: videoControlButtonContent, + microphoneState: actionButtonMicrophoneState, + isCollapsed: true + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + if let state = self.callState, state.isMyVideoActive { + if case let .group(groupCall) = self.currentCall { + groupCall.switchVideoCamera() + } + } else { + self.onAudioRoutePressed() + } + }, + animateAlpha: false + )), + environment: {}, + containerSize: CGSize(width: 32.0, height: 32.0) + ) + let _ = self.videoButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( @@ -2714,12 +2762,33 @@ final class VideoChatScreenComponent: Component { environment: {}, containerSize: CGSize(width: actionButtonDiameter, height: actionButtonDiameter) ) + + let videoControlButtonSpacing: CGFloat = 8.0 + + var videoButtonFrame = leftActionButtonFrame + if displayVideoControlButton { + let totalVideoButtonsHeight = actionButtonDiameter + videoControlButtonSpacing + videoControlButtonSize.height + videoButtonFrame.origin.y = videoButtonFrame.minY + floor((videoButtonFrame.height - totalVideoButtonsHeight) / 2.0) + videoControlButtonSpacing + videoControlButtonSize.height + } + + let videoControlButtonFrame = CGRect(origin: CGPoint(x: videoButtonFrame.minX + floor((videoButtonFrame.width - videoControlButtonSize.width) / 2.0), y: videoButtonFrame.minY - videoControlButtonSpacing - videoControlButtonSize.height), size: videoControlButtonSize) + + if let videoControlButtonView = self.videoControlButton.view { + if videoControlButtonView.superview == nil { + self.containerView.addSubview(videoControlButtonView) + } + transition.setPosition(view: videoControlButtonView, position: videoControlButtonFrame.center) + transition.setBounds(view: videoControlButtonView, bounds: CGRect(origin: CGPoint(), size: videoControlButtonFrame.size)) + alphaTransition.setAlpha(view: videoControlButtonView, alpha: displayVideoControlButton ? 1.0 : 0.0) + transition.setScale(view: videoControlButtonView, scale: displayVideoControlButton ? 1.0 : 0.001) + } + if let videoButtonView = self.videoButton.view { if videoButtonView.superview == nil { self.containerView.addSubview(videoButtonView) } - transition.setPosition(view: videoButtonView, position: leftActionButtonFrame.center) - transition.setBounds(view: videoButtonView, bounds: CGRect(origin: CGPoint(), size: leftActionButtonFrame.size)) + transition.setPosition(view: videoButtonView, position: videoButtonFrame.center) + transition.setBounds(view: videoButtonView, bounds: CGRect(origin: CGPoint(), size: videoButtonFrame.size)) } let _ = self.leaveButton.update( diff --git a/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift b/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift index fc245b1f57..7c1fc58584 100644 --- a/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift +++ b/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift @@ -28,6 +28,7 @@ public struct UserLimitsConfiguration: Equatable { public var maxGiveawayCountriesCount: Int32 public var maxGiveawayPeriodSeconds: Int32 public var maxChannelRecommendationsCount: Int32 + public var maxConferenceParticipantCount: Int32 public static var defaultValue: UserLimitsConfiguration { return UserLimitsConfiguration( @@ -56,7 +57,8 @@ public struct UserLimitsConfiguration: Equatable { maxGiveawayChannelsCount: 10, maxGiveawayCountriesCount: 10, maxGiveawayPeriodSeconds: 86400 * 31, - maxChannelRecommendationsCount: 10 + maxChannelRecommendationsCount: 10, + maxConferenceParticipantCount: 100 ) } @@ -86,7 +88,8 @@ public struct UserLimitsConfiguration: Equatable { maxGiveawayChannelsCount: Int32, maxGiveawayCountriesCount: Int32, maxGiveawayPeriodSeconds: Int32, - maxChannelRecommendationsCount: Int32 + maxChannelRecommendationsCount: Int32, + maxConferenceParticipantCount: Int32 ) { self.maxPinnedChatCount = maxPinnedChatCount self.maxPinnedSavedChatCount = maxPinnedSavedChatCount @@ -114,6 +117,7 @@ public struct UserLimitsConfiguration: Equatable { self.maxGiveawayCountriesCount = maxGiveawayCountriesCount self.maxGiveawayPeriodSeconds = maxGiveawayPeriodSeconds self.maxChannelRecommendationsCount = maxChannelRecommendationsCount + self.maxConferenceParticipantCount = maxConferenceParticipantCount } } @@ -167,5 +171,6 @@ extension UserLimitsConfiguration { self.maxGiveawayCountriesCount = getGeneralValue("giveaway_countries_max", orElse: defaultValue.maxGiveawayCountriesCount) self.maxGiveawayPeriodSeconds = getGeneralValue("giveaway_period_max", orElse: defaultValue.maxGiveawayPeriodSeconds) self.maxChannelRecommendationsCount = getValue("recommended_channels_limit", orElse: defaultValue.maxChannelRecommendationsCount) + self.maxConferenceParticipantCount = getGeneralValue("conference_call_size_limit", orElse: defaultValue.maxConferenceParticipantCount) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/ConfigurationData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/ConfigurationData.swift index 7a04b9c3d1..e9d24a6cef 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/ConfigurationData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/ConfigurationData.swift @@ -62,6 +62,7 @@ public enum EngineConfiguration { public let maxGiveawayCountriesCount: Int32 public let maxGiveawayPeriodSeconds: Int32 public let maxChannelRecommendationsCount: Int32 + public let maxConferenceParticipantCount: Int32 public static var defaultValue: UserLimits { return UserLimits(UserLimitsConfiguration.defaultValue) @@ -93,7 +94,8 @@ public enum EngineConfiguration { maxGiveawayChannelsCount: Int32, maxGiveawayCountriesCount: Int32, maxGiveawayPeriodSeconds: Int32, - maxChannelRecommendationsCount: Int32 + maxChannelRecommendationsCount: Int32, + maxConferenceParticipantCount: Int32 ) { self.maxPinnedChatCount = maxPinnedChatCount self.maxPinnedSavedChatCount = maxPinnedSavedChatCount @@ -121,6 +123,7 @@ public enum EngineConfiguration { self.maxGiveawayCountriesCount = maxGiveawayCountriesCount self.maxGiveawayPeriodSeconds = maxGiveawayPeriodSeconds self.maxChannelRecommendationsCount = maxChannelRecommendationsCount + self.maxConferenceParticipantCount = maxConferenceParticipantCount } } } @@ -183,7 +186,8 @@ public extension EngineConfiguration.UserLimits { maxGiveawayChannelsCount: userLimitsConfiguration.maxGiveawayChannelsCount, maxGiveawayCountriesCount: userLimitsConfiguration.maxGiveawayCountriesCount, maxGiveawayPeriodSeconds: userLimitsConfiguration.maxGiveawayPeriodSeconds, - maxChannelRecommendationsCount: userLimitsConfiguration.maxChannelRecommendationsCount + maxChannelRecommendationsCount: userLimitsConfiguration.maxChannelRecommendationsCount, + maxConferenceParticipantCount: userLimitsConfiguration.maxConferenceParticipantCount ) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinLink.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinLink.swift index fb1eed8be1..1bf370f92d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinLink.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinLink.swift @@ -178,17 +178,23 @@ func _internal_joinCallLinkInformation(_ hash: String, account: Account) -> Sign } } -func _internal_joinCallInvitationInformation(account: Account, messageId: MessageId) -> Signal { +public enum JoinCallLinkInfoError { + case generic + case flood + case doesNotExist +} + +func _internal_joinCallInvitationInformation(account: Account, messageId: MessageId) -> Signal { return _internal_getCurrentGroupCall(account: account, reference: .message(id: messageId)) - |> mapError { error -> JoinLinkInfoError in + |> mapError { error -> JoinCallLinkInfoError in switch error { case .generic: return .generic } } - |> mapToSignal { call -> Signal in - guard let call = call else { - return .fail(.generic) + |> mapToSignal { call -> Signal in + guard let call else { + return .fail(.doesNotExist) } var members: [EnginePeer] = [] for participant in call.topParticipants { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift index 1f2ff94b75..a8b746b691 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift @@ -842,7 +842,7 @@ public extension TelegramEngine { return _internal_joinCallLinkInformation(hash, account: self.account) } - public func joinCallInvitationInformation(messageId: EngineMessage.Id) -> Signal { + public func joinCallInvitationInformation(messageId: EngineMessage.Id) -> Signal { return _internal_joinCallInvitationInformation(account: self.account, messageId: messageId) } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 11349d0ca7..e0c228ffef 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -469,6 +469,9 @@ swift_library( "//submodules/TelegramUI/Components/Settings/AccountFreezeInfoScreen", "//submodules/TelegramUI/Components/JoinSubjectScreen", "//submodules/TelegramUI/Components/Chat/QuickShareScreen", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/BlurredBackgroundComponent", + "//submodules/TelegramUI/Components/CheckComponent", "//third-party/recaptcha:RecaptchaEnterprise", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageCallBubbleContentNode/Sources/ChatMessageCallBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageCallBubbleContentNode/Sources/ChatMessageCallBubbleContentNode.swift index ccb94e8d77..8f414535fd 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageCallBubbleContentNode/Sources/ChatMessageCallBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageCallBubbleContentNode/Sources/ChatMessageCallBubbleContentNode.swift @@ -114,7 +114,6 @@ public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode { var callDuration: Int32? var callSuccessful = true var isVideo = false - var hasCallButton = true var updateConferenceTimerEndTimeout: Int32? for media in item.message.media { if let action = media as? TelegramMediaAction, case let .phoneCall(_, discardReason, duration, isVideoValue) = action.action { @@ -173,9 +172,7 @@ public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode { #else missedTimeout = 30 #endif - if conferenceCall.duration != nil { - hasCallButton = false - } + let currentTime = Int32(Date().timeIntervalSince1970) if conferenceCall.flags.contains(.isMissed) { titleString = "Declined Group Call" @@ -296,9 +293,7 @@ public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode { boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right boundingSize.height += layoutConstants.text.bubbleInsets.top + layoutConstants.text.bubbleInsets.bottom - if hasCallButton { - boundingSize.width += 54.0 - } + boundingSize.width += 54.0 return (boundingSize.width, { boundingWidth in return (boundingSize, { [weak self] animation, _, _ in @@ -359,7 +354,6 @@ public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode { if let buttonImage = buttonImage { strongSelf.buttonNode.setImage(buttonImage, for: []) strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: boundingWidth - buttonImage.size.width - 8.0, y: 15.0), size: buttonImage.size) - strongSelf.buttonNode.isHidden = !hasCallButton } if let activeConferenceUpdateTimer = strongSelf.activeConferenceUpdateTimer { @@ -411,10 +405,6 @@ public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode { } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { - if self.buttonNode.isHidden { - return ChatMessageBubbleContentTapAction(content: .none) - } - if self.buttonNode.frame.contains(point) { return ChatMessageBubbleContentTapAction(content: .ignore) } else if self.bounds.contains(point), let item = self.item { diff --git a/submodules/TelegramUI/Components/JoinSubjectScreen/Sources/JoinSubjectScreen.swift b/submodules/TelegramUI/Components/JoinSubjectScreen/Sources/JoinSubjectScreen.swift index 467fe9e858..5f03c035fe 100644 --- a/submodules/TelegramUI/Components/JoinSubjectScreen/Sources/JoinSubjectScreen.swift +++ b/submodules/TelegramUI/Components/JoinSubjectScreen/Sources/JoinSubjectScreen.swift @@ -405,7 +405,8 @@ private final class JoinSubjectScreenComponent: Component { isStream: false ), reference: .link(slug: groupCall.slug), - beginWithVideo: false + beginWithVideo: false, + invitePeerIds: [] ) self.environment?.controller()?.dismiss() diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index f2c780dd82..f3d3d2ec17 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -2894,6 +2894,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } if conferenceCall.duration != nil { + self.context.sharedContext.openCreateGroupCallUI(context: self.context, peerIds: conferenceCall.otherParticipants, parentController: self) return } @@ -2919,8 +2920,21 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G isStream: false ), reference: .message(id: message.id), - beginWithVideo: conferenceCall.flags.contains(.isVideo) + beginWithVideo: conferenceCall.flags.contains(.isVideo), + invitePeerIds: [] ) + }, error: { [weak self] error in + guard let self else { + return + } + switch error { + case .doesNotExist: + self.context.sharedContext.openCreateGroupCallUI(context: self.context, peerIds: conferenceCall.otherParticipants, parentController: self) + default: + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + //TODO:localize + self.present(textAlertController(context: self.context, title: nil, text: "An error occurred", actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + } }) }, longTap: { [weak self] action, params in if let self { diff --git a/submodules/TelegramUI/Sources/ComposeController.swift b/submodules/TelegramUI/Sources/ComposeController.swift index 8700b655e6..1738e15ebf 100644 --- a/submodules/TelegramUI/Sources/ComposeController.swift +++ b/submodules/TelegramUI/Sources/ComposeController.swift @@ -125,7 +125,7 @@ public class ComposeControllerImpl: ViewController, ComposeController { self.contactsNode.openCreateNewGroup = { [weak self] in if let strongSelf = self { - let controller = strongSelf.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: strongSelf.context, mode: .groupCreation, onlyWriteable: true)) + let controller = strongSelf.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: strongSelf.context, mode: .groupCreation(isCall: false), onlyWriteable: true)) (strongSelf.navigationController as? NavigationController)?.pushViewController(controller, completion: { [weak self] in if let strongSelf = self { strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true) diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index bce2a49f12..e97a1232a6 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -241,8 +241,13 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection } switch self.mode { - case .groupCreation: - let maxCount: Int32 = self.limitsConfiguration?.maxSupergroupMemberCount ?? 5000 + case let .groupCreation(isCall): + let maxCount: Int32 + if isCall { + maxCount = self.context.userLimits.maxConferenceParticipantCount + } else { + maxCount = self.limitsConfiguration?.maxSupergroupMemberCount ?? 5000 + } let count: Int switch self.contactsNode.contentNode { case let .contacts(contactsNode): @@ -250,8 +255,12 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection case let .chats(chatsNode): count = chatsNode.currentState.selectedPeerIds.count } - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: "\(count)/\(maxCount)") - if self.rightNavigationButton == nil { + if isCall && count == 0 { + self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: nil) + } else { + self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: "\(count)/\(maxCount)") + } + if self.rightNavigationButton == nil && !isCall { let rightNavigationButton = UIBarButtonItem(title: self.presentationData.strings.Common_Next, style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) self.rightNavigationButton = rightNavigationButton self.navigationItem.rightBarButtonItem = self.rightNavigationButton @@ -328,7 +337,12 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection var addedToken: EditableTokenListToken? var removedTokenId: AnyHashable? - let maxRegularCount: Int32 = strongSelf.limitsConfiguration?.maxGroupMemberCount ?? 200 + let maxRegularCount: Int32 + if case .groupCreation(true) = strongSelf.mode { + maxRegularCount = strongSelf.context.userLimits.maxConferenceParticipantCount + } else { + maxRegularCount = strongSelf.limitsConfiguration?.maxGroupMemberCount ?? 200 + } var displayCountAlert = false var selectionState: ContactListNodeGroupSelectionState? @@ -349,6 +363,10 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection if updatedState.selectedPeerIndices[.peer(peer.id)] == nil { removedTokenId = peer.id } else { + var selectedPeerMap = updatedState.selectedPeerMap + selectedPeerMap[.peer(peer.id)] = .peer(peer: peer, isGlobal: false, participantCount: nil) + updatedState = updatedState.withSelectedPeerMap(selectedPeerMap) + if updatedState.selectedPeerIndices.count >= maxRegularCount { displayCountAlert = true updatedState = updatedState.withToggledPeerId(.peer(peer.id)) @@ -415,6 +433,24 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection } } } + + if !self.params.initialSelectedPeers.isEmpty { + for peer in self.params.initialSelectedPeers { + self.contactsNode.openPeer?(.peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil)) + } + /*if case let .contacts(contactsNode) = self.contactsNode.contentNode { + contactsNode.updateSelectionState { state in + var updatedState = state ?? ContactListNodeGroupSelectionState() + var selectedPeerMap = updatedState.selectedPeerMap + for peer in self.params.initialSelectedPeers { + updatedState = updatedState.withToggledPeerId(.peer(peer.id)) + selectedPeerMap[.peer(peer.id)] = .peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil) + } + updatedState = updatedState.withSelectedPeerMap(selectedPeerMap) + return updatedState + } + }*/ + } self.contactsNode.openPeerMore = { [weak self] peer, node, gesture in guard let self, case let .peer(peer, _, _) = peer, let node = node as? ContextReferenceContentNode else { @@ -538,8 +574,13 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection break } switch strongSelf.mode { - case .groupCreation: - let maxCount: Int32 = strongSelf.limitsConfiguration?.maxSupergroupMemberCount ?? 5000 + case let .groupCreation(isCall): + let maxCount: Int32 + if isCall { + maxCount = strongSelf.context.userLimits.maxConferenceParticipantCount + } else { + maxCount = strongSelf.limitsConfiguration?.maxSupergroupMemberCount ?? 5000 + } strongSelf.titleView.title = CounterControllerTitle(title: strongSelf.presentationData.strings.Compose_NewGroupTitle, counter: "\(updatedCount)/\(maxCount)") case .premiumGifting: let maxCount: Int32 = strongSelf.limit ?? 10 diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index d2c4b95f4c..e555404005 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -14,6 +14,7 @@ import MultiAnimationRenderer import EditableTokenListNode import SolidRoundedButtonNode import ContextUI +import ComponentFlow private struct SearchResultEntry: Identifiable { let index: Int @@ -53,6 +54,7 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { var searchResultsNode: ContactListNode? private let context: AccountContext + private let mode: ContactMultiselectionControllerMode private var containerLayout: (ContainerViewLayout, CGFloat, CGFloat)? @@ -81,12 +83,15 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { private let isPeerEnabled: ((EnginePeer) -> Bool)? private let onlyWriteable: Bool private let isGroupInvitation: Bool + + private var bottomPanel: ComponentView? init(navigationBar: NavigationBar?, context: AccountContext, presentationData: PresentationData, updatedPresentationData: (initial: PresentationData, signal: Signal)?, mode: ContactMultiselectionControllerMode, isPeerEnabled: ((EnginePeer) -> Bool)?, attemptDisabledItemSelection: ((EnginePeer, ChatListDisabledPeerReason) -> Void)?, options: Signal<[ContactListAdditionalOption], NoError>, filters: [ContactListFilter], onlyWriteable: Bool, isGroupInvitation: Bool, limit: Int32?, reachedSelectionLimit: ((Int32) -> Void)?, present: @escaping (ViewController, Any?) -> Void) { self.navigationBar = navigationBar self.context = context self.presentationData = presentationData + self.mode = mode self.animationCache = context.animationCache self.animationRenderer = context.animationRenderer @@ -120,6 +125,17 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { self.footerPanelNode = FooterPanelNode(theme: self.presentationData.theme, strings: self.presentationData.strings, action: { proceedImpl?() }) + case let .groupCreation(isCall): + if isCall { + //TODO:localize + placeholder = "Search for contacts or usernames" + self.footerPanelNode = FooterPanelNode(theme: self.presentationData.theme, strings: self.presentationData.strings, action: { + proceedImpl?() + }) + } else { + placeholder = self.presentationData.strings.Compose_TokenListPlaceholder + self.footerPanelNode = nil + } default: placeholder = self.presentationData.strings.Compose_TokenListPlaceholder self.footerPanelNode = nil @@ -462,7 +478,24 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { if case let .contacts(contactListNode) = self.contentNode { count = contactListNode.selectionState?.selectedPeerIndices.count ?? 0 } - footerPanelNode.count = count + if case let .groupCreation(isCall) = self.mode, isCall { + //TODO:localize + if count == 0 { + // Don't set anything to prevent state update + } else if count <= 1 { + let callTitle: String + if case let .contacts(contactListNode) = self.contentNode, let peer = contactListNode.selectedPeers.first, case let .peer(peer, _, _) = peer { + callTitle = "Call \(EnginePeer(peer).compactDisplayTitle)" + } else { + callTitle = "Call" + } + footerPanelNode.content = FooterPanelNode.Content(title: callTitle, badge: "") + } else { + footerPanelNode.content = FooterPanelNode.Content(title: "Call", badge: "\(count)") + } + } else { + footerPanelNode.content = FooterPanelNode.Content(title: self.presentationData.strings.Premium_Gift_ContactSelection_Proceed, badge: count == 0 ? "" : "\(count)") + } let panelHeight = footerPanelNode.updateLayout(width: layout.size.width, sideInset: layout.safeInsets.left, bottomInset: headerInsets.bottom, transition: transition) if count == 0 { transition.updateFrame(node: footerPanelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height), size: CGSize(width: layout.size.width, height: panelHeight))) @@ -509,6 +542,16 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { private final class FooterPanelNode: ASDisplayNode { + struct Content: Equatable { + let title: String + let badge: String + + init(title: String, badge: String) { + self.title = title + self.badge = badge + } + } + private let theme: PresentationTheme private let strings: PresentationStrings @@ -517,11 +560,11 @@ private final class FooterPanelNode: ASDisplayNode { private var validLayout: (CGFloat, CGFloat, CGFloat)? - var count: Int = 0 { + var content: Content { didSet { - if self.count != oldValue && self.count > 0 { - self.button.title = self.strings.Premium_Gift_ContactSelection_Proceed - self.button.badge = "\(self.count)" + if self.content != oldValue { + self.button.title = content.title + self.button.badge = content.badge.isEmpty ? nil : content.badge if let (width, sideInset, bottomInset) = self.validLayout { let _ = self.updateLayout(width: width, sideInset: sideInset, bottomInset: bottomInset, transition: .immediate) @@ -538,6 +581,8 @@ private final class FooterPanelNode: ASDisplayNode { self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor self.button = SolidRoundedButtonView(theme: SolidRoundedButtonTheme(theme: theme), height: 48.0, cornerRadius: 10.0) + + self.content = Content(title: self.strings.Premium_Gift_ContactSelection_Proceed, badge: "") super.init() diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 31962b885a..d3ec4e7a5a 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -80,6 +80,7 @@ import ShareController import AccountFreezeInfoScreen import JoinSubjectScreen import OldChannelsController +import InviteLinksUI private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -1904,6 +1905,200 @@ public final class SharedAccountContextImpl: SharedAccountContext { return controller } + public func openCreateGroupCallUI(context: AccountContext, peerIds: [EnginePeer.Id], parentController: ViewController) { + let _ = (context.engine.data.get( + EngineDataList(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak parentController] peers in + guard let parentController else { + return + } + + let peers = peers.compactMap({ $0 }) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let controller = context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams( + context: context, + title: presentationData.strings.Calls_NewCall, + mode: .groupCreation(isCall: true), + options: .single([]), + filters: [.excludeSelf], + onlyWriteable: true, + isGroupInvitation: false, + isPeerEnabled: nil, + attemptDisabledItemSelection: nil, + alwaysEnabled: false, + limit: nil, + reachedLimit: nil, + openProfile: nil, + sendMessage: nil, + initialSelectedPeers: peers + )) + controller.navigationPresentation = .modal + if let navigationController = parentController.navigationController as? NavigationController { + navigationController.pushViewController(controller) + } else if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController { + navigationController.pushViewController(controller) + } + + let _ = (controller.result + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak controller] result in + guard case let .result(rawPeerIds, _) = result else { + controller?.dismiss() + return + } + let peerIds = rawPeerIds.compactMap { id -> EnginePeer.Id? in + if case let .peer(id) = id { + return id + } + return nil + } + if peerIds.isEmpty { + controller?.dismiss() + return + } + + if peerIds.count == 1 { + //TODO:release isVideo + controller?.dismiss() + self.performCall(context: context, parentController: parentController, peerId: peerIds[0], isVideo: false, began: { + let _ = (context.sharedContext.hasOngoingCall.get() + |> filter { $0 } + |> timeout(1.0, queue: Queue.mainQueue(), alternate: .single(true)) + |> delay(0.5, queue: Queue.mainQueue()) + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { _ in + if let controller, let navigationController = controller.navigationController as? NavigationController { + if navigationController.viewControllers.last === controller { + let _ = navigationController.popViewController(animated: true) + } + } + }) + }) + } else { + self.createGroupCall(context: context, parentController: parentController, peerIds: peerIds, completion: { + controller?.dismiss() + }) + } + }) + }) + } + + private func performCall(context: AccountContext, parentController: ViewController, peerId: EnginePeer.Id, isVideo: Bool, began: (() -> Void)? = nil) { + let _ = (context.account.viewTracker.peerView(peerId) + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak parentController] view in + guard let parentController else { + return + } + guard let peer = peerViewMainPeer(view) else { + return + } + + if let cachedUserData = view.cachedData as? CachedUserData, cachedUserData.callsPrivate { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + parentController.present(textAlertController(context: context, title: presentationData.strings.Call_ConnectionErrorTitle, text: presentationData.strings.Call_PrivacyErrorMessage(EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + return + } + + context.requestCall(peerId: peerId, isVideo: isVideo, completion: { + began?() + }) + }) + } + + private func createGroupCall(context: AccountContext, parentController: ViewController, peerIds: [EnginePeer.Id], completion: (() -> Void)? = nil) { + parentController.view.endEditing(true) + + var cancelImpl: (() -> Void)? + var signal = context.engine.calls.createConferenceCall() + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let progressSignal = Signal { [weak parentController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + parentController?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.3, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + signal = signal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + + let disposable = (signal + |> deliverOnMainQueue).startStandalone(next: { [weak parentController] call in + guard let parentController else { + return + } + + let openCall: () -> Void = { + context.sharedContext.callManager?.joinConferenceCall( + accountContext: context, + initialCall: EngineGroupCallDescription( + id: call.callInfo.id, + accessHash: call.callInfo.accessHash, + title: call.callInfo.title, + scheduleTimestamp: nil, + subscribedToScheduled: false, + isStream: false + ), + reference: .id(id: call.callInfo.id, accessHash: call.callInfo.accessHash), + beginWithVideo: false, + invitePeerIds: peerIds + ) + completion?() + } + + if !peerIds.isEmpty { + openCall() + } else { + let controller = InviteLinkInviteController( + context: context, + updatedPresentationData: nil, + mode: .groupCall(InviteLinkInviteController.Mode.GroupCall(callId: call.callInfo.id, accessHash: call.callInfo.accessHash, isRecentlyCreated: true, canRevoke: true)), + initialInvite: .link(link: call.link, title: nil, isPermanent: true, requestApproval: false, isRevoked: false, adminId: context.account.peerId, date: 0, startDate: nil, expireDate: nil, usageLimit: nil, count: nil, requestedCount: nil, pricing: nil), + parentNavigationController: parentController.navigationController as? NavigationController, + completed: { [weak parentController] result in + guard let parentController else { + return + } + if let result { + switch result { + case .linkCopied: + //TODO:localize + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + parentController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_linkcopied", scale: 0.08, colors: ["info1.info1.stroke": UIColor.clear, "info2.info2.Fill": UIColor.clear], title: nil, text: "Call link copied.", customUndoText: "View Call", timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in + if case .undo = action { + openCall() + } + return false + }), in: .window(.root)) + case .openCall: + openCall() + } + } + } + ) + parentController.present(controller, in: .window(.root), with: nil) + } + }) + + cancelImpl = { + disposable.dispose() + } + } + public func openExternalUrl(context: AccountContext, urlContext: OpenURLContext, url: String, forceExternal: Bool, presentationData: PresentationData, navigationController: NavigationController?, dismissInput: @escaping () -> Void) { openExternalUrlImpl(context: context, urlContext: urlContext, url: url, forceExternal: forceExternal, presentationData: presentationData, navigationController: navigationController, dismissInput: dismissInput) }