From 2ab4af656b8189f5bb8b59d0e41876aa501c242d Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 8 Apr 2025 16:35:41 +0400 Subject: [PATCH] Conference calls --- .../Sources/AccountContext.swift | 2 + .../ContactMultiselectionController.swift | 5 +- .../Sources/CallListController.swift | 18 +- .../TelegramEngine/Peers/JoinLink.swift | 16 +- .../Peers/TelegramEnginePeers.swift | 2 +- .../ChatMessageCallBubbleContentNode.swift | 14 +- .../TelegramUI/Sources/ChatController.swift | 13 ++ .../ContactMultiselectionController.swift | 25 ++- .../Sources/SharedAccountContext.swift | 195 ++++++++++++++++++ 9 files changed, 267 insertions(+), 23 deletions(-) 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 3c0720a145..43f815e7d0 100644 --- a/submodules/AccountContext/Sources/ContactMultiselectionController.swift +++ b/submodules/AccountContext/Sources/ContactMultiselectionController.swift @@ -109,6 +109,7 @@ 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, @@ -125,7 +126,8 @@ public final class ContactMultiselectionControllerParams { limit: Int32? = nil, reachedLimit: ((Int32) -> Void)? = nil, openProfile: ((EnginePeer) -> Void)? = nil, - sendMessage: ((EnginePeer) -> Void)? = nil + sendMessage: ((EnginePeer) -> Void)? = nil, + initialSelectedPeers: [EnginePeer] = [] ) { self.context = context self.updatedPresentationData = updatedPresentationData @@ -142,6 +144,7 @@ public final class ContactMultiselectionControllerParams { self.reachedLimit = reachedLimit self.openProfile = openProfile self.sendMessage = sendMessage + self.initialSelectedPeers = initialSelectedPeers } } diff --git a/submodules/CallListUI/Sources/CallListController.swift b/submodules/CallListUI/Sources/CallListController.swift index 7c155f916d..0607e99d09 100644 --- a/submodules/CallListUI/Sources/CallListController.swift +++ b/submodules/CallListUI/Sources/CallListController.swift @@ -667,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 @@ -676,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 } @@ -700,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 } @@ -728,6 +728,18 @@ public final class CallListController: TelegramBaseController { 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/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/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/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index fbcec4709f..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 } @@ -2922,6 +2923,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G 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/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index 94131b233b..e97a1232a6 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -337,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? @@ -428,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 { 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) }