import Foundation import UIKit import SwiftSignalKit import Display import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AccountContext import ViewControllerComponent import MultilineTextComponent import BalancedTextComponent import ButtonComponent import BundleIconComponent import Markdown import TelegramCore import AvatarNode import TelegramStringFormatting import AnimatedAvatarSetNode import UndoUI import PresentationDataUtils private final class JoinSubjectScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let mode: JoinSubjectScreenMode init( context: AccountContext, mode: JoinSubjectScreenMode ) { self.context = context self.mode = mode } static func ==(lhs: JoinSubjectScreenComponent, rhs: JoinSubjectScreenComponent) -> Bool { return true } private final class ScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } } private struct ItemLayout: Equatable { var containerSize: CGSize var containerInset: CGFloat var bottomInset: CGFloat var topInset: CGFloat init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat) { self.containerSize = containerSize self.containerInset = containerInset self.bottomInset = bottomInset self.topInset = topInset } } final class View: UIView, UIScrollViewDelegate { private let dimView: UIView private let backgroundLayer: SimpleLayer private let navigationBarContainer: SparseContainerView private let navigationBackgroundView: BlurredBackgroundView private let navigationBarSeparator: SimpleLayer private let scrollView: ScrollView private let scrollContentClippingView: SparseContainerView private let scrollContentView: UIView private let closeButton = ComponentView() private let peerAvatar = ComponentView() private let callIconBackground = ComponentView() private let callIcon = ComponentView() private let title = ComponentView() private var subtitle: ComponentView? private var descriptionText: ComponentView? private var contentSeparator: SimpleLayer? private var previewPeersText: ComponentView? private var previewPeersAvatarsNode: AnimatedAvatarSetNode? private var previewPeersAvatarsContext: AnimatedAvatarSetContext? private let titleTransformContainer: UIView private let bottomPanelContainer: UIView private let actionButton = ComponentView() private let bottomText = ComponentView() private let bottomOverscrollLimit: CGFloat private var isFirstTimeApplyingModalFactor: Bool = true private var ignoreScrolling: Bool = false private var component: JoinSubjectScreenComponent? private weak var state: EmptyComponentState? private var environment: ViewControllerComponentContainer.Environment? private var isUpdating: Bool = false private var itemLayout: ItemLayout? private var topOffsetDistance: CGFloat? private var cachedCloseImage: UIImage? private var isJoining: Bool = false private var joinDisposable: Disposable? override init(frame: CGRect) { self.bottomOverscrollLimit = 200.0 self.dimView = UIView() self.backgroundLayer = SimpleLayer() self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] self.backgroundLayer.cornerRadius = 10.0 self.navigationBarContainer = SparseContainerView() self.navigationBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) self.navigationBarSeparator = SimpleLayer() self.scrollView = ScrollView() self.scrollContentClippingView = SparseContainerView() self.scrollContentClippingView.clipsToBounds = true self.scrollContentView = UIView() self.titleTransformContainer = UIView() self.titleTransformContainer.isUserInteractionEnabled = false self.bottomPanelContainer = UIView() super.init(frame: frame) self.addSubview(self.dimView) self.layer.addSublayer(self.backgroundLayer) self.scrollView.delaysContentTouches = true self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false self.scrollView.contentInsetAdjustmentBehavior = .never if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = true self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.scrollView.clipsToBounds = true self.addSubview(self.scrollContentClippingView) self.scrollContentClippingView.addSubview(self.scrollView) self.scrollView.addSubview(self.scrollContentView) self.addSubview(self.navigationBarContainer) self.addSubview(self.titleTransformContainer) self.addSubview(self.bottomPanelContainer) self.navigationBarContainer.addSubview(self.navigationBackgroundView) self.navigationBarContainer.layer.addSublayer(self.navigationBarSeparator) self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.joinDisposable?.dispose() } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } if !self.backgroundLayer.frame.contains(point) { return self.dimView } if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { return result } let result = super.hitTest(point, with: event) return result } @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { guard let environment = self.environment, let controller = environment.controller() else { return } controller.dismiss() } } private func updateScrolling(transition: ComponentTransition) { guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { return } var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset let titleCenterY: CGFloat = -itemLayout.topInset + itemLayout.containerInset + 54.0 * 0.5 let titleTransformDistance: CGFloat = 20.0 let titleY: CGFloat = max(titleCenterY, self.titleTransformContainer.center.y + topOffset + itemLayout.containerInset) transition.setSublayerTransform(view: self.titleTransformContainer, transform: CATransform3DMakeTranslation(0.0, titleY - self.titleTransformContainer.center.y, 0.0)) let titleYDistance: CGFloat = titleY - titleCenterY let titleTransformFraction: CGFloat = 1.0 - max(0.0, min(1.0, titleYDistance / titleTransformDistance)) let titleMinScale: CGFloat = 17.0 / 24.0 let titleScale: CGFloat = 1.0 * (1.0 - titleTransformFraction) + titleMinScale * titleTransformFraction if let titleView = self.title.view { transition.setScale(view: titleView, scale: titleScale) } let navigationAlpha: CGFloat = titleTransformFraction transition.setAlpha(view: self.navigationBackgroundView, alpha: navigationAlpha) transition.setAlpha(layer: self.navigationBarSeparator, alpha: navigationAlpha) topOffset = max(0.0, topOffset) transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) self.topOffsetDistance = topOffsetDistance var topOffsetFraction = topOffset / topOffsetDistance topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) let transitionFactor: CGFloat = 1.0 - topOffsetFraction var modalOverlayTransition = transition if self.isFirstTimeApplyingModalFactor { self.isFirstTimeApplyingModalFactor = false modalOverlayTransition = .spring(duration: 0.5) } if self.isUpdating { DispatchQueue.main.async { [weak controller] in guard let controller else { return } controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: modalOverlayTransition.containedViewLayoutTransition) } } else { controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: modalOverlayTransition.containedViewLayoutTransition) } } func animateIn() { self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.titleTransformContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.bottomPanelContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } func animateOut(completion: @escaping () -> Void) { let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in completion() }) self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) self.titleTransformContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) self.bottomPanelContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) if let environment = self.environment, let controller = environment.controller() { controller.updateModalStyleOverlayTransitionFactor(0.0, transition: .animated(duration: 0.3, curve: .easeInOut)) } } private func navigateToPeer(peer: EnginePeer) { guard let component = self.component else { return } guard let controller = self.environment?.controller() else { return } guard let navigationController = controller.navigationController as? NavigationController else { return } var viewControllers = navigationController.viewControllers guard let index = viewControllers.firstIndex(where: { $0 === controller }) else { return } let context = component.context if case .user = peer { if let peerInfoController = context.sharedContext.makePeerInfoController( context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil ) { viewControllers.insert(peerInfoController, at: index) } } else { let chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .standard(.default), params: nil) viewControllers.insert(chatController, at: index) } navigationController.setViewControllers(viewControllers, animated: true) controller.dismiss() } private func performJoinAction() { if self.isJoining { return } guard let component = self.component else { return } switch component.mode { case let .group(group): self.joinDisposable?.dispose() self.isJoining = true if !self.isUpdating { self.state?.updated(transition: .immediate) } self.joinDisposable = (component.context.engine.peers.joinChatInteractively(with: group.link) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let component = self.component else { return } if group.isRequest { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } self.environment?.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .inviteRequestSent(title: presentationData.strings.MemberRequests_RequestToJoinSent, text: group.isGroup ? presentationData.strings.MemberRequests_RequestToJoinSentDescriptionGroup : presentationData.strings.MemberRequests_RequestToJoinSentDescriptionChannel ), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) } else { if let peer { self.navigateToPeer(peer: peer) } } self.environment?.controller()?.dismiss() }, error: { [weak self] error in guard let self, let component = self.component else { return } self.isJoining = false if !self.isUpdating { self.state?.updated(transition: .immediate) } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } switch error { case .tooMuchJoined: if let parentNavigationController = self.environment?.controller()?.navigationController as? NavigationController { let context = component.context parentNavigationController.pushViewController(component.context.sharedContext.makeOldChannelsController(context: component.context, updatedPresentationData: nil, intent: .join, completed: { [weak parentNavigationController] value in if value { parentNavigationController?.pushViewController(JoinSubjectScreen(context: context, mode: .group(group))) } })) } else { self.environment?.controller()?.present(textAlertController(context: component.context, title: nil, text: presentationData.strings.Join_ChannelsTooMuch, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) } case .tooMuchUsers: self.environment?.controller()?.present(textAlertController(context: component.context, title: nil, text: presentationData.strings.Conversation_UsersTooMuchError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) case .requestSent: if group.isRequest { self.environment?.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .inviteRequestSent(title: presentationData.strings.MemberRequests_RequestToJoinSent, text: group.isGroup ? presentationData.strings.MemberRequests_RequestToJoinSentDescriptionGroup : presentationData.strings.MemberRequests_RequestToJoinSentDescriptionChannel ), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) } case .flood: self.environment?.controller()?.present(textAlertController(context: component.context, title: nil, text: presentationData.strings.TwoStepAuth_FloodError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) case .generic: break } self.environment?.controller()?.dismiss() }) case let .groupCall(groupCall): component.context.sharedContext.callManager?.joinConferenceCall( accountContext: component.context, initialCall: EngineGroupCallDescription( id: groupCall.id, accessHash: groupCall.accessHash, title: nil, scheduleTimestamp: nil, subscribedToScheduled: false, isStream: false ), reference: .link(slug: groupCall.slug), beginWithVideo: false, invitePeerIds: [] ) self.environment?.controller()?.dismiss() } } func update(component: JoinSubjectScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let environment = environment[ViewControllerComponentContainer.Environment.self].value let themeUpdated = self.environment?.theme !== environment.theme let resetScrolling = self.scrollView.bounds.width != availableSize.width let sideInset: CGFloat = 16.0 + environment.safeInsets.left if self.component == nil { } self.component = component self.state = state self.environment = environment if themeUpdated { self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) self.backgroundLayer.backgroundColor = environment.theme.actionSheet.opaqueItemBackgroundColor.cgColor self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) self.navigationBarSeparator.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor } transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) var contentHeight: CGFloat = 0.0 let closeImage: UIImage if let image = self.cachedCloseImage, !themeUpdated { closeImage = image } else { closeImage = generateCloseButtonImage(backgroundColor: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05), foregroundColor: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.4))! self.cachedCloseImage = closeImage } let closeButtonSize = self.closeButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Image(image: closeImage, size: closeImage.size)), action: { [weak self] in guard let self, let controller = self.environment?.controller() else { return } controller.dismiss() } ).minSize(CGSize(width: 62.0, height: 56.0))), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) let closeButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - environment.safeInsets.right - closeButtonSize.width, y: 0.0), size: closeButtonSize) if let closeButtonView = self.closeButton.view { if closeButtonView.superview == nil { self.navigationBarContainer.addSubview(closeButtonView) } transition.setFrame(view: closeButtonView, frame: closeButtonFrame) } let containerInset: CGFloat = environment.statusBarHeight + 10.0 let clippingY: CGFloat let titleString: String let subtitleString: String? let descriptionTextString: String? let previewPeers: [EnginePeer] let totalMemberCount: Int switch component.mode { case let .group(group): contentHeight += 31.0 titleString = group.title subtitleString = group.isPublic ? "public group" : "private group" descriptionTextString = group.about previewPeers = group.members totalMemberCount = Int(group.memberCount) let peerAvatarSize = self.peerAvatar.update( transition: transition, component: AnyComponent(AvatarComponent( context: component.context, peer: EnginePeer.legacyGroup(TelegramGroup( id: EnginePeer.Id(namespace: Namespaces.Peer.CloudGroup, id: EnginePeer.Id.Id._internalFromInt64Value(1)), title: group.title, photo: group.image.flatMap { image in [image] } ?? [], participantCount: 0, role: .member, membership: .Left, flags: [], defaultBannedRights: nil, migrationReference: nil, creationDate: 0, version: 0 )) )), environment: {}, containerSize: CGSize(width: 90.0, height: 90.0) ) let peerAvatarFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - peerAvatarSize.width) * 0.5), y: contentHeight), size: peerAvatarSize) if let peerAvatarView = self.peerAvatar.view { if peerAvatarView.superview == nil { self.scrollContentView.addSubview(peerAvatarView) } transition.setFrame(view: peerAvatarView, frame: peerAvatarFrame) } contentHeight += peerAvatarSize.height + 21.0 case let .groupCall(groupCall): //TODO:localize titleString = "Group Call" subtitleString = nil descriptionTextString = "You are invited to join a group call." previewPeers = groupCall.members totalMemberCount = groupCall.totalMemberCount contentHeight += 31.0 let callIconBackgroundSize = self.callIconBackground.update( transition: transition, component: AnyComponent(FilledRoundedRectangleComponent( color: environment.theme.list.itemCheckColors.fillColor, cornerRadius: .minEdge, smoothCorners: false )), environment: {}, containerSize: CGSize(width: 90.0, height: 90.0) ) let callIconBackgroundFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - callIconBackgroundSize.width) * 0.5), y: contentHeight), size: callIconBackgroundSize) if let callIconBackgroundView = self.callIconBackground.view { if callIconBackgroundView.superview == nil { self.scrollContentView.addSubview(callIconBackgroundView) } transition.setFrame(view: callIconBackgroundView, frame: callIconBackgroundFrame) } let callIconSize = self.callIcon.update( transition: transition, component: AnyComponent(BundleIconComponent( name: "Call/CallAcceptButton", tintColor: environment.theme.list.itemCheckColors.foregroundColor, scaleFactor: 1.1 )), environment: {}, containerSize: callIconBackgroundSize ) let callIconFrame = CGRect(origin: CGPoint(x: callIconBackgroundFrame.minX + floor((callIconBackgroundFrame.width - callIconSize.width) * 0.5), y: callIconBackgroundFrame.minY + floor((callIconBackgroundFrame.height - callIconSize.height) * 0.5)), size: callIconSize) if let callIconView = self.callIcon.view { if callIconView.superview == nil { self.scrollContentView.addSubview(callIconView) } transition.setFrame(view: callIconView, frame: callIconFrame) } contentHeight += callIconBackgroundSize.height + 21.0 } let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: titleString, font: Font.bold(24.0), textColor: environment.theme.list.itemPrimaryTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize) if let titleView = title.view { if titleView.superview == nil { self.titleTransformContainer.addSubview(titleView) } titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) transition.setPosition(view: self.titleTransformContainer, position: titleFrame.center) } contentHeight += titleSize.height + 4.0 let navigationBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 54.0)) transition.setFrame(view: self.navigationBackgroundView, frame: navigationBackgroundFrame) self.navigationBackgroundView.update(size: navigationBackgroundFrame.size, cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) transition.setFrame(layer: self.navigationBarSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: 54.0), size: CGSize(width: availableSize.width, height: UIScreenPixel))) if let subtitleString { let subtitle: ComponentView if let current = self.subtitle { subtitle = current } else { subtitle = ComponentView() self.subtitle = subtitle } let subtitleSize = subtitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .markdown( text: subtitleString, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemSecondaryTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) } ) ), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) if let subtitleView = subtitle.view { if subtitleView.superview == nil { self.scrollContentView.addSubview(subtitleView) } transition.setPosition(view: subtitleView, position: subtitleFrame.center) subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) } contentHeight += subtitleSize.height } else if let subtitle = self.subtitle { self.subtitle = nil subtitle.view?.removeFromSuperview() } if let descriptionTextString { contentHeight += 10.0 let descriptionText: ComponentView if let current = self.descriptionText { descriptionText = current } else { descriptionText = ComponentView() self.descriptionText = descriptionText } let descriptionTextSize = descriptionText.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown( text: descriptionTextString, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) } ) ), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize) if let descriptionTextView = descriptionText.view { if descriptionTextView.superview == nil { self.scrollContentView.addSubview(descriptionTextView) } transition.setPosition(view: descriptionTextView, position: descriptionTextFrame.center) descriptionTextView.bounds = CGRect(origin: CGPoint(), size: descriptionTextFrame.size) } contentHeight += descriptionTextSize.height } else if let descriptionText = self.descriptionText { self.descriptionText = nil descriptionText.view?.removeFromSuperview() } if !previewPeers.isEmpty { contentHeight += 11.0 //TODO:localize let previewPeersString: String switch component.mode { case .group: if previewPeers.count == 1 { previewPeersString = "**\(previewPeers[0].compactDisplayTitle)** already joined this group." } else { let firstPeers = previewPeers.prefix(upTo: 2) let peersTextArray = firstPeers.map { "**\($0.compactDisplayTitle)**" } var peersText = "" if #available(iOS 13.0, *) { let listFormatter = ListFormatter() listFormatter.locale = localeWithStrings(environment.strings) if let value = listFormatter.string(from: peersTextArray) { peersText = value } } if peersText.isEmpty { for i in 0 ..< peersTextArray.count { if i != 0 { peersText.append(", ") } peersText.append(peersTextArray[i]) } } if totalMemberCount > firstPeers.count { previewPeersString = "\(peersText) and **\(totalMemberCount - firstPeers.count)** other people already joined this group." } else { previewPeersString = "\(peersText) already joined this group." } } case .groupCall: if previewPeers.count == 1 { previewPeersString = "**\(previewPeers[0].compactDisplayTitle)** already joined this call." } else { let firstPeers = previewPeers.prefix(upTo: 2) let peersTextArray = firstPeers.map { "**\($0.compactDisplayTitle)**" } var peersText = "" if #available(iOS 13.0, *) { let listFormatter = ListFormatter() listFormatter.locale = localeWithStrings(environment.strings) if let value = listFormatter.string(from: peersTextArray) { peersText = value } } if peersText.isEmpty { for i in 0 ..< peersTextArray.count { if i != 0 { peersText.append(", ") } peersText.append(peersTextArray[i]) } } if totalMemberCount > firstPeers.count { previewPeersString = "\(peersText) and **\(totalMemberCount - firstPeers.count)** other people already joined this call." } else { previewPeersString = "\(peersText) already joined this call." } } } let contentSeparator: SimpleLayer if let current = self.contentSeparator { contentSeparator = current } else { contentSeparator = SimpleLayer() self.contentSeparator = contentSeparator self.scrollContentView.layer.addSublayer(contentSeparator) } if themeUpdated { contentSeparator.backgroundColor = environment.theme.list.itemPlainSeparatorColor.cgColor } contentHeight += 8.0 transition.setFrame(layer: contentSeparator, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: UIScreenPixel))) contentHeight += 10.0 let previewPeersAvatarsNode: AnimatedAvatarSetNode let previewPeersAvatarsContext: AnimatedAvatarSetContext if let current = self.previewPeersAvatarsNode, let currentContext = self.previewPeersAvatarsContext { previewPeersAvatarsNode = current previewPeersAvatarsContext = currentContext } else { previewPeersAvatarsNode = AnimatedAvatarSetNode() previewPeersAvatarsContext = AnimatedAvatarSetContext() self.previewPeersAvatarsNode = previewPeersAvatarsNode self.previewPeersAvatarsContext = previewPeersAvatarsContext } let avatarsContent = previewPeersAvatarsContext.update(peers: previewPeers.count <= 3 ? previewPeers : Array(previewPeers.prefix(upTo: 3)), animated: false) let avatarsSize = previewPeersAvatarsNode.update( context: component.context, content: avatarsContent, itemSize: CGSize(width: 40.0, height: 40.0), customSpacing: 24.0, font: avatarPlaceholderFont(size: 18.0), animated: false, synchronousLoad: true ) contentHeight += 8.0 let previewPeersAvatarsFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - avatarsSize.width) * 0.5), y: contentHeight), size: avatarsSize) if previewPeersAvatarsNode.view.superview == nil { self.scrollContentView.addSubview(previewPeersAvatarsNode.view) } transition.setFrame(view: previewPeersAvatarsNode.view, frame: previewPeersAvatarsFrame) contentHeight += 53.0 let previewPeersText: ComponentView if let current = self.previewPeersText { previewPeersText = current } else { previewPeersText = ComponentView() self.previewPeersText = previewPeersText } let previewPeersTextSize = previewPeersText.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown( text: previewPeersString, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) } ) ), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let previewPeersTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - previewPeersTextSize.width) * 0.5), y: contentHeight), size: previewPeersTextSize) if let previewPeersTextView = previewPeersText.view { if previewPeersTextView.superview == nil { self.scrollContentView.addSubview(previewPeersTextView) } transition.setFrame(view: previewPeersTextView, frame: previewPeersTextFrame) } contentHeight += previewPeersTextSize.height + 23.0 } else { contentHeight += 18.0 if let contentSeparator = self.contentSeparator { self.contentSeparator = nil contentSeparator.removeFromSuperlayer() } if let previewPeersText = self.previewPeersText { self.previewPeersText = nil previewPeersText.view?.removeFromSuperview() } } let actionButtonTitle: String switch component.mode { case .group: actionButtonTitle = environment.strings.Invitation_JoinGroup case .groupCall: //TODO:localize actionButtonTitle = "Join Group Call" } let actionButtonSize = self.actionButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( color: environment.theme.list.itemCheckColors.fillColor, foreground: environment.theme.list.itemCheckColors.foregroundColor, pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(ButtonTextContentComponent( text: actionButtonTitle, badge: 0, textColor: environment.theme.list.itemCheckColors.foregroundColor, badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, badgeForeground: environment.theme.list.itemCheckColors.fillColor )) ), isEnabled: true, displaysProgress: self.isJoining, action: { [weak self] in guard let self else { return } self.performJoinAction() } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) ) let bottomPanelHeight = 10.0 + environment.safeInsets.bottom + actionButtonSize.height let bottomPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelHeight), size: CGSize(width: availableSize.width, height: bottomPanelHeight)) transition.setFrame(view: self.bottomPanelContainer, frame: bottomPanelFrame) let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: actionButtonSize) if let actionButtonView = self.actionButton.view { if actionButtonView.superview == nil { self.bottomPanelContainer.addSubview(actionButtonView) } transition.setFrame(view: actionButtonView, frame: actionButtonFrame) } contentHeight += bottomPanelHeight clippingY = bottomPanelFrame.minY - 8.0 let topInset: CGFloat = max(0.0, availableSize.height - containerInset - contentHeight) let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset) self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset) transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: clippingY - containerInset)) transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) self.ignoreScrolling = true let previousBounds = self.scrollView.bounds transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) if contentSize != self.scrollView.contentSize { self.scrollView.contentSize = contentSize } if resetScrolling { self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) } else { if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds if bounds.maxY != previousBounds.maxY { let offsetY = previousBounds.maxY - bounds.maxY transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) } } } self.ignoreScrolling = false self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class JoinSubjectScreen: ViewControllerComponentContainer { private let context: AccountContext private var isDismissed: Bool = false public init( context: AccountContext, mode: JoinSubjectScreenMode ) { self.context = context super.init(context: context, component: JoinSubjectScreenComponent( context: context, mode: mode ), navigationBarAppearance: .none) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.view.disablesInteractiveModalDismiss = true if let componentView = self.node.hostView.componentView as? JoinSubjectScreenComponent.View { componentView.animateIn() } } override public func dismiss(completion: (() -> Void)? = nil) { if !self.isDismissed { self.isDismissed = true if let componentView = self.node.hostView.componentView as? JoinSubjectScreenComponent.View { componentView.animateOut(completion: { [weak self] in completion?() self?.dismiss(animated: false) }) } else { self.dismiss(animated: false) } } } } private final class AvatarComponent: Component { let context: AccountContext let peer: EnginePeer let size: CGSize? init(context: AccountContext, peer: EnginePeer, size: CGSize? = nil) { self.context = context self.peer = peer self.size = size } static func ==(lhs: AvatarComponent, rhs: AvatarComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.peer != rhs.peer { return false } if lhs.size != rhs.size { return false } return true } final class View: UIView { private var avatarNode: AvatarNode? private var component: AvatarComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: AvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let size = component.size ?? availableSize let avatarNode: AvatarNode if let current = self.avatarNode { avatarNode = current } else { avatarNode = AvatarNode(font: avatarPlaceholderFont(size: floor(size.width * 0.5))) avatarNode.displaysAsynchronously = false self.avatarNode = avatarNode self.addSubview(avatarNode.view) } avatarNode.frame = CGRect(origin: CGPoint(), size: size) avatarNode.setPeer( context: component.context, theme: component.context.sharedContext.currentPresentationData.with({ $0 }).theme, peer: component.peer, synchronousLoad: true, displayDimensions: size ) avatarNode.updateSize(size: size) return size } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(backgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setLineWidth(2.0) context.setLineCap(.round) context.setStrokeColor(foregroundColor.cgColor) context.beginPath() context.move(to: CGPoint(x: 10.0, y: 10.0)) context.addLine(to: CGPoint(x: 20.0, y: 20.0)) context.move(to: CGPoint(x: 20.0, y: 10.0)) context.addLine(to: CGPoint(x: 10.0, y: 20.0)) context.strokePath() }) }