diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 0a067efc1c..552754ecfb 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -918,6 +918,7 @@ private final class NotificationServiceHandler { case logout case poll(peerId: PeerId, content: NotificationContent, messageId: MessageId?) case deleteMessage([MessageId]) + case readReactions([MessageId]) case readMessage(MessageId) case call(CallData) } @@ -948,6 +949,20 @@ private final class NotificationServiceHandler { action = .deleteMessage(messagesDeleted) } } + case "READ_REACTION": + if let peerId { + if let messageId = messageId { + action = .readReactions([MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageId)]) + } else if let messageIds = payloadJson["messages"] as? String { + var messages: [MessageId] = [] + for messageId in messageIds.split(separator: ",") { + if let messageIdValue = Int32(messageId) { + messages.append(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageIdValue)) + } + } + action = .readReactions(messages) + } + } case "READ_HISTORY": if let peerId = peerId { if let messageIdString = payloadJson["max_id"] as? String { @@ -1060,7 +1075,12 @@ private final class NotificationServiceHandler { } else { content.category = category } - + + if aps["r"] != nil || aps["react_emoji"] != nil { + content.category = "t" + } else if payloadJson["r"] != nil || payloadJson["react_emoji"] != nil { + content.category = "t" + } let _ = messageId @@ -1588,6 +1608,45 @@ private final class NotificationServiceHandler { } }) }) + case let .readReactions(ids): + Logger.shared.log("NotificationService \(episode)", "Will read reactions \(ids)") + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in + var removeIdentifiers: [String] = [] + for notification in notifications { + if notification.request.content.categoryIdentifier != "t" { + continue + } + if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerIdValue = Int64(peerIdString), let messageIdString = notification.request.content.userInfo["msg_id"] as? String, let messageIdValue = Int32(messageIdString) { + for id in ids { + if PeerId(peerIdValue) == id.peerId && messageIdValue == id.id { + removeIdentifiers.append(notification.request.identifier) + } + } + } + } + + let completeRemoval: () -> Void = { + guard let strongSelf = self else { + return + } + var content = NotificationContent(isLockedMessage: nil) + Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)") + + updateCurrentContent(content) + + completed() + } + + if !removeIdentifiers.isEmpty { + Logger.shared.log("NotificationService \(episode)", "Will try to remove \(removeIdentifiers.count) notifications") + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: removeIdentifiers) + queue.after(1.0, { + completeRemoval() + }) + } else { + completeRemoval() + } + }) case let .readMessage(id): Logger.shared.log("NotificationService \(episode)", "Will read message \(id)") let _ = (stateManager.postbox.transaction { transaction -> Void in diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index ac84368a67..512728e03d 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -5823,6 +5823,8 @@ Sorry for the inconvenience."; "VoiceChat.Audio" = "audio"; "VoiceChat.Leave" = "leave"; +"LiveStream.Expand" = "expand"; + "VoiceChat.SpeakPermissionEveryone" = "New participants can speak"; "VoiceChat.SpeakPermissionAdmin" = "New paricipants are muted"; "VoiceChat.Share" = "Share Invite Link"; @@ -5959,7 +5961,9 @@ Sorry for the inconvenience."; "LiveStream.RecordingInProgress" = "Live stream is being recorded"; "VoiceChat.StopRecordingTitle" = "Stop Recording?"; -"VoiceChat.StopRecordingStop" = "Stop"; +"VoiceChat.StopRecordingStop" = "Stop Recording"; + +"LiveStream.StopLiveStream" = "Stop Live Stream"; "VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; @@ -7420,6 +7424,7 @@ Sorry for the inconvenience."; "LiveStream.NoViewers" = "No viewers"; "LiveStream.ViewerCount_1" = "1 viewer"; "LiveStream.ViewerCount_any" = "%@ viewers"; +"LiveStream.Watching" = "watching"; "LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; "LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; @@ -7536,6 +7541,7 @@ Sorry for the inconvenience."; "PeerInfo.AutoDeleteSettingOther" = "Other..."; "PeerInfo.AutoDeleteDisable" = "Disable"; "PeerInfo.AutoDeleteInfo" = "Automatically delete messages sent in this chat after a certain period of time."; +"PeerInfo.ChannelAutoDeleteInfo" = "Automatically delete messages sent in this channel after a certain period of time."; "PeerInfo.ClearMessages" = "Clear Messages"; "PeerInfo.ClearConfirmationUser" = "Are you sure you want to delete all messages with %@?"; @@ -9334,3 +9340,5 @@ Sorry for the inconvenience."; "ChatList.EmptyListTooltip" = "Send a message or\nstart a group here."; "Username.BotTitle" = "Public Links"; + +"Notification.LockScreenReactionPlaceholder" = "Reaction"; diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 087002b3b6..94832b4542 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -3561,17 +3561,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } case let .forum(peerId): - self.joinForumDisposable.set((self.context.peerChannelMemberCategoriesContextsManager.join(engine: context.engine, peerId: peerId, hash: nil) - |> afterDisposed { [weak self] in - Queue.mainQueue().async { - if let strongSelf = self { - let _ = strongSelf - /*strongSelf.activityIndicator.isHidden = true - strongSelf.activityIndicator.stopAnimating() - strongSelf.isJoining = false*/ + let presentationData = self.presentationData + let progressSignal = Signal { [weak self] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil)) + self?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() } } - }).start(error: { [weak self] error in + } + |> runOn(Queue.mainQueue()) + |> delay(0.8, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + let signal: Signal = self.context.peerChannelMemberCategoriesContextsManager.join(engine: self.context.engine, peerId: peerId, hash: nil) + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + + self.joinForumDisposable.set((signal + |> deliverOnMainQueue).start(error: { [weak self] error in guard let strongSelf = self else { return } @@ -4213,9 +4225,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } let engine = self.context.engine + + let hasArchived = engine.messages.chatList(group: .archive, count: 10) + |> take(1) + |> map { list -> Bool in + return !list.items.isEmpty + } + self.chatListDisplayNode.mainContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerIds[0], threadId: nil)) - let _ = (ApplicationSpecificNotice.incrementArchiveChatTips(accountManager: self.context.sharedContext.accountManager, count: 1) - |> deliverOnMainQueue).start(next: { [weak self] previousHintCount in + let _ = (combineLatest( + ApplicationSpecificNotice.incrementArchiveChatTips(accountManager: self.context.sharedContext.accountManager, count: 1), + hasArchived + ) + |> deliverOnMainQueue).start(next: { [weak self] previousHintCount, hasArchived in let _ = (engine.peers.updatePeersGroupIdInteractively(peerIds: peerIds, groupId: .archive) |> deliverOnMainQueue).start(completed: { guard let strongSelf = self else { @@ -4256,14 +4278,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var title = peerIds.count == 1 ? strongSelf.presentationData.strings.ChatList_UndoArchiveTitle : strongSelf.presentationData.strings.ChatList_UndoArchiveMultipleTitle let text: String let undo: Bool - switch previousHintCount { - case 0: - text = strongSelf.presentationData.strings.ChatList_UndoArchiveText1 - undo = false - default: - text = title - title = "" - undo = true + if hasArchived || previousHintCount != 0 { + text = title + title = "" + undo = true + } else { + text = strongSelf.presentationData.strings.ChatList_UndoArchiveText1 + undo = false } let controller = UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .archivedChat(peerId: peerIds[0].toInt64(), title: title, text: text, undo: undo), elevatedLayout: false, animateInAsReplacement: true, action: action) strongSelf.present(controller, in: .current) diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index e05badfeb4..7169a163e8 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -329,6 +329,8 @@ private final class ChatListContainerItemNode: ASDisplayNode { } private let context: AccountContext + private weak var controller: ChatListControllerImpl? + private let location: ChatListControllerLocation private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer private var presentationData: PresentationData @@ -348,13 +350,18 @@ private final class ChatListContainerItemNode: ASDisplayNode { private var pollFilterUpdatesDisposable: Disposable? private var chatFilterUpdatesDisposable: Disposable? + private var peerDataDisposable: Disposable? private var chatFolderUpdates: ChatFolderUpdates? + private var canReportPeer: Bool = false + private(set) var validLayout: (size: CGSize, insets: UIEdgeInsets, visualNavigationHeight: CGFloat, originalNavigationHeight: CGFloat, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat)? - init(context: AccountContext, location: ChatListControllerLocation, filter: ChatListFilter?, chatListMode: ChatListNodeMode, previewing: Bool, isInlineMode: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, becameEmpty: @escaping (ChatListFilter?) -> Void, emptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { + init(context: AccountContext, controller: ChatListControllerImpl?, location: ChatListControllerLocation, filter: ChatListFilter?, chatListMode: ChatListNodeMode, previewing: Bool, isInlineMode: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, becameEmpty: @escaping (ChatListFilter?) -> Void, emptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { self.context = context + self.controller = controller + self.location = location self.animationCache = animationCache self.animationRenderer = animationRenderer self.presentationData = presentationData @@ -504,11 +511,33 @@ private final class ChatListContainerItemNode: ASDisplayNode { } }) } + + if case let .forum(peerId) = location { + self.peerDataDisposable = (context.engine.data.subscribe( + TelegramEngine.EngineData.Item.Peer.StatusSettings(id: peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self] statusSettings in + guard let self else { + return + } + var canReportPeer = false + if let statusSettings, statusSettings.flags.contains(.canReport) { + canReportPeer = true + } + if self.canReportPeer != canReportPeer { + self.canReportPeer = canReportPeer + if let (size, insets, visualNavigationHeight, originalNavigationHeight, inlineNavigationLocation, inlineNavigationTransitionFraction) = self.validLayout { + self.updateLayout(size: size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, transition: .animated(duration: 0.4, curve: .spring)) + } + } + }) + } } deinit { self.pollFilterUpdatesDisposable?.dispose() self.chatFilterUpdatesDisposable?.dispose() + self.peerDataDisposable?.dispose() } private func layoutEmptyShimmerEffectNode(node: ChatListShimmerNode, size: CGSize, insets: UIEdgeInsets, verticalOffset: CGFloat, transition: ContainedViewLayoutTransition) { @@ -580,6 +609,7 @@ private final class ChatListContainerItemNode: ASDisplayNode { component: AnyComponent(ActionPanelComponent( theme: self.presentationData.theme, title: title, + color: .accent, action: { [weak self] in guard let self, let chatFolderUpdates = self.chatFolderUpdates else { return @@ -603,6 +633,72 @@ private final class ChatListContainerItemNode: ASDisplayNode { } } + topPanel.size = CGSize(width: size.width, height: topPanelHeight) + listInsets.top += topPanelHeight + additionalTopInset += topPanelHeight + } else if self.canReportPeer { + let topPanel: TopPanelItem + var topPanelTransition = Transition(transition) + if let current = self.topPanel { + topPanel = current + } else { + topPanelTransition = .immediate + topPanel = TopPanelItem() + self.topPanel = topPanel + } + + let title: String = self.presentationData.strings.Conversation_ReportSpamAndLeave + + let topPanelHeight: CGFloat = 44.0 + + let _ = topPanel.view.update( + transition: topPanelTransition, + component: AnyComponent(ActionPanelComponent( + theme: self.presentationData.theme, + title: title, + color: .destructive, + action: { [weak self] in + guard let self, case let .forum(peerId) = self.location else { + return + } + + let actionSheet = ActionSheetController(presentationData: self.presentationData) + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: self.presentationData.strings.Conversation_ReportSpamGroupConfirmation), + ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ReportSpamAndLeave, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + if let self { + self.controller?.setInlineChatList(location: nil) + let _ = self.context.engine.peers.removePeerChat(peerId: peerId, reportChatSpam: true).start() + } + }) + ]), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.listNode.present?(actionSheet) + }, + dismissAction: { [weak self] in + guard let self, case let .forum(peerId) = self.location else { + return + } + let _ = self.context.engine.peers.dismissPeerStatusOptions(peerId: peerId).start() + } + )), + environment: {}, + containerSize: CGSize(width: size.width, height: topPanelHeight) + ) + if let topPanelView = topPanel.view.view { + if topPanelView.superview == nil { + self.view.addSubview(topPanelView) + } + } + topPanel.size = CGSize(width: size.width, height: topPanelHeight) listInsets.top += topPanelHeight additionalTopInset += topPanelHeight @@ -635,6 +731,7 @@ private final class ChatListContainerItemNode: ASDisplayNode { public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDelegate { private let context: AccountContext + private weak var controller: ChatListControllerImpl? let location: ChatListControllerLocation private let chatListMode: ChatListNodeMode private let previewing: Bool @@ -847,8 +944,9 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele var didBeginSelectingChats: (() -> Void)? public var displayFilterLimit: (() -> Void)? - public init(context: AccountContext, location: ChatListControllerLocation, chatListMode: ChatListNodeMode = .chatList(appendContacts: true), previewing: Bool, controlsHistoryPreload: Bool, isInlineMode: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, filterBecameEmpty: @escaping (ChatListFilter?) -> Void, filterEmptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { + public init(context: AccountContext, controller: ChatListControllerImpl?, location: ChatListControllerLocation, chatListMode: ChatListNodeMode = .chatList(appendContacts: true), previewing: Bool, controlsHistoryPreload: Bool, isInlineMode: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, filterBecameEmpty: @escaping (ChatListFilter?) -> Void, filterEmptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { self.context = context + self.controller = controller self.location = location self.chatListMode = chatListMode self.previewing = previewing @@ -872,7 +970,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele self.backgroundColor = presentationData.theme.chatList.backgroundColor - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: nil, chatListMode: chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: nil, chatListMode: chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1170,7 +1268,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele itemNode.emptyNode?.restartAnimation() completion?() } else if self.pendingItemNode == nil { - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: self.availableFilters[index].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: self.availableFilters[index].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1288,7 +1386,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele validNodeIds.append(id) if self.itemNodes[id] == nil && self.enableAdjacentFilterLoading && !self.disableItemNodeOperationsWhileAnimating { - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: self.availableFilters[i].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: self.availableFilters[i].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1421,7 +1519,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { var filterBecameEmpty: ((ChatListFilter?) -> Void)? var filterEmptyAction: ((ChatListFilter?) -> Void)? var secondaryEmptyAction: (() -> Void)? - self.mainContainerNode = ChatListContainerNode(context: context, location: location, previewing: previewing, controlsHistoryPreload: controlsHistoryPreload, isInlineMode: false, presentationData: presentationData, animationCache: animationCache, animationRenderer: animationRenderer, filterBecameEmpty: { filter in + self.mainContainerNode = ChatListContainerNode(context: context, controller: controller, location: location, previewing: previewing, controlsHistoryPreload: controlsHistoryPreload, isInlineMode: false, presentationData: presentationData, animationCache: animationCache, animationRenderer: animationRenderer, filterBecameEmpty: { filter in filterBecameEmpty?(filter) }, filterEmptyAction: { filter in filterEmptyAction?(filter) @@ -1849,7 +1947,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { forumPeerId = peerId } - let inlineStackContainerNode = ChatListContainerNode(context: self.context, location: location, previewing: false, controlsHistoryPreload: false, isInlineMode: true, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { [weak self] _ in self?.emptyListAction?(forumPeerId) }, secondaryEmptyAction: {}) + let inlineStackContainerNode = ChatListContainerNode(context: self.context, controller: self.controller, location: location, previewing: false, controlsHistoryPreload: false, isInlineMode: true, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { [weak self] _ in self?.emptyListAction?(forumPeerId) }, secondaryEmptyAction: {}) return inlineStackContainerNode } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 6cdfc5f62c..fd358cff29 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -1775,7 +1775,7 @@ public final class ChatListNode: ListView { })*/ let contacts: Signal<[ChatListContactPeer], NoError> - if case .chatList(groupId: .root) = location, chatListFilter == nil { + if case .chatList(groupId: .root) = location, chatListFilter == nil, case .chatList = mode { contacts = ApplicationSpecificNotice.displayChatListContacts(accountManager: context.sharedContext.accountManager) |> distinctUntilChanged |> mapToSignal { value -> Signal<[ChatListContactPeer], NoError> in diff --git a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift index df3d4fae55..af917c84f2 100644 --- a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift +++ b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift @@ -180,7 +180,7 @@ public final class _UpdatedChildComponent { var _opacity: CGFloat? var _cornerRadius: CGFloat? var _clipsToBounds: Bool? - + fileprivate var transitionAppear: Transition.Appear? fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)? fileprivate var transitionDisappear: Transition.Disappear? @@ -240,7 +240,7 @@ public final class _UpdatedChildComponent { self._position = position return self } - + @discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent { self._scale = scale return self @@ -702,6 +702,7 @@ public extension CombinedComponent { } else { updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint()) } + updatedChild.view.alpha = updatedChild._opacity ?? 1.0 updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false updatedChild.view.layer.cornerRadius = updatedChild._cornerRadius ?? 0.0 diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 59b7b962c4..740e9d710c 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -46,6 +46,9 @@ public enum ContextMenuActionItemTextColor { public enum ContextMenuActionResult { case `default` case dismissWithoutContent + /// Temporary + static var safeStreamRecordingDismissWithoutContent: ContextMenuActionResult { .dismissWithoutContent } + case custom(ContainedViewLayoutTransition) } diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index d50dd29b60..e29ebf1ed3 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -499,16 +499,19 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C private let getController: () -> ContextControllerProtocol? private let item: ContextMenuCustomItem + private let requestDismiss: (ContextMenuActionResult) -> Void private var presentationData: PresentationData? private var itemNode: ContextMenuCustomNode? init( getController: @escaping () -> ContextControllerProtocol?, - item: ContextMenuCustomItem + item: ContextMenuCustomItem, + requestDismiss: @escaping (ContextMenuActionResult) -> Void ) { self.getController = getController self.item = item + self.requestDismiss = requestDismiss super.init() } @@ -529,7 +532,12 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C presentationData: presentationData, getController: self.getController, actionSelected: { result in - let _ = result + switch result { + case .dismissWithoutContent/* where ContextMenuActionResult.safeStreamRecordingDismissWithoutContent == .dismissWithoutContent*/: + self.requestDismiss(result) + + default: break + } } ) self.itemNode = itemNode @@ -601,7 +609,8 @@ final class ContextControllerActionsListStackItem: ContextControllerActionsStack return Item( node: ContextControllerActionsListCustomItemNode( getController: getController, - item: customItem + item: customItem, + requestDismiss: requestDismiss ), separatorNode: ASDisplayNode() ) diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index 099518966b..6a31d7403d 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -269,6 +269,9 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo super.init() + self.view.addSubview(self.scroller) + self.scroller.isHidden = true + self.addSubnode(self.backgroundNode) self.addSubnode(self.clippingNode) self.clippingNode.addSubnode(self.scrollNode) diff --git a/submodules/DrawingUI/Sources/StickerPickerScreen.swift b/submodules/DrawingUI/Sources/StickerPickerScreen.swift index 7e5720995d..85e9b74f03 100644 --- a/submodules/DrawingUI/Sources/StickerPickerScreen.swift +++ b/submodules/DrawingUI/Sources/StickerPickerScreen.swift @@ -377,7 +377,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) content.masks?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -455,7 +456,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) var stickerPeekBehavior: EmojiContentPeekBehaviorImpl? @@ -580,7 +582,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) if let (layout, navigationHeight) = self.currentLayout { diff --git a/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift b/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift index 82bacf44f3..6720f7df92 100644 --- a/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift +++ b/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift @@ -46,7 +46,7 @@ public final class ManagedAnimationState { } else if let unpackedData = TGGUnzipData(data, 5 * 1024 * 1024) { data = unpackedData } - guard let instance = LottieInstance(data: data, fitzModifier: .none, colorReplacements: item.replaceColors, cacheKey: item.source.cacheKey) else { + guard let instance = LottieInstance(data: data, fitzModifier: .none, colorReplacements: item.replaceColors, cacheKey: "") else { return nil } resolvedInstance = instance diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift index 8f5178f2b7..57b977e836 100644 --- a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift @@ -50,6 +50,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent let peerId: EnginePeer.Id private(set) var credentials: GroupCallStreamCredentials? + var isDelayingLoadingIndication: Bool = true private var credentialsDisposable: Disposable? private let activeActionDisposable = MetaDisposable() @@ -100,6 +101,13 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent return } + strongSelf.isDelayingLoadingIndication = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak strongSelf] in + guard let strongSelf else { return } + strongSelf.isDelayingLoadingIndication = false + strongSelf.updated(transition: .easeInOut(duration: 0.3)) + } + var cancelImpl: (() -> Void)? let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let progressSignal = Signal { [weak baseController] subscriber in @@ -397,7 +405,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent context.add(credentialsCopyKeyButton .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0)) ) - } else { + } else if !context.state.isDelayingLoadingIndication { let activityIndicator = activityIndicator.update( component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor), availableSize: CGSize(width: 100.0, height: 100.0), diff --git a/submodules/Postbox/Sources/ChatListView.swift b/submodules/Postbox/Sources/ChatListView.swift index f6e82fa722..56a37baaaa 100644 --- a/submodules/Postbox/Sources/ChatListView.swift +++ b/submodules/Postbox/Sources/ChatListView.swift @@ -677,6 +677,7 @@ final class MutableChatListView { switch entry { case let .IntermediateMessageEntry(index, messageIndex): var renderedMessages: [Message] = [] + if let messageIndex = messageIndex { if let messageGroup = postbox.messageHistoryTable.getMessageGroup(at: messageIndex, limit: 10) { renderedMessages.append(contentsOf: messageGroup.compactMap(postbox.renderIntermediateMessage)) diff --git a/submodules/Postbox/Sources/ChatListViewState.swift b/submodules/Postbox/Sources/ChatListViewState.swift index 20eca6a4a3..1551b9a486 100644 --- a/submodules/Postbox/Sources/ChatListViewState.swift +++ b/submodules/Postbox/Sources/ChatListViewState.swift @@ -1599,6 +1599,37 @@ struct ChatListViewState { } } + var needsMigrationMerge = false + for message in renderedMessages { + if postbox.seedConfiguration.isPeerUpgradeMessage(message) { + if renderedMessages.count == 1 { + needsMigrationMerge = true + } + } + } + + if needsMigrationMerge, let associatedMessageId = postbox.cachedPeerDataTable.get(index.messageIndex.id.peerId)?.associatedHistoryMessageId { + let innerMessages = postbox.messageHistoryTable.fetch( + peerId: associatedMessageId.peerId, + namespace: associatedMessageId.namespace, + tag: nil, + threadId: nil, + from: .absoluteUpperBound().withPeerId(associatedMessageId.peerId).withNamespace(associatedMessageId.namespace), + includeFrom: true, + to: .absoluteLowerBound().withPeerId(associatedMessageId.peerId).withNamespace(associatedMessageId.namespace), + ignoreMessagesInTimestampRange: nil, + limit: 2 + ) + for innerMessage in innerMessages { + let message = postbox.renderIntermediateMessage(innerMessage) + if !postbox.seedConfiguration.isPeerUpgradeMessage(message) { + renderedMessages.removeAll() + renderedMessages.append(message) + break + } + } + } + var autoremoveTimeout: Int32? if let cachedData = postbox.cachedPeerDataTable.get(index.messageIndex.id.peerId) { autoremoveTimeout = postbox.seedConfiguration.decodeAutoremoveTimeout(cachedData) diff --git a/submodules/Postbox/Sources/SeedConfiguration.swift b/submodules/Postbox/Sources/SeedConfiguration.swift index f39902dd77..704eb78956 100644 --- a/submodules/Postbox/Sources/SeedConfiguration.swift +++ b/submodules/Postbox/Sources/SeedConfiguration.swift @@ -76,6 +76,7 @@ public final class SeedConfiguration { public let mergeMessageAttributes: ([MessageAttribute], inout [MessageAttribute]) -> Void public let decodeMessageThreadInfo: (CodableEntry) -> Message.AssociatedThreadInfo? public let decodeAutoremoveTimeout: (CachedPeerData) -> Int32? + public let isPeerUpgradeMessage: (Message) -> Bool public init( globalMessageIdsPeerIdNamespaces: Set, @@ -101,7 +102,8 @@ public final class SeedConfiguration { defaultGlobalNotificationSettings: PostboxGlobalNotificationSettings, mergeMessageAttributes: @escaping ([MessageAttribute], inout [MessageAttribute]) -> Void, decodeMessageThreadInfo: @escaping (CodableEntry) -> Message.AssociatedThreadInfo?, - decodeAutoremoveTimeout: @escaping (CachedPeerData) -> Int32? + decodeAutoremoveTimeout: @escaping (CachedPeerData) -> Int32?, + isPeerUpgradeMessage: @escaping (Message) -> Bool ) { self.globalMessageIdsPeerIdNamespaces = globalMessageIdsPeerIdNamespaces self.initializeChatListWithHole = initializeChatListWithHole @@ -123,5 +125,6 @@ public final class SeedConfiguration { self.mergeMessageAttributes = mergeMessageAttributes self.decodeMessageThreadInfo = decodeMessageThreadInfo self.decodeAutoremoveTimeout = decodeAutoremoveTimeout + self.isPeerUpgradeMessage = isPeerUpgradeMessage } } diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index d2ff3e60cc..1ea0d48d3b 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -1583,7 +1583,8 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { ), externalExpansionView: self.view, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: nil ) } diff --git a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift index cfcecf8841..3df985500b 100644 --- a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift +++ b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift @@ -519,7 +519,9 @@ private func installedStickerPacksControllerEntries(context: AccountContext, pre if let archived = archived, !archived.isEmpty { entries.append(.archived(presentationData.theme, presentationData.strings.StickerPacksSettings_ArchivedPacks, Int32(archived.count), archived)) } - entries.append(.emoji(presentationData.theme, presentationData.strings.StickerPacksSettings_Emoji, emojiCount)) + if emojiCount != 0 { + entries.append(.emoji(presentationData.theme, presentationData.strings.StickerPacksSettings_Emoji, emojiCount)) + } if let quickReaction = quickReaction, let availableReactions = availableReactions { entries.append(.quickReaction(presentationData.strings.Settings_QuickReactionSetup_NavigationTitle, quickReaction, availableReactions)) } diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index a527784fa8..37163a13ff 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -477,6 +477,35 @@ public final class StandaloneShimmerEffect { self.updateLayer() } + public func updateHorizontal(background: UIColor, foreground: UIColor) { + if self.background == background && self.foreground == foreground { + return + } + self.background = background + self.foreground = foreground + + self.image = generateImage(CGSize(width: 320, height: 1), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(background.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + + context.clip(to: CGRect(origin: CGPoint(), size: size)) + + let transparentColor = foreground.withAlphaComponent(0.0).cgColor + let peakColor = foreground.cgColor + + var locations: [CGFloat] = [0.0, 0.44, 0.55, 1.0] + let colors: [CGColor] = [transparentColor, peakColor, peakColor, transparentColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations) else { return } + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.2), end: CGPoint(x: size.width, y: 0.8), options: CGGradientDrawingOptions()) + }) + + self.updateHorizontalLayer() + } + public func updateLayer() { guard let layer = self.layer, let image = self.image else { return @@ -495,4 +524,24 @@ public final class StandaloneShimmerEffect { layer.add(animation, forKey: "shimmer") } } + + private func updateHorizontalLayer() { + guard let layer = self.layer, let image = self.image else { + return + } + + layer.contents = image.cgImage + + if layer.animation(forKey: "shimmer") == nil { + var delay: TimeInterval { 1.6 } + let animation = CABasicAnimation(keyPath: "contentsRect.origin.x") + animation.fromValue = NSNumber(floatLiteral: delay) + animation.toValue = NSNumber(floatLiteral: -delay) + animation.isAdditive = true + animation.repeatCount = .infinity + animation.duration = 0.8 * delay + animation.timingFunction = .init(name: .easeInEaseOut) + layer.add(animation, forKey: "shimmer") + } + } } diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift new file mode 100644 index 0000000000..8ebe446403 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -0,0 +1,407 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +private let purple = UIColor(rgb: 0xdf44b8) +private let pink = UIColor(rgb: 0x3851eb) + +public final class AnimatedCountView: UIView { + let countLabel = AnimatedCountLabel() + let subtitleLabel = UILabel() + + private let foregroundView = UIView() + private let foregroundGradientLayer = CAGradientLayer() + private let maskingView = UIView() + private var scaleFactor: CGFloat { 0.7 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.foregroundGradientLayer.type = .radial + self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] + self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.foregroundView.mask = self.maskingView + self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) + + self.addSubview(self.foregroundView) + self.addSubview(self.subtitleLabel) + + self.maskingView.addSubview(countLabel) + countLabel.clipsToBounds = false + subtitleLabel.textAlignment = .center + self.clipsToBounds = false + + subtitleLabel.textColor = .white + } + + override public func layoutSubviews() { + super.layoutSubviews() + + self.updateFrames() + } + + func updateFrames(transition: ComponentFlow.Transition? = nil) { + let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height + let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: self.countLabel.attributedText?.length == 0 ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) + if let transition { + transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)) + transition.setFrame(view: self.maskingView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.countLabel, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.subtitleLabel, frame: subtitleFrame) + } else { + self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) + self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) + self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) + + countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) + subtitleLabel.frame = subtitleFrame + } + + } + + func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { + self.setupGradientAnimations() + + let backgroundGradientColors: [CGColor] + if gradientColors.count == 1 { + backgroundGradientColors = [gradientColors[0], gradientColors[0]] + } else { + backgroundGradientColors = gradientColors + } + self.foregroundGradientLayer.colors = backgroundGradientColors + + let text: String = countString + self.countLabel.fontSize = fontSize + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor((fontSize + 4.0) / 3.0), 12.0), weight: .semibold)]) + self.subtitleLabel.isHidden = subtitle.isEmpty + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + self?.setupGradientAnimations() + } + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } +} + +class AnimatedCharLayer: CATextLayer { + var text: String? { + get { + self.string as? String ?? (self.string as? NSAttributedString)?.string + } + set { + self.string = newValue + } + } + var attributedText: NSAttributedString? { + get { + self.string as? NSAttributedString + } + set { + self.string = newValue + } + } + + var layer: CALayer { self } + + override init() { + super.init() + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + override init(layer: Any) { + super.init(layer: layer) + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class AnimatedCountLabel: UILabel { + override var text: String? { + get { + chars.reduce("") { $0 + ($1.text ?? "") } + } + set { +// update(with: newValue ?? "") + } + } + + override var attributedText: NSAttributedString? { + get { + let string = NSMutableAttributedString() + for char in chars { + string.append(char.attributedText ?? NSAttributedString()) + } + return string + } + set { + udpateAttributed(with: newValue ?? NSAttributedString()) + } + } + + private var chars = [AnimatedCharLayer]() + private let containerView = UIView() + + var itemWidth: CGFloat { 36 * fontSize / 60 } + var commaWidthForSpacing: CGFloat { 12 * fontSize / 60 } + var commaFrameWidth: CGFloat { 36 * fontSize / 60 } + var interItemSpacing: CGFloat { 0 * fontSize / 60 } + var didBegin = false + var fontSize: CGFloat = 60 + var scaleFactor: CGFloat { 1 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + containerView.clipsToBounds = false + addSubview(containerView) + self.clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { + if let characters { + var offset = characters[0.. index && characters[index].string == "," { + if index > 0, ["1", "7"].contains(characters[index - 1].string) { + offset -= commaWidthForSpacing * 0.5 + } else { + offset -= commaWidthForSpacing / 6// 3 + } + } + return offset + } else { + return offsetForChar(at: index, within: self.chars.compactMap(\.attributedText)) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + let countWidth = offsetForChar(at: chars.count) - interItemSpacing + containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height) + chars.enumerated().forEach { (index, char) in + let offset = offsetForChar(at: index) + char.frame.origin.x = offset + char.frame.origin.y = 0 + char.frame.size.height = containerView.bounds.height + } + } + + func udpateAttributed(with newString: NSAttributedString) { + let interItemSpacing: CGFloat = 0 + + let separatedStrings = Array(newString.string).map { String($0) } + var range = NSRange(location: 0, length: 0) + var newChars = [NSAttributedString]() + for string in separatedStrings { + range.length = string.count + let attributedString = newString.attributedSubstring(from: range) + newChars.append(attributedString) + range.location += range.length + } + + let currentChars = chars.map { $0.attributedText ?? .init() } + + let maxAnimationDuration: TimeInterval = 1.2 + var numberOfChanges = abs(newChars.count - currentChars.count) + for index in 0.. self.bounds.width { + let scale = (self.bounds.width - 32) / (countWidth * scaleFactor) + containerView.transform = .init(scaleX: scale, y: scale) + } else { + containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor) + } + } + } else if countWidth > 0 { + containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height) + didBegin = true + } + self.clipsToBounds = false + } + func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { + let beginTimeOffset: CFTimeInterval = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { + let beginTime: CFTimeInterval = 0 + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 1 + opacityInAnimation.toValue = 0 + opacityInAnimation.fillMode = .forwards + opacityInAnimation.isRemovedOnCompletion = false + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 1 + scaleOutAnimation.toValue = 0.0 + + let translate = CABasicAnimation(keyPath: "transform.translation") + translate.fromValue = CGPoint.zero + translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3) + + let group = CAAnimationGroup() + group.animations = [opacityInAnimation, scaleOutAnimation, translate] + group.duration = duration + group.beginTime = beginTimeOffset + beginTime + group.fillMode = .forwards + group.isRemovedOnCompletion = false + group.completion = { _ in + layer.removeFromSuperlayer() + } + layer.add(group, forKey: "out") + } + } + + func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { + + let beginTimeOffset: CFTimeInterval = 0 // CACurrentMediaTime() + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in + let beginTime: CFTimeInterval = 0 + newLayer.opacity = 0 + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 0 + opacityInAnimation.toValue = 1 + opacityInAnimation.duration = duration + opacityInAnimation.beginTime = beginTimeOffset + beginTime + opacityInAnimation.fillMode = .backwards + newLayer.opacity = 1 + newLayer.add(opacityInAnimation, forKey: "opacity") + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 0 + scaleOutAnimation.toValue = 1 + scaleOutAnimation.duration = duration + scaleOutAnimation.beginTime = beginTimeOffset + beginTime + newLayer.add(scaleOutAnimation, forKey: "scalein") + + let animation = CAKeyframeAnimation() + animation.keyPath = "position.y" + animation.values = [20 * fontSize / 60, -6 * fontSize / 60, 0] + animation.keyTimes = [0, 0.64, 1] + animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) + animation.duration = duration / 0.64 + animation.beginTime = beginTimeOffset + beginTime + animation.isAdditive = true + newLayer.add(animation, forKey: "pos") + } + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 70fae82726..4ab979bc37 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -18,504 +18,7 @@ import BundleIconComponent import CreateExternalMediaStreamScreen import HierarchyTrackingLayer import UndoPanelComponent - -final class NavigationBackButtonComponent: Component { - let text: String - let color: UIColor - - init(text: String, color: UIColor) { - self.text = text - self.color = color - } - - static func ==(lhs: NavigationBackButtonComponent, rhs: NavigationBackButtonComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.color != rhs.color { - return false - } - return false - } - - public final class View: UIView { - private let arrowView: UIImageView - private let textView: ComponentHostView - - private var component: NavigationBackButtonComponent? - - override init(frame: CGRect) { - self.arrowView = UIImageView() - self.textView = ComponentHostView() - - super.init(frame: frame) - - self.addSubview(self.arrowView) - self.addSubview(self.textView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: NavigationBackButtonComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let spacing: CGFloat = 6.0 - let innerArrowInset: CGFloat = -8.0 - - if self.component?.color != component.color { - self.arrowView.image = NavigationBarTheme.generateBackArrowImage(color: component.color) - } - - self.component = component - - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.regular(17.0), - color: component.color - )), - environment: {}, - containerSize: availableSize - ) - - var leftInset: CGFloat = 0.0 - var size = textSize - if let arrowImage = self.arrowView.image { - size.width += innerArrowInset + arrowImage.size.width + spacing - size.height = max(size.height, arrowImage.size.height) - - self.arrowView.frame = CGRect(origin: CGPoint(x: innerArrowInset, y: floor((size.height - arrowImage.size.height) / 2.0)), size: arrowImage.size) - leftInset += innerArrowInset + arrowImage.size.width + spacing - } - self.textView.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - -final class StreamTitleComponent: Component { - let text: String - let isRecording: Bool - - init(text: String, isRecording: Bool) { - self.text = text - self.isRecording = isRecording - } - - static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.isRecording != rhs.isRecording { - return false - } - return false - } - - public final class View: UIView { - private let textView: ComponentHostView - private var indicatorView: UIImageView? - - private let trackingLayer: HierarchyTrackingLayer - - override init(frame: CGRect) { - self.textView = ComponentHostView() - - self.trackingLayer = HierarchyTrackingLayer() - - super.init(frame: frame) - - self.addSubview(self.textView) - - self.trackingLayer.didEnterHierarchy = { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.updateIndicatorAnimation() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateIndicatorAnimation() { - guard let indicatorView = self.indicatorView else { - return - } - if indicatorView.layer.animation(forKey: "blink") == nil { - let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber] - animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] - animation.duration = 0.7 - animation.autoreverses = true - animation.repeatCount = Float.infinity - indicatorView.layer.add(animation, forKey: "recording") - } - } - - func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.semibold(17.0), - color: .white - )), - environment: {}, - containerSize: availableSize - ) - - if component.isRecording { - if self.indicatorView == nil { - let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) - self.addSubview(indicatorView) - self.indicatorView = indicatorView - - self.updateIndicatorAnimation() - } - } else { - if let indicatorView = self.indicatorView { - self.indicatorView = nil - indicatorView.removeFromSuperview() - } - } - - let sideInset: CGFloat = 20.0 - let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) - let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - self.textView.frame = textFrame - - if let indicatorView = self.indicatorView, let image = indicatorView.image { - indicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) - } - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - -private final class NavigationBarComponent: CombinedComponent { - let topInset: CGFloat - let sideInset: CGFloat - let leftItem: AnyComponent? - let rightItems: [AnyComponentWithIdentity] - let centerItem: AnyComponent? - - init( - topInset: CGFloat, - sideInset: CGFloat, - leftItem: AnyComponent?, - rightItems: [AnyComponentWithIdentity], - centerItem: AnyComponent? - ) { - self.topInset = topInset - self.sideInset = sideInset - self.leftItem = leftItem - self.rightItems = rightItems - self.centerItem = centerItem - } - - static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { - if lhs.topInset != rhs.topInset { - return false - } - if lhs.sideInset != rhs.sideInset { - return false - } - if lhs.leftItem != rhs.leftItem { - return false - } - if lhs.rightItems != rhs.rightItems { - return false - } - if lhs.centerItem != rhs.centerItem { - return false - } - - return true - } - - static var body: Body { - let background = Child(Rectangle.self) - let leftItem = Child(environment: Empty.self) - let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) - let centerItem = Child(environment: Empty.self) - - return { context in - var availableWidth = context.availableSize.width - let sideInset: CGFloat = 16.0 + context.component.sideInset - - let contentHeight: CGFloat = 44.0 - let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) - - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) - - let leftItem = context.component.leftItem.flatMap { leftItemComponent in - return leftItem.update( - component: leftItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let leftItem = leftItem { - availableWidth -= leftItem.size.width - } - - var rightItemList: [_UpdatedChildComponent] = [] - for item in context.component.rightItems { - let item = rightItems[item.id].update( - component: item.component, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - rightItemList.append(item) - availableWidth -= item.size.width - } - - let centerItem = context.component.centerItem.flatMap { centerItemComponent in - return centerItem.update( - component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let centerItem = centerItem { - availableWidth -= centerItem.size.width - } - - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - - var centerLeftInset = sideInset - if let leftItem = leftItem { - context.add(leftItem - .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - centerLeftInset += leftItem.size.width + 4.0 - } - - var centerRightInset = sideInset - var rightItemX = context.availableSize.width - sideInset - for item in rightItemList.reversed() { - context.add(item - .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - rightItemX -= item.size.width + 8.0 - centerRightInset += item.size.width + 8.0 - } - - let maxCenterInset = max(centerLeftInset, centerRightInset) - if let centerItem = centerItem { - context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - } - - return size - } - } -} - -private final class OriginInfoComponent: CombinedComponent { - let title: String - let subtitle: String - - init( - title: String, - subtitle: String - ) { - self.title = title - self.subtitle = subtitle - } - - static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool { - if lhs.title != rhs.title { - return false - } - if lhs.subtitle != rhs.subtitle { - return false - } - - return true - } - - static var body: Body { - let title = Child(Text.self) - let subtitle = Child(Text.self) - - return { context in - let spacing: CGFloat = 0.0 - - let title = title.update( - component: Text( - text: context.component.title, font: Font.semibold(17.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - let subtitle = subtitle.update( - component: Text( - text: context.component.subtitle, font: Font.regular(14.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - var size = CGSize(width: max(title.size.width, subtitle.size.width), height: title.size.height + spacing + subtitle.size.height) - size.width = min(size.width, context.availableSize.width) - size.height = min(size.height, context.availableSize.height) - - context.add(title - .position(CGPoint(x: size.width / 2.0, y: title.size.height / 2.0)) - ) - context.add(subtitle - .position(CGPoint(x: size.width / 2.0, y: title.size.height + spacing + subtitle.size.height / 2.0)) - ) - - return size - } - } -} - -private final class ToolbarComponent: CombinedComponent { - let bottomInset: CGFloat - let sideInset: CGFloat - let leftItem: AnyComponent? - let rightItem: AnyComponent? - let centerItem: AnyComponent? - - init( - bottomInset: CGFloat, - sideInset: CGFloat, - leftItem: AnyComponent?, - rightItem: AnyComponent?, - centerItem: AnyComponent? - ) { - self.bottomInset = bottomInset - self.sideInset = sideInset - self.leftItem = leftItem - self.rightItem = rightItem - self.centerItem = centerItem - } - - static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool { - if lhs.bottomInset != rhs.bottomInset { - return false - } - if lhs.sideInset != rhs.sideInset { - return false - } - if lhs.leftItem != rhs.leftItem { - return false - } - if lhs.rightItem != rhs.rightItem { - return false - } - if lhs.centerItem != rhs.centerItem { - return false - } - - return true - } - - static var body: Body { - let background = Child(Rectangle.self) - let leftItem = Child(environment: Empty.self) - let rightItem = Child(environment: Empty.self) - let centerItem = Child(environment: Empty.self) - - return { context in - var availableWidth = context.availableSize.width - let sideInset: CGFloat = 16.0 + context.component.sideInset - - let contentHeight: CGFloat = 44.0 - let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) - - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) - - let leftItem = context.component.leftItem.flatMap { leftItemComponent in - return leftItem.update( - component: leftItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let leftItem = leftItem { - availableWidth -= leftItem.size.width - } - - let rightItem = context.component.rightItem.flatMap { rightItemComponent in - return rightItem.update( - component: rightItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let rightItem = rightItem { - availableWidth -= rightItem.size.width - } - - let centerItem = context.component.centerItem.flatMap { centerItemComponent in - return centerItem.update( - component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let centerItem = centerItem { - availableWidth -= centerItem.size.width - } - - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - - var centerLeftInset = sideInset - if let leftItem = leftItem { - context.add(leftItem - .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) - ) - centerLeftInset += leftItem.size.width + 4.0 - } - - var centerRightInset = sideInset - if let rightItem = rightItem { - context.add(rightItem - .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) - ) - centerRightInset += rightItem.size.width + 4.0 - } - - let maxCenterInset = max(centerLeftInset, centerRightInset) - if let centerItem = centerItem { - context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) - ) - } - - return size - } - } -} +import AvatarNode public final class MediaStreamComponent: CombinedComponent { struct OriginInfo: Equatable { @@ -550,8 +53,11 @@ public final class MediaStreamComponent: CombinedComponent { private(set) var displayUI: Bool = true var dismissOffset: CGFloat = 0.0 - - var storedIsLandscape: Bool? + var initialOffset: CGFloat = 0.0 + var storedIsFullscreen: Bool? + var isFullscreen: Bool = false + var videoSize: CGSize? + var prevFullscreenOrientation: UIDeviceOrientation? private(set) var canManageCall: Bool = false let isPictureInPictureSupported: Bool @@ -566,16 +72,24 @@ public final class MediaStreamComponent: CombinedComponent { private var isVisibleInHierarchyDisposable: Disposable? private var scheduledDismissUITimer: SwiftSignalKit.Timer? + var videoStalled: Bool = true + + var videoIsPlayable: Bool { + !videoStalled && hasVideo + } +// var wantsPiP: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) + private let infoThrottler = Throttler.init(duration: 5, queue: .main) + init(call: PresentationGroupCallImpl) { self.call = call if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { self.isPictureInPictureSupported = true } else { - self.isPictureInPictureSupported = false + self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported() } super.init() @@ -596,53 +110,6 @@ public final class MediaStreamComponent: CombinedComponent { } strongSelf.hasVideo = true strongSelf.updated(transition: .immediate) - - /*let engine = strongSelf.call.accountContext.engine - guard let info = strongSelf.call.initialCall else { - return - } - let _ = (engine.calls.getAudioBroadcastDataSource(callId: info.id, accessHash: info.accessHash) - |> mapToSignal { source -> Signal in - guard let source else { - return .single(nil) - } - - let time = engine.calls.requestStreamState(dataSource: source, callId: info.id, accessHash: info.accessHash) - |> map { state -> Int64? in - guard let state else { - return nil - } - return state.channels.first?.latestTimestamp - } - - return time - |> mapToSignal { latestTimestamp -> Signal in - guard let latestTimestamp else { - return .single(nil) - } - - let durationMilliseconds: Int64 = 32000 - let bufferOffset: Int64 = 1 * durationMilliseconds - let timestampId = latestTimestamp - bufferOffset - - return engine.calls.getVideoBroadcastPart(dataSource: source, callId: info.id, accessHash: info.accessHash, timestampIdMilliseconds: timestampId, durationMilliseconds: durationMilliseconds, channelId: 2, quality: 0) - |> mapToSignal { result -> Signal in - switch result.status { - case let .data(data): - return .single(data) - case .notReady, .resyncNeeded, .rejoinNeeded: - return .single(nil) - } - } - } - } - |> deliverOnMainQueue).start(next: { [weak self] data in - guard let self, let data else { - return - } - let _ = self - let _ = data - })*/ }) let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) @@ -654,6 +121,24 @@ public final class MediaStreamComponent: CombinedComponent { } var updated = false +// TODO: remove debug timer +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + var shouldReplaceNoViewersWithOne: Bool { true } + let membersCount = members.totalCount // Int.random(in: 0..<10000000) // + strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(membersCount, 1) : membersCount) { [weak strongSelf] latestCount in + let _ = members.totalCount + guard let strongSelf = strongSelf else { return } + var updated = false + let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) + if strongSelf.originInfo != originInfo { + strongSelf.originInfo = originInfo + updated = true + } + if updated { + strongSelf.updated(transition: .immediate) + } + } +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -674,12 +159,6 @@ public final class MediaStreamComponent: CombinedComponent { updated = true } - let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) - if strongSelf.originInfo != originInfo { - strongSelf.originInfo = originInfo - updated = true - } - if updated { strongSelf.updated(transition: .immediate) } @@ -702,6 +181,8 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.deactivatePictureInPictureIfVisible.invoke(Void()) }) + } else { + // MARK: TODO: fullscreen ui toggle } } }) @@ -752,18 +233,46 @@ public final class MediaStreamComponent: CombinedComponent { return State(call: self.call) } - public static var body: Body { + class Local { let background = Child(Rectangle.self) + let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) - let navigationBar = Child(NavigationBarComponent.self) - let toolbar = Child(ToolbarComponent.self) + let sheet = Child(StreamSheetComponent.self) + let topItem = Child(environment: Empty.self) + let fullscreenBottomItem = Child(environment: Empty.self) + let buttonsRow = Child(environment: Empty.self) let activatePictureInPicture = StoredActionSlot(Action.self) let deactivatePictureInPicture = StoredActionSlot(Void.self) let moreButtonTag = GenericComponentViewTag() let moreAnimationTag = GenericComponentViewTag() + } + + public static var body: Body { + let local = Local() return { context in + _body(context, local) // { context in + } + } + + private static func _body(_ context: CombinedComponentContext, _ local: Local) -> CGSize { + let background = local.background + let dismissTapComponent = local.dismissTapComponent + let video = local.video + let sheet = local.sheet + let topItem = local.topItem + let fullscreenBottomItem = local.fullscreenBottomItem + let buttonsRow = local.buttonsRow + + let activatePictureInPicture = local.activatePictureInPicture + let deactivatePictureInPicture = local.deactivatePictureInPicture + let moreButtonTag = local.moreButtonTag + let moreAnimationTag = local.moreAnimationTag + + func makeBody() -> CGSize { + let canEnforceOrientation = UIDevice.current.model != "iPad" + var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { } else { @@ -771,7 +280,7 @@ public final class MediaStreamComponent: CombinedComponent { } let background = background.update( - component: Rectangle(color: .black), + component: Rectangle(color: .black.withAlphaComponent(0.0)), availableSize: context.availableSize, transition: context.transition ) @@ -781,15 +290,65 @@ public final class MediaStreamComponent: CombinedComponent { let controller = environment.controller context.state.deactivatePictureInPictureIfVisible.connect { - guard let controller = controller() else { - return - } - if controller.view.window == nil { + guard let controller = controller(), controller.view.window != nil else { return } + + state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } + let isFullscreen: Bool + let isLandscape = context.availableSize.width > context.availableSize.height + // Always fullscreen in landscape + if forceFullScreenInLandscape && isLandscape && !state.isFullscreen { + state.isFullscreen = true + isFullscreen = true + } else if !isLandscape && state.isFullscreen && canEnforceOrientation { + state.prevFullscreenOrientation = nil + state.isFullscreen = false + isFullscreen = false + } else { + isFullscreen = state.isFullscreen + } + + let videoInset: CGFloat + if !isFullscreen { + videoInset = 16.0 + } else { + videoInset = 0.0 + } + + let videoHeight: CGFloat = forceFullScreenInLandscape + ? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16.0 * 9.0 + let bottomPadding = 32.0 + environment.safeInsets.bottom + let requiredSheetHeight: CGFloat = isFullscreen + ? context.availableSize.height + : (44.0 + videoHeight + 40.0 + 69.0 + 16.0 + 32.0 + 70.0 + bottomPadding + 8.0) + + let safeAreaTopInView: CGFloat + if #available(iOS 16.0, *) { + safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + } else { + safeAreaTopInView = context.view.safeAreaInsets.top + } + + let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30.0 + + var dragOffset = context.state.dismissOffset + if isFullyDragged { + dragOffset = max(context.state.dismissOffset, requiredSheetHeight - context.availableSize.height + safeAreaTopInView) + } + + let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - requiredSheetHeight + dragOffset) + let dismissTapComponent = dismissTapComponent.update( + component: Rectangle(color: .red.withAlphaComponent(0)), + availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), + transition: context.transition + ) + // (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen + // (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden() let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -797,6 +356,9 @@ public final class MediaStreamComponent: CombinedComponent { isVisible: environment.isVisible && context.state.isVisibleInHierarchy, isAdmin: context.state.canManageCall, peerTitle: context.state.peerTitle, + isFullscreen: isFullscreen, + videoLoading: context.state.videoStalled, + callPeer: context.state.chatPeer, activatePictureInPicture: activatePictureInPicture, deactivatePictureInPicture: deactivatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in @@ -806,11 +368,21 @@ public final class MediaStreamComponent: CombinedComponent { } call.accountContext.sharedContext.mainWindow?.inCallNavigate?() - completed() }, pictureInPictureClosed: { [weak call] in let _ = call?.leave(terminateIfPossible: false) + }, + onVideoSizeRetrieved: { [weak state] size in + state?.videoSize = size + }, + onVideoPlaybackLiveChange: { [weak state] isLive in + guard let state else { return } + let wasLive = !state.videoStalled + if isLive != wasLive { + state.videoStalled = !isLive + state.updated() + } } ), availableSize: context.availableSize, @@ -818,31 +390,64 @@ public final class MediaStreamComponent: CombinedComponent { ) var navigationRightItems: [AnyComponentWithIdentity] = [] - if context.state.isPictureInPictureSupported, context.state.hasVideo { + + // let videoIsPlayable = context.state.videoIsPlayable + // if state.wantsPiP && state.hasVideo { + // state.wantsPiP = false + // DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // activatePictureInPicture.invoke(Action { + // guard let controller = controller() as? MediaStreamComponentController else { + // return + // } + // controller.dismiss(closing: false, manual: true) + // }) + // } + // } + + if context.state.isPictureInPictureSupported { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Media Gallery/PictureInPictureButton", - tintColor: .white - )), - action: { + content: AnyComponent(ZStack([ + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( + name: "Call/pip", + tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6) + ))) + ] + )), + action: { [weak state] in + guard let state, state.hasVideo else { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + // state?.wantsPiP = true + controller.dismiss(closing: false, manual: true) + return + } + activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { return } controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) } + var topLeftButton: AnyComponent? if context.state.canManageCall { let whiteColor = UIColor(white: 1.0, alpha: 1.0) - navigationRightItems.append(AnyComponentWithIdentity(id: "more", component: AnyComponent(Button( + topLeftButton = AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - strokeColor: .white, - strokeWidth: 1.5, - size: CGSize(width: 22.0, height: 22.0) + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) ))), AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( @@ -854,7 +459,7 @@ public final class MediaStreamComponent: CombinedComponent { "Point 3.Group 1.Fill 1": whiteColor, "Point 1.Group 1.Fill 1": whiteColor ], - size: CGSize(width: 22.0, height: 22.0) + size: CGSize(width: 32.0, height: 32.0) ).tagged(moreAnimationTag))), ])), action: { [weak call, weak state] in @@ -878,7 +483,7 @@ public final class MediaStreamComponent: CombinedComponent { items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak call, weak controller, weak state] _, a in + }, action: { [weak call, weak controller, weak state] _, dismissWithResult in guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else { return } @@ -886,10 +491,10 @@ public final class MediaStreamComponent: CombinedComponent { let initialTitle = state.callTitle ?? "" let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - + let title: String = presentationData.strings.LiveStream_EditTitle let text: String = presentationData.strings.LiveStream_EditTitleText - + let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in guard let call = call else { return @@ -899,21 +504,20 @@ public final class MediaStreamComponent: CombinedComponent { if let title = title, title != initialTitle { call.updateTitle(title) - + let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string - + let _ = text //strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) } }) controller.present(editController, in: .window(.root)) - a(.default) + dismissWithResult(.default) }))) if let recordingStartTimestamp = state.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, f in - f(.dismissWithoutContent) + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, dismissWithResult in guard let call = call, let controller = controller else { return @@ -948,6 +552,8 @@ public final class MediaStreamComponent: CombinedComponent { })*/ })]) controller.present(alertController, in: .window(.root)) + + dismissWithResult(.dismissWithoutContent) }), false)) } else { let text = presentationData.strings.LiveStream_StartRecording @@ -974,7 +580,6 @@ public final class MediaStreamComponent: CombinedComponent { return } - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } if let title = title { @@ -1007,14 +612,34 @@ public final class MediaStreamComponent: CombinedComponent { a(.default) }))) - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.VoiceChat_StopRecordingStop, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_StopLiveStream, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) }, action: { [weak call] _, a in guard let call = call else { return } - - let _ = call.leave(terminateIfPossible: true).start() + let alertController = textAlertController( + context: call.accountContext, + forceTheme: defaultDarkPresentationTheme, + title: presentationData.strings.LiveStream_EndConfirmationTitle, + text: presentationData.strings.LiveStream_EndConfirmationText, + actions: [ + TextAlertAction( + type: .genericAction, + title: presentationData.strings.Common_Cancel, + action: {} + ), + TextAlertAction( + type: .destructiveAction, + title: presentationData.strings.VoiceChat_EndConfirmationEnd, + action: { [weak call] in + guard let call = call else { + return + } + let _ = call.leave(terminateIfPossible: true).start() + }) + ]) + controller.present(alertController, in: .window(.root)) a(.default) }))) @@ -1066,30 +691,21 @@ public final class MediaStreamComponent: CombinedComponent { }*/ controller.presentInGlobalOverlay(contextController) } - ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)))) + ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)) } - let navigationBar = navigationBar.update( - component: NavigationBarComponent( - topInset: environment.statusBarHeight, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - ), - rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: environment.strings.VoiceChatChannel_Title, isRecording: state.recordingStartTimestamp != nil)) - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition + let navigationComponent = NavigationBarComponent( + topInset: environment.statusBarHeight, + sideInset: environment.safeInsets.left, + backgroundVisible: isFullscreen, + leftItem: topLeftButton, + rightItems: navigationRightItems, + centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isLive: context.state.videoIsPlayable)) ) - let isLandscape = context.availableSize.width > context.availableSize.height - if context.state.storedIsLandscape != isLandscape { - context.state.storedIsLandscape = isLandscape - if isLandscape { + if context.state.storedIsFullscreen != isFullscreen { + context.state.storedIsFullscreen = isFullscreen + if isFullscreen { context.state.scheduleDismissUI() } else { context.state.cancelScheduledDismissUI() @@ -1098,108 +714,326 @@ public final class MediaStreamComponent: CombinedComponent { var infoItem: AnyComponent? if let originInfo = context.state.originInfo { - let memberCountString: String - if originInfo.memberCount == 0 { - memberCountString = environment.strings.LiveStream_NoViewers - } else { - memberCountString = environment.strings.LiveStream_ViewerCount(Int32(originInfo.memberCount)) - } infoItem = AnyComponent(OriginInfoComponent( - title: state.callTitle ?? originInfo.title, - subtitle: memberCountString + memberCount: originInfo.memberCount )) } + let availableSize = context.availableSize + let safeAreaTop = safeAreaTopInView - let toolbar = toolbar.update( - component: ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return + let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in + guard let state = state else { + return + } + switch panState { + case .began: + state.initialOffset = state.dismissOffset + case let .updated(offset): + state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) + case let .ended(velocity): + if velocity.y > 200.0 { + if state.isFullscreen { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + state.dismissOffset = 0.0 + if canEnforceOrientation, let controller = controller() as? MediaStreamComponentController { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) } - controller.presentShare() - } - ).minSize(CGSize(width: 44.0, height: 44.0))), - rightItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isLandscape ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - if let controller = controller() as? MediaStreamComponentController { - controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + } else { + if isFullyDragged || state.initialOffset != 0 { + state.updateDismissOffset(value: 0.0, interactive: false) + } else { + if state.isPictureInPictureSupported { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + if state.hasVideo { + activatePictureInPicture.invoke(Action { + controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } + }) + } else { + // state.wantsPiP = true + controller.dismiss(closing: false, manual: true) + } + } else { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + } } } - ).minSize(CGSize(width: 44.0, height: 44.0))), - centerItem: infoItem - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition - ) + } else { + if isFullyDragged { + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + if velocity.y < -200 { + // Expand + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + state.updateDismissOffset(value: 0.0, interactive: false) + } + } + } + } + } - let height = context.availableSize.height context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) .gesture(.tap { [weak state] in - guard let state = state else { + guard let state = state, state.isFullscreen else { return } state.toggleDisplayUI() }) - .gesture(.pan { [weak state] panState in - guard let state = state else { + .gesture(.pan { panState in + onPanGesture(panState) + }) + ) + + context.add(dismissTapComponent + .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) + .gesture(.tap { + guard let controller = controller() as? MediaStreamComponentController else { return } - switch panState { - case .began: - break - case let .updated(offset): - state.updateDismissOffset(value: offset.y, interactive: true) - case let .ended(velocity): - if abs(velocity.y) > 200.0 { - if state.isPictureInPictureSupported { - activatePictureInPicture.invoke(Action { [weak state] in - guard let state = state, let controller = controller() as? MediaStreamComponentController else { - return - } - state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) - controller.dismiss(closing: false, manual: true) - }) - } else { - if let controller = controller() as? MediaStreamComponentController { - controller.dismiss(closing: false, manual: true) + controller.dismiss(closing: false, manual: true) + }) + .gesture(.pan(onPanGesture)) + ) + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let imageRenderScale = UIScreen.main.scale + let bottomComponent = AnyComponent(ButtonsRowComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor], + image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), + // TODO: localize: + title: presentationData.strings.VoiceChat_ShareShort)), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 65, height: 80))), + rightItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in + context.translateBy(x: size.width / 2, y: size.height / 2) + context.scaleBy(x: 0.4, y: 0.4) + context.translateBy(x: -size.width / 2, y: -size.height / 2) + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + let lineWidth: CGFloat = size.width / 7 + context.setLineWidth(lineWidth - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + }), + title: presentationData.strings.VoiceChat_Leave + )), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + centerItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44.0 * imageRenderScale), opaque: false, rotatedContext: { size, context in + + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + let lineSide = size.width / 5 + let centerOffset = size.width / 20 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) + context.strokePath() + + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) + context.strokePath() + }), + title: presentationData.strings.LiveStream_Expand + )), + action: { [weak state] in + guard let state = state else { return } + + if let controller = controller() as? MediaStreamComponentController { + state.isFullscreen.toggle() + if state.isFullscreen { + state.dismissOffset = 0.0 + let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation + switch currentOrientation { + case .landscapeLeft: + controller.updateOrientation(orientation: .landscapeRight) + case .landscapeRight: + controller.updateOrientation(orientation: .landscapeLeft) + default: + controller.updateOrientation(orientation: .landscapeRight) } + } else { + state.prevFullscreenOrientation = UIDevice.current.orientation + controller.updateOrientation(orientation: .portrait) + } + if !canEnforceOrientation { + state.updated(transition: .easeInOut(duration: 0.25)) } - } else { - state.updateDismissOffset(value: 0.0, interactive: false) } } + ).minSize(CGSize(width: 44.0, height: 44.0))) + )) + + let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight) + let topOffset: CGFloat = isFullscreen + ? max(context.state.dismissOffset, 0) + : (context.availableSize.height - requiredSheetHeight + dragOffset) + + let sheet = sheet.update( + component: StreamSheetComponent( + topOffset: topOffset, + sheetHeight: sheetHeight, + backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: bottomPadding, + participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998) // [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + isFullyExtended: isFullyDragged, + deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, + videoHeight: videoHeight, + isFullscreen: isFullscreen, + fullscreenTopComponent: AnyComponent(navigationComponent), + fullscreenBottomComponent: bottomComponent + ), + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + ) + + var availableWidth: CGFloat { context.availableSize.width } + var contentHeight: CGFloat { 44.0 } + + let topItem = topItem.update( + component: AnyComponent(navigationComponent), + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + rightItem: /*state.hasVideo ?*/ AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) + } + } + } + ).minSize(CGSize(width: 64.0, height: 80.0))), + centerItem: infoItem + )) + + let buttonsRow = buttonsRow.update( + component: bottomComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullscreenBottomItem = fullscreenBottomItem.update( + component: fullScreenToolbarComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let videoPos: CGFloat + + if isFullscreen { + videoPos = context.availableSize.height / 2 + dragOffset + } else { + videoPos = topOffset + 28.0 + 28.0 + videoHeight / 2 + } + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) + ) + + context.add(topItem + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0))) + .opacity((!isFullscreen || state.displayUI) ? 1.0 : 0.0) + .gesture(.pan { panState in + onPanGesture(panState) }) ) - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0 + context.state.dismissOffset)) + context.add(buttonsRow + .opacity(isFullscreen ? 0.0 : 1.0) + .position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50.0 / 2 + topOffset - bottomPadding)) ) - context.add(navigationBar - .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) + context.add(fullscreenBottomItem + .opacity((isFullscreen && state.displayUI) ? 1.0 : 0.0) + .position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0)) ) - - context.add(toolbar - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) - ) - return context.availableSize } + return makeBody() } + } public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { @@ -1243,26 +1077,23 @@ public final class MediaStreamComponentController: ViewControllerComponentContai view.expandFromPictureInPicture() } - if let validLayout = self.validLayout { self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } - self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in - self?.view.layer.cornerRadius = 0.0 + self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in }) - self.view.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in + + self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.view.layer.allowsGroupOpacity = false }) + if backgroundDimView.superview == nil { + guard let superview = view.superview else { return } + superview.insertSubview(backgroundDimView, belowSubview: view) + } } override public func viewDidDisappear(_ animated: Bool) { @@ -1271,20 +1102,37 @@ public final class MediaStreamComponentController: ViewControllerComponentContai DispatchQueue.main.async { self.onViewDidDisappear?() } - - if let initialOrientation = self.initialOrientation { - self.initialOrientation = nil - self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) - } + } + + override public func viewDidLoad() { + super.viewDidLoad() + // TODO: replace with actual color + backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3) + self.view.clipsToBounds = false + } + + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let dimViewSide: CGFloat = max(view.bounds.width, view.bounds.height) + backgroundDimView.frame = .init(x: view.bounds.midX - dimViewSide / 2, y: -view.bounds.height * 3, width: dimViewSide, height: view.bounds.height * 4) } public func dismiss(closing: Bool, manual: Bool) { self.dismiss(completion: nil) } + let backgroundDimView = UIView() + override public func dismiss(completion: (() -> Void)? = nil) { self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in + + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in guard let strongSelf = self else { completion?() return @@ -1292,18 +1140,6 @@ public final class MediaStreamComponentController: ViewControllerComponentContai strongSelf.view.layer.allowsGroupOpacity = false strongSelf.dismissImpl(completion: completion) }) - - if let validLayout = self.validLayout { - self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } - - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in - }) - self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } } private func dismissImpl(completion: (() -> Void)? = nil) { @@ -1430,3 +1266,847 @@ public final class MediaStreamComponentController: ViewControllerComponentContai }) } } + +// MARK: - Subcomponents + +private final class NavigationBarComponent: CombinedComponent { + let topInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItems: [AnyComponentWithIdentity] + let centerItem: AnyComponent? + let backgroundVisible: Bool + + init( + topInset: CGFloat, + sideInset: CGFloat, + backgroundVisible: Bool, + leftItem: AnyComponent?, + rightItems: [AnyComponentWithIdentity], + centerItem: AnyComponent? + ) { + self.topInset = 0 // topInset + self.sideInset = sideInset + self.backgroundVisible = backgroundVisible + + self.leftItem = leftItem + self.rightItems = rightItems + self.centerItem = centerItem + } + + static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { + if lhs.topInset != rhs.topInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItems != rhs.rightItems { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let background = Child(Rectangle.self) + let leftItem = Child(environment: Empty.self) + let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 16.0 + context.component.sideInset + + let contentHeight: CGFloat = 44.0 + let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) + + let background = background.update( + component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), + availableSize: CGSize(width: size.width, height: size.height), + transition: context.transition + ) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + var rightItemList: [_UpdatedChildComponent] = [] + for item in context.component.rightItems { + let item = rightItems[item.id].update( + component: item.component, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + rightItemList.append(item) + availableWidth -= item.size.width + } + + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: availableWidth - 44.0 - 44.0, height: contentHeight), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + .opacity(context.component.backgroundVisible ? 1 : 0) + ) + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var rightItemX = context.availableSize.width - sideInset + for item in rightItemList.reversed() { + context.add(item + .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) + ) + rightItemX -= item.size.width + 8.0 + } + + let accumulatedOffset: CGFloat = 16.0 + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: context.availableSize.width / 2 - accumulatedOffset, y: context.component.topInset + contentHeight / 2.0)) + ) + } + + return size + } + } +} + +private final class StreamTitleComponent: Component { + private final class LiveIndicatorView: UIView { + private let label = UILabel() + private let stalledAnimatedGradient = CAGradientLayer() + private var wasLive = false + + var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.addSubview(label) + + let liveString = NSAttributedString( + string: "LIVE", + attributes: [ + .font: Font.with(size: 11.0, design: .round, weight: .bold), + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }(), + .foregroundColor: UIColor.white, + .kern: -0.6 + ] + ) + self.label.attributedText = liveString + + self.layer.addSublayer(stalledAnimatedGradient) + self.clipsToBounds = true + self.toggle(isLive: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = bounds + stalledAnimatedGradient.frame = bounds + self.layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } + + func toggle(isLive: Bool) { + if isLive { + if !self.wasLive { + self.wasLive = true + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 1.12, 0.9, 1.0] + anim.keyTimes = [0, 0.5, 0.8, 1] + anim.duration = 0.4 + self.layer.add(anim, forKey: "transform") + + UIView.animate(withDuration: 0.15, animations: { + self.toggle(isLive: true) }) + return + } + self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1) + self.stalledAnimatedGradient.opacity = 0 + self.stalledAnimatedGradient.removeAllAnimations() + } else { + if wasLive { + wasLive = false + UIView.animate(withDuration: 0.3) { + self.toggle(isLive: false) + } + return + } + self.backgroundColor = UIColor(white: 0.36, alpha: 1) + stalledAnimatedGradient.opacity = 1 + } + wasLive = isLive + } + } + + private let text: String + private let isRecording: Bool + private let isLive: Bool + + init(text: String, isRecording: Bool, isLive: Bool) { + self.text = text + self.isRecording = isRecording + self.isLive = isLive + } + + static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + if lhs.isRecording != rhs.isRecording { + return false + } + if lhs.isLive != rhs.isLive { + return false + } + return false + } + + public final class View: UIView { + private var indicatorView: UIImageView? + private let liveIndicatorView = LiveIndicatorView() + private let titleLabel = UILabel() + private var titleFadeLayer = CALayer() + + private let trackingLayer: HierarchyTrackingLayer + + override init(frame: CGRect) { + self.trackingLayer = HierarchyTrackingLayer() + + super.init(frame: frame) + + self.addSubview(self.titleLabel) + self.addSubview(self.liveIndicatorView) + + self.trackingLayer.didEnterHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.updateIndicatorAnimation() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateIndicatorAnimation() { + guard let indicatorView = self.indicatorView else { + return + } + if indicatorView.layer.animation(forKey: "blink") == nil { + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber] + animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] + animation.duration = 0.7 + animation.autoreverses = true + animation.repeatCount = Float.infinity + indicatorView.layer.add(animation, forKey: "recording") + } + } + + func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth + let liveIndicatorHeight: CGFloat = 20.0 + + let currentText = self.titleLabel.text + if currentText != component.text { + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2) { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } else { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } + self.titleLabel.font = Font.semibold(17.0) + self.titleLabel.textColor = .white + self.titleLabel.numberOfLines = 1 + + let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) + + if component.isRecording { + if self.indicatorView == nil { + let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) + self.addSubview(indicatorView) + self.indicatorView = indicatorView + + self.updateIndicatorAnimation() + } + } else { + if let indicatorView = self.indicatorView { + self.indicatorView = nil + indicatorView.removeFromSuperview() + } + } + let sideInset: CGFloat = 20.0 + let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) + let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) + + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2, options: .transitionCrossDissolve) { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + } else { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight)) + self.liveIndicatorView.toggle(isLive: component.isLive) + + if let indicatorView = self.indicatorView, let image = indicatorView.image { + indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) + } + + return size + } + + private func updateTitleFadeLayer(constrainedTextFrame: CGRect) { + guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }), + textBounds.width > constrainedTextFrame.width + else { + titleLabel.layer.mask = nil + titleLabel.frame = constrainedTextFrame + self.titleLabel.textAlignment = .center + return + } + + var isRTL: Bool = false + if let string = titleLabel.attributedText { + let coreTextLine = CTLineCreateWithAttributedString(string) + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count > 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + } + + let gradientInset: CGFloat = 0.0 + let gradientRadius: CGFloat = 50.0 + let extraSpaceToFitTruncation: CGFloat = 100.0 + + let solidPartLayer = CALayer() + solidPartLayer.backgroundColor = UIColor.black.cgColor + + let availableWidth: CGFloat = constrainedTextFrame.width - gradientRadius + + if isRTL { + solidPartLayer.frame = CGRect( + origin: CGPoint(x: constrainedTextFrame.width + extraSpaceToFitTruncation - availableWidth, y: 0), + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + + self.titleLabel.textAlignment = .right + + titleLabel.frame = CGRect(x: constrainedTextFrame.minX - extraSpaceToFitTruncation, y: constrainedTextFrame.minY, width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height) + } else { + self.titleLabel.textAlignment = .left + + solidPartLayer.frame = CGRect( + origin: .zero, + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + titleLabel.frame = CGRect(origin: constrainedTextFrame.origin, size: CGSize(width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height)) + } + + titleFadeLayer = CALayer() + titleFadeLayer.addSublayer(solidPartLayer) + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor] + if isRTL { + gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } else { + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } + titleFadeLayer.addSublayer(gradientLayer) + titleFadeLayer.masksToBounds = false + + titleFadeLayer.frame = titleLabel.bounds + + titleLabel.layer.mask = titleFadeLayer + } + + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} + + +private final class OriginInfoComponent: CombinedComponent { + let participantsCount: Int + + init( + memberCount: Int + ) { + self.participantsCount = memberCount + } + + static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool { + if lhs.participantsCount != rhs.participantsCount { + return false + } + + return true + } + + static var body: Body { + let viewerCounter = Child(ParticipantsComponent.self) + + return { context in + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent( + count: context.component.participantsCount, + showsSubtitle: true, + fontSize: 18.0, + gradientColors: [UIColor.white.cgColor] + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: context.transition + ) + let heightReduction: CGFloat = 16.0 + var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction) + size.width = min(size.width, context.availableSize.width) + size.height = min(size.height, context.availableSize.height) + + context.add(viewerCounter + .position(CGPoint(x: size.width / 2.0, y: context.availableSize.height / 2.0 + 16.0 - heightReduction / 2)) + ) + + return size + } + } +} + +private final class ToolbarComponent: CombinedComponent { + let bottomInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItem: AnyComponent? + let centerItem: AnyComponent? + + init( + bottomInset: CGFloat, + sideInset: CGFloat, + leftItem: AnyComponent?, + rightItem: AnyComponent?, + centerItem: AnyComponent? + ) { + self.bottomInset = bottomInset + self.sideInset = sideInset + self.leftItem = leftItem + self.rightItem = rightItem + self.centerItem = centerItem + } + + static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool { + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItem != rhs.rightItem { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let background = Child(Rectangle.self) + let leftItem = Child(environment: Empty.self) + let rightItem = Child(environment: Empty.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 16.0 + context.component.sideInset + + let contentHeight: CGFloat = 44.0 + let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) + + let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + let rightItem = context.component.rightItem.flatMap { rightItemComponent in + return rightItem.update( + component: rightItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let rightItem = rightItem { + availableWidth -= rightItem.size.width + } + + let temporaryOffsetForSmallerSubtitle: CGFloat = 12 + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight - temporaryOffsetForSmallerSubtitle / 2), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + ) + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var centerRightInset = sideInset + if let rightItem = rightItem { + context.add(rightItem + .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerRightInset += rightItem.size.width + 4.0 + } + + let maxCenterInset = max(centerLeftInset, centerRightInset) + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0 - temporaryOffsetForSmallerSubtitle)) + ) + } + + return size + } + } +} + +private final class ButtonsRowComponent: CombinedComponent { + let bottomInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItem: AnyComponent? + let centerItem: AnyComponent? + + init( + bottomInset: CGFloat, + sideInset: CGFloat, + leftItem: AnyComponent?, + rightItem: AnyComponent?, + centerItem: AnyComponent? + ) { + self.bottomInset = bottomInset + self.sideInset = sideInset + self.leftItem = leftItem + self.rightItem = rightItem + self.centerItem = centerItem + } + + static func ==(lhs: ButtonsRowComponent, rhs: ButtonsRowComponent) -> Bool { + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItem != rhs.rightItem { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let leftItem = Child(environment: Empty.self) + let rightItem = Child(environment: Empty.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 48.0 + context.component.sideInset + + let contentHeight: CGFloat = 80.0 + let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + let rightItem = context.component.rightItem.flatMap { rightItemComponent in + return rightItem.update( + component: rightItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let rightItem = rightItem { + availableWidth -= rightItem.size.width + } + + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var centerRightInset = sideInset + if let rightItem = rightItem { + context.add(rightItem + .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerRightInset += rightItem.size.width + 4.0 + } + + let maxCenterInset = max(centerLeftInset, centerRightInset) + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) + ) + } + + return size + } + } +} + +final class RoundGradientButtonComponent: Component { + init(gradientColors: [CGColor], icon: String? = nil, image: UIImage? = nil, title: String) { + self.gradientColors = gradientColors + self.icon = icon + self.image = image + self.title = title + } + + static func == (lhs: RoundGradientButtonComponent, rhs: RoundGradientButtonComponent) -> Bool { + if lhs.icon != rhs.icon { + return false + } + if lhs.gradientColors != rhs.gradientColors { + return false + } + return true + } + + let gradientColors: [CGColor] + let icon: String? + let image: UIImage? + let title: String + + final class View: UIView { + let gradientLayer = CAGradientLayer() + let iconView = UIImageView() + let titleLabel = UILabel() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + gradientLayer.type = .radial + gradientLayer.startPoint = .init(x: 1, y: 1) + gradientLayer.endPoint = .init(x: 0, y: 0) + + self.layer.addSublayer(gradientLayer) + self.addSubview(iconView) + self.clipsToBounds = false + + self.addSubview(titleLabel) + titleLabel.textAlignment = .center + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = .white + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + titleLabel.invalidateIntrinsicContentSize() + let heightForIcon = bounds.height - max(round(titleLabel.intrinsicContentSize.height), 12) - 8.0 + iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon) + gradientLayer.masksToBounds = true + gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2 + gradientLayer.frame = iconView.frame + titleLabel.frame = .init(x: 0, y: bounds.height - titleLabel.intrinsicContentSize.height, width: bounds.width, height: titleLabel.intrinsicContentSize.height) + } + } + + func makeView() -> View { + View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) } + let gradientColors: [CGColor] + if self.gradientColors.count == 1 { + gradientColors = [self.gradientColors[0], self.gradientColors[0]] + } else { + gradientColors = self.gradientColors + } + view.gradientLayer.colors = gradientColors + view.titleLabel.text = title + view.setNeedsLayout() + return availableSize + } +} + +public final class Throttler { + public var duration: TimeInterval = 0.25 + public var queue: DispatchQueue = .main + public var isEnabled: Bool { duration > 0 } + + private var isThrottling: Bool = false + private var lastValue: T? + private var accumulator = Set() + private var lastCompletedValue: T? + + public init(duration: TimeInterval = 0.25, queue: DispatchQueue = .main) { + self.duration = duration + self.queue = queue + } + + public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) { + queue.async { [self] in + accumulator.insert(value) + + if !isThrottling { + isThrottling = true + lastValue = nil + completion?(value) + self.lastCompletedValue = value + } else { + lastValue = value + } + + if lastValue == nil { + queue.asyncAfter(deadline: .now() + duration) { [self] in + accumulator.removeAll() + // TODO: quick fix, replace with timer + queue.asyncAfter(deadline: .now() + duration) { [self] in + isThrottling = false + } + + guard + let lastValue = lastValue, + lastCompletedValue != lastValue || includingLatest + else { return } + + accumulator.insert(lastValue) + self.lastValue = nil + completion?(lastValue) + lastCompletedValue = lastValue + } + } + } + } + + public func cancelCurrent() { + lastValue = nil + isThrottling = false + accumulator.removeAll() + } + + public func canEmit(_ value: T) -> Bool { + !accumulator.contains(value) + } +} + +public extension Throttler where T == Bool { + func throttle(includingLatest: Bool = false, _ completion: ((T) -> Void)?) { + publish(true, includingLatest: includingLatest, using: completion) + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 951ffad732..4bda7c0fca 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -1,11 +1,16 @@ import Foundation import UIKit import ComponentFlow -import ActivityIndicatorComponent import AccountContext import AVKit import MultilineTextComponent import Display +import ShimmerEffect + +import TelegramCore +import SwiftSignalKit +import AvatarNode +import Postbox final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl @@ -17,6 +22,11 @@ final class MediaStreamVideoComponent: Component { let deactivatePictureInPicture: ActionSlot let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void let pictureInPictureClosed: () -> Void + let isFullscreen: Bool + let onVideoSizeRetrieved: (CGSize) -> Void + let videoLoading: Bool + let callPeer: Peer? + let onVideoPlaybackLiveChange: (Bool) -> Void init( call: PresentationGroupCallImpl, @@ -24,20 +34,31 @@ final class MediaStreamVideoComponent: Component { isVisible: Bool, isAdmin: Bool, peerTitle: String, + isFullscreen: Bool, + videoLoading: Bool, + callPeer: Peer?, activatePictureInPicture: ActionSlot>, deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, - pictureInPictureClosed: @escaping () -> Void + pictureInPictureClosed: @escaping () -> Void, + onVideoSizeRetrieved: @escaping (CGSize) -> Void, + onVideoPlaybackLiveChange: @escaping (Bool) -> Void ) { self.call = call self.hasVideo = hasVideo self.isVisible = isVisible self.isAdmin = isAdmin self.peerTitle = peerTitle + self.videoLoading = videoLoading self.activatePictureInPicture = activatePictureInPicture self.deactivatePictureInPicture = deactivatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.pictureInPictureClosed = pictureInPictureClosed + self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange + + self.callPeer = callPeer + self.isFullscreen = isFullscreen + self.onVideoSizeRetrieved = onVideoSizeRetrieved } public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool { @@ -56,7 +77,12 @@ final class MediaStreamVideoComponent: Component { if lhs.peerTitle != rhs.peerTitle { return false } - + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + if lhs.videoLoading != rhs.videoLoading { + return false + } return true } @@ -70,7 +96,7 @@ final class MediaStreamVideoComponent: Component { return State() } - public final class View: UIScrollView, AVPictureInPictureControllerDelegate, ComponentTaggedView { + public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView { public final class Tag { } @@ -78,9 +104,11 @@ final class MediaStreamVideoComponent: Component { private let blurTintView: UIView private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? - private var activityIndicatorView: ComponentHostView? - private var noSignalView: ComponentHostView? + private var videoPlaceholderView: UIView? + private var noSignalView: ComponentHostView? + private let loadingBlurView = CustomIntensityVisualEffectView(effect: UIBlurEffect(style: .light), intensity: 0.4) + private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? private var component: MediaStreamVideoComponent? @@ -88,15 +116,50 @@ final class MediaStreamVideoComponent: Component { private var requestedExpansion: Bool = false - private var noSignalTimer: Timer? + private var noSignalTimer: Foundation.Timer? private var noSignalTimeout: Bool = false + private let videoBlurGradientMask = CAGradientLayer() + private let videoBlurSolidMask = CALayer() + + private var wasVisible = true + private var borderShimmer = StandaloneShimmerEffect() + private let shimmerBorderLayer = CALayer() + private let placeholderView = UIImageView() + + private var videoStalled = false { + didSet { + if videoStalled != oldValue { + self.updateVideoStalled(isStalled: self.videoStalled, transition: nil) +// state?.updated() + } + } + } + var onVideoPlaybackChange: ((Bool) -> Void) = { _ in } + + private var frameInputDisposable: Disposable? + + private var stallTimer: Foundation.Timer? + private let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + + private var avatarDisposable: Disposable? + private var didBeginLoadingAvatar = false + private var timeLastFrameReceived: CFAbsoluteTime? + + private var isFullscreen: Bool = false + private let videoLoadingThrottler = Throttler(duration: 1, queue: .main) + private var wasFullscreen: Bool = false + private var isAnimating = false + private var didRequestBringBack = false + private weak var state: State? + private var lastPresentation: UIView? + private var pipTrackDisplayLink: CADisplayLink? + override init(frame: CGRect) { self.blurTintView = UIView() self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) - super.init(frame: frame) self.isUserInteractionEnabled = false @@ -109,6 +172,13 @@ final class MediaStreamVideoComponent: Component { fatalError("init(coder:) has not been implemented") } + deinit { + avatarDisposable?.dispose() + frameInputDisposable?.dispose() + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + public func matches(tag: Any) -> Bool { if let _ = tag as? Tag { return true @@ -123,60 +193,224 @@ final class MediaStreamVideoComponent: Component { } } + private func updateVideoStalled(isStalled: Bool, transition: Transition?) { + if isStalled { + guard let component = self.component else { return } + + if let frameView = lastFrame[component.call.peerId.id.description] { + frameView.removeFromSuperview() + placeholderView.subviews.forEach { $0.removeFromSuperview() } + placeholderView.addSubview(frameView) + frameView.frame = placeholderView.bounds + } + + if !hadVideo && placeholderView.superview == nil { + addSubview(placeholderView) + } + + let needsFadeInAnimation = hadVideo + + if loadingBlurView.superview == nil { + addSubview(loadingBlurView) + if needsFadeInAnimation { + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 0 + anim.toValue = 1 + loadingBlurView.layer.opacity = 1 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + loadingBlurView.layer.add(anim, forKey: "opacity") + } + } + loadingBlurView.layer.zPosition = 998 + self.noSignalView?.layer.zPosition = loadingBlurView.layer.zPosition + 1 + if shimmerBorderLayer.superlayer == nil { + loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) + } + loadingBlurView.clipsToBounds = true + + let cornerRadius = loadingBlurView.layer.cornerRadius + shimmerBorderLayer.cornerRadius = cornerRadius + shimmerBorderLayer.masksToBounds = true + shimmerBorderLayer.compositingFilter = "softLightBlendMode" + + let borderMask = CAShapeLayer() + + shimmerBorderLayer.mask = borderMask + + if let transition, shimmerBorderLayer.mask != nil { + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = initialPath + transition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) + + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + transition.setShapeLayerPath(layer: borderMask, path: borderMaskPath) + } else { + shimmerBorderLayer.frame = loadingBlurView.bounds + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = borderMaskPath + } + + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + borderMask.compositingFilter = "softLightBlendMode" + + borderShimmer = StandaloneShimmerEffect() + borderShimmer.layer = shimmerBorderLayer + borderShimmer.updateHorizontal(background: .clear, foreground: .white) + loadingBlurView.alpha = 1 + } else { + if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 { + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.4 + anim.fromValue = 1.0 + anim.toValue = 0.0 + self.loadingBlurView.layer.opacity = 0 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + isAnimating = true + anim.completion = { [weak self] _ in + guard self?.videoStalled == false else { return } + self?.loadingBlurView.removeFromSuperview() + self?.placeholderView.removeFromSuperview() + self?.isAnimating = false + } + loadingBlurView.layer.add(anim, forKey: "opacity") + } + } + } + func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state + self.component = component + self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange + self.isFullscreen = component.isFullscreen + + if let peer = component.callPeer, !didBeginLoadingAvatar { + didBeginLoadingAvatar = true + + avatarDisposable = peerAvatarCompleteImage(account: component.call.account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true).start(next: { [weak self] image in + DispatchQueue.main.async { + self?.placeholderView.contentMode = .scaleAspectFill + self?.placeholderView.image = image + } + }) + } + + if !component.hasVideo || component.videoLoading || self.videoStalled { + updateVideoStalled(isStalled: true, transition: transition) + } else { + updateVideoStalled(isStalled: false, transition: transition) + } if component.hasVideo, self.videoView == nil { if let input = component.call.video(endpointId: "unified") { + var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in + guard let strongSelf = self else { return timer.invalidate() } + + let currentTime = CFAbsoluteTimeGetCurrent() + if let lastFrameTime = strongSelf.timeLastFrameReceived, + currentTime - lastFrameTime > 0.5 { + strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) + } + } + } } + + // TODO: use mapToThrottled (?) + frameInputDisposable = input.start(next: { [weak self] input in + guard let strongSelf = self else { return } + + strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent() + strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) + } + }) + stallTimer = _stallTimer + self.clipsToBounds = component.isFullscreen // or just true if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) + videoBlurView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoBlurView.alpha = 1 + } + self.videoBlurGradientMask.type = .radial + self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5) + self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0) + + self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor + self.videoBlurGradientMask.addSublayer(videoBlurSolidMask) + } - if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) - + videoView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoView.alpha = 1 + } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { + sampleBufferVideoView.sampleBufferLayer.masksToBounds = true + if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } - if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { - final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { - - } - - func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { - return CMTimeRange(start: .zero, duration: .positiveInfinity) - } - - func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { - completionHandler() - } - - public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } + final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { + var onTransitionFinished: (() -> Void)? + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { + } - let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + return CMTimeRange(start: .zero, duration: .positiveInfinity) + } - pictureInPictureController.delegate = self - pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true - pictureInPictureController.requiresLinearPlayback = true + func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } - self.pictureInPictureController = pictureInPictureController + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + onTransitionFinished?() + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { + completionHandler() + } + + public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } } + var pictureInPictureController: AVPictureInPictureController? = nil + if #available(iOS 15.0, *) { + pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: { + let delegate = PlaybackDelegateImpl() + delegate.onTransitionFinished = { + } + return delegate + }())) + pictureInPictureController?.playerLayer.masksToBounds = false + pictureInPictureController?.playerLayer.cornerRadius = 10 + } else if AVPictureInPictureController.isPictureInPictureSupported() { + pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer())) + } + + pictureInPictureController?.delegate = self + if #available(iOS 14.2, *) { + pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true + } + if #available(iOS 14.0, *) { + pictureInPictureController?.requiresLinearPlayback = true + } + self.pictureInPictureController = pictureInPictureController } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -189,26 +423,86 @@ final class MediaStreamVideoComponent: Component { strongSelf.hadVideo = true - strongSelf.activityIndicatorView?.removeFromSuperview() - strongSelf.activityIndicatorView = nil - strongSelf.noSignalTimer?.invalidate() strongSelf.noSignalTimer = nil strongSelf.noSignalTimeout = false strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView = nil - //strongSelf.translatesAutoresizingMaskIntoConstraints = false - //strongSelf.maximumZoomScale = 4.0 - state?.updated(transition: .immediate) } } } + } else if component.isFullscreen { + if fullScreenBackgroundPlaceholder.superview == nil { + insertSubview(fullScreenBackgroundPlaceholder, at: 0) + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 1) + } + fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5) + } else { + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 0, completion: { didComplete in + if didComplete { + self.fullScreenBackgroundPlaceholder.removeFromSuperview() + } + }) + } + fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) + + let videoInset: CGFloat + if !component.isFullscreen { + videoInset = 16 + } else { + videoInset = 0 + } + + let videoSize: CGSize + let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10 + + let videoFrameUpdateTransition: Transition + if self.wasFullscreen != component.isFullscreen { + videoFrameUpdateTransition = transition + } else { + videoFrameUpdateTransition = transition.withAnimation(.none) } if let videoView = self.videoView { + if videoView.bounds.size.width > 0, + videoView.alpha > 0, + self.hadVideo, + let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { + lastFrame[component.call.peerId.id.description] = snapshot + } + + var aspect = videoView.getAspect() + if component.isFullscreen && self.hadVideo { + if aspect <= 0.01 { + aspect = 16.0 / 9 + } + } else if !self.hadVideo { + aspect = 16.0 / 9 + } + + if component.isFullscreen { + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + } else { + // Limiting by smallest side -- redundant if passing precalculated availableSize + let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2 + let availableVideoHeight = availableVideoWidth * 9.0 / 16 + + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight)) + } + let blurredVideoSize = component.isFullscreen ? availableSize : videoSize.aspectFilled(availableSize) + + component.onVideoSizeRetrieved(videoSize) + var isVideoVisible = component.isVisible + + if !wasVisible && component.isVisible { + videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2) + } else if wasVisible && !component.isVisible { + videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2) + } + if let pictureInPictureController = self.pictureInPictureController { if pictureInPictureController.isPictureInPictureActive { isVideoVisible = true @@ -216,44 +510,81 @@ final class MediaStreamVideoComponent: Component { } videoView.updateIsEnabled(isVideoVisible) + videoView.clipsToBounds = true + videoView.layer.cornerRadius = videoCornerRadius - var aspect = videoView.getAspect() - if aspect <= 0.01 { - aspect = 3.0 / 4.0 - } + self.wasFullscreen = component.isFullscreen + let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(availableSize) - let blurredVideoSize = videoSize.aspectFilled(availableSize) - - transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) + videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil) if let videoBlurView = self.videoBlurView { - videoBlurView.updateIsEnabled(component.isVisible) - transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) + videoBlurView.updateIsEnabled(component.isVisible) + if component.isFullscreen { + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: CGRect( + origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), + size: blurredVideoSize + ), completion: nil) + } else { + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -70.0 * aspect, dy: -70.0)) + } + + videoBlurView.layer.mask = videoBlurGradientMask + + if !component.isFullscreen { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 0) + } else { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 1) + } + + videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds) + videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds) } + } else { + videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } - if !self.hadVideo { - var activityIndicatorTransition = transition - let activityIndicatorView: ComponentHostView - if let current = self.activityIndicatorView { - activityIndicatorView = current - } else { - activityIndicatorTransition = transition.withAnimation(.none) - activityIndicatorView = ComponentHostView() - self.activityIndicatorView = activityIndicatorView - self.addSubview(activityIndicatorView) + let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) + + if loadingBlurView.frame == .zero { + loadingBlurView.frame = loadingBlurViewFrame + } else { + // Using Transition.setFrame on UIVisualEffectView causes instant update of sublayers + switch videoFrameUpdateTransition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [self] in + loadingBlurView.frame = loadingBlurViewFrame + }) + + default: + loadingBlurView.frame = loadingBlurViewFrame } - - let activityIndicatorSize = activityIndicatorView.update( - transition: transition, - component: AnyComponent(ActivityIndicatorComponent(color: .white)), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - let activityIndicatorFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - activityIndicatorSize.width) / 2.0), y: floor((availableSize.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize) - activityIndicatorTransition.setFrame(view: activityIndicatorView, frame: activityIndicatorFrame, completion: nil) + } + videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius) + videoFrameUpdateTransition.setFrame(view: placeholderView, frame: loadingBlurViewFrame) + videoFrameUpdateTransition.setCornerRadius(layer: placeholderView.layer, cornerRadius: videoCornerRadius) + placeholderView.clipsToBounds = true + placeholderView.subviews.forEach { + videoFrameUpdateTransition.setFrame(view: $0, frame: placeholderView.bounds) + } + + let initialShimmerBounds = shimmerBorderLayer.bounds + videoFrameUpdateTransition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) + + let borderMask = CAShapeLayer() + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + borderMask.path = initialPath + + videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)) + + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + shimmerBorderLayer.mask = borderMask + shimmerBorderLayer.cornerRadius = videoCornerRadius + + if !self.hadVideo { if self.noSignalTimer == nil { if #available(iOS 10.0, *) { @@ -278,7 +609,10 @@ final class MediaStreamVideoComponent: Component { noSignalTransition = transition.withAnimation(.none) noSignalView = ComponentHostView() self.noSignalView = noSignalView + self.addSubview(noSignalView) + noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1 + noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } @@ -293,7 +627,7 @@ final class MediaStreamVideoComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0) ) - noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: activityIndicatorFrame.maxY + 24.0), size: noSignalSize), completion: nil) + noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0), size: noSignalSize), completion: nil) } } @@ -320,30 +654,84 @@ final class MediaStreamVideoComponent: Component { return availableSize } + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) { + let presentationParent = self.window ?? self + presentationParent.addSubview(presentation) + presentation.frame = presentationParent.convert(videoView.frame, from: self) + + if let callId = self.component?.call.peerId.id.description { + lastFrame[callId] = presentation + } + + videoView.alpha = 0 + lastPresentation?.removeFromSuperview() + lastPresentation = presentation + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.lastPresentation?.removeFromSuperview() + self.lastPresentation = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + } + UIView.animate(withDuration: 0.1) { [self] in + videoBlurView?.alpha = 0 + } + // TODO: assure player window + UIApplication.shared.windows.first?.layer.cornerRadius = 10.0 + UIApplication.shared.windows.first?.layer.masksToBounds = true + + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = CADisplayLink(target: self, selector: #selector(observePiPWindow)) + self.pipTrackDisplayLink?.add(to: .main, forMode: .default) + } + + @objc func observePiPWindow() { + let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0 + if pipViewDidBecomeVisible { + lastPresentation?.removeFromSuperview() + lastPresentation = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + } + public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { guard let component = self.component else { completionHandler(false) return } - + didRequestBringBack = true component.bringBackControllerForPictureInPictureDeactivation { completionHandler(true) } } func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.didRequestBringBack = false self.state?.updated(transition: .immediate) } func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { if self.requestedExpansion { self.requestedExpansion = false - } else { + } else if !didRequestBringBack { self.component?.pictureInPictureClosed() } + didRequestBringBack = false + // TODO: extract precise animation timing or observe window changes + // Handle minimized case separatelly (can we detect minimized?) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.videoView?.alpha = 1 + } + UIView.animate(withDuration: 0.3) { [self] in + self.videoBlurView?.alpha = 1 + } } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.videoView?.alpha = 1 self.state?.updated(transition: .immediate) } } @@ -356,3 +744,27 @@ final class MediaStreamVideoComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, transition: transition) } } + +// TODO: move to appropriate place +fileprivate var lastFrame: [String: UIView] = [:] + +private final class CustomIntensityVisualEffectView: UIVisualEffectView { + private var animator: UIViewPropertyAnimator! + + init(effect: UIVisualEffect, intensity: CGFloat) { + super.init(effect: nil) + animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [weak self] in self?.effect = effect } + animator.startAnimation() + animator.pauseAnimation() + animator.fractionComplete = intensity + animator.pausesOnCompletion = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + deinit { + animator.stopAnimation(true) + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift new file mode 100644 index 0000000000..a2aefbe5cb --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift @@ -0,0 +1,78 @@ +import Foundation +import Display +import UIKit +import ComponentFlow +import TelegramPresentationData +import TelegramStringFormatting + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xe4436c) + +final class ParticipantsComponent: Component { + private let count: Int + private let showsSubtitle: Bool + private let fontSize: CGFloat + private let gradientColors: [CGColor] + + init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { + self.count = count + self.showsSubtitle = showsSubtitle + self.fontSize = fontSize + self.gradientColors = gradientColors + } + + static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { + if lhs.count != rhs.count { + return false + } + if lhs.showsSubtitle != rhs.showsSubtitle { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + return true + } + + func makeView() -> View { + View(frame: .zero) + } + + func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { + view.counter.update( + countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", + // TODO: localize + subtitle: self.showsSubtitle ? (self.count > 0 ? /*environment.strings.LiveStream_Watching*/"watching" : /*environment.strings.LiveStream_NoViewers.lowercased()*/"no viewers") : "", + fontSize: self.fontSize, + gradientColors: self.gradientColors + ) + switch transition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames(transition: transition) + }) + + default: + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames() + } + return availableSize + } + + final class View: UIView { + let counter = AnimatedCountView() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(counter) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift new file mode 100644 index 0000000000..c9735c3967 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -0,0 +1,262 @@ +import Foundation +import UIKit +import ComponentFlow +import ActivityIndicatorComponent +import AccountContext +import AVKit +import MultilineTextComponent +import Display + +final class StreamSheetComponent: CombinedComponent { + let sheetHeight: CGFloat + let topOffset: CGFloat + let backgroundColor: UIColor + let participantsCount: Int + let bottomPadding: CGFloat + let isFullyExtended: Bool + let deviceCornerRadius: CGFloat + let videoHeight: CGFloat + + let isFullscreen: Bool + let fullscreenTopComponent: AnyComponent + let fullscreenBottomComponent: AnyComponent + + init( + topOffset: CGFloat, + sheetHeight: CGFloat, + backgroundColor: UIColor, + bottomPadding: CGFloat, + participantsCount: Int, + isFullyExtended: Bool, + deviceCornerRadius: CGFloat, + videoHeight: CGFloat, + isFullscreen: Bool, + fullscreenTopComponent: AnyComponent, + fullscreenBottomComponent: AnyComponent + ) { + self.topOffset = topOffset + self.sheetHeight = sheetHeight + self.backgroundColor = backgroundColor + self.bottomPadding = bottomPadding + self.participantsCount = participantsCount + self.isFullyExtended = isFullyExtended + self.deviceCornerRadius = deviceCornerRadius + self.videoHeight = videoHeight + + self.isFullscreen = isFullscreen + self.fullscreenTopComponent = fullscreenTopComponent + self.fullscreenBottomComponent = fullscreenBottomComponent + } + + static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { + if lhs.topOffset != rhs.topOffset { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.sheetHeight != rhs.sheetHeight { + return false + } + if !lhs.backgroundColor.isEqual(rhs.backgroundColor) { + return false + } + if lhs.bottomPadding != rhs.bottomPadding { + return false + } + if lhs.participantsCount != rhs.participantsCount { + return false + } + if lhs.isFullyExtended != rhs.isFullyExtended { + return false + } + if lhs.videoHeight != rhs.videoHeight { + return false + } + + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + + if lhs.fullscreenTopComponent != rhs.fullscreenTopComponent { + return false + } + + if lhs.fullscreenBottomComponent != rhs.fullscreenBottomComponent { + return false + } + + return true + } + + final class View: UIView { + var overlayComponentsFrames = [CGRect]() + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subframe in overlayComponentsFrames { + if subframe.contains(point) { return true } + } + return false + } + + func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { + return availableSize + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + // Debug interactive area +// guard let context = UIGraphicsGetCurrentContext() else { return } +// context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor) +// overlayComponentsFrames.forEach { frame in +// context.addRect(frame) +// context.fillPath() +// } + } + } + + func makeView() -> View { + View() + } + + public final class State: ComponentState { + override init() { + super.init() + } + } + + public func makeState() -> State { + return State() + } + + private weak var state: State? + + static var body: Body { + let background = Child(SheetBackgroundComponent.self) + let viewerCounter = Child(ParticipantsComponent.self) + + return { context in + let size = context.availableSize + + let topOffset = context.component.topOffset + let backgroundExtraOffset: CGFloat + if #available(iOS 16.0, *) { + // In iOS 16 context.view does not inherit safeAreaInsets, quick fix: + let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0 + } else { + backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 + } + + let background = background.update( + component: SheetBackgroundComponent( + color: context.component.backgroundColor, + radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 10.0, + offset: backgroundExtraOffset + ), + availableSize: CGSize(width: size.width, height: context.component.sheetHeight), + transition: context.transition + ) + + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent(count: context.component.participantsCount, fontSize: 44.0), + availableSize: CGSize(width: context.availableSize.width, height: 70), + transition: context.transition + ) + + let isFullscreen = context.component.isFullscreen + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) + ) + + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] + context.view.backgroundColor = .clear + + let videoHeight = context.component.videoHeight + let sheetHeight = context.component.sheetHeight + let animatedParticipantsVisible = !isFullscreen + + context.add(viewerCounter + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0)) + .opacity(animatedParticipantsVisible ? 1 : 0) + ) + + return size + } + } +} + +final class SheetBackgroundComponent: Component { + private let color: UIColor + private let radius: CGFloat + private let offset: CGFloat + + class View: UIView { + private let backgroundView = UIView() + + func update(availableSize: CGSize, color: UIColor, cornerRadius: CGFloat, offset: CGFloat, transition: Transition) { + if backgroundView.superview == nil { + self.addSubview(backgroundView) + } + + let extraBottomForReleaseAnimation: CGFloat = 500 + + if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { + if transition.animation.isImmediate { + UIView.animate(withDuration: 0.4) { [self] in + backgroundView.backgroundColor = color + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) + } + + let anim = CABasicAnimation(keyPath: "cornerRadius") + anim.fromValue = backgroundView.layer.cornerRadius + backgroundView.layer.cornerRadius = cornerRadius + anim.toValue = cornerRadius + anim.duration = 0.4 + backgroundView.layer.add(anim, forKey: "cornerRadius") + } else { + transition.setBackgroundColor(view: backgroundView, color: color) + transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))) + transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius) + } + } else { + backgroundView.backgroundColor = color + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) + backgroundView.layer.cornerRadius = cornerRadius + } + backgroundView.isUserInteractionEnabled = false + backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + backgroundView.clipsToBounds = true + backgroundView.layer.masksToBounds = true + } + } + + func makeView() -> View { + View() + } + + static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool { + if !lhs.color.isEqual(rhs.color) { + return false + } + if lhs.radius != rhs.radius { + return false + } + if lhs.offset != rhs.offset { + return false + } + return true + } + + public init(color: UIColor, radius: CGFloat, offset: CGFloat) { + self.color = color + self.radius = radius + self.offset = offset + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition) + return availableSize + } +} diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift index b6020e48de..00655e1e92 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift @@ -36,12 +36,12 @@ import DeviceAccess let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) let fullscreenBackgroundColor = UIColor(rgb: 0x000000) -private let smallButtonSize = CGSize(width: 36.0, height: 36.0) -private let sideButtonSize = CGSize(width: 56.0, height: 56.0) -private let topPanelHeight: CGFloat = 63.0 +let smallButtonSize = CGSize(width: 36.0, height: 36.0) +let sideButtonSize = CGSize(width: 56.0, height: 56.0) +let topPanelHeight: CGFloat = 63.0 let bottomAreaHeight: CGFloat = 206.0 -private let fullscreenBottomAreaHeight: CGFloat = 80.0 -private let bottomGradientHeight: CGFloat = 70.0 +let fullscreenBottomAreaHeight: CGFloat = 80.0 +let bottomGradientHeight: CGFloat = 70.0 func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { if !top && !bottom { diff --git a/submodules/TelegramCore/BUILD b/submodules/TelegramCore/BUILD index 5a883c9356..980b3eff65 100644 --- a/submodules/TelegramCore/BUILD +++ b/submodules/TelegramCore/BUILD @@ -48,6 +48,7 @@ swift_library( "//submodules/ManagedFile:ManagedFile", "//submodules/Utils/RangeSet:RangeSet", "//submodules/Utils/DarwinDirStat", + "//submodules/Emoji", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift index 90d653e163..5fbc467e5a 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift @@ -111,7 +111,8 @@ public extension TelegramChannel { .banSendStickers, .banSendPolls, .banSendFiles, - .banSendInline + .banSendInline, + .banSendMusic ] if let bannedRights = self.bannedRights, bannedRights.flags.intersection(flags) == flags { diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift index d0a85c23cd..2408b3f899 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift @@ -30,7 +30,8 @@ public extension TelegramGroup { .banSendStickers, .banSendPolls, .banSendFiles, - .banSendInline + .banSendInline, + .banSendMusic ] if let defaultBannedRights = self.defaultBannedRights, defaultBannedRights.flags.intersection(flags) == flags { return false diff --git a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift index 176b04e711..4330fb5ab9 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift @@ -2,6 +2,7 @@ import Foundation import Postbox import TelegramApi import SwiftSignalKit +import Emoji public enum EnqueueMessageGrouping { case none @@ -287,18 +288,25 @@ public func resendMessages(account: Account, messageIds: [MessageId]) -> Signal< var filteredAttributes: [MessageAttribute] = [] var replyToMessageId: MessageId? var bubbleUpEmojiOrStickersets: [ItemCollectionId] = [] + var forwardSource: MessageId? inner: for attribute in message.attributes { if let attribute = attribute as? ReplyMessageAttribute { replyToMessageId = attribute.messageId } else if let attribute = attribute as? OutgoingMessageInfoAttribute { bubbleUpEmojiOrStickersets = attribute.bubbleUpEmojiOrStickersets continue inner + } else if let attribute = attribute as? ForwardSourceInfoAttribute { + forwardSource = attribute.messageId } else { filteredAttributes.append(attribute) } } - - messages.append(.message(text: message.text, attributes: filteredAttributes, inlineStickers: [:], mediaReference: message.media.first.flatMap(AnyMediaReference.standalone), replyToMessageId: replyToMessageId, localGroupingKey: message.groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)) + + if let forwardSource { + messages.append(.forward(source: forwardSource, threadId: nil, grouping: .auto, attributes: filteredAttributes, correlationId: nil)) + } else { + messages.append(.message(text: message.text, attributes: filteredAttributes, inlineStickers: [:], mediaReference: message.media.first.flatMap(AnyMediaReference.standalone), replyToMessageId: replyToMessageId, localGroupingKey: message.groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)) + } } } let _ = enqueueMessages(transaction: transaction, account: account, peerId: peerId, messages: messages.map { (false, $0) }) @@ -399,6 +407,14 @@ func enqueueMessages(transaction: Transaction, account: Account, peerId: PeerId, transaction.storeMediaIfNotPresent(media: file) } + for emoji in text.emojis { + if emoji.isSingleEmoji { + if !emojiItems.contains(where: { $0.content == .text(emoji) }) { + emojiItems.append(RecentEmojiItem(.text(emoji))) + } + } + } + var peerAutoremoveTimeout: Int32? if let peer = peer as? TelegramSecretChat { var isAction = false diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift index e050fbcbcd..d8195e20b6 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift @@ -149,6 +149,19 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { } } return nil + }, + isPeerUpgradeMessage: { message in + for media in message.media { + if let action = media as? TelegramMediaAction { + switch action.action { + case .groupMigratedToChannel, .channelMigratedFromGroup: + return true + default: + break + } + } + } + return false } ) }() diff --git a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift index 990b07d8b0..e306922d14 100644 --- a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift +++ b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift @@ -414,11 +414,13 @@ public func foldLineBreaks(_ text: String) -> String { public func foldLineBreaks(_ text: NSAttributedString) -> NSAttributedString { let remainingString = NSMutableAttributedString(attributedString: text) + var lines: [NSAttributedString] = [] while true { if let range = remainingString.string.range(of: "\n") { let mappedRange = NSRange(range, in: remainingString.string) - lines.append(remainingString.attributedSubstring(from: NSRange(location: 0, length: mappedRange.upperBound - 1))) + let restString = remainingString.attributedSubstring(from: NSRange(location: 0, length: mappedRange.upperBound - 1)) + lines.append(restString) remainingString.replaceCharacters(in: NSRange(location: 0, length: mappedRange.upperBound), with: "") } else { if lines.isEmpty { diff --git a/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift b/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift index e827d85d98..2e946f37db 100644 --- a/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift +++ b/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift @@ -7,19 +7,27 @@ import ComponentDisplayAdapters import AppBundle public final class ActionPanelComponent: Component { + public enum Color { + case accent + case destructive + } + public let theme: PresentationTheme public let title: String + public let color: Color public let action: () -> Void public let dismissAction: () -> Void public init( theme: PresentationTheme, title: String, + color: Color, action: @escaping () -> Void, dismissAction: @escaping () -> Void ) { self.theme = theme self.title = title + self.color = color self.action = action self.dismissAction = dismissAction } @@ -31,6 +39,9 @@ public final class ActionPanelComponent: Component { if lhs.title != rhs.title { return false } + if lhs.color != rhs.color { + return false + } return true } @@ -136,9 +147,17 @@ public final class ActionPanelComponent: Component { let rightInset: CGFloat = 44.0 + let resolvedColor: UIColor + switch component.color { + case .accent: + resolvedColor = component.theme.rootController.navigationBar.accentTextColor + case .destructive: + resolvedColor = component.theme.list.itemDestructiveColor + } + let titleSize = self.title.update( transition: .immediate, - component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: component.theme.rootController.navigationBar.accentTextColor)), + component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: resolvedColor)), environment: {}, containerSize: CGSize(width: availableSize.width - rightInset, height: availableSize.height) ) diff --git a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift index 3d57ab5d24..fd58e86a12 100644 --- a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift +++ b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift @@ -686,7 +686,8 @@ final class AvatarEditorScreenComponent: Component { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: true + hideBackground: true, + stateContext: nil ) data.stickers?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -813,7 +814,8 @@ final class AvatarEditorScreenComponent: Component { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: true + hideBackground: true, + stateContext: nil ) self.state?.updated(transition: .immediate) diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 0e4f25ab67..3ddeb72b0b 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -87,6 +87,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } } + public final class StateContext { + let emojiState = EmojiPagerContentComponent.StateContext() + + public init() { + } + } + public static func hasPremium(context: AccountContext, chatPeerId: EnginePeer.Id?, premiumIfSavedMessages: Bool) -> Signal { let hasPremium: Signal if premiumIfSavedMessages, let chatPeerId = chatPeerId, chatPeerId == context.account.peerId { @@ -237,6 +244,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } private let context: AccountContext + private let stateContext: StateContext? private let entityKeyboardView: ComponentHostView private let defaultToEmojiTab: Bool @@ -581,11 +589,12 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { |> distinctUntilChanged } - public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPeerId: PeerId?) { + public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPeerId: PeerId?, stateContext: StateContext?) { self.context = context self.currentInputData = currentInputData self.defaultToEmojiTab = defaultToEmojiTab self.opaqueTopPanelBackground = opaqueTopPanelBackground + self.stateContext = stateContext self.controllerInteraction = controllerInteraction @@ -1217,7 +1226,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: self.stateContext?.emojiState ) self.stickerInputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -1510,7 +1520,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: nil ) self.inputDataDisposable = (combineLatest(queue: .mainQueue(), @@ -1950,8 +1961,25 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { var updatedGroups: [EmojiPagerContentComponent.ItemGroup] = [] var staticIsFirst = false - if let first = itemGroups.first, first.groupId == AnyHashable("static") { - staticIsFirst = true + let topStaticGroups: [String] = [ + "static", + "recent", + "featuredTop" + ] + for group in itemGroups { + var found = false + for topStaticGroup in topStaticGroups { + if group.groupId == AnyHashable(topStaticGroup) { + if group.groupId == AnyHashable("static") { + staticIsFirst = true + } + found = true + break + } + } + if !found { + break + } } for group in itemGroups { @@ -2379,7 +2407,8 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: hideBackground + hideBackground: hideBackground, + stateContext: nil ) let semaphore = DispatchSemaphore(value: 0) @@ -2411,7 +2440,8 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi opaqueTopPanelBackground: true, controllerInteraction: nil, interfaceInteraction: nil, - chatPeerId: nil + chatPeerId: nil, + stateContext: nil ) self.inputNode = inputNode inputNode.clipContentToTopPanel = true diff --git a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift index ef73e6e853..68f6e7504c 100644 --- a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift @@ -674,7 +674,8 @@ public final class EmojiStatusSelectionController: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) strongSelf.refreshLayout(transition: .immediate) diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 0a9b72b465..560d832e04 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -2244,6 +2244,13 @@ public final class EmojiPagerContentComponent: Component { } } + public final class StateContext { + var scrollPosition: CGFloat = 0.0 + + public init() { + } + } + public final class SynchronousLoadBehavior { public let isDisabled: Bool @@ -2313,6 +2320,7 @@ public final class EmojiPagerContentComponent: Component { public let useOpaqueTheme: Bool public let hideBackground: Bool public let scrollingStickersGridPromise = ValuePromise(false) + public let stateContext: StateContext? public init( performItemAction: @escaping (AnyHashable, Item, UIView, CGRect, CALayer, Bool) -> Void, @@ -2337,7 +2345,8 @@ public final class EmojiPagerContentComponent: Component { externalBackground: ExternalBackground?, externalExpansionView: UIView?, useOpaqueTheme: Bool, - hideBackground: Bool + hideBackground: Bool, + stateContext: StateContext? ) { self.performItemAction = performItemAction self.deleteBackwards = deleteBackwards @@ -2362,6 +2371,7 @@ public final class EmojiPagerContentComponent: Component { self.externalExpansionView = externalExpansionView self.useOpaqueTheme = useOpaqueTheme self.hideBackground = hideBackground + self.stateContext = stateContext } } @@ -5209,6 +5219,10 @@ public final class EmojiPagerContentComponent: Component { self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false, previousItemPositions: nil, updatedItemPositions: nil) self.updateScrollingOffset(isReset: false, transition: .immediate) + + if let stateContext = self.component?.inputInteractionHolder.inputInteraction?.stateContext { + stateContext.scrollPosition = scrollView.bounds.minY + } } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { @@ -6493,9 +6507,13 @@ public final class EmojiPagerContentComponent: Component { let previousSize = self.scrollView.bounds.size var resetScrolling = false + var isFirstUpdate = false if self.scrollView.bounds.isEmpty && component.displaySearchWithPlaceholder != nil { resetScrolling = true } + if previousComponent == nil { + isFirstUpdate = true + } if previousComponent?.itemContentUniqueId != component.itemContentUniqueId { resetScrolling = true } @@ -6601,7 +6619,11 @@ public final class EmojiPagerContentComponent: Component { } if resetScrolling { - self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) + var resetScrollY: CGFloat = 0.0 + if isFirstUpdate, let stateContext = component.inputInteractionHolder.inputInteraction?.stateContext { + resetScrollY = stateContext.scrollPosition + } + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: resetScrollY), size: scrollSize) } self.ignoreScrolling = false @@ -7072,10 +7094,15 @@ public final class EmojiPagerContentComponent: Component { var itemGroups: [ItemGroup] = [] var itemGroupIndexById: [AnyHashable: Int] = [:] - let appendUnicodeEmoji = { + let maybeAppendUnicodeEmoji = { + let groupId: AnyHashable = "static" + + if itemGroupIndexById[groupId] != nil { + return + } + if areUnicodeEmojiEnabled { for (subgroupId, list) in staticEmojiMapping { - let groupId: AnyHashable = "static" for emojiString in list { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -7097,10 +7124,6 @@ public final class EmojiPagerContentComponent: Component { } } - if !hasPremium { - appendUnicodeEmoji() - } - var installedCollectionIds = Set() for (id, _, _) in view.collectionInfos { installedCollectionIds.insert(id) @@ -7744,6 +7767,10 @@ public final class EmojiPagerContentComponent: Component { } } + if !hasPremium { + maybeAppendUnicodeEmoji() + } + if areCustomEmojiEnabled { for entry in view.entries { guard let item = entry.item as? StickerPackItem else { @@ -7889,7 +7916,7 @@ public final class EmojiPagerContentComponent: Component { } if hasPremium { - appendUnicodeEmoji() + maybeAppendUnicodeEmoji() } var displaySearchWithPlaceholder: String? diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift index 255699b257..bd542f9918 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift @@ -401,7 +401,8 @@ public final class EmojiSearchContent: ASDisplayNode, EntitySearchContainerNode externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) self.dataDisposable = ( diff --git a/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift b/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift index 7b92eacbbc..a36e244c2f 100644 --- a/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift +++ b/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift @@ -1013,7 +1013,8 @@ private final class ForumCreateTopicScreenComponent: CombinedComponent { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) } } diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json new file mode 100644 index 0000000000..940a6d1b19 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "close.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png new file mode 100644 index 0000000000..76eb185ef2 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png new file mode 100644 index 0000000000..b05e22edd7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png new file mode 100644 index 0000000000..8aee41f11e Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json new file mode 100644 index 0000000000..8a5c7bd6df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "expand.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "expand@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "expand@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png new file mode 100644 index 0000000000..e29cf1f2f7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png new file mode 100644 index 0000000000..c310341294 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png new file mode 100644 index 0000000000..34504618f6 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json new file mode 100644 index 0000000000..8176b1f584 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "more.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png new file mode 100644 index 0000000000..514d9875e7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png new file mode 100644 index 0000000000..7f9d98b485 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png new file mode 100644 index 0000000000..7d4d62533d Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json new file mode 100644 index 0000000000..fde2fc9c91 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png new file mode 100644 index 0000000000..bbb43de088 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png new file mode 100644 index 0000000000..adc3d2036d Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png new file mode 100644 index 0000000000..e58514672f Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json new file mode 100644 index 0000000000..e1308a36f9 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "share.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png new file mode 100644 index 0000000000..1301ea0768 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png new file mode 100644 index 0000000000..004e1d87c6 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png new file mode 100644 index 0000000000..5d1a2c011a Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png differ diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index fb828fb0a3..9db9edbbb1 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -328,11 +328,13 @@ public final class AccountContextImpl: AccountContext { }) self.currentCountriesConfiguration = Atomic(value: CountriesConfiguration(countries: loadCountryCodes())) - let currentCountriesConfiguration = self.currentCountriesConfiguration - self.countriesConfigurationDisposable = (self.engine.localization.getCountriesList(accountManager: sharedContext.accountManager, langCode: nil) - |> deliverOnMainQueue).start(next: { value in - let _ = currentCountriesConfiguration.swap(CountriesConfiguration(countries: value)) - }) + if !temp { + let currentCountriesConfiguration = self.currentCountriesConfiguration + self.countriesConfigurationDisposable = (self.engine.localization.getCountriesList(accountManager: sharedContext.accountManager, langCode: nil) + |> deliverOnMainQueue).start(next: { value in + let _ = currentCountriesConfiguration.swap(CountriesConfiguration(countries: value)) + }) + } let queue = Queue() self.deviceSpecificContactImportContexts = QueueLocalObject(queue: queue, generate: { diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 78a5260652..27044decc3 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -2399,7 +2399,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { displayNames in - self.registerForNotifications(replyString: presentationData.strings.Notification_Reply, messagePlaceholderString: presentationData.strings.Conversation_InputTextPlaceholder, hiddenContentString: presentationData.strings.Watch_MessageView_Title, includeNames: displayNames, authorize: authorize, completion: completion) + self.registerForNotifications(replyString: presentationData.strings.Notification_Reply, messagePlaceholderString: presentationData.strings.Conversation_InputTextPlaceholder, hiddenContentString: presentationData.strings.Watch_MessageView_Title, hiddenReactionContentString: presentationData.strings.Notification_LockScreenReactionPlaceholder, includeNames: displayNames, authorize: authorize, completion: completion) }) } - private func registerForNotifications(replyString: String, messagePlaceholderString: String, hiddenContentString: String, includeNames: Bool, authorize: Bool = true, completion: @escaping (Bool) -> Void = { _ in }) { + private func registerForNotifications(replyString: String, messagePlaceholderString: String, hiddenContentString: String, hiddenReactionContentString: String, includeNames: Bool, authorize: Bool = true, completion: @escaping (Bool) -> Void = { _ in }) { let notificationCenter = UNUserNotificationCenter.current() Logger.shared.log("App \(self.episodeId)", "register for notifications: get settings (authorize: \(authorize))") notificationCenter.getNotificationSettings(completionHandler: { settings in @@ -2525,6 +2525,7 @@ private func extractAccountManagerState(records: AccountRecordsView() private var didInitializeInputMediaNodeDataPromise: Bool = false private var inputMediaNodeDataDisposable: Disposable? + private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() let navigateButtons: ChatHistoryNavigationButtons @@ -227,7 +228,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { private var isLoadingValue: Bool = false private var isLoadingEarlier: Bool = false private func updateIsLoading(isLoading: Bool, earlier: Bool, animated: Bool) { - let useLoadingPlaceholder = self.chatLocation.peerId?.namespace != Namespaces.Peer.CloudUser + let useLoadingPlaceholder = "".isEmpty let updated = isLoading != self.isLoadingValue || (isLoading && earlier && !self.isLoadingEarlier) @@ -2640,7 +2641,8 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { defaultToEmojiTab: !self.chatPresentationInterfaceState.interfaceState.effectiveInputState.inputText.string.isEmpty || self.chatPresentationInterfaceState.interfaceState.forwardMessageIds != nil || self.openStickersBeginWithEmoji, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, - chatPeerId: peerId + chatPeerId: peerId, + stateContext: self.inputMediaNodeStateContext ) self.openStickersBeginWithEmoji = false diff --git a/submodules/TelegramUI/Sources/ChatLoadingNode.swift b/submodules/TelegramUI/Sources/ChatLoadingNode.swift index bb015b4a8e..2bbea87b3a 100644 --- a/submodules/TelegramUI/Sources/ChatLoadingNode.swift +++ b/submodules/TelegramUI/Sources/ChatLoadingNode.swift @@ -360,7 +360,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { let messageContainer = self.messageContainers[k] let messageSize = messageContainer.frame.size - messageContainer.update(size: size, hasAvatar: self.chatType != .channel, rect: CGRect(origin: CGPoint(x: 0.0, y: offset - messageSize.height), size: messageSize), transition: transition) + messageContainer.update(size: size, hasAvatar: self.chatType != .channel && self.chatType != .user, rect: CGRect(origin: CGPoint(x: 0.0, y: offset - messageSize.height), size: messageSize), transition: transition) offset -= messageSize.height } } @@ -388,6 +388,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { enum ChatType: Equatable { case generic + case user case group case channel } @@ -395,7 +396,9 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { func updatePresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState) { var chatType: ChatType = .channel if let peer = chatPresentationInterfaceState.renderedPeer?.peer { - if peer is TelegramGroup { + if peer is TelegramUser { + chatType = .user + } else if peer is TelegramGroup { chatType = .group } else if let channel = peer as? TelegramChannel { if case .group = channel.info { @@ -469,7 +472,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { for messageContainer in self.messageContainers { let messageSize = dimensions[index % 14] - messageContainer.update(size: bounds.size, hasAvatar: self.chatType != .channel, rect: CGRect(origin: CGPoint(x: 0.0, y: bounds.size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) + messageContainer.update(size: bounds.size, hasAvatar: self.chatType != .channel && self.chatType != .user, rect: CGRect(origin: CGPoint(x: 0.0, y: bounds.size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) offset += messageSize.height index += 1 } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 8aed6eda5b..6a1ec04f8a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -1431,8 +1431,13 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } else { if isCrosspostFromChannel, let sourceReference = sourceReference, let _ = firstMessage.peers[sourceReference.messageId.peerId] as? TelegramChannel { authorIsChannel = true + authorRank = attributes.rank + } else { + authorRank = attributes.rank + if authorRank == nil && message.author?.id == peer.id { + authorRank = .admin + } } - authorRank = attributes.rank } } else { if isCrosspostFromChannel, let _ = firstMessage.forwardInfo?.source as? TelegramChannel { diff --git a/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift b/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift index 2a6388245d..8341979f19 100644 --- a/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift @@ -168,7 +168,7 @@ class ChatMessageReplyInfoNode: ASDisplayNode { let messageText: NSAttributedString if isText { - var text = arguments.message.text + var text = foldLineBreaks(arguments.message.text) var messageEntities = arguments.message.textEntitiesAttribute?.entities ?? [] if let translateToLanguage = arguments.associatedData.translateToLanguage, !text.isEmpty { diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 8c99cffcda..75015054fe 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -5171,27 +5171,28 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate subItems.append(.separator) - if case .group = channel.info { - subItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo + "\n\n" + strongSelf.presentationData.strings.AutoremoveSetup_AdditionalGlobalSettingsInfo, textLayout: .multiline, textFont: .small, parseMarkdown: true, icon: { _ in - return nil - }, textLinkAction: { [weak c] in - c?.dismiss(completion: nil) - + let baseText: String + if case .broadcast = channel.info { + baseText = strongSelf.presentationData.strings.PeerInfo_ChannelAutoDeleteInfo + } else { + baseText = strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo + } + + subItems.append(.action(ContextMenuActionItem(text: baseText + "\n\n" + strongSelf.presentationData.strings.AutoremoveSetup_AdditionalGlobalSettingsInfo, textLayout: .multiline, textFont: .small, parseMarkdown: true, icon: { _ in + return nil + }, textLinkAction: { [weak c] in + c?.dismiss(completion: nil) + + guard let self else { + return + } + self.context.sharedContext.openResolvedUrl(.settings(.autoremoveMessages), context: self.context, urlContext: .generic, navigationController: self.controller?.navigationController as? NavigationController, forceExternal: false, openPeer: { _, _ in }, sendFile: nil, sendSticker: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: nil, present: { _, _ in }, dismissInput: { [weak self] in guard let self else { return } - self.context.sharedContext.openResolvedUrl(.settings(.autoremoveMessages), context: self.context, urlContext: .generic, navigationController: self.controller?.navigationController as? NavigationController, forceExternal: false, openPeer: { _, _ in }, sendFile: nil, sendSticker: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: nil, present: { _, _ in }, dismissInput: { [weak self] in - guard let self else { - return - } - self.controller?.view.endEditing(true) - }, contentContext: nil) - }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) - } else { - subItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo, textLayout: .multiline, textFont: .small, icon: { _ in - return nil - }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) - } + self.controller?.view.endEditing(true) + }, contentContext: nil) + }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) c.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) }))) diff --git a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift index 4f144e12ad..9f19f80371 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift @@ -203,7 +203,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { } if hasFilters { - self.mainContainerNode = ChatListContainerNode(context: context, location: chatListLocation, chatListMode: chatListMode, previewing: false, controlsHistoryPreload: false, isInlineMode: false, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in + self.mainContainerNode = ChatListContainerNode(context: context, controller: nil, location: chatListLocation, chatListMode: chatListMode, previewing: false, controlsHistoryPreload: false, isInlineMode: false, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { _ in }, secondaryEmptyAction: { })