diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 7031f6ae98..07f71ef870 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -3001,6 +3001,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } else { match = false } + case .createBot: + break } if match { return true diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 63e0e3937f..f207a1e779 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -2328,6 +2328,8 @@ public final class ChatListNode: ListViewImpl { } else { match = false } + case .createBot: + break } if match { return true diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index 3e1de195d2..d2ecbb720b 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -56,19 +56,21 @@ public extension CheckNodeTheme { } } -public enum CheckNodeContent { - case check +public enum CheckNodeContent: Equatable { + case check(isRectangle: Bool) case counter(Int) } private final class CheckNodeParameters: NSObject { + let isRectangle: Bool let theme: CheckNodeTheme let content: CheckNodeContent let animationProgress: CGFloat let selected: Bool let animatingOut: Bool - init(theme: CheckNodeTheme, content: CheckNodeContent, animationProgress: CGFloat, selected: Bool, animatingOut: Bool) { + init(isRectangle: Bool, theme: CheckNodeTheme, content: CheckNodeContent, animationProgress: CGFloat, selected: Bool, animatingOut: Bool) { + self.isRectangle = isRectangle self.theme = theme self.content = content self.animationProgress = animationProgress @@ -86,7 +88,7 @@ public class CheckNode: ASDisplayNode { } } - public init(theme: CheckNodeTheme, content: CheckNodeContent = .check) { + public init(theme: CheckNodeTheme, content: CheckNodeContent = .check(isRectangle: false)) { self.theme = theme self.content = content @@ -161,7 +163,7 @@ public class CheckNode: ASDisplayNode { } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return CheckNodeParameters(theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + return CheckNodeParameters(isRectangle: self.content == .check(isRectangle: true), theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { @@ -200,7 +202,7 @@ public class InteractiveCheckNode: CheckNode { public var valueChanged: ((Bool) -> Void)? - override public init(theme: CheckNodeTheme, content: CheckNodeContent = .check) { + override public init(theme: CheckNodeTheme, content: CheckNodeContent = .check(isRectangle: false)) { self.buttonNode = HighlightTrackingButtonNode() super.init(theme: theme, content: content) @@ -250,7 +252,7 @@ public class CheckLayer: CALayer { public override init() { self.theme = CheckNodeTheme(backgroundColor: .white, strokeColor: .blue, borderColor: .white, overlayBorder: false, hasInset: false, hasShadow: false) - self.content = .check + self.content = .check(isRectangle: false) super.init() @@ -270,7 +272,7 @@ public class CheckLayer: CALayer { self.isOpaque = false } - public init(theme: CheckNodeTheme, content: CheckNodeContent = .check) { + public init(theme: CheckNodeTheme, content: CheckNodeContent = .check(isRectangle: false)) { self.theme = theme self.content = content @@ -364,7 +366,7 @@ public class CheckLayer: CALayer { CheckLayer.drawContents( context: context, size: size, - parameters: CheckNodeParameters(theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + parameters: CheckNodeParameters(isRectangle: self.content == .check(isRectangle: true), theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) ) })?.cgImage } @@ -414,13 +416,24 @@ public class CheckLayer: CALayer { let fillProgress: CGFloat = parameters.animationProgress context.setFillColor(parameters.theme.backgroundColor.mixedWith(parameters.theme.borderColor, alpha: 1.0 - fillProgress).cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + if parameters.isRectangle { + context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 7.0).cgPath) + context.fillPath() + } else { + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + } let innerDiameter: CGFloat = (fillProgress * 0.0) + (1.0 - fillProgress) * (size.width - borderWidth * 2.0) context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - innerDiameter) * 0.5, y: (size.height - innerDiameter) * 0.5), size: CGSize(width: innerDiameter, height: innerDiameter))) + if parameters.isRectangle { + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: (size.width - innerDiameter) * 0.5, y: (size.height - innerDiameter) * 0.5), size: CGSize(width: innerDiameter, height: innerDiameter)), cornerRadius: 6.0).cgPath) + context.fillPath() + } else { + context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - innerDiameter) * 0.5, y: (size.height - innerDiameter) * 0.5), size: CGSize(width: innerDiameter, height: innerDiameter))) + } context.setBlendMode(.normal) } } else { diff --git a/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift b/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift index ee5776c341..d363d8f88a 100644 --- a/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift +++ b/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift @@ -65,7 +65,7 @@ public final class GridMessageSelectionLayer: CALayer { public let checkLayer: CheckLayer public init(theme: CheckNodeTheme) { - self.checkLayer = CheckLayer(theme: theme, content: .check) + self.checkLayer = CheckLayer(theme: theme, content: .check(isRectangle: false)) super.init() diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index 5930f45b72..d5b061c33a 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -167,6 +167,12 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att } public enum AssetsMode: Equatable { + public enum PollMode: Equatable { + case description + case quizAnswer + case option + } + case `default` case wallpaper case story @@ -174,6 +180,7 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att case cover case createSticker case createAvatar + case poll(PollMode) } case assets(PHAssetCollection?, AssetsMode) @@ -2037,6 +2044,17 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att self.titleView.title = presentationData.strings.MediaPicker_AddImage case .cover: self.titleView.title = presentationData.strings.MediaPicker_ChooseCover + case let .poll(pollMode): + self.titleView.title = presentationData.strings.MediaPicker_Recents + switch pollMode { + case .description: + self.titleView.subtitle = "Add media to the poll description" + case .quizAnswer: + self.titleView.subtitle = "Add media to the quiz explanation" + case .option: + self.titleView.subtitle = "Add media to this option" + } + self.titleView.isEnabled = true } } } else { diff --git a/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift b/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift index f01f636801..301f078bcd 100644 --- a/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift @@ -16,12 +16,2904 @@ import BalancedTextComponent import BundleIconComponent import Markdown import TextFormat +import ButtonComponent import SolidRoundedButtonComponent import BlurredBackgroundComponent import UndoUI import ConfettiEffect import PremiumPeerShortcutComponent import ScrollComponent +import ResizableSheetComponent +import GlassBarButtonComponent + +private final class SheetContent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + let peer: EnginePeer? + let memberPeer: EnginePeer? + let mode: PremiumBoostLevelsScreen.Mode + let status: ChannelBoostStatus? + let boostState: InternalBoostState.DisplayData? + let boost: () -> Void + let copyLink: (String) -> Void + let dismiss: () -> Void + let openStats: (() -> Void)? + let openGift: (() -> Void)? + let openPeer: ((EnginePeer) -> Void)? + let updated: () -> Void + + init( + context: AccountContext, + peerId: EnginePeer.Id, + peer: EnginePeer?, + memberPeer: EnginePeer?, + mode: PremiumBoostLevelsScreen.Mode, + status: ChannelBoostStatus?, + boostState: InternalBoostState.DisplayData?, + boost: @escaping () -> Void, + copyLink: @escaping (String) -> Void, + dismiss: @escaping () -> Void, + openStats: (() -> Void)?, + openGift: (() -> Void)?, + openPeer: ((EnginePeer) -> Void)?, + updated: @escaping () -> Void + ) { + self.context = context + self.peerId = peerId + self.peer = peer + self.memberPeer = memberPeer + self.mode = mode + self.status = status + self.boostState = boostState + self.boost = boost + self.copyLink = copyLink + self.dismiss = dismiss + self.openStats = openStats + self.openGift = openGift + self.openPeer = openPeer + self.updated = updated + } + + static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peerId != rhs.peerId { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.memberPeer != rhs.memberPeer { + return false + } + if lhs.mode != rhs.mode { + return false + } + if lhs.status != rhs.status { + return false + } + if lhs.boostState != rhs.boostState { + return false + } + return true + } + + final class State: ComponentState { + var cachedChevronImage: (UIImage, PresentationTheme)? + var cachedIconImage: UIImage? + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let iconBackground = Child(Image.self) + let icon = Child(BundleIconComponent.self) + + let peerShortcut = Child(Button.self) + let text = Child(BalancedTextComponent.self) + let alternateText = Child(List.self) + let limit = Child(PremiumLimitDisplayComponent.self) + let linkButton = Child(SolidRoundedButtonComponent.self) + let boostButton = Child(ButtonComponent.self) + let copyButton = Child(ButtonComponent.self) + + let orLeftLine = Child(Rectangle.self) + let orRightLine = Child(Rectangle.self) + let orText = Child(MultilineTextComponent.self) + let giftText = Child(BalancedTextComponent.self) + + let levels = Child(List.self) + + return { context in + let component = context.component + let environment = context.environment[ViewControllerComponentContainer.Environment.self].value + let theme = environment.theme + let strings = environment.strings + + let state = context.state + + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + let sideInset: CGFloat = 16.0 + let textSideInset: CGFloat = 32.0 + + let iconName = "Premium/Boost" + let peerName = component.peer?.compactDisplayTitle ?? "" + + var isGroup = false + if case let .channel(channel) = component.peer, case .group = channel.info { + isGroup = true + } + + let level: Int + let boosts: Int + let remaining: Int? + let progress: CGFloat + let myBoostCount: Int + if let boostState = component.boostState { + level = Int(boostState.level) + boosts = Int(boostState.boosts) + if let nextLevelBoosts = boostState.nextLevelBoosts { + remaining = max(0, Int(nextLevelBoosts - boostState.boosts)) + progress = max(0.0, min(1.0, CGFloat(boostState.boosts - boostState.currentLevelBoosts) / CGFloat(nextLevelBoosts - boostState.currentLevelBoosts))) + } else { + remaining = nil + progress = 1.0 + } + myBoostCount = Int(boostState.myBoostCount) + } else if let status = component.status { + level = status.level + boosts = status.boosts + if let nextLevelBoosts = status.nextLevelBoosts { + remaining = max(0, nextLevelBoosts - status.boosts) + progress = max(0.0, min(1.0, CGFloat(status.boosts - status.currentLevelBoosts) / CGFloat(nextLevelBoosts - status.currentLevelBoosts))) + } else { + remaining = nil + progress = 1.0 + } + myBoostCount = 0 + } else { + level = 0 + boosts = 0 + remaining = nil + progress = 0.0 + myBoostCount = 0 + } + + var textString = "" + + var isCurrent = false + switch component.mode { + case let .owner(subject): + if let remaining { + var needsSecondParagraph = true + + if let subject { + let requiredLevel = subject.requiredLevel(group: isGroup, context: context.component.context, configuration: premiumConfiguration) + + let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(level) + 1) + let valueString = strings.ChannelBoost_MoreBoosts(Int32(remaining)) + switch subject { + case .stories: + if level == 0 { + textString = isGroup ? strings.GroupBoost_EnableStoriesText(valueString).string : strings.ChannelBoost_EnableStoriesText(valueString).string + } else { + textString = isGroup ? strings.GroupBoost_IncreaseLimitText(valueString, storiesString).string : strings.ChannelBoost_IncreaseLimitText(valueString, storiesString).string + } + needsSecondParagraph = isGroup + case let .channelReactions(reactionCount): + textString = strings.ChannelBoost_CustomReactionsText("\(reactionCount)", "\(reactionCount)").string + needsSecondParagraph = false + case .nameColors: + textString = strings.ChannelBoost_EnableNameColorLevelText("\(requiredLevel)").string + case .nameIcon: + textString = strings.ChannelBoost_EnableNameIconLevelText("\(requiredLevel)").string + case .profileColors: + textString = isGroup ? strings.GroupBoost_EnableProfileColorLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableProfileColorLevelText("\(requiredLevel)").string + case .profileIcon: + textString = isGroup ? strings.GroupBoost_EnableProfileIconLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableProfileIconLevelText("\(premiumConfiguration.minChannelProfileIconLevel)").string + case .emojiStatus: + textString = isGroup ? strings.GroupBoost_EnableEmojiStatusLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableEmojiStatusLevelText("\(requiredLevel)").string + case .wallpaper: + textString = isGroup ? strings.GroupBoost_EnableWallpaperLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableWallpaperLevelText("\(requiredLevel)").string + case .customWallpaper: + textString = isGroup ? strings.GroupBoost_EnableCustomWallpaperLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableCustomWallpaperLevelText("\(requiredLevel)").string + case .audioTranscription: + textString = "" + case .emojiPack: + textString = strings.GroupBoost_EnableEmojiPackLevelText("\(requiredLevel)").string + case .noAds: + textString = strings.ChannelBoost_EnableNoAdsLevelText("\(requiredLevel)").string + case .wearGift: + textString = strings.ChannelBoost_WearGiftLevelText("\(requiredLevel)").string + case .autoTranslate: + textString = strings.ChannelBoost_AutoTranslateLevelText("\(requiredLevel)").string + } + } else { + let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) + if myBoostCount > 0 { + if remaining == 0 { + textString = isGroup ? strings.GroupBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string : strings.ChannelBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string + } else { + textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string + } + } else { + textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string + } + } + + if needsSecondParagraph { + textString += " \(isGroup ? strings.GroupBoost_PremiumUsersCanBoost : strings.ChannelBoost_PremiumUsersCanBoost)" + } + } else { + textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string + } + case let .user(mode): + switch mode { + case let .groupPeer(_, peerBoostCount): + let memberName = component.memberPeer?.compactDisplayTitle ?? "" + let timesString = strings.GroupBoost_MemberBoosted_Times(Int32(peerBoostCount)) + let memberString = strings.GroupBoost_MemberBoosted(memberName, timesString).string + if myBoostCount > 0 { + if let remaining, remaining != 0 { + let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) + textString = "\(memberString) \(strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string)" + } else { + textString = memberString + } + } else { + textString = "\(memberString) \(strings.GroupBoost_MemberBoosted_BoostForBadge(peerName).string)" + } + isCurrent = true + case let .unrestrict(unrestrictCount): + let timesString = strings.GroupBoost_BoostToUnrestrict_Times(Int32(unrestrictCount)) + textString = strings.GroupBoost_BoostToUnrestrict(timesString, peerName).string + isCurrent = true + default: + if let remaining { + let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) + if myBoostCount > 0 { + if remaining == 0 { + textString = isGroup ? strings.GroupBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string : strings.ChannelBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string + } else { + textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string + } + } else { + textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string + } + } else { + textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string + } + isCurrent = mode == .current + } + case .features: + textString = isGroup ? strings.GroupBoost_AdditionalFeaturesText : strings.ChannelBoost_AdditionalFeaturesText + } + + let defaultTitle = strings.ChannelBoost_Level("\(level)").string + let defaultValue = "" + let premiumValue = strings.ChannelBoost_Level("\(level + 1)").string + let premiumTitle = "" + + var contentSize: CGSize = CGSize(width: context.availableSize.width, height: 56.0) + + let textFont = Font.regular(15.0) + let boldTextFont = Font.semibold(15.0) + let textColor = theme.actionSheet.primaryTextColor + let linkColor = theme.actionSheet.controlAccentColor + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in + return (TelegramTextAttributes.URL, contents) + }) + + let gradientColors = [ + UIColor(rgb: 0x0077ff), + UIColor(rgb: 0x6b93ff), + UIColor(rgb: 0x8878ff), + UIColor(rgb: 0xe46ace) + ] + + if case let .user(mode) = component.mode, case .external = mode, let peer = component.peer { + contentSize.height += 10.0 + + let peerShortcut = peerShortcut.update( + component: Button( + content: AnyComponent( + PremiumPeerShortcutComponent( + context: component.context, + theme: theme, + peer: peer + ) + ), + action: { + component.dismiss() + Queue.mainQueue().after(0.35) { + component.openPeer?(peer) + } + } + ), + availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(peerShortcut + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + peerShortcut.size.height / 2.0)) + ) + contentSize.height += peerShortcut.size.height + 2.0 + } + + if case .features = component.mode { + contentSize.height -= 14.0 + + let iconSize = CGSize(width: 90.0, height: 90.0) + let gradientImage: UIImage + if let current = state.cachedIconImage { + gradientImage = current + } else { + gradientImage = generateFilledCircleImage(diameter: iconSize.width, color: theme.actionSheet.controlAccentColor)! + context.state.cachedIconImage = gradientImage + } + + let iconBackground = iconBackground.update( + component: Image(image: gradientImage), + availableSize: iconSize, + transition: .immediate + ) + context.add(iconBackground + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0)) + ) + + let icon = icon.update( + component: BundleIconComponent( + name: "Premium/BoostLarge", + tintColor: .white + ), + availableSize: CGSize(width: 90.0, height: 90.0), + transition: .immediate + ) + context.add(icon + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0)) + ) + contentSize.height += iconSize.height + contentSize.height += 52.0 + } else { + let limit = limit.update( + component: PremiumLimitDisplayComponent( + inactiveColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3), + activeColors: gradientColors, + inactiveTitle: defaultTitle, + inactiveValue: defaultValue, + inactiveTitleColor: theme.list.itemPrimaryTextColor, + activeTitle: premiumTitle, + activeValue: premiumValue, + activeTitleColor: .white, + badgeIconName: iconName, + badgeText: "\(boosts)", + badgePosition: progress, + badgeGraphPosition: progress, + invertProgress: true, + isPremiumDisabled: false + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), + transition: context.transition + ) + context.add(limit + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + limit.size.height / 2.0)) + ) + + contentSize.height += limit.size.height + 23.0 + } + + if myBoostCount > 0 { + let alternateTitle = isCurrent ? strings.ChannelBoost_YouBoostedChannelText(peerName).string : strings.ChannelBoost_YouBoostedOtherChannelText + + var alternateBadge: String? + if myBoostCount > 1 { + alternateBadge = "X\(myBoostCount)" + } + + let alternateText = alternateText.update( + component: List( + [ + AnyComponentWithIdentity( + id: "title", + component: AnyComponent( + BoostedTitleContent(text: NSAttributedString(string: alternateTitle, font: Font.semibold(15.0), textColor: textColor), badge: alternateBadge) + ) + ), + AnyComponentWithIdentity( + id: "text", + component: AnyComponent( + BalancedTextComponent( + text: .markdown(text: textString, attributes: markdownAttributes), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.1 + ) + ) + ) + ], + centerAlignment: true + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(alternateText + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + alternateText.size.height / 2.0)) + .appear(ComponentTransition.Appear({ _, view, transition in + transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true) + transition.animateAlpha(view: view, from: 0.0, to: 1.0) + })) + .disappear(ComponentTransition.Disappear({ view, transition, completion in + view.superview?.sendSubviewToBack(view) + transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true) + transition.setAlpha(view: view, alpha: 0.0, completion: { _ in + completion() + }) + })) + ) + contentSize.height += alternateText.size.height + 20.0 + } else { + let text = text.update( + component: BalancedTextComponent( + text: .markdown(text: textString, attributes: markdownAttributes), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(text + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)) + .appear(ComponentTransition.Appear({ _, view, transition in + transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true) + transition.animateAlpha(view: view, from: 0.0, to: 1.0) + })) + .disappear(ComponentTransition.Disappear({ view, transition, completion in + view.superview?.sendSubviewToBack(view) + transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true) + transition.setAlpha(view: view, alpha: 0.0, completion: { _ in + completion() + }) + })) + ) + contentSize.height += text.size.height + 20.0 + } + + if case .owner = component.mode, let status = component.status { + contentSize.height += 7.0 + + let linkButton = linkButton.update( + component: SolidRoundedButtonComponent( + title: status.url.replacingOccurrences(of: "https://", with: ""), + theme: SolidRoundedButtonComponent.Theme( + backgroundColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3), + backgroundColors: [], + foregroundColor: theme.list.itemPrimaryTextColor + ), + font: .regular, + fontSize: 17.0, + height: 52.0, + cornerRadius: 26.0, + action: { + component.copyLink(status.url) + component.dismiss() + } + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + context.add(linkButton + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + linkButton.size.height / 2.0)) + ) + contentSize.height += linkButton.size.height + 16.0 + + let boostButton = boostButton.update( + component: ButtonComponent( + background: ButtonComponent.Background( + style: .glass, + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity(id: 0, component: AnyComponent(Text(text: strings.ChannelBoost_Boost, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))), + action: { + component.boost() + } + ), + availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 52.0), + transition: context.transition + ) + + let copyButton = copyButton.update( + component: ButtonComponent( + background: ButtonComponent.Background( + style: .glass, + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity(id: 0, component: AnyComponent(Text(text: strings.ChannelBoost_Copy, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))), + action: { + component.copyLink(status.url) + component.dismiss() + } + ), + availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 52.0), + transition: context.transition + ) + + let boostButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentSize.height), size: boostButton.size) + context.add(boostButton + .position(boostButtonFrame.center) + ) + let copyButtonFrame = CGRect(origin: CGPoint(x: context.availableSize.width - sideInset - copyButton.size.width, y: contentSize.height), size: copyButton.size) + context.add(copyButton + .position(copyButtonFrame.center) + ) + contentSize.height += boostButton.size.height + + if premiumConfiguration.giveawayGiftsPurchaseAvailable { + let orText = orText.update( + component: MultilineTextComponent(text: .plain(NSAttributedString(string: strings.ChannelBoost_Or, font: Font.regular(15.0), textColor: textColor.withAlphaComponent(0.8), paragraphAlignment: .center))), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(orText + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 27.0)) + ) + + let orLeftLine = orLeftLine.update( + component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)), + availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel), + transition: .immediate + ) + context.add(orLeftLine + .position(CGPoint(x: context.availableSize.width / 2.0 - orText.size.width / 2.0 - 11.0 - 45.0, y: contentSize.height + 27.0)) + ) + + let orRightLine = orRightLine.update( + component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)), + availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel), + transition: .immediate + ) + context.add(orRightLine + .position(CGPoint(x: context.availableSize.width / 2.0 + orText.size.width / 2.0 + 11.0 + 45.0, y: contentSize.height + 27.0)) + ) + + if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme { + state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme) + } + + + let giftString = isGroup ? strings.Premium_Group_BoostByGiveawayDescription : strings.Premium_BoostByGiveawayDescription + let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString + + if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { + giftAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: giftAttributedString.string)) + } + let giftText = giftText.update( + component: BalancedTextComponent( + text: .plain(giftAttributedString), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.1, + highlightColor: linkColor.withAlphaComponent(0.1), + highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), + highlightAction: { _ in + return nil + }, + tapAction: { _, _ in + component.openGift?() + } + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(giftText + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 50.0 + giftText.size.height / 2.0)) + ) + contentSize.height += giftText.size.height + 50.0 + 23.0 + } + } + + var nextLevels: ClosedRange? + if level < 10 { + nextLevels = Int32(level) + 1 ... 10 + } + + var levelItems: [AnyComponentWithIdentity] = [] + + var nameColorsAtLevel: [(Int32, Int32)] = [] + var nameColorsCountMap: [Int32: Int32] = [:] + for color in context.component.context.peerNameColors.displayOrder { + if let level = context.component.context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[color] { + if let current = nameColorsCountMap[level] { + nameColorsCountMap[level] = current + 1 + } else { + nameColorsCountMap[level] = 1 + } + } + } + for (key, value) in nameColorsCountMap { + nameColorsAtLevel.append((key, value)) + } + + var profileColorsAtLevel: [(Int32, Int32)] = [] + var profileColorsCountMap: [Int32: Int32] = [:] + for color in context.component.context.peerNameColors.profileDisplayOrder { + if let level = isGroup ? context.component.context.peerNameColors.profileColorsGroupMinRequiredBoostLevel[color] : context.component.context.peerNameColors.profileColorsChannelMinRequiredBoostLevel[color] { + if let current = profileColorsCountMap[level] { + profileColorsCountMap[level] = current + 1 + } else { + profileColorsCountMap[level] = 1 + } + } + } + for (key, value) in profileColorsCountMap { + profileColorsAtLevel.append((key, value)) + } + + var isFeatures = false + if case .features = component.mode { + isFeatures = true + } + + func layoutLevel(_ level: Int32) { + var perks: [LevelSectionComponent.Perk] = [] + + if !isGroup && level >= requiredBoostSubjectLevel(subject: .autoTranslate, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.autoTranslate) + } + + perks.append(.story(level)) + + if !isGroup { + perks.append(.reaction(level)) + } + + var nameColorsCount: Int32 = 0 + for (colorLevel, count) in nameColorsAtLevel { + if level >= colorLevel && colorLevel == 1 { + nameColorsCount = count + } + } + if !isGroup && nameColorsCount > 0 { + perks.append(.nameColor(nameColorsCount)) + } + + var profileColorsCount: Int32 = 0 + for (colorLevel, count) in profileColorsAtLevel { + if level >= colorLevel { + profileColorsCount += count + } + } + if profileColorsCount > 0 { + perks.append(.profileColor(profileColorsCount)) + } + + if isGroup && level >= requiredBoostSubjectLevel(subject: .emojiPack, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.emojiPack) + } + + if level >= requiredBoostSubjectLevel(subject: .profileIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.profileIcon) + } + + if isGroup && level >= requiredBoostSubjectLevel(subject: .audioTranscription, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.audioTranscription) + } + + var linkColorsCount: Int32 = 0 + for (colorLevel, count) in nameColorsAtLevel { + if level >= colorLevel { + linkColorsCount += count + } + } + if !isGroup && linkColorsCount > 0 { + perks.append(.linkColor(linkColorsCount)) + } + + if !isGroup && level >= requiredBoostSubjectLevel(subject: .nameIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.linkIcon) + } + if level >= requiredBoostSubjectLevel(subject: .emojiStatus, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.emojiStatus) + } + if level >= requiredBoostSubjectLevel(subject: .wallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.wallpaper(8)) + } + if level >= requiredBoostSubjectLevel(subject: .customWallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.customWallpaper) + } + if !isGroup && level >= requiredBoostSubjectLevel(subject: .noAds, group: isGroup, context: component.context, configuration: premiumConfiguration) { + perks.append(.noAds) + } + + levelItems.append( + AnyComponentWithIdentity( + id: level, component: AnyComponent( + LevelSectionComponent( + theme: theme, + strings: strings, + level: level, + isFirst: !isFeatures && levelItems.isEmpty, + perks: perks.reversed(), + isGroup: isGroup + ) + ) + ) + ) + } + + if let nextLevels { + for level in nextLevels { + layoutLevel(level) + } + } + + if !isGroup { + let noAdsLevel = requiredBoostSubjectLevel(subject: .noAds, group: false, context: component.context, configuration: premiumConfiguration) + if let nextLevels, noAdsLevel <= nextLevels.upperBound { + } else if level < noAdsLevel { + layoutLevel(noAdsLevel) + } + } + + if !levelItems.isEmpty { + let levels = levels.update( + component: List(levelItems), + availableSize: CGSize(width: context.availableSize.width, height: 100000.0), + transition: context.transition + ) + context.add(levels + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + levels.size.height / 2.0 )) + ) + contentSize.height += levels.size.height + 80.0 + contentSize.height += 60.0 + } + + return contentSize + } + } +} + +private final class PremiumBoostLevelsSheetComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + let mode: PremiumBoostLevelsScreen.Mode + let status: ChannelBoostStatus? + let myBoostStatus: MyBoostStatus? + let replacedBoosts: (Int32, [EnginePeer])? + let openStats: (() -> Void)? + let openGift: (() -> Void)? + let openPeer: ((EnginePeer) -> Void)? + let forceDark: Bool + + init( + context: AccountContext, + peerId: EnginePeer.Id, + mode: PremiumBoostLevelsScreen.Mode, + status: ChannelBoostStatus?, + myBoostStatus: MyBoostStatus?, + replacedBoosts: (Int32, [EnginePeer])?, + openStats: (() -> Void)?, + openGift: (() -> Void)?, + openPeer: ((EnginePeer) -> Void)?, + forceDark: Bool + ) { + self.context = context + self.peerId = peerId + self.mode = mode + self.status = status + self.myBoostStatus = myBoostStatus + self.replacedBoosts = replacedBoosts + self.openStats = openStats + self.openGift = openGift + self.openPeer = openPeer + self.forceDark = forceDark + } + + static func ==(lhs: PremiumBoostLevelsSheetComponent, rhs: PremiumBoostLevelsSheetComponent) -> Bool { + return true + } + + final class State: ComponentState { + private let context: AccountContext + private let peerId: EnginePeer.Id + private let mode: PremiumBoostLevelsScreen.Mode + private let status: ChannelBoostStatus? + private let myBoostStatus: MyBoostStatus? + private let openPeer: ((EnginePeer) -> Void)? + private let forceDark: Bool + + weak var controller: PremiumBoostLevelsScreen? + + private(set) var peer: EnginePeer? + private(set) var memberPeer: EnginePeer? + private var peerDisposable: Disposable? + + private var currentMyBoostCount: Int32 = 0 + private var myBoostCount: Int32 = 0 + private var availableBoosts: [MyBoostStatus.Boost] = [] + private var occupiedBoosts: [MyBoostStatus.Boost] = [] + private let updatedState = Promise() + + private(set) var boostState: InternalBoostState.DisplayData? + + init( + context: AccountContext, + peerId: EnginePeer.Id, + mode: PremiumBoostLevelsScreen.Mode, + status: ChannelBoostStatus?, + myBoostStatus: MyBoostStatus?, + replacedBoosts: (Int32, [EnginePeer])?, + openPeer: ((EnginePeer) -> Void)?, + forceDark: Bool + ) { + self.context = context + self.peerId = peerId + self.mode = mode + self.status = status + self.myBoostStatus = myBoostStatus + self.openPeer = openPeer + self.forceDark = forceDark + + super.init() + + var userId: EnginePeer.Id? + if case let .user(mode) = mode, case let .groupPeer(peerId, _) = mode { + userId = peerId + } + var peerIds: [EnginePeer.Id] = [peerId] + if let userId { + peerIds.append(userId) + } + self.peerDisposable = (context.engine.data.get( + EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) |> deliverOnMainQueue).startStrict(next: { [weak self] peers in + guard let self else { + return + } + if let maybePeer = peers[peerId] { + self.peer = maybePeer + } + if let userId, let maybePeer = peers[userId] { + self.memberPeer = maybePeer + } + self.updated() + }) + + if let status, let myBoostStatus { + var myBoostCount: Int32 = 0 + var currentMyBoostCount: Int32 = 0 + var availableBoosts: [MyBoostStatus.Boost] = [] + var occupiedBoosts: [MyBoostStatus.Boost] = [] + + for boost in myBoostStatus.boosts { + if let boostPeer = boost.peer { + if boostPeer.id == peerId { + myBoostCount += 1 + } else { + occupiedBoosts.append(boost) + } + } else { + availableBoosts.append(boost) + } + } + + let boosts = max(Int32(status.boosts), myBoostCount) + let initialState = InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts) + self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 0, replacedBoosts: replacedBoosts?.0) + + self.updatedState.set(.single(InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts + 1))) + + if let (replacedBoosts, sourcePeers) = replacedBoosts { + currentMyBoostCount += 1 + + self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 1) + Queue.mainQueue().justDispatch { + self.updated(transition: .easeInOut(duration: 0.2)) + } + + Queue.mainQueue().after(0.3) { + guard let controller = self.controller else { + return + } + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + + var groupCount: Int32 = 0 + var channelCount: Int32 = 0 + for peer in sourcePeers { + if case let .channel(channel) = peer { + switch channel.info { + case .broadcast: + channelCount += 1 + case .group: + groupCount += 1 + } + } + } + let otherText: String + if channelCount > 0 && groupCount == 0 { + otherText = presentationData.strings.ReassignBoost_OtherChannels(channelCount) + } else if groupCount > 0 && channelCount == 0 { + otherText = presentationData.strings.ReassignBoost_OtherGroups(groupCount) + } else { + otherText = presentationData.strings.ReassignBoost_OtherGroupsAndChannels(Int32(sourcePeers.count)) + } + let text = presentationData.strings.ReassignBoost_Success(presentationData.strings.ReassignBoost_Boosts(replacedBoosts), otherText).string + let undoController = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "BoostReplace", scale: 0.066, colors: [:], title: nil, text: text, customUndoText: nil, timeout: 4.0), elevatedLayout: false, position: .top, action: { _ in return true }) + controller.present(undoController, in: .current) + } + } + + self.availableBoosts = availableBoosts + self.occupiedBoosts = occupiedBoosts + self.myBoostCount = myBoostCount + self.currentMyBoostCount = currentMyBoostCount + } + } + + deinit { + self.peerDisposable?.dispose() + } + + func updateBoostState() { + guard let controller = self.controller else { + return + } + let context = self.context + let peerId = self.peerId + let isGroup = self.isGroup ?? false + let mode = self.mode + let status = self.status + let isPremium = context.isPremium + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with({ $0 })) + let canBoostAgain = premiumConfiguration.boostsPerGiftCount > 0 + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let forceDark = self.forceDark + let boostStatusUpdated = controller.boostStatusUpdated + + if let _ = status?.nextLevelBoosts { + if let availableBoost = self.availableBoosts.first { + self.currentMyBoostCount += 1 + self.myBoostCount += 1 + + let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [availableBoost.slot]) + |> deliverOnMainQueue).startStandalone(next: { [weak self] myBoostStatus in + self?.updatedState.set(context.engine.peers.getChannelBoostStatus(peerId: peerId) + |> beforeNext { boostStatus in + if let boostStatus, let myBoostStatus { + Queue.mainQueue().async { + boostStatusUpdated(boostStatus, myBoostStatus) + } + } + } + |> map { status in + if let status { + return InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: Int32(status.boosts + 1)) + } else { + return nil + } + }) + }) + + let _ = (self.updatedState.get() + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] state in + guard let self, let state else { + return + } + self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) + self.updated(transition: .easeInOut(duration: 0.2)) + + self.animateSuccess() + }) + + self.availableBoosts.removeFirst() + } else if !self.occupiedBoosts.isEmpty, let myBoostStatus = self.myBoostStatus { + if canBoostAgain { + let navigationController = controller.navigationController + let openPeer = self.openPeer + + var dismissReplaceImpl: (() -> Void)? + let replaceController = ReplaceBoostScreen(context: context, peerId: peerId, myBoostStatus: myBoostStatus, replaceBoosts: { slots in + var sourcePeerIds = Set() + var sourcePeers: [EnginePeer] = [] + for boost in myBoostStatus.boosts { + if slots.contains(boost.slot) { + if let peer = boost.peer { + if !sourcePeerIds.contains(peer.id) { + sourcePeerIds.insert(peer.id) + sourcePeers.append(peer) + } + } + } + } + + let _ = context.engine.peers.applyChannelBoost(peerId: peerId, slots: slots).startStandalone(completed: { + let _ = combineLatest( + queue: Queue.mainQueue(), + context.engine.peers.getChannelBoostStatus(peerId: peerId), + context.engine.peers.getMyBoostStatus() + ).startStandalone(next: { boostStatus, myBoostStatus in + dismissReplaceImpl?() + + if let boostStatus, let myBoostStatus { + boostStatusUpdated(boostStatus, myBoostStatus) + } + + let levelsController = PremiumBoostLevelsScreen( + context: context, + peerId: peerId, + mode: mode, + status: boostStatus, + myBoostStatus: myBoostStatus, + replacedBoosts: (Int32(slots.count), sourcePeers), + openStats: nil, + openGift: nil, + openPeer: openPeer, + forceDark: forceDark + ) + levelsController.boostStatusUpdated = boostStatusUpdated + if let navigationController { + navigationController.pushViewController(levelsController, animated: true) + } + }) + }) + }) + + if let navigationController = controller.navigationController { + controller.dismiss(animated: true) + navigationController.pushViewController(replaceController, animated: true) + } + + dismissReplaceImpl = { [weak replaceController] in + replaceController?.dismiss(animated: true) + } + } else if let boost = self.occupiedBoosts.first, let occupiedPeer = boost.peer { + if let cooldown = boost.cooldownUntil { + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let timeout = cooldown - currentTime + let valueText = timeIntervalString(strings: presentationData.strings, value: timeout, usage: .afterTime, preferLowerValue: false) + let alertController = textAlertController( + sharedContext: context.sharedContext, + updatedPresentationData: nil, + title: presentationData.strings.ChannelBoost_Error_BoostTooOftenTitle, + text: isGroup ? presentationData.strings.GroupBoost_Error_BoostTooOftenText(valueText).string : presentationData.strings.ChannelBoost_Error_BoostTooOftenText(valueText).string, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) + ], + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + } else { + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { [weak controller] peer in + guard let peer, let controller else { + return + } + let replaceController = replaceBoostConfirmationController(context: context, fromPeers: [occupiedPeer], toPeer: peer, commit: { [weak self] in + self?.currentMyBoostCount += 1 + self?.myBoostCount += 1 + let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [boost.slot]) + |> deliverOnMainQueue).startStandalone(completed: { [weak self] in + guard let self else { + return + } + let _ = (self.updatedState.get() + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] state in + guard let self, let state else { + return + } + self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) + self.updated(transition: .easeInOut(duration: 0.2)) + + self.animateSuccess() + }) + }) + }) + controller.present(replaceController, in: .window(.root)) + }) + } + } else { + controller.dismiss(animated: true, completion: nil) + } + } else { + if isPremium { + if !canBoostAgain { + controller.dismissAnimated() + } else { + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { [weak controller] peer in + guard let peer, let controller else { + return + } + let alertController = textAlertController( + sharedContext: context.sharedContext, + updatedPresentationData: nil, + title: presentationData.strings.ChannelBoost_MoreBoosts_Title, + text: presentationData.strings.ChannelBoost_MoreBoosts_Text(peer.compactDisplayTitle, "\(premiumConfiguration.boostsPerGiftCount)").string, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.ChannelBoost_MoreBoosts_Gift, action: { [weak controller] in + if let navigationController = controller?.navigationController { + controller?.dismissAnimated() + + Queue.mainQueue().after(0.4) { + let giftController = context.sharedContext.makePremiumGiftController(context: context, source: .channelBoost, completion: nil) + navigationController.pushViewController(giftController, animated: true) + } + } + }), + TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Close, action: {}) + ], + actionLayout: .vertical, + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + }) + } + } else { + let alertController = textAlertController( + sharedContext: context.sharedContext, + updatedPresentationData: nil, + title: presentationData.strings.ChannelBoost_Error_PremiumNeededTitle, + text: isGroup ? presentationData.strings.GroupBoost_Error_PremiumNeededText : presentationData.strings.ChannelBoost_Error_PremiumNeededText, + actions: [ + TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Yes, action: { [weak controller] in + if let navigationController = controller?.navigationController { + controller?.dismissAnimated() + + let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .channelBoost(peerId), forceDark: forceDark, dismissed: nil) + navigationController.pushViewController(premiumController, animated: true) + } + }) + ], + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + } + } + } else { + controller.dismissAnimated() + } + } + + func copyLink(_ link: String) { + guard let controller = self.controller else { + return + } + + UIPasteboard.general.string = link + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + if let previousController = controller.navigationController?.viewControllers.reversed().first(where: { $0 !== controller }) as? ViewController { + previousController.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { _ in return false }), in: .current) + } + } + + private func animateSuccess() { + guard let controller = self.controller else { + return + } + HapticFeedback().impact() + controller.view.addSubview(ConfettiView(frame: controller.view.bounds)) + } + + var isGroup: Bool? { + var isGroup: Bool? + if let peer = self.peer { + if case let .channel(channel) = peer, case .group = channel.info { + isGroup = true + } else { + isGroup = false + } + } + return isGroup + } + } + + func makeState() -> State { + return State(context: self.context, peerId: self.peerId, mode: self.mode, status: self.status, myBoostStatus: self.myBoostStatus, replacedBoosts: self.replacedBoosts, openPeer: self.openPeer, forceDark: self.forceDark) + } + + static var body: Body { + let sheet = Child(ResizableSheetComponent<(EnvironmentType)>.self) + let animateOut = StoredActionSlot(Action.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + let controller = environment.controller + let state = context.state + let theme = environment.theme.withModalBlocksBackground() + let strings = environment.strings + + let dismiss: (Bool) -> Void = { animated in + if animated { + animateOut.invoke(Action { _ in + if let controller = controller() { + controller.dismiss(completion: nil) + } + }) + } else { + if let controller = controller() { + controller.dismiss(completion: nil) + } + } + } + + let titleString: String + switch context.component.mode { + case let .owner(subject): + if let status = context.component.status, let _ = status.nextLevelBoosts { + if let subject { + switch subject { + case .stories: + if status.level == 0 { + titleString = strings.ChannelBoost_EnableStories + } else { + titleString = strings.ChannelBoost_IncreaseLimit + } + case .nameColors: + titleString = strings.ChannelBoost_NameColor + case .nameIcon: + titleString = strings.ChannelBoost_NameIcon + case .profileColors: + titleString = strings.ChannelBoost_ProfileColor + case .profileIcon: + titleString = strings.ChannelBoost_ProfileIcon + case .channelReactions: + titleString = strings.ChannelBoost_CustomReactions + case .emojiStatus: + titleString = strings.ChannelBoost_EmojiStatus + case .wallpaper: + titleString = strings.ChannelBoost_Wallpaper + case .customWallpaper: + titleString = strings.ChannelBoost_CustomWallpaper + case .audioTranscription: + titleString = strings.GroupBoost_AudioTranscription + case .emojiPack: + titleString = strings.GroupBoost_EmojiPack + case .noAds: + titleString = strings.ChannelBoost_NoAds + case .wearGift: + titleString = strings.ChannelBoost_WearGift + case .autoTranslate: + titleString = strings.ChannelBoost_AutoTranslate + } + } else { + titleString = state.isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current + } + } else { + titleString = strings.ChannelBoost_MaxLevelReached + } + case let .user(mode): + var remaining: Int? + if let status = context.component.status, let nextLevelBoosts = status.nextLevelBoosts { + remaining = nextLevelBoosts - status.boosts + } + + if let _ = remaining { + if case .current = mode { + titleString = state.isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current + } else { + titleString = state.isGroup == true ? strings.GroupBoost_Title_Other : strings.ChannelBoost_Title_Other + } + } else { + titleString = strings.ChannelBoost_MaxLevelReached + } + case .features: + titleString = "" //strings.GroupBoost_AdditionalFeatures + } + + var rightItem: AnyComponent? + if let openStats = context.component.openStats { + rightItem = AnyComponent( + GlassBarButtonComponent( + size: CGSize(width: 44.0, height: 44.0), + backgroundColor: nil, + isDark: theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "stats", component: AnyComponent( + BundleIconComponent( + name: "Premium/Stats", + tintColor: theme.chat.inputPanel.panelControlColor + ) + )), + action: { _ in + dismiss(true) + Queue.mainQueue().after(0.35) { + openStats() + } + } + ) + ) + } + + let sheet = sheet.update( + component: ResizableSheetComponent( + content: AnyComponent(SheetContent( + context: context.component.context, + peerId: context.component.peerId, + peer: state.peer, + memberPeer: state.memberPeer, + mode: context.component.mode, + status: context.component.status, + boostState: state.boostState, + boost: { [weak state] in + state?.updateBoostState() + }, + copyLink: { [weak state] link in + state?.copyLink(link) + }, + dismiss: { + dismiss(true) + }, + openStats: context.component.openStats, + openGift: context.component.openGift, + openPeer: context.component.openPeer, + updated: { + //updated() + } + )), + titleItem: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor))) + ), + leftItem: AnyComponent( + GlassBarButtonComponent( + size: CGSize(width: 44.0, height: 44.0), + backgroundColor: nil, + isDark: theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "close", component: AnyComponent( + BundleIconComponent( + name: "Navigation/Close", + tintColor: theme.chat.inputPanel.panelControlColor + ) + )), + action: { _ in + dismiss(true) + } + ) + ), + rightItem: rightItem, + bottomItem: "".isEmpty ? nil : AnyComponent( + ButtonComponent( + background: ButtonComponent.Background( + style: .glass, + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent( + ButtonTextContentComponent( + text: strings.Common_OK, + badge: 0, + textColor: theme.list.itemCheckColors.foregroundColor, + badgeBackground: theme.list.itemCheckColors.foregroundColor, + badgeForeground: theme.list.itemCheckColors.fillColor + ) + ) + ), + action: { + dismiss(true) + } + ) + ), + backgroundColor: .color(theme.list.modalBlocksBackgroundColor), + animateOut: animateOut + ), + environment: { + environment + ResizableSheetComponentEnvironment( + theme: theme, + statusBarHeight: environment.statusBarHeight, + safeInsets: environment.safeInsets, + metrics: environment.metrics, + deviceMetrics: environment.deviceMetrics, + isDisplaying: environment.value.isVisible, + isCentered: environment.metrics.widthClass == .regular, + screenSize: context.availableSize, + regularMetricsSize: CGSize(width: 430.0, height: 900.0), + dismiss: { animated in + dismiss(animated) + } + ) + }, + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + return context.availableSize + } + } +} + +//private final class BoostLevelsContainerComponent: CombinedComponent { +// class ExternalState { +// var isGroup: Bool = false +// var contentHeight: CGFloat = 0.0 +// } +// +// let context: AccountContext +// let theme: PresentationTheme +// let strings: PresentationStrings +// let externalState: ExternalState +// let peerId: EnginePeer.Id +// let mode: PremiumBoostLevelsScreen.Mode +// let status: ChannelBoostStatus? +// let boostState: InternalBoostState.DisplayData? +// let boost: () -> Void +// let copyLink: (String) -> Void +// let dismiss: () -> Void +// let openStats: (() -> Void)? +// let openGift: (() -> Void)? +// let openPeer: ((EnginePeer) -> Void)? +// let updated: () -> Void +// +// init( +// context: AccountContext, +// theme: PresentationTheme, +// strings: PresentationStrings, +// externalState: ExternalState, +// peerId: EnginePeer.Id, +// mode: PremiumBoostLevelsScreen.Mode, +// status: ChannelBoostStatus?, +// boostState: InternalBoostState.DisplayData?, +// boost: @escaping () -> Void, +// copyLink: @escaping (String) -> Void, +// dismiss: @escaping () -> Void, +// openStats: (() -> Void)?, +// openGift: (() -> Void)?, +// openPeer: ((EnginePeer) -> Void)?, +// updated: @escaping () -> Void +// ) { +// self.context = context +// self.theme = theme +// self.strings = strings +// self.externalState = externalState +// self.peerId = peerId +// self.mode = mode +// self.status = status +// self.boostState = boostState +// self.boost = boost +// self.copyLink = copyLink +// self.dismiss = dismiss +// self.openStats = openStats +// self.openGift = openGift +// self.openPeer = openPeer +// self.updated = updated +// } +// +// static func ==(lhs: BoostLevelsContainerComponent, rhs: BoostLevelsContainerComponent) -> Bool { +// if lhs.context !== rhs.context { +// return false +// } +// if lhs.theme !== rhs.theme { +// return false +// } +// if lhs.peerId != rhs.peerId { +// return false +// } +// if lhs.mode != rhs.mode { +// return false +// } +// if lhs.status != rhs.status { +// return false +// } +// if lhs.boostState != rhs.boostState { +// return false +// } +// return true +// } +// +// final class State: ComponentState { +// var topContentOffset: CGFloat = 0.0 +// var cachedStatsImage: (UIImage, PresentationTheme)? +// var cachedCloseImage: (UIImage, PresentationTheme)? +// +// private var disposable: Disposable? +// private(set) var peer: EnginePeer? +// +// init(context: AccountContext, peerId: EnginePeer.Id, updated: @escaping () -> Void) { +// super.init() +// +// self.disposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) +// |> deliverOnMainQueue).startStrict(next: { [weak self] peer in +// guard let self else { +// return +// } +// self.peer = peer +// updated() +// }) +// } +// +// deinit { +// self.disposable?.dispose() +// } +// } +// +// func makeState() -> State { +// return State(context: self.context, peerId: self.peerId, updated: self.updated) +// } +// +// static var body: Body { +// let background = Child(Rectangle.self) +// let scroll = Child(ScrollComponent.self) +// let topPanel = Child(BlurredBackgroundComponent.self) +// let topSeparator = Child(Rectangle.self) +// let title = Child(MultilineTextComponent.self) +// let statsButton = Child(Button.self) +// let closeButton = Child(Button.self) +// +// let externalScrollState = ScrollComponent.ExternalState() +// +// return { context in +// let state = context.state +// +// let theme = context.component.theme +// let strings = context.component.context.sharedContext.currentPresentationData.with { $0 }.strings +// +// let topInset: CGFloat = 56.0 +// +// let component = context.component +// +// var isGroup: Bool? +// if let peer = state.peer { +// if case let .channel(channel) = peer, case .group = channel.info { +// isGroup = true +// } else { +// isGroup = false +// } +// } +// +// if let isGroup { +// component.externalState.isGroup = isGroup +// let updated = component.updated +// let scroll = scroll.update( +// component: ScrollComponent( +// content: AnyComponent( +// SheetContent( +// context: component.context, +// theme: component.theme, +// strings: component.strings, +// insets: .zero, +// peerId: component.peerId, +// isGroup: isGroup, +// mode: component.mode, +// status: component.status, +// boostState: component.boostState, +// boost: component.boost, +// copyLink: component.copyLink, +// dismiss: component.dismiss, +// openStats: component.openStats, +// openGift: component.openGift, +// openPeer: component.openPeer, +// updated: { [weak state] in +// updated() +// } +// ) +// ), +// externalState: externalScrollState, +// contentInsets: UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0), +// contentOffsetUpdated: { [weak state] topContentOffset, _ in +// state?.topContentOffset = topContentOffset +// Queue.mainQueue().justDispatch { +// state?.updated(transition: .immediate) +// } +// }, +// contentOffsetWillCommit: { _ in } +// ), +// availableSize: context.availableSize, +// transition: context.transition +// ) +// component.externalState.contentHeight = externalScrollState.contentHeight +// +// let background = background.update( +// component: Rectangle(color: theme.overallDarkAppearance ? theme.list.blocksBackgroundColor : theme.list.plainBackgroundColor), +// availableSize: scroll.size, +// transition: context.transition +// ) +// context.add(background +// .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) +// ) +// +// context.add(scroll +// .position(CGPoint(x: context.availableSize.width / 2.0, y: scroll.size.height / 2.0)) +// ) +// } +// +// let titleString: String +// var titleFont = Font.semibold(17.0) +// +// switch component.mode { +// case let .owner(subject): +// if let status = component.status, let _ = status.nextLevelBoosts { +// if let subject { +// switch subject { +// case .stories: +// if status.level == 0 { +// titleString = strings.ChannelBoost_EnableStories +// } else { +// titleString = strings.ChannelBoost_IncreaseLimit +// } +// case .nameColors: +// titleString = strings.ChannelBoost_NameColor +// case .nameIcon: +// titleString = strings.ChannelBoost_NameIcon +// case .profileColors: +// titleString = strings.ChannelBoost_ProfileColor +// case .profileIcon: +// titleString = strings.ChannelBoost_ProfileIcon +// case .channelReactions: +// titleString = strings.ChannelBoost_CustomReactions +// case .emojiStatus: +// titleString = strings.ChannelBoost_EmojiStatus +// case .wallpaper: +// titleString = strings.ChannelBoost_Wallpaper +// case .customWallpaper: +// titleString = strings.ChannelBoost_CustomWallpaper +// case .audioTranscription: +// titleString = strings.GroupBoost_AudioTranscription +// case .emojiPack: +// titleString = strings.GroupBoost_EmojiPack +// case .noAds: +// titleString = strings.ChannelBoost_NoAds +// case .wearGift: +// titleString = strings.ChannelBoost_WearGift +// case .autoTranslate: +// titleString = strings.ChannelBoost_AutoTranslate +// } +// } else { +// titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current +// } +// } else { +// titleString = strings.ChannelBoost_MaxLevelReached +// } +// case let .user(mode): +// var remaining: Int? +// if let status = component.status, let nextLevelBoosts = status.nextLevelBoosts { +// remaining = nextLevelBoosts - status.boosts +// } +// +// if let _ = remaining { +// if case .current = mode { +// titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current +// } else { +// titleString = isGroup == true ? strings.GroupBoost_Title_Other : strings.ChannelBoost_Title_Other +// } +// } else { +// titleString = strings.ChannelBoost_MaxLevelReached +// } +// case .features: +// titleString = strings.GroupBoost_AdditionalFeatures +// titleFont = Font.semibold(20.0) +// } +// +// let title = title.update( +// component: MultilineTextComponent( +// text: .plain(NSAttributedString(string: titleString, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)), +// horizontalAlignment: .center, +// truncationType: .end, +// maximumNumberOfLines: 1 +// ), +// availableSize: context.availableSize, +// transition: context.transition +// ) +// +// let topPanelAlpha: CGFloat +// let titleOriginY: CGFloat +// let titleScale: CGFloat +// if case .features = component.mode { +// if state.topContentOffset > 78.0 { +// topPanelAlpha = min(30.0, state.topContentOffset - 78.0) / 30.0 +// } else { +// topPanelAlpha = 0.0 +// } +// +// let titleTopOriginY = topPanel.size.height / 2.0 +// let titleBottomOriginY: CGFloat = 146.0 +// let titleOriginDelta = titleTopOriginY - titleBottomOriginY +// +// let fraction = min(1.0, state.topContentOffset / abs(titleOriginDelta)) +// titleOriginY = titleBottomOriginY + fraction * titleOriginDelta +// titleScale = 1.0 - max(0.0, fraction * 0.2) +// } else { +// topPanelAlpha = min(30.0, state.topContentOffset) / 30.0 +// titleOriginY = topPanel.size.height / 2.0 +// titleScale = 1.0 +// } +// + +// } +// } +//} + +public class PremiumBoostLevelsScreen: ViewControllerComponentContainer { + public enum Mode: Equatable { + public enum UserMode: Equatable { + case external + case current + case groupPeer(EnginePeer.Id, Int) + case unrestrict(Int) + } + case user(mode: UserMode) + case owner(subject: BoostSubject?) + case features + } + + private let context: AccountContext + + public var boostStatusUpdated: (ChannelBoostStatus, MyBoostStatus) -> Void = { _, _ in } + public var disposed: () -> Void = {} + + public init( + context: AccountContext, + peerId: EnginePeer.Id, + mode: Mode, + status: ChannelBoostStatus?, + myBoostStatus: MyBoostStatus? = nil, + replacedBoosts: (Int32, [EnginePeer])? = nil, + openStats: (() -> Void)? = nil, + openGift: (() -> Void)? = nil, + openPeer: ((EnginePeer) -> Void)? = nil, + forceDark: Bool = false + ) { + self.context = context + + super.init( + context: context, + component: PremiumBoostLevelsSheetComponent( + context: context, + peerId: peerId, + mode: mode, + status: status, + myBoostStatus: myBoostStatus, + replacedBoosts: replacedBoosts, + openStats: openStats, + openGift: openGift, + openPeer: openPeer, + forceDark: forceDark + ), + navigationBarAppearance: .none, + statusBarStyle: .ignore, + theme: forceDark ? .dark : .default + ) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposed() + } + + public func dismissAnimated() { + if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent.View.Tag()) as? ResizableSheetComponent.View { + view.dismissAnimated() + } + } +} + +//public class PremiumBoostLevelsScreen: ViewController { +// public enum Mode: Equatable { +// public enum UserMode: Equatable { +// case external +// case current +// case groupPeer(EnginePeer.Id, Int) +// case unrestrict(Int) +// } +// case user(mode: UserMode) +// case owner(subject: BoostSubject?) +// case features +// } +// +// final class Node: ViewControllerTracingNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { +// private var presentationData: PresentationData +// private weak var controller: PremiumBoostLevelsScreen? +// +// let dim: ASDisplayNode +// let wrappingView: UIView +// let containerView: UIView +// +// let contentView: ComponentHostView +// let footerContainerView: UIView +// let footerView: ComponentHostView +// +// private let containerExternalState = BoostLevelsContainerComponent.ExternalState() +// +// private(set) var isExpanded = false +// private var panGestureRecognizer: UIPanGestureRecognizer? +// private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?)? +// +// private let hapticFeedback = HapticFeedback() +// +// private var currentIsVisible: Bool = false +// private var currentLayout: ContainerViewLayout? +// +// init(context: AccountContext, controller: PremiumBoostLevelsScreen) { +// self.presentationData = context.sharedContext.currentPresentationData.with { $0 } +// if controller.forceDark { +// self.presentationData = self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) +// } +// self.presentationData = self.presentationData.withUpdated(theme: self.presentationData.theme.withModalBlocksBackground()) +// +// self.controller = controller +// +// self.dim = ASDisplayNode() +// self.dim.alpha = 0.0 +// self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) +// +// self.wrappingView = UIView() +// self.containerView = UIView() +// self.contentView = ComponentHostView() +// +// self.footerContainerView = UIView() +// self.footerView = ComponentHostView() +// +// super.init() +// +// self.containerView.clipsToBounds = true +// self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.blocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor +// +// self.addSubnode(self.dim) +// +// self.view.addSubview(self.wrappingView) +// self.wrappingView.addSubview(self.containerView) +// self.containerView.addSubview(self.contentView) +// +// if case .user = controller.mode { +// self.containerView.addSubview(self.footerContainerView) +// self.footerContainerView.addSubview(self.footerView) +// } +// +// if let status = controller.status, let myBoostStatus = controller.myBoostStatus { +// var myBoostCount: Int32 = 0 +// var currentMyBoostCount: Int32 = 0 +// var availableBoosts: [MyBoostStatus.Boost] = [] +// var occupiedBoosts: [MyBoostStatus.Boost] = [] +// +// for boost in myBoostStatus.boosts { +// if let boostPeer = boost.peer { +// if boostPeer.id == controller.peerId { +// myBoostCount += 1 +// } else { +// occupiedBoosts.append(boost) +// } +// } else { +// availableBoosts.append(boost) +// } +// } +// +// let boosts = max(Int32(status.boosts), myBoostCount) +// let initialState = InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts) +// self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 0, replacedBoosts: controller.replacedBoosts?.0) +// +// self.updatedState.set(.single(InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts + 1))) +// +// if let (replacedBoosts, sourcePeers) = controller.replacedBoosts { +// currentMyBoostCount += 1 +// +// self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 1) +// Queue.mainQueue().justDispatch { +// self.updated(transition: .easeInOut(duration: 0.2)) +// } +// +// Queue.mainQueue().after(0.3) { +// let presentationData = context.sharedContext.currentPresentationData.with { $0 } +// +// var groupCount: Int32 = 0 +// var channelCount: Int32 = 0 +// for peer in sourcePeers { +// if case let .channel(channel) = peer { +// switch channel.info { +// case .broadcast: +// channelCount += 1 +// case .group: +// groupCount += 1 +// } +// } +// } +// let otherText: String +// if channelCount > 0 && groupCount == 0 { +// otherText = presentationData.strings.ReassignBoost_OtherChannels(channelCount) +// } else if groupCount > 0 && channelCount == 0 { +// otherText = presentationData.strings.ReassignBoost_OtherGroups(groupCount) +// } else { +// otherText = presentationData.strings.ReassignBoost_OtherGroupsAndChannels(Int32(sourcePeers.count)) +// } +// let text = presentationData.strings.ReassignBoost_Success(presentationData.strings.ReassignBoost_Boosts(replacedBoosts), otherText).string +// let undoController = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "BoostReplace", scale: 0.066, colors: [:], title: nil, text: text, customUndoText: nil, timeout: 4.0), elevatedLayout: false, position: .top, action: { _ in return true }) +// controller.present(undoController, in: .current) +// } +// } +// +// self.availableBoosts = availableBoosts +// self.occupiedBoosts = occupiedBoosts +// self.myBoostCount = myBoostCount +// self.currentMyBoostCount = currentMyBoostCount +// } +// } +// +// override func didLoad() { +// super.didLoad() +// +// let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) +// panRecognizer.delegate = self.wrappedGestureRecognizerDelegate +// panRecognizer.delaysTouchesBegan = false +// panRecognizer.cancelsTouchesInView = true +// self.panGestureRecognizer = panRecognizer +// self.wrappingView.addGestureRecognizer(panRecognizer) +// +// self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) +// self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) +// } +// +// @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { +// if case .ended = recognizer.state { +// self.controller?.dismiss(animated: true) +// } +// } +// +// override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { +// if let layout = self.currentLayout { +// if case .regular = layout.metrics.widthClass { +// return false +// } +// } +// return true +// } +// +// func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { +// if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { +// if let scrollView = otherGestureRecognizer.view as? UIScrollView { +// if scrollView.contentSize.width > scrollView.contentSize.height { +// return false +// } +// } +// return true +// } +// return false +// } +// +// private var isDismissing = false +// func animateIn() { +// ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0) +// +// let targetPosition = self.containerView.center +// let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) +// +// self.containerView.center = startPosition +// let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) +// transition.animateView(allowUserInteraction: true, { +// self.containerView.center = targetPosition +// }, completion: { _ in +// }) +// } +// +// func animateOut(completion: @escaping () -> Void = {}) { +// self.isDismissing = true +// +// let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) +// positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in +// self?.controller?.dismiss(animated: false, completion: completion) +// }) +// let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) +// alphaTransition.updateAlpha(node: self.dim, alpha: 0.0) +// +// self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition) +// } +// +// func requestLayout(transition: ComponentTransition) { +// guard let layout = self.currentLayout else { +// return +// } +// self.containerLayoutUpdated(layout: layout, forceUpdate: true, transition: transition) +// } +// +// private var dismissOffset: CGFloat? +// func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, transition: ComponentTransition) { +// guard !self.isDismissing else { +// return +// } +// self.currentLayout = layout +// +// self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0)) +// +// let isLandscape = layout.orientation == .landscape +// +// var containerTopInset: CGFloat = 0.0 +// let clipFrame: CGRect +// if layout.metrics.widthClass == .compact { +// self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25) +// if isLandscape { +// self.containerView.layer.cornerRadius = 0.0 +// } else { +// self.containerView.layer.cornerRadius = 10.0 +// } +// +// if #available(iOS 11.0, *) { +// if layout.safeInsets.bottom.isZero { +// self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] +// } else { +// self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] +// } +// } +// +// if isLandscape { +// clipFrame = CGRect(origin: CGPoint(), size: layout.size) +// } else { +// let coveredByModalTransition: CGFloat = 0.0 +// containerTopInset = 10.0 +// if let statusBarHeight = layout.statusBarHeight { +// containerTopInset += statusBarHeight +// } +// +// let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset)) +// let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width +// let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition +// let maxScaledTopInset: CGFloat = containerTopInset - 10.0 +// let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition +// let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) +// +// clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height) +// } +// } else { +// self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) +// self.containerView.layer.cornerRadius = 10.0 +// +// let verticalInset: CGFloat = 44.0 +// +// let maxSide = max(layout.size.width, layout.size.height) +// let minSide = min(layout.size.width, layout.size.height) +// let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0) +// clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) +// } +// +// transition.setFrame(view: self.containerView, frame: clipFrame) +// +// var effectiveExpanded = self.isExpanded +// if case .regular = layout.metrics.widthClass { +// effectiveExpanded = true +// } +// +// self.updated(transition: transition, forceUpdate: forceUpdate) +// +// let contentHeight = self.containerExternalState.contentHeight +// if contentHeight > 0.0 && contentHeight < 400.0, let view = self.footerView.componentView as? FooterComponent.View { +// view.backgroundView.alpha = 0.0 +// view.separator.opacity = 0.0 +// } +// let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset +// +// let topInset: CGFloat +// if let (panInitialTopInset, panOffset, _) = self.panGestureArguments { +// if effectiveExpanded { +// topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset)) +// } else { +// topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) +// } +// } else if let dismissOffset = self.dismissOffset, !dismissOffset.isZero { +// topInset = edgeTopInset * dismissOffset +// } else { +// topInset = effectiveExpanded ? 0.0 : edgeTopInset +// } +// transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil) +// +// let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset) +// self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition) +// +// let footerHeight = self.footerHeight +// let convertedFooterFrame = self.view.convert(CGRect(origin: CGPoint(x: clipFrame.minX, y: clipFrame.maxY - footerHeight), size: CGSize(width: clipFrame.width, height: footerHeight)), to: self.containerView) +// transition.setFrame(view: self.footerContainerView, frame: convertedFooterFrame) +// } +// +// private var boostState: InternalBoostState.DisplayData? +// func updated(transition: ComponentTransition, forceUpdate: Bool = false) { +// guard let controller = self.controller else { +// return +// } +// let contentSize = self.contentView.update( +// transition: transition, +// component: AnyComponent( +// BoostLevelsContainerComponent( +// context: controller.context, +// theme: self.presentationData.theme, +// strings: self.presentationData.strings, +// externalState: self.containerExternalState, +// peerId: controller.peerId, +// mode: controller.mode, +// status: controller.status, +// boostState: self.boostState, +// boost: { [weak controller] in +// guard let controller else { +// return +// } +// controller.node.updateBoostState() +// }, +// copyLink: { [weak self, weak controller] link in +// guard let self else { +// return +// } +// UIPasteboard.general.string = link +// +// if let previousController = controller?.navigationController?.viewControllers.reversed().first(where: { $0 !== controller }) as? ViewController { +// previousController.present(UndoOverlayController(presentationData: self.presentationData, content: .linkCopied(title: nil, text: self.presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { _ in return false }), in: .current) +// } +// }, +// dismiss: { [weak controller] in +// controller?.dismiss(animated: true) +// }, +// openStats: controller.openStats, +// openGift: controller.openGift, +// openPeer: controller.openPeer, +// updated: { [weak self] in +// self?.requestLayout(transition: .immediate) +// } +// ) +// ), +// environment: {}, +// forceUpdate: forceUpdate, +// containerSize: self.containerView.bounds.size +// ) +// self.contentView.frame = CGRect(origin: .zero, size: contentSize) +// +// let footerHeight = self.footerHeight +// +// let actionTitle: String +// if self.currentMyBoostCount > 0 { +// actionTitle = self.presentationData.strings.ChannelBoost_BoostAgain +// } else { +// actionTitle = self.containerExternalState.isGroup ? self.presentationData.strings.GroupBoost_BoostGroup : self.presentationData.strings.ChannelBoost_BoostChannel +// } +// +// let footerSize = self.footerView.update( +// transition: .immediate, +// component: AnyComponent( +// FooterComponent( +// context: controller.context, +// theme: self.presentationData.theme, +// title: actionTitle, +// action: { [weak self] in +// guard let self else { +// return +// } +// self.buttonPressed() +// } +// ) +// ), +// environment: {}, +// containerSize: CGSize(width: self.containerView.bounds.width, height: footerHeight) +// ) +// self.footerView.frame = CGRect(origin: .zero, size: footerSize) +// } +// +// private var didPlayAppearAnimation = false +// func updateIsVisible(isVisible: Bool) { +// if self.currentIsVisible == isVisible { +// return +// } +// self.currentIsVisible = isVisible +// +// guard let layout = self.currentLayout else { +// return +// } +// self.containerLayoutUpdated(layout: layout, transition: .immediate) +// +// if !self.didPlayAppearAnimation { +// self.didPlayAppearAnimation = true +// self.animateIn() +// } +// } +// +// private var footerHeight: CGFloat { +// if let mode = self.controller?.mode, case .owner = mode { +// return 0.0 +// } +// +// guard let layout = self.currentLayout else { +// return 58.0 +// } +// +// var footerHeight: CGFloat = 8.0 + 50.0 +// footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0 +// return footerHeight +// } +// +// private var defaultTopInset: CGFloat { +// guard let layout = self.currentLayout else { +// return 210.0 +// } +// if case .compact = layout.metrics.widthClass { +// let bottomPanelPadding: CGFloat = 12.0 +// let bottomInset: CGFloat = layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : bottomPanelPadding +// let panelHeight: CGFloat = bottomPanelPadding + 50.0 + bottomInset + 28.0 +// +// var defaultTopInset = layout.size.height - layout.size.width - 128.0 - panelHeight +// +// let containerTopInset = 10.0 + (layout.statusBarHeight ?? 0.0) +// let contentHeight = self.containerExternalState.contentHeight +// let footerHeight = self.footerHeight +// if contentHeight > 0.0 { +// let delta = (layout.size.height - defaultTopInset - containerTopInset) - contentHeight - footerHeight - 16.0 +// if delta > 0.0 { +// defaultTopInset += delta +// } +// } +// return defaultTopInset +// } else { +// return 210.0 +// } +// } +// +// private func findVerticalScrollView(view: UIView?) -> UIScrollView? { +// if let view = view { +// if let view = view as? UIScrollView, view.contentSize.height > view.contentSize.width { +// return view +// } +// return findVerticalScrollView(view: view.superview) +// } else { +// return nil +// } +// } +// +// @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { +// guard let layout = self.currentLayout else { +// return +// } +// +// let isLandscape = layout.orientation == .landscape +// let edgeTopInset = isLandscape ? 0.0 : defaultTopInset +// +// switch recognizer.state { +// case .began: +// let point = recognizer.location(in: self.view) +// let currentHitView = self.hitTest(point, with: nil) +// +// var scrollView = self.findVerticalScrollView(view: currentHitView) +// if scrollView?.frame.height == self.frame.width { +// scrollView = nil +// } +// if scrollView?.isDescendant(of: self.view) == false { +// scrollView = nil +// } +// +// let topInset: CGFloat +// if self.isExpanded { +// topInset = 0.0 +// } else { +// topInset = edgeTopInset +// } +// +// self.panGestureArguments = (topInset, 0.0, scrollView) +// case .changed: +// guard let (topInset, panOffset, scrollView) = self.panGestureArguments else { +// return +// } +// let contentOffset = scrollView?.contentOffset.y ?? 0.0 +// +// var translation = recognizer.translation(in: self.view).y +// +// var currentOffset = topInset + translation +// +// let epsilon = 1.0 +// if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon { +// scrollView.bounces = false +// scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) +// } else if let scrollView = scrollView { +// translation = panOffset +// currentOffset = topInset + translation +// if self.isExpanded { +// recognizer.setTranslation(CGPoint(), in: self.view) +// } else if currentOffset > 0.0 { +// scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) +// } +// } +// +// if scrollView == nil { +// translation = max(0.0, translation) +// } +// +// self.panGestureArguments = (topInset, translation, scrollView) +// +// if !self.isExpanded { +// if currentOffset > 0.0, let scrollView = scrollView { +// scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView) +// } +// } +// +// var bounds = self.bounds +// if self.isExpanded { +// bounds.origin.y = -max(0.0, translation - edgeTopInset) +// } else { +// bounds.origin.y = -translation +// } +// bounds.origin.y = min(0.0, bounds.origin.y) +// self.bounds = bounds +// +// self.containerLayoutUpdated(layout: layout, transition: .immediate) +// case .ended: +// guard let (currentTopInset, panOffset, scrollView) = self.panGestureArguments else { +// return +// } +// self.panGestureArguments = nil +// +// let contentOffset = scrollView?.contentOffset.y ?? 0.0 +// +// let translation = recognizer.translation(in: self.view).y +// var velocity = recognizer.velocity(in: self.view) +// +// if self.isExpanded { +// if contentOffset > 0.1 { +// velocity = CGPoint() +// } +// } +// +// var bounds = self.bounds +// if self.isExpanded { +// bounds.origin.y = -max(0.0, translation - edgeTopInset) +// } else { +// bounds.origin.y = -translation +// } +// bounds.origin.y = min(0.0, bounds.origin.y) +// +// scrollView?.bounces = true +// +// let offset = currentTopInset + panOffset +// let topInset: CGFloat = edgeTopInset +// +// var dismissing = false +// if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) { +// self.controller?.dismiss(animated: true, completion: nil) +// dismissing = true +// } else if self.isExpanded { +// if velocity.y > 300.0 || offset > topInset / 2.0 { +// self.isExpanded = false +// if let scrollView = scrollView { +// scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) +// } +// +// let distance = topInset - offset +// let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) +// let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) +// +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) +// } else { +// self.isExpanded = true +// +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) +// } +// } else if scrollView != nil, (velocity.y < -300.0 || offset < topInset / 2.0) { +// let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) +// let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) +// self.isExpanded = true +// +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) +// } else { +// if let scrollView = scrollView { +// scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) +// } +// +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) +// } +// +// if !dismissing { +// var bounds = self.bounds +// let previousBounds = bounds +// bounds.origin.y = 0.0 +// self.bounds = bounds +// self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) +// } +// case .cancelled: +// self.panGestureArguments = nil +// +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) +// default: +// break +// } +// } +// +// func updateDismissOffset(_ offset: CGFloat) { +// guard self.isExpanded, let layout = self.currentLayout else { +// return +// } +// +// self.dismissOffset = offset +// self.containerLayoutUpdated(layout: layout, transition: .immediate) +// } +// +// func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { +// guard isExpanded != self.isExpanded else { +// return +// } +// self.dismissOffset = nil +// self.isExpanded = isExpanded +// +// guard let layout = self.currentLayout else { +// return +// } +// self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) +// } +// +// private var currentMyBoostCount: Int32 = 0 +// private var myBoostCount: Int32 = 0 +// private var availableBoosts: [MyBoostStatus.Boost] = [] +// private var occupiedBoosts: [MyBoostStatus.Boost] = [] +// private let updatedState = Promise() +// +// private func updateBoostState() { +// guard let controller = self.controller else { +// return +// } +// let context = controller.context +// let peerId = controller.peerId +// let mode = controller.mode +// let status = controller.status +// let isPremium = controller.context.isPremium +// let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with({ $0 })) +// let canBoostAgain = premiumConfiguration.boostsPerGiftCount > 0 +// let presentationData = self.presentationData +// let forceDark = controller.forceDark +// let boostStatusUpdated = controller.boostStatusUpdated +// +// if let _ = status?.nextLevelBoosts { +// if let availableBoost = self.availableBoosts.first { +// self.currentMyBoostCount += 1 +// self.myBoostCount += 1 +// +// let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [availableBoost.slot]) +// |> deliverOnMainQueue).startStandalone(next: { [weak self] myBoostStatus in +// self?.updatedState.set(context.engine.peers.getChannelBoostStatus(peerId: peerId) +// |> beforeNext { [weak self] boostStatus in +// if let self, let boostStatus, let myBoostStatus { +// Queue.mainQueue().async { +// self.controller?.boostStatusUpdated(boostStatus, myBoostStatus) +// } +// } +// } +// |> map { status in +// if let status { +// return InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: Int32(status.boosts + 1)) +// } else { +// return nil +// } +// }) +// }) +// +// let _ = (self.updatedState.get() +// |> take(1) +// |> deliverOnMainQueue).startStandalone(next: { [weak self] state in +// guard let self, let state else { +// return +// } +// self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) +// self.updated(transition: .easeInOut(duration: 0.2)) +// +// self.animateSuccess() +// }) +// +// self.availableBoosts.removeFirst() +// } else if !self.occupiedBoosts.isEmpty, let myBoostStatus = controller.myBoostStatus { +// if canBoostAgain { +// let navigationController = controller.navigationController +// let openPeer = controller.openPeer +// +// var dismissReplaceImpl: (() -> Void)? +// let replaceController = ReplaceBoostScreen(context: context, peerId: peerId, myBoostStatus: myBoostStatus, replaceBoosts: { slots in +// var sourcePeerIds = Set() +// var sourcePeers: [EnginePeer] = [] +// for boost in myBoostStatus.boosts { +// if slots.contains(boost.slot) { +// if let peer = boost.peer { +// if !sourcePeerIds.contains(peer.id) { +// sourcePeerIds.insert(peer.id) +// sourcePeers.append(peer) +// } +// } +// } +// } +// +// let _ = context.engine.peers.applyChannelBoost(peerId: peerId, slots: slots).startStandalone(completed: { +// let _ = combineLatest( +// queue: Queue.mainQueue(), +// context.engine.peers.getChannelBoostStatus(peerId: peerId), +// context.engine.peers.getMyBoostStatus() +// ).startStandalone(next: { boostStatus, myBoostStatus in +// dismissReplaceImpl?() +// +// if let boostStatus, let myBoostStatus { +// boostStatusUpdated(boostStatus, myBoostStatus) +// } +// +// let levelsController = PremiumBoostLevelsScreen( +// context: context, +// peerId: peerId, +// mode: mode, +// status: boostStatus, +// myBoostStatus: myBoostStatus, +// replacedBoosts: (Int32(slots.count), sourcePeers), +// openStats: nil, +// openGift: nil, +// openPeer: openPeer, +// forceDark: forceDark +// ) +// levelsController.boostStatusUpdated = boostStatusUpdated +// if let navigationController { +// navigationController.pushViewController(levelsController, animated: true) +// } +// }) +// }) +// }) +// +// if let navigationController = controller.navigationController { +// controller.dismiss(animated: true) +// navigationController.pushViewController(replaceController, animated: true) +// } +// +// dismissReplaceImpl = { [weak replaceController] in +// replaceController?.dismiss(animated: true) +// } +// } else if let boost = self.occupiedBoosts.first, let occupiedPeer = boost.peer { +// if let cooldown = boost.cooldownUntil { +// let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) +// let timeout = cooldown - currentTime +// let valueText = timeIntervalString(strings: presentationData.strings, value: timeout, usage: .afterTime, preferLowerValue: false) +// let alertController = textAlertController( +// sharedContext: context.sharedContext, +// updatedPresentationData: nil, +// title: presentationData.strings.ChannelBoost_Error_BoostTooOftenTitle, +// text: self.containerExternalState.isGroup ? presentationData.strings.GroupBoost_Error_BoostTooOftenText(valueText).string : presentationData.strings.ChannelBoost_Error_BoostTooOftenText(valueText).string, +// actions: [ +// TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) +// ], +// parseMarkdown: true +// ) +// controller.present(alertController, in: .window(.root)) +// } else { +// let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) +// |> deliverOnMainQueue).start(next: { [weak controller] peer in +// guard let peer, let controller else { +// return +// } +// let replaceController = replaceBoostConfirmationController(context: context, fromPeers: [occupiedPeer], toPeer: peer, commit: { [weak self] in +// self?.currentMyBoostCount += 1 +// self?.myBoostCount += 1 +// let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [boost.slot]) +// |> deliverOnMainQueue).startStandalone(completed: { [weak self] in +// guard let self else { +// return +// } +// let _ = (self.updatedState.get() +// |> take(1) +// |> deliverOnMainQueue).startStandalone(next: { [weak self] state in +// guard let self, let state else { +// return +// } +// self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) +// self.updated(transition: .easeInOut(duration: 0.2)) +// +// self.animateSuccess() +// }) +// }) +// }) +// controller.present(replaceController, in: .window(.root)) +// }) +// } +// } else { +// controller.dismiss(animated: true, completion: nil) +// } +// } else { +// if isPremium { +// if !canBoostAgain { +// controller.dismiss(animated: true, completion: nil) +// } else { +// let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) +// |> deliverOnMainQueue).start(next: { [weak controller] peer in +// guard let peer, let controller else { +// return +// } +// let alertController = textAlertController( +// sharedContext: context.sharedContext, +// updatedPresentationData: nil, +// title: presentationData.strings.ChannelBoost_MoreBoosts_Title, +// text: presentationData.strings.ChannelBoost_MoreBoosts_Text(peer.compactDisplayTitle, "\(premiumConfiguration.boostsPerGiftCount)").string, +// actions: [ +// TextAlertAction(type: .defaultAction, title: presentationData.strings.ChannelBoost_MoreBoosts_Gift, action: { [weak controller] in +// if let navigationController = controller?.navigationController { +// controller?.dismiss(animated: true, completion: nil) +// +// Queue.mainQueue().after(0.4) { +// let giftController = context.sharedContext.makePremiumGiftController(context: context, source: .channelBoost, completion: nil) +// navigationController.pushViewController(giftController, animated: true) +// } +// } +// }), +// TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Close, action: {}) +// ], +// actionLayout: .vertical, +// parseMarkdown: true +// ) +// controller.present(alertController, in: .window(.root)) +// }) +// } +// } else { +// let alertController = textAlertController( +// sharedContext: context.sharedContext, +// updatedPresentationData: nil, +// title: presentationData.strings.ChannelBoost_Error_PremiumNeededTitle, +// text: self.containerExternalState.isGroup ? presentationData.strings.GroupBoost_Error_PremiumNeededText : presentationData.strings.ChannelBoost_Error_PremiumNeededText, +// actions: [ +// TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), +// TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Yes, action: { [weak controller] in +// if let navigationController = controller?.navigationController { +// controller?.dismiss(animated: true) +// +// let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .channelBoost(peerId), forceDark: forceDark, dismissed: nil) +// navigationController.pushViewController(premiumController, animated: true) +// } +// }) +// ], +// parseMarkdown: true +// ) +// controller.present(alertController, in: .window(.root)) +// } +// } +// } else { +// controller.dismiss(animated: true) +// } +// } +// +// func buttonPressed() { +// self.updateBoostState() +// } +// +// private func animateSuccess() { +// self.hapticFeedback.impact() +// self.view.addSubview(ConfettiView(frame: self.view.bounds)) +// +// if self.isExpanded { +// self.update(isExpanded: false, transition: .animated(duration: 0.4, curve: .spring)) +// } +// } +// } +// +// var node: Node { +// return self.displayNode as! Node +// } +// +// private let context: AccountContext +// private let peerId: EnginePeer.Id +// private let mode: Mode +// private let status: ChannelBoostStatus? +// private let myBoostStatus: MyBoostStatus? +// private let replacedBoosts: (Int32, [EnginePeer])? +// private let openStats: (() -> Void)? +// private let openGift: (() -> Void)? +// private let openPeer: ((EnginePeer) -> Void)? +// private let forceDark: Bool +// +// private var currentLayout: ContainerViewLayout? +// +// public var boostStatusUpdated: (ChannelBoostStatus, MyBoostStatus) -> Void = { _, _ in } +// public var disposed: () -> Void = {} +// +// public init( +// context: AccountContext, +// peerId: EnginePeer.Id, +// mode: Mode, +// status: ChannelBoostStatus?, +// myBoostStatus: MyBoostStatus? = nil, +// replacedBoosts: (Int32, [EnginePeer])? = nil, +// openStats: (() -> Void)? = nil, +// openGift: (() -> Void)? = nil, +// openPeer: ((EnginePeer) -> Void)? = nil, +// forceDark: Bool = false +// ) { +// self.context = context +// self.peerId = peerId +// self.mode = mode +// self.status = status +// self.myBoostStatus = myBoostStatus +// self.replacedBoosts = replacedBoosts +// self.openStats = openStats +// self.openGift = openGift +// self.openPeer = openPeer +// self.forceDark = forceDark +// +// super.init(navigationBarPresentationData: nil) +// +// self.navigationPresentation = .flatModal +// self.statusBar.statusBarStyle = .Ignore +// +// self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) +// } +// +// required public init(coder aDecoder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// deinit { +// self.disposed() +// } +// +//} + +//private final class FooterComponent: Component { +// let context: AccountContext +// let theme: PresentationTheme +// let title: String +// let action: () -> Void +// +// init(context: AccountContext, theme: PresentationTheme, title: String, action: @escaping () -> Void) { +// self.context = context +// self.theme = theme +// self.title = title +// self.action = action +// } +// +// static func ==(lhs: FooterComponent, rhs: FooterComponent) -> Bool { +// if lhs.context !== rhs.context { +// return false +// } +// if lhs.theme !== rhs.theme { +// return false +// } +// if lhs.title != rhs.title { +// return false +// } +// return true +// } +// +// final class View: UIView { +// let backgroundView: BlurredBackgroundView +// let separator = SimpleLayer() +// +// private let button = ComponentView() +// +// private var component: FooterComponent? +// private weak var state: EmptyComponentState? +// +// override init(frame: CGRect) { +// self.backgroundView = BlurredBackgroundView(color: nil) +// +// super.init(frame: frame) +// +// self.backgroundView.clipsToBounds = true +// +// self.addSubview(self.backgroundView) +// self.layer.addSublayer(self.separator) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// func update(component: FooterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { +// self.component = component +// self.state = state +// +// let bounds = CGRect(origin: .zero, size: availableSize) +// +// self.backgroundView.updateColor(color: component.theme.rootController.tabBar.backgroundColor, transition: transition.containedViewLayoutTransition) +// self.backgroundView.update(size: bounds.size, transition: transition.containedViewLayoutTransition) +// transition.setFrame(view: self.backgroundView, frame: bounds) +// +// self.separator.backgroundColor = component.theme.rootController.tabBar.separatorColor.cgColor +// transition.setFrame(layer: self.separator, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: UIScreenPixel))) +// +// let gradientColors = [ +// UIColor(rgb: 0x0077ff), +// UIColor(rgb: 0x6b93ff), +// UIColor(rgb: 0x8878ff), +// UIColor(rgb: 0xe46ace) +// ] +// +// let buttonSize = self.button.update( +// transition: .immediate, +// component: AnyComponent( +// SolidRoundedButtonComponent( +// title: component.title, +// theme: SolidRoundedButtonComponent.Theme( +// backgroundColor: .black, +// backgroundColors: gradientColors, +// foregroundColor: .white +// ), +// font: .bold, +// fontSize: 17.0, +// height: 50.0, +// cornerRadius: 10.0, +// gloss: true, +// iconName: "Premium/BoostChannel", +// animationName: nil, +// iconPosition: .left, +// action: { +// component.action() +// } +// ) +// ), +// environment: {}, +// containerSize: CGSize(width: availableSize.width - 32.0, height: availableSize.height) +// ) +// +// if let view = self.button.view { +// if view.superview == nil { +// self.addSubview(view) +// } +// let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: 8.0), size: buttonSize) +// view.frame = buttonFrame +// } +// +// 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) +// } +//} + +private struct InternalBoostState: Equatable { + let level: Int32 + let currentLevelBoosts: Int32 + let nextLevelBoosts: Int32? + let boosts: Int32 + + struct DisplayData: Equatable { + let level: Int32 + let boosts: Int32 + let currentLevelBoosts: Int32 + let nextLevelBoosts: Int32? + let myBoostCount: Int32 + } + + func displayData(myBoostCount: Int32, currentMyBoostCount: Int32, replacedBoosts: Int32? = nil) -> DisplayData { + var currentLevel = self.level + var nextLevelBoosts = self.nextLevelBoosts + var currentLevelBoosts = self.currentLevelBoosts + var boosts = self.boosts + if let replacedBoosts { + boosts = max(currentLevelBoosts, boosts - replacedBoosts) + } + + if currentMyBoostCount > 0 && self.boosts == currentLevelBoosts { + currentLevel = max(0, currentLevel - 1) + nextLevelBoosts = currentLevelBoosts + currentLevelBoosts = max(0, currentLevelBoosts - 1) + } + + return DisplayData( + level: currentLevel, + boosts: boosts, + currentLevelBoosts: currentLevelBoosts, + nextLevelBoosts: nextLevelBoosts, + myBoostCount: myBoostCount + ) + } +} func requiredBoostSubjectLevel(subject: BoostSubject, group: Bool, context: AccountContext, configuration: PremiumConfiguration) -> Int32 { switch subject { @@ -399,2343 +3291,3 @@ private final class LevelSectionComponent: CombinedComponent { } } } - -private final class SheetContent: CombinedComponent { - typealias EnvironmentType = (Empty, ScrollChildEnvironment) - - let context: AccountContext - let theme: PresentationTheme - let strings: PresentationStrings - let insets: UIEdgeInsets - - let peerId: EnginePeer.Id - let isGroup: Bool - let mode: PremiumBoostLevelsScreen.Mode - let status: ChannelBoostStatus? - let boostState: InternalBoostState.DisplayData? - let initialized: Bool - - let boost: () -> Void - let copyLink: (String) -> Void - let dismiss: () -> Void - let openStats: (() -> Void)? - let openGift: (() -> Void)? - let openPeer: ((EnginePeer) -> Void)? - let updated: () -> Void - - init(context: AccountContext, - theme: PresentationTheme, - strings: PresentationStrings, - insets: UIEdgeInsets, - peerId: EnginePeer.Id, - isGroup: Bool, - mode: PremiumBoostLevelsScreen.Mode, - status: ChannelBoostStatus?, - boostState: InternalBoostState.DisplayData?, - initialized: Bool, - boost: @escaping () -> Void, - copyLink: @escaping (String) -> Void, - dismiss: @escaping () -> Void, - openStats: (() -> Void)?, - openGift: (() -> Void)?, - openPeer: ((EnginePeer) -> Void)?, - updated: @escaping () -> Void - ) { - self.context = context - self.theme = theme - self.strings = strings - self.insets = insets - self.peerId = peerId - self.isGroup = isGroup - self.mode = mode - self.status = status - self.boostState = boostState - self.initialized = initialized - self.boost = boost - self.copyLink = copyLink - self.dismiss = dismiss - self.openStats = openStats - self.openGift = openGift - self.openPeer = openPeer - self.updated = updated - } - - static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.theme !== rhs.theme { - return false - } - if lhs.insets != rhs.insets { - return false - } - if lhs.peerId != rhs.peerId { - return false - } - if lhs.isGroup != rhs.isGroup { - return false - } - if lhs.mode != rhs.mode { - return false - } - if lhs.status != rhs.status { - return false - } - if lhs.boostState != rhs.boostState { - return false - } - if lhs.initialized != rhs.initialized { - return false - } - return true - } - - final class State: ComponentState { - var cachedChevronImage: (UIImage, PresentationTheme)? - var cachedIconImage: UIImage? - - private(set) var peer: EnginePeer? - private(set) var memberPeer: EnginePeer? - - private var disposable: Disposable? - - init(context: AccountContext, peerId: EnginePeer.Id, userId: EnginePeer.Id?, updated: @escaping () -> Void) { - super.init() - - var peerIds: [EnginePeer.Id] = [peerId] - if let userId { - peerIds.append(userId) - } - - self.disposable = (context.engine.data.get( - EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) - ) |> deliverOnMainQueue).startStrict(next: { [weak self] peers in - guard let self else { - return - } - if let maybePeer = peers[peerId] { - self.peer = maybePeer - } - if let userId, let maybePeer = peers[userId] { - self.memberPeer = maybePeer - } - updated() - }) - } - - deinit { - self.disposable?.dispose() - } - } - - func makeState() -> State { - var userId: EnginePeer.Id? - if case let .user(mode) = mode, case let .groupPeer(peerId, _) = mode { - userId = peerId - } - return State(context: self.context, peerId: self.peerId, userId: userId, updated: self.updated) - } - - static var body: Body { - let iconBackground = Child(Image.self) - let icon = Child(BundleIconComponent.self) - //let icon = Child(LottieComponent.self) - - let peerShortcut = Child(Button.self) - let text = Child(BalancedTextComponent.self) - let alternateText = Child(List.self) - let limit = Child(PremiumLimitDisplayComponent.self) - let linkButton = Child(SolidRoundedButtonComponent.self) - let boostButton = Child(SolidRoundedButtonComponent.self) - let copyButton = Child(SolidRoundedButtonComponent.self) - - let orLeftLine = Child(Rectangle.self) - let orRightLine = Child(Rectangle.self) - let orText = Child(MultilineTextComponent.self) - let giftText = Child(BalancedTextComponent.self) - - let levels = Child(List.self) - - return { context in - let component = context.component - let theme = component.theme - let strings = component.strings - - let state = context.state - - let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) - let sideInset: CGFloat = 16.0 // + environment.safeInsets.left - let textSideInset: CGFloat = 32.0 // + environment.safeInsets.left - - let iconName = "Premium/Boost" - let peerName = state.peer?.compactDisplayTitle ?? "" - - let isGroup = component.isGroup - - let level: Int - let boosts: Int - let remaining: Int? - let progress: CGFloat - let myBoostCount: Int - if let boostState = component.boostState { - level = Int(boostState.level) - boosts = Int(boostState.boosts) - if let nextLevelBoosts = boostState.nextLevelBoosts { - remaining = max(0, Int(nextLevelBoosts - boostState.boosts)) - progress = max(0.0, min(1.0, CGFloat(boostState.boosts - boostState.currentLevelBoosts) / CGFloat(nextLevelBoosts - boostState.currentLevelBoosts))) - } else { - remaining = nil - progress = 1.0 - } - myBoostCount = Int(boostState.myBoostCount) - } else if let status = component.status { - level = status.level - boosts = status.boosts - if let nextLevelBoosts = status.nextLevelBoosts { - remaining = max(0, nextLevelBoosts - status.boosts) - progress = max(0.0, min(1.0, CGFloat(status.boosts - status.currentLevelBoosts) / CGFloat(nextLevelBoosts - status.currentLevelBoosts))) - } else { - remaining = nil - progress = 1.0 - } - myBoostCount = 0 - } else { - level = 0 - boosts = 0 - remaining = nil - progress = 0.0 - myBoostCount = 0 - } - - var textString = "" - - var isCurrent = false - switch component.mode { - case let .owner(subject): - if let remaining { - var needsSecondParagraph = true - - if let subject { - let requiredLevel = subject.requiredLevel(group: isGroup, context: context.component.context, configuration: premiumConfiguration) - - let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(level) + 1) - let valueString = strings.ChannelBoost_MoreBoosts(Int32(remaining)) - switch subject { - case .stories: - if level == 0 { - textString = isGroup ? strings.GroupBoost_EnableStoriesText(valueString).string : strings.ChannelBoost_EnableStoriesText(valueString).string - } else { - textString = isGroup ? strings.GroupBoost_IncreaseLimitText(valueString, storiesString).string : strings.ChannelBoost_IncreaseLimitText(valueString, storiesString).string - } - needsSecondParagraph = isGroup - case let .channelReactions(reactionCount): - textString = strings.ChannelBoost_CustomReactionsText("\(reactionCount)", "\(reactionCount)").string - needsSecondParagraph = false - case .nameColors: - textString = strings.ChannelBoost_EnableNameColorLevelText("\(requiredLevel)").string - case .nameIcon: - textString = strings.ChannelBoost_EnableNameIconLevelText("\(requiredLevel)").string - case .profileColors: - textString = isGroup ? strings.GroupBoost_EnableProfileColorLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableProfileColorLevelText("\(requiredLevel)").string - case .profileIcon: - textString = isGroup ? strings.GroupBoost_EnableProfileIconLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableProfileIconLevelText("\(premiumConfiguration.minChannelProfileIconLevel)").string - case .emojiStatus: - textString = isGroup ? strings.GroupBoost_EnableEmojiStatusLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableEmojiStatusLevelText("\(requiredLevel)").string - case .wallpaper: - textString = isGroup ? strings.GroupBoost_EnableWallpaperLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableWallpaperLevelText("\(requiredLevel)").string - case .customWallpaper: - textString = isGroup ? strings.GroupBoost_EnableCustomWallpaperLevelText("\(requiredLevel)").string : strings.ChannelBoost_EnableCustomWallpaperLevelText("\(requiredLevel)").string - case .audioTranscription: - textString = "" - case .emojiPack: - textString = strings.GroupBoost_EnableEmojiPackLevelText("\(requiredLevel)").string - case .noAds: - textString = strings.ChannelBoost_EnableNoAdsLevelText("\(requiredLevel)").string - case .wearGift: - textString = strings.ChannelBoost_WearGiftLevelText("\(requiredLevel)").string - case .autoTranslate: - textString = strings.ChannelBoost_AutoTranslateLevelText("\(requiredLevel)").string - } - } else { - let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) - if myBoostCount > 0 { - if remaining == 0 { - textString = isGroup ? strings.GroupBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string : strings.ChannelBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string - } else { - textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string - } - } else { - textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string - } - } - - if needsSecondParagraph { - textString += " \(isGroup ? strings.GroupBoost_PremiumUsersCanBoost : strings.ChannelBoost_PremiumUsersCanBoost)" - } - } else { - textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string - } - case let .user(mode): - switch mode { - case let .groupPeer(_, peerBoostCount): - let memberName = state.memberPeer?.compactDisplayTitle ?? "" - let timesString = strings.GroupBoost_MemberBoosted_Times(Int32(peerBoostCount)) - let memberString = strings.GroupBoost_MemberBoosted(memberName, timesString).string - if myBoostCount > 0 { - if let remaining, remaining != 0 { - let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) - textString = "\(memberString) \(strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string)" - } else { - textString = memberString - } - } else { - textString = "\(memberString) \(strings.GroupBoost_MemberBoosted_BoostForBadge(peerName).string)" - } - isCurrent = true - case let .unrestrict(unrestrictCount): - let timesString = strings.GroupBoost_BoostToUnrestrict_Times(Int32(unrestrictCount)) - textString = strings.GroupBoost_BoostToUnrestrict(timesString, peerName).string - isCurrent = true - default: - if let remaining { - let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining)) - if myBoostCount > 0 { - if remaining == 0 { - textString = isGroup ? strings.GroupBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string : strings.ChannelBoost_MoreBoostsNeeded_Boosted_Level_Text("\(level + 1)").string - } else { - textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string - } - } else { - textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string - } - } else { - textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string - } - isCurrent = mode == .current - } - case .features: - textString = isGroup ? strings.GroupBoost_AdditionalFeaturesText : strings.ChannelBoost_AdditionalFeaturesText - } - - let defaultTitle = strings.ChannelBoost_Level("\(level)").string - let defaultValue = "" - let premiumValue = strings.ChannelBoost_Level("\(level + 1)").string - let premiumTitle = "" - - var contentSize: CGSize = CGSize(width: context.availableSize.width, height: 44.0) - - let textFont = Font.regular(15.0) - let boldTextFont = Font.semibold(15.0) - let textColor = theme.actionSheet.primaryTextColor - let linkColor = theme.actionSheet.controlAccentColor - let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in - return (TelegramTextAttributes.URL, contents) - }) - - let gradientColors = [ - UIColor(rgb: 0x0077ff), - UIColor(rgb: 0x6b93ff), - UIColor(rgb: 0x8878ff), - UIColor(rgb: 0xe46ace) - ] - let buttonGradientColors = [ - UIColor(rgb: 0x007afe), - UIColor(rgb: 0x5494ff) - ] - - if case let .user(mode) = component.mode, case .external = mode, let peer = state.peer { - contentSize.height += 10.0 - - let peerShortcut = peerShortcut.update( - component: Button( - content: AnyComponent( - PremiumPeerShortcutComponent( - context: component.context, - theme: component.theme, - peer: peer - ) - ), - action: { - component.dismiss() - Queue.mainQueue().after(0.35) { - component.openPeer?(peer) - } - } - ), - availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.height), - transition: .immediate - ) - context.add(peerShortcut - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + peerShortcut.size.height / 2.0)) - ) - contentSize.height += peerShortcut.size.height + 2.0 - } - - if case .features = component.mode { - contentSize.height -= 14.0 - - let iconSize = CGSize(width: 90.0, height: 90.0) - let gradientImage: UIImage - if let current = state.cachedIconImage { - gradientImage = current - } else { - gradientImage = generateFilledCircleImage(diameter: iconSize.width, color: theme.actionSheet.controlAccentColor)! - context.state.cachedIconImage = gradientImage - } - - let iconBackground = iconBackground.update( - component: Image(image: gradientImage), - availableSize: iconSize, - transition: .immediate - ) - context.add(iconBackground - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0)) - ) - - let icon = icon.update( - component: BundleIconComponent( - name: "Premium/BoostLarge", - tintColor: .white - ), - availableSize: CGSize(width: 90.0, height: 90.0), - transition: .immediate - ) - context.add(icon - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0)) - ) - contentSize.height += iconSize.height - contentSize.height += 52.0 - } else { - let limit = limit.update( - component: PremiumLimitDisplayComponent( - inactiveColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3), - activeColors: gradientColors, - inactiveTitle: defaultTitle, - inactiveValue: defaultValue, - inactiveTitleColor: theme.list.itemPrimaryTextColor, - activeTitle: premiumTitle, - activeValue: premiumValue, - activeTitleColor: .white, - badgeIconName: iconName, - badgeText: "\(boosts)", - badgePosition: progress, - badgeGraphPosition: progress, - invertProgress: true, - isPremiumDisabled: false - ), - availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), - transition: context.transition - ) - context.add(limit - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + limit.size.height / 2.0)) - ) - - contentSize.height += limit.size.height + 23.0 - } - - if myBoostCount > 0 { - let alternateTitle = isCurrent ? strings.ChannelBoost_YouBoostedChannelText(peerName).string : strings.ChannelBoost_YouBoostedOtherChannelText - - var alternateBadge: String? - if myBoostCount > 1 { - alternateBadge = "X\(myBoostCount)" - } - - let alternateText = alternateText.update( - component: List( - [ - AnyComponentWithIdentity( - id: "title", - component: AnyComponent( - BoostedTitleContent(text: NSAttributedString(string: alternateTitle, font: Font.semibold(15.0), textColor: textColor), badge: alternateBadge) - ) - ), - AnyComponentWithIdentity( - id: "text", - component: AnyComponent( - BalancedTextComponent( - text: .markdown(text: textString, attributes: markdownAttributes), - horizontalAlignment: .center, - maximumNumberOfLines: 0, - lineSpacing: 0.1 - ) - ) - ) - ], - centerAlignment: true - ), - availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), - transition: .immediate - ) - context.add(alternateText - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + alternateText.size.height / 2.0)) - .appear(ComponentTransition.Appear({ _, view, transition in - transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true) - transition.animateAlpha(view: view, from: 0.0, to: 1.0) - })) - .disappear(ComponentTransition.Disappear({ view, transition, completion in - view.superview?.sendSubviewToBack(view) - transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true) - transition.setAlpha(view: view, alpha: 0.0, completion: { _ in - completion() - }) - })) - ) - contentSize.height += alternateText.size.height + 20.0 - } else { - let text = text.update( - component: BalancedTextComponent( - text: .markdown(text: textString, attributes: markdownAttributes), - horizontalAlignment: .center, - maximumNumberOfLines: 0, - lineSpacing: 0.2 - ), - availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), - transition: .immediate - ) - context.add(text - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)) - .appear(ComponentTransition.Appear({ _, view, transition in - transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true) - transition.animateAlpha(view: view, from: 0.0, to: 1.0) - })) - .disappear(ComponentTransition.Disappear({ view, transition, completion in - view.superview?.sendSubviewToBack(view) - transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true) - transition.setAlpha(view: view, alpha: 0.0, completion: { _ in - completion() - }) - })) - ) - contentSize.height += text.size.height + 20.0 - } - - if case .owner = component.mode, let status = component.status { - contentSize.height += 7.0 - - let linkButton = linkButton.update( - component: SolidRoundedButtonComponent( - title: status.url.replacingOccurrences(of: "https://", with: ""), - theme: SolidRoundedButtonComponent.Theme( - backgroundColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3), - backgroundColors: [], - foregroundColor: theme.list.itemPrimaryTextColor - ), - font: .regular, - fontSize: 17.0, - height: 50.0, - cornerRadius: 10.0, - action: { - component.copyLink(status.url) - component.dismiss() - } - ), - availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), - transition: context.transition - ) - context.add(linkButton - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + linkButton.size.height / 2.0)) - ) - contentSize.height += linkButton.size.height + 16.0 - - let boostButton = boostButton.update( - component: SolidRoundedButtonComponent( - title: strings.ChannelBoost_Boost, - theme: SolidRoundedButtonComponent.Theme( - backgroundColor: .black, - backgroundColors: buttonGradientColors, - foregroundColor: .white - ), - font: .bold, - fontSize: 17.0, - height: 50.0, - cornerRadius: 10.0, - gloss: false, - iconName: nil, - animationName: nil, - iconPosition: .left, - action: { - component.boost() - } - ), - availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0), - transition: context.transition - ) - - let copyButton = copyButton.update( - component: SolidRoundedButtonComponent( - title: strings.ChannelBoost_Copy, - theme: SolidRoundedButtonComponent.Theme( - backgroundColor: .black, - backgroundColors: buttonGradientColors, - foregroundColor: .white - ), - font: .bold, - fontSize: 17.0, - height: 50.0, - cornerRadius: 10.0, - gloss: false, - iconName: nil, - animationName: nil, - iconPosition: .left, - action: { - component.copyLink(status.url) - component.dismiss() - } - ), - availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0), - transition: context.transition - ) - - let boostButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentSize.height), size: boostButton.size) - context.add(boostButton - .position(boostButtonFrame.center) - ) - let copyButtonFrame = CGRect(origin: CGPoint(x: context.availableSize.width - sideInset - copyButton.size.width, y: contentSize.height), size: copyButton.size) - context.add(copyButton - .position(copyButtonFrame.center) - ) - contentSize.height += boostButton.size.height - - if premiumConfiguration.giveawayGiftsPurchaseAvailable { - let orText = orText.update( - component: MultilineTextComponent(text: .plain(NSAttributedString(string: strings.ChannelBoost_Or, font: Font.regular(15.0), textColor: textColor.withAlphaComponent(0.8), paragraphAlignment: .center))), - availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), - transition: .immediate - ) - context.add(orText - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 27.0)) - ) - - let orLeftLine = orLeftLine.update( - component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)), - availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel), - transition: .immediate - ) - context.add(orLeftLine - .position(CGPoint(x: context.availableSize.width / 2.0 - orText.size.width / 2.0 - 11.0 - 45.0, y: contentSize.height + 27.0)) - ) - - let orRightLine = orRightLine.update( - component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)), - availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel), - transition: .immediate - ) - context.add(orRightLine - .position(CGPoint(x: context.availableSize.width / 2.0 + orText.size.width / 2.0 + 11.0 + 45.0, y: contentSize.height + 27.0)) - ) - - if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme { - state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme) - } - - - let giftString = isGroup ? strings.Premium_Group_BoostByGiveawayDescription : strings.Premium_BoostByGiveawayDescription - let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString - - if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { - giftAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: giftAttributedString.string)) - } - let giftText = giftText.update( - component: BalancedTextComponent( - text: .plain(giftAttributedString), - horizontalAlignment: .center, - maximumNumberOfLines: 0, - lineSpacing: 0.1, - highlightColor: linkColor.withAlphaComponent(0.1), - highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), - highlightAction: { _ in - return nil - }, - tapAction: { _, _ in - component.openGift?() - } - ), - availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), - transition: .immediate - ) - context.add(giftText - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 50.0 + giftText.size.height / 2.0)) - ) - contentSize.height += giftText.size.height + 50.0 + 23.0 - } - } - - var nextLevels: ClosedRange? - if level < 10 { - nextLevels = Int32(level) + 1 ... 10 - } - - var levelItems: [AnyComponentWithIdentity] = [] - - var nameColorsAtLevel: [(Int32, Int32)] = [] - var nameColorsCountMap: [Int32: Int32] = [:] - for color in context.component.context.peerNameColors.displayOrder { - if let level = context.component.context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[color] { - if let current = nameColorsCountMap[level] { - nameColorsCountMap[level] = current + 1 - } else { - nameColorsCountMap[level] = 1 - } - } - } - for (key, value) in nameColorsCountMap { - nameColorsAtLevel.append((key, value)) - } - - var profileColorsAtLevel: [(Int32, Int32)] = [] - var profileColorsCountMap: [Int32: Int32] = [:] - for color in context.component.context.peerNameColors.profileDisplayOrder { - if let level = isGroup ? context.component.context.peerNameColors.profileColorsGroupMinRequiredBoostLevel[color] : context.component.context.peerNameColors.profileColorsChannelMinRequiredBoostLevel[color] { - if let current = profileColorsCountMap[level] { - profileColorsCountMap[level] = current + 1 - } else { - profileColorsCountMap[level] = 1 - } - } - } - for (key, value) in profileColorsCountMap { - profileColorsAtLevel.append((key, value)) - } - - var isFeatures = false - if case .features = component.mode { - isFeatures = true - } - - - func layoutLevel(_ level: Int32) { - var perks: [LevelSectionComponent.Perk] = [] - - if !isGroup && level >= requiredBoostSubjectLevel(subject: .autoTranslate, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.autoTranslate) - } - - perks.append(.story(level)) - - if !isGroup { - perks.append(.reaction(level)) - } - - var nameColorsCount: Int32 = 0 - for (colorLevel, count) in nameColorsAtLevel { - if level >= colorLevel && colorLevel == 1 { - nameColorsCount = count - } - } - if !isGroup && nameColorsCount > 0 { - perks.append(.nameColor(nameColorsCount)) - } - - var profileColorsCount: Int32 = 0 - for (colorLevel, count) in profileColorsAtLevel { - if level >= colorLevel { - profileColorsCount += count - } - } - if profileColorsCount > 0 { - perks.append(.profileColor(profileColorsCount)) - } - - if isGroup && level >= requiredBoostSubjectLevel(subject: .emojiPack, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.emojiPack) - } - - if level >= requiredBoostSubjectLevel(subject: .profileIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.profileIcon) - } - - if isGroup && level >= requiredBoostSubjectLevel(subject: .audioTranscription, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.audioTranscription) - } - - var linkColorsCount: Int32 = 0 - for (colorLevel, count) in nameColorsAtLevel { - if level >= colorLevel { - linkColorsCount += count - } - } - if !isGroup && linkColorsCount > 0 { - perks.append(.linkColor(linkColorsCount)) - } - - if !isGroup && level >= requiredBoostSubjectLevel(subject: .nameIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.linkIcon) - } - if level >= requiredBoostSubjectLevel(subject: .emojiStatus, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.emojiStatus) - } - if level >= requiredBoostSubjectLevel(subject: .wallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.wallpaper(8)) - } - if level >= requiredBoostSubjectLevel(subject: .customWallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.customWallpaper) - } - if !isGroup && level >= requiredBoostSubjectLevel(subject: .noAds, group: isGroup, context: component.context, configuration: premiumConfiguration) { - perks.append(.noAds) - } - - levelItems.append( - AnyComponentWithIdentity( - id: level, component: AnyComponent( - LevelSectionComponent( - theme: component.theme, - strings: component.strings, - level: level, - isFirst: !isFeatures && levelItems.isEmpty, - perks: perks.reversed(), - isGroup: isGroup - ) - ) - ) - ) - } - - if let nextLevels { - for level in nextLevels { - layoutLevel(level) - } - } - - if !isGroup { - let noAdsLevel = requiredBoostSubjectLevel(subject: .noAds, group: false, context: component.context, configuration: premiumConfiguration) - if let nextLevels, noAdsLevel <= nextLevels.upperBound { - } else if level < noAdsLevel { - layoutLevel(noAdsLevel) - } - } - - if !levelItems.isEmpty { - let levels = levels.update( - component: List(levelItems), - availableSize: CGSize(width: context.availableSize.width, height: 100000.0), - transition: context.transition - ) - context.add(levels - .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + levels.size.height / 2.0 )) - ) - contentSize.height += levels.size.height + 80.0 - contentSize.height += 60.0 - } - - return contentSize - } - } -} - -private final class BoostLevelsContainerComponent: CombinedComponent { - class ExternalState { - var isGroup: Bool = false - var contentHeight: CGFloat = 0.0 - } - - let context: AccountContext - let theme: PresentationTheme - let strings: PresentationStrings - let externalState: ExternalState - let peerId: EnginePeer.Id - let mode: PremiumBoostLevelsScreen.Mode - let status: ChannelBoostStatus? - let boostState: InternalBoostState.DisplayData? - let boost: () -> Void - let copyLink: (String) -> Void - let dismiss: () -> Void - let openStats: (() -> Void)? - let openGift: (() -> Void)? - let openPeer: ((EnginePeer) -> Void)? - let updated: () -> Void - - init( - context: AccountContext, - theme: PresentationTheme, - strings: PresentationStrings, - externalState: ExternalState, - peerId: EnginePeer.Id, - mode: PremiumBoostLevelsScreen.Mode, - status: ChannelBoostStatus?, - boostState: InternalBoostState.DisplayData?, - boost: @escaping () -> Void, - copyLink: @escaping (String) -> Void, - dismiss: @escaping () -> Void, - openStats: (() -> Void)?, - openGift: (() -> Void)?, - openPeer: ((EnginePeer) -> Void)?, - updated: @escaping () -> Void - ) { - self.context = context - self.theme = theme - self.strings = strings - self.externalState = externalState - self.peerId = peerId - self.mode = mode - self.status = status - self.boostState = boostState - self.boost = boost - self.copyLink = copyLink - self.dismiss = dismiss - self.openStats = openStats - self.openGift = openGift - self.openPeer = openPeer - self.updated = updated - } - - static func ==(lhs: BoostLevelsContainerComponent, rhs: BoostLevelsContainerComponent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.theme !== rhs.theme { - return false - } - if lhs.peerId != rhs.peerId { - return false - } - if lhs.mode != rhs.mode { - return false - } - if lhs.status != rhs.status { - return false - } - if lhs.boostState != rhs.boostState { - return false - } - return true - } - - final class State: ComponentState { - var topContentOffset: CGFloat = 0.0 - var cachedStatsImage: (UIImage, PresentationTheme)? - var cachedCloseImage: (UIImage, PresentationTheme)? - - var initialized = false - - private var disposable: Disposable? - private(set) var peer: EnginePeer? - - init(context: AccountContext, peerId: EnginePeer.Id, updated: @escaping () -> Void) { - super.init() - - self.disposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) - |> deliverOnMainQueue).startStrict(next: { [weak self] peer in - guard let self else { - return - } - self.peer = peer - updated() - }) - } - - deinit { - self.disposable?.dispose() - } - } - - func makeState() -> State { - return State(context: self.context, peerId: self.peerId, updated: self.updated) - } - - static var body: Body { - let background = Child(Rectangle.self) - let scroll = Child(ScrollComponent.self) - let topPanel = Child(BlurredBackgroundComponent.self) - let topSeparator = Child(Rectangle.self) - let title = Child(MultilineTextComponent.self) - let statsButton = Child(Button.self) - let closeButton = Child(Button.self) - - let externalScrollState = ScrollComponent.ExternalState() - - return { context in - let state = context.state - - let theme = context.component.theme - let strings = context.component.context.sharedContext.currentPresentationData.with { $0 }.strings - - let topInset: CGFloat = 56.0 - - let component = context.component - - var isGroup: Bool? - if let peer = state.peer { - if case let .channel(channel) = peer, case .group = channel.info { - isGroup = true - } else { - isGroup = false - } - } - - if let isGroup { - component.externalState.isGroup = isGroup - let updated = component.updated - let scroll = scroll.update( - component: ScrollComponent( - content: AnyComponent( - SheetContent( - context: component.context, - theme: component.theme, - strings: component.strings, - insets: .zero, - peerId: component.peerId, - isGroup: isGroup, - mode: component.mode, - status: component.status, - boostState: component.boostState, - initialized: state.initialized, - boost: component.boost, - copyLink: component.copyLink, - dismiss: component.dismiss, - openStats: component.openStats, - openGift: component.openGift, - openPeer: component.openPeer, - updated: { [weak state] in - state?.initialized = true - updated() - } - ) - ), - externalState: externalScrollState, - contentInsets: UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0), - contentOffsetUpdated: { [weak state] topContentOffset, _ in - state?.topContentOffset = topContentOffset - Queue.mainQueue().justDispatch { - state?.updated(transition: .immediate) - } - }, - contentOffsetWillCommit: { _ in } - ), - availableSize: context.availableSize, - transition: context.transition - ) - component.externalState.contentHeight = externalScrollState.contentHeight - - let background = background.update( - component: Rectangle(color: theme.overallDarkAppearance ? theme.list.blocksBackgroundColor : theme.list.plainBackgroundColor), - availableSize: scroll.size, - transition: context.transition - ) - context.add(background - .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) - ) - - context.add(scroll - .position(CGPoint(x: context.availableSize.width / 2.0, y: scroll.size.height / 2.0)) - ) - } - - let topPanel = topPanel.update( - component: BlurredBackgroundComponent( - color: theme.rootController.navigationBar.blurredBackgroundColor - ), - availableSize: CGSize(width: context.availableSize.width, height: topInset), - transition: context.transition - ) - - let topSeparator = topSeparator.update( - component: Rectangle( - color: theme.rootController.navigationBar.separatorColor - ), - availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel), - transition: context.transition - ) - - let titleString: String - var titleFont = Font.semibold(17.0) - - switch component.mode { - case let .owner(subject): - if let status = component.status, let _ = status.nextLevelBoosts { - if let subject { - switch subject { - case .stories: - if status.level == 0 { - titleString = strings.ChannelBoost_EnableStories - } else { - titleString = strings.ChannelBoost_IncreaseLimit - } - case .nameColors: - titleString = strings.ChannelBoost_NameColor - case .nameIcon: - titleString = strings.ChannelBoost_NameIcon - case .profileColors: - titleString = strings.ChannelBoost_ProfileColor - case .profileIcon: - titleString = strings.ChannelBoost_ProfileIcon - case .channelReactions: - titleString = strings.ChannelBoost_CustomReactions - case .emojiStatus: - titleString = strings.ChannelBoost_EmojiStatus - case .wallpaper: - titleString = strings.ChannelBoost_Wallpaper - case .customWallpaper: - titleString = strings.ChannelBoost_CustomWallpaper - case .audioTranscription: - titleString = strings.GroupBoost_AudioTranscription - case .emojiPack: - titleString = strings.GroupBoost_EmojiPack - case .noAds: - titleString = strings.ChannelBoost_NoAds - case .wearGift: - titleString = strings.ChannelBoost_WearGift - case .autoTranslate: - titleString = strings.ChannelBoost_AutoTranslate - } - } else { - titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current - } - } else { - titleString = strings.ChannelBoost_MaxLevelReached - } - case let .user(mode): - var remaining: Int? - if let status = component.status, let nextLevelBoosts = status.nextLevelBoosts { - remaining = nextLevelBoosts - status.boosts - } - - if let _ = remaining { - if case .current = mode { - titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current - } else { - titleString = isGroup == true ? strings.GroupBoost_Title_Other : strings.ChannelBoost_Title_Other - } - } else { - titleString = strings.ChannelBoost_MaxLevelReached - } - case .features: - titleString = strings.GroupBoost_AdditionalFeatures - titleFont = Font.semibold(20.0) - } - - let title = title.update( - component: MultilineTextComponent( - text: .plain(NSAttributedString(string: titleString, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)), - horizontalAlignment: .center, - truncationType: .end, - maximumNumberOfLines: 1 - ), - availableSize: context.availableSize, - transition: context.transition - ) - - let topPanelAlpha: CGFloat - let titleOriginY: CGFloat - let titleScale: CGFloat - if case .features = component.mode { - if state.topContentOffset > 78.0 { - topPanelAlpha = min(30.0, state.topContentOffset - 78.0) / 30.0 - } else { - topPanelAlpha = 0.0 - } - - let titleTopOriginY = topPanel.size.height / 2.0 - let titleBottomOriginY: CGFloat = 146.0 - let titleOriginDelta = titleTopOriginY - titleBottomOriginY - - let fraction = min(1.0, state.topContentOffset / abs(titleOriginDelta)) - titleOriginY = titleBottomOriginY + fraction * titleOriginDelta - titleScale = 1.0 - max(0.0, fraction * 0.2) - } else { - topPanelAlpha = min(30.0, state.topContentOffset) / 30.0 - titleOriginY = topPanel.size.height / 2.0 - titleScale = 1.0 - } - - context.add(topPanel - .position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height / 2.0)) - .opacity(topPanelAlpha) - ) - context.add(topSeparator - .position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height)) - .opacity(topPanelAlpha) - ) - context.add(title - .position(CGPoint(x: context.availableSize.width / 2.0, y: titleOriginY)) - .scale(titleScale) - ) - - if let openStats = component.openStats { - let statsButton = statsButton.update( - component: Button( - content: AnyComponent( - BundleIconComponent( - name: "Premium/Stats", - tintColor: component.theme.list.itemAccentColor - ) - ), - action: { - component.dismiss() - Queue.mainQueue().after(0.35) { - openStats() - } - } - ).minSize(CGSize(width: 44.0, height: 44.0)), - availableSize: context.availableSize, - transition: .immediate - ) - context.add(statsButton - .position(CGPoint(x: 31.0, y: 28.0)) - ) - } - - let closeImage: UIImage - if let (image, theme) = state.cachedCloseImage, theme === component.theme { - closeImage = image - } else { - closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)! - state.cachedCloseImage = (closeImage, theme) - } - let closeButton = closeButton.update( - component: Button( - content: AnyComponent(Image(image: closeImage)), - action: { - component.dismiss() - } - ), - availableSize: CGSize(width: 30.0, height: 30.0), - transition: .immediate - ) - context.add(closeButton - .position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0)) - ) - - return context.availableSize - } - } -} - -public class PremiumBoostLevelsScreen: ViewController { - public enum Mode: Equatable { - public enum UserMode: Equatable { - case external - case current - case groupPeer(EnginePeer.Id, Int) - case unrestrict(Int) - } - case user(mode: UserMode) - case owner(subject: BoostSubject?) - case features - } - - final class Node: ViewControllerTracingNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { - private var presentationData: PresentationData - private weak var controller: PremiumBoostLevelsScreen? - - let dim: ASDisplayNode - let wrappingView: UIView - let containerView: UIView - - let contentView: ComponentHostView - let footerContainerView: UIView - let footerView: ComponentHostView - - private let containerExternalState = BoostLevelsContainerComponent.ExternalState() - - private(set) var isExpanded = false - private var panGestureRecognizer: UIPanGestureRecognizer? - private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?)? - - private let hapticFeedback = HapticFeedback() - - private var currentIsVisible: Bool = false - private var currentLayout: ContainerViewLayout? - - init(context: AccountContext, controller: PremiumBoostLevelsScreen) { - self.presentationData = context.sharedContext.currentPresentationData.with { $0 } - if controller.forceDark { - self.presentationData = self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) - } - self.presentationData = self.presentationData.withUpdated(theme: self.presentationData.theme.withModalBlocksBackground()) - - self.controller = controller - - self.dim = ASDisplayNode() - self.dim.alpha = 0.0 - self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) - - self.wrappingView = UIView() - self.containerView = UIView() - self.contentView = ComponentHostView() - - self.footerContainerView = UIView() - self.footerView = ComponentHostView() - - super.init() - - self.containerView.clipsToBounds = true - self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.blocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor - - self.addSubnode(self.dim) - - self.view.addSubview(self.wrappingView) - self.wrappingView.addSubview(self.containerView) - self.containerView.addSubview(self.contentView) - - if case .user = controller.mode { - self.containerView.addSubview(self.footerContainerView) - self.footerContainerView.addSubview(self.footerView) - } - - if let status = controller.status, let myBoostStatus = controller.myBoostStatus { - var myBoostCount: Int32 = 0 - var currentMyBoostCount: Int32 = 0 - var availableBoosts: [MyBoostStatus.Boost] = [] - var occupiedBoosts: [MyBoostStatus.Boost] = [] - - for boost in myBoostStatus.boosts { - if let boostPeer = boost.peer { - if boostPeer.id == controller.peerId { - myBoostCount += 1 - } else { - occupiedBoosts.append(boost) - } - } else { - availableBoosts.append(boost) - } - } - - let boosts = max(Int32(status.boosts), myBoostCount) - let initialState = InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts) - self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 0, replacedBoosts: controller.replacedBoosts?.0) - - self.updatedState.set(.single(InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts + 1))) - - if let (replacedBoosts, sourcePeers) = controller.replacedBoosts { - currentMyBoostCount += 1 - - self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 1) - Queue.mainQueue().justDispatch { - self.updated(transition: .easeInOut(duration: 0.2)) - } - - Queue.mainQueue().after(0.3) { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - - var groupCount: Int32 = 0 - var channelCount: Int32 = 0 - for peer in sourcePeers { - if case let .channel(channel) = peer { - switch channel.info { - case .broadcast: - channelCount += 1 - case .group: - groupCount += 1 - } - } - } - let otherText: String - if channelCount > 0 && groupCount == 0 { - otherText = presentationData.strings.ReassignBoost_OtherChannels(channelCount) - } else if groupCount > 0 && channelCount == 0 { - otherText = presentationData.strings.ReassignBoost_OtherGroups(groupCount) - } else { - otherText = presentationData.strings.ReassignBoost_OtherGroupsAndChannels(Int32(sourcePeers.count)) - } - let text = presentationData.strings.ReassignBoost_Success(presentationData.strings.ReassignBoost_Boosts(replacedBoosts), otherText).string - let undoController = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "BoostReplace", scale: 0.066, colors: [:], title: nil, text: text, customUndoText: nil, timeout: 4.0), elevatedLayout: false, position: .top, action: { _ in return true }) - controller.present(undoController, in: .current) - } - } - - self.availableBoosts = availableBoosts - self.occupiedBoosts = occupiedBoosts - self.myBoostCount = myBoostCount - self.currentMyBoostCount = currentMyBoostCount - } - } - - override func didLoad() { - super.didLoad() - - let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) - panRecognizer.delegate = self.wrappedGestureRecognizerDelegate - panRecognizer.delaysTouchesBegan = false - panRecognizer.cancelsTouchesInView = true - self.panGestureRecognizer = panRecognizer - self.wrappingView.addGestureRecognizer(panRecognizer) - - self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) - self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) - } - - @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - self.controller?.dismiss(animated: true) - } - } - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let layout = self.currentLayout { - if case .regular = layout.metrics.widthClass { - return false - } - } - return true - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { - if let scrollView = otherGestureRecognizer.view as? UIScrollView { - if scrollView.contentSize.width > scrollView.contentSize.height { - return false - } - } - return true - } - return false - } - - private var isDismissing = false - func animateIn() { - ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0) - - let targetPosition = self.containerView.center - let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) - - self.containerView.center = startPosition - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) - transition.animateView(allowUserInteraction: true, { - self.containerView.center = targetPosition - }, completion: { _ in - }) - } - - func animateOut(completion: @escaping () -> Void = {}) { - self.isDismissing = true - - let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) - positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in - self?.controller?.dismiss(animated: false, completion: completion) - }) - let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) - alphaTransition.updateAlpha(node: self.dim, alpha: 0.0) - - self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition) - } - - func requestLayout(transition: ComponentTransition) { - guard let layout = self.currentLayout else { - return - } - self.containerLayoutUpdated(layout: layout, forceUpdate: true, transition: transition) - } - - private var dismissOffset: CGFloat? - func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, transition: ComponentTransition) { - guard !self.isDismissing else { - return - } - self.currentLayout = layout - - self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0)) - - let isLandscape = layout.orientation == .landscape - - var containerTopInset: CGFloat = 0.0 - let clipFrame: CGRect - if layout.metrics.widthClass == .compact { - self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25) - if isLandscape { - self.containerView.layer.cornerRadius = 0.0 - } else { - self.containerView.layer.cornerRadius = 10.0 - } - - if #available(iOS 11.0, *) { - if layout.safeInsets.bottom.isZero { - self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - } else { - self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] - } - } - - if isLandscape { - clipFrame = CGRect(origin: CGPoint(), size: layout.size) - } else { - let coveredByModalTransition: CGFloat = 0.0 - containerTopInset = 10.0 - if let statusBarHeight = layout.statusBarHeight { - containerTopInset += statusBarHeight - } - - let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset)) - let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width - let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition - let maxScaledTopInset: CGFloat = containerTopInset - 10.0 - let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition - let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) - - clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height) - } - } else { - self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) - self.containerView.layer.cornerRadius = 10.0 - - let verticalInset: CGFloat = 44.0 - - let maxSide = max(layout.size.width, layout.size.height) - let minSide = min(layout.size.width, layout.size.height) - let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0) - clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) - } - - transition.setFrame(view: self.containerView, frame: clipFrame) - - var effectiveExpanded = self.isExpanded - if case .regular = layout.metrics.widthClass { - effectiveExpanded = true - } - - self.updated(transition: transition, forceUpdate: forceUpdate) - - let contentHeight = self.containerExternalState.contentHeight - if contentHeight > 0.0 && contentHeight < 400.0, let view = self.footerView.componentView as? FooterComponent.View { - view.backgroundView.alpha = 0.0 - view.separator.opacity = 0.0 - } - let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset - - let topInset: CGFloat - if let (panInitialTopInset, panOffset, _) = self.panGestureArguments { - if effectiveExpanded { - topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset)) - } else { - topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) - } - } else if let dismissOffset = self.dismissOffset, !dismissOffset.isZero { - topInset = edgeTopInset * dismissOffset - } else { - topInset = effectiveExpanded ? 0.0 : edgeTopInset - } - transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil) - - let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset) - self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition) - - let footerHeight = self.footerHeight - let convertedFooterFrame = self.view.convert(CGRect(origin: CGPoint(x: clipFrame.minX, y: clipFrame.maxY - footerHeight), size: CGSize(width: clipFrame.width, height: footerHeight)), to: self.containerView) - transition.setFrame(view: self.footerContainerView, frame: convertedFooterFrame) - } - - private var boostState: InternalBoostState.DisplayData? - func updated(transition: ComponentTransition, forceUpdate: Bool = false) { - guard let controller = self.controller else { - return - } - let contentSize = self.contentView.update( - transition: transition, - component: AnyComponent( - BoostLevelsContainerComponent( - context: controller.context, - theme: self.presentationData.theme, - strings: self.presentationData.strings, - externalState: self.containerExternalState, - peerId: controller.peerId, - mode: controller.mode, - status: controller.status, - boostState: self.boostState, - boost: { [weak controller] in - guard let controller else { - return - } - controller.node.updateBoostState() - }, - copyLink: { [weak self, weak controller] link in - guard let self else { - return - } - UIPasteboard.general.string = link - - if let previousController = controller?.navigationController?.viewControllers.reversed().first(where: { $0 !== controller }) as? ViewController { - previousController.present(UndoOverlayController(presentationData: self.presentationData, content: .linkCopied(title: nil, text: self.presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { _ in return false }), in: .current) - } - }, - dismiss: { [weak controller] in - controller?.dismiss(animated: true) - }, - openStats: controller.openStats, - openGift: controller.openGift, - openPeer: controller.openPeer, - updated: { [weak self] in - self?.requestLayout(transition: .immediate) - } - ) - ), - environment: {}, - forceUpdate: forceUpdate, - containerSize: self.containerView.bounds.size - ) - self.contentView.frame = CGRect(origin: .zero, size: contentSize) - - let footerHeight = self.footerHeight - - let actionTitle: String - if self.currentMyBoostCount > 0 { - actionTitle = self.presentationData.strings.ChannelBoost_BoostAgain - } else { - actionTitle = self.containerExternalState.isGroup ? self.presentationData.strings.GroupBoost_BoostGroup : self.presentationData.strings.ChannelBoost_BoostChannel - } - - let footerSize = self.footerView.update( - transition: .immediate, - component: AnyComponent( - FooterComponent( - context: controller.context, - theme: self.presentationData.theme, - title: actionTitle, - action: { [weak self] in - guard let self else { - return - } - self.buttonPressed() - } - ) - ), - environment: {}, - containerSize: CGSize(width: self.containerView.bounds.width, height: footerHeight) - ) - self.footerView.frame = CGRect(origin: .zero, size: footerSize) - } - - private var didPlayAppearAnimation = false - func updateIsVisible(isVisible: Bool) { - if self.currentIsVisible == isVisible { - return - } - self.currentIsVisible = isVisible - - guard let layout = self.currentLayout else { - return - } - self.containerLayoutUpdated(layout: layout, transition: .immediate) - - if !self.didPlayAppearAnimation { - self.didPlayAppearAnimation = true - self.animateIn() - } - } - - private var footerHeight: CGFloat { - if let mode = self.controller?.mode, case .owner = mode { - return 0.0 - } - - guard let layout = self.currentLayout else { - return 58.0 - } - - var footerHeight: CGFloat = 8.0 + 50.0 - footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0 - return footerHeight - } - - private var defaultTopInset: CGFloat { - guard let layout = self.currentLayout else { - return 210.0 - } - if case .compact = layout.metrics.widthClass { - let bottomPanelPadding: CGFloat = 12.0 - let bottomInset: CGFloat = layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : bottomPanelPadding - let panelHeight: CGFloat = bottomPanelPadding + 50.0 + bottomInset + 28.0 - - var defaultTopInset = layout.size.height - layout.size.width - 128.0 - panelHeight - - let containerTopInset = 10.0 + (layout.statusBarHeight ?? 0.0) - let contentHeight = self.containerExternalState.contentHeight - let footerHeight = self.footerHeight - if contentHeight > 0.0 { - let delta = (layout.size.height - defaultTopInset - containerTopInset) - contentHeight - footerHeight - 16.0 - if delta > 0.0 { - defaultTopInset += delta - } - } - return defaultTopInset - } else { - return 210.0 - } - } - - private func findVerticalScrollView(view: UIView?) -> UIScrollView? { - if let view = view { - if let view = view as? UIScrollView, view.contentSize.height > view.contentSize.width { - return view - } - return findVerticalScrollView(view: view.superview) - } else { - return nil - } - } - - @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { - guard let layout = self.currentLayout else { - return - } - - let isLandscape = layout.orientation == .landscape - let edgeTopInset = isLandscape ? 0.0 : defaultTopInset - - switch recognizer.state { - case .began: - let point = recognizer.location(in: self.view) - let currentHitView = self.hitTest(point, with: nil) - - var scrollView = self.findVerticalScrollView(view: currentHitView) - if scrollView?.frame.height == self.frame.width { - scrollView = nil - } - if scrollView?.isDescendant(of: self.view) == false { - scrollView = nil - } - - let topInset: CGFloat - if self.isExpanded { - topInset = 0.0 - } else { - topInset = edgeTopInset - } - - self.panGestureArguments = (topInset, 0.0, scrollView) - case .changed: - guard let (topInset, panOffset, scrollView) = self.panGestureArguments else { - return - } - let contentOffset = scrollView?.contentOffset.y ?? 0.0 - - var translation = recognizer.translation(in: self.view).y - - var currentOffset = topInset + translation - - let epsilon = 1.0 - if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon { - scrollView.bounces = false - scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) - } else if let scrollView = scrollView { - translation = panOffset - currentOffset = topInset + translation - if self.isExpanded { - recognizer.setTranslation(CGPoint(), in: self.view) - } else if currentOffset > 0.0 { - scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) - } - } - - if scrollView == nil { - translation = max(0.0, translation) - } - - self.panGestureArguments = (topInset, translation, scrollView) - - if !self.isExpanded { - if currentOffset > 0.0, let scrollView = scrollView { - scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView) - } - } - - var bounds = self.bounds - if self.isExpanded { - bounds.origin.y = -max(0.0, translation - edgeTopInset) - } else { - bounds.origin.y = -translation - } - bounds.origin.y = min(0.0, bounds.origin.y) - self.bounds = bounds - - self.containerLayoutUpdated(layout: layout, transition: .immediate) - case .ended: - guard let (currentTopInset, panOffset, scrollView) = self.panGestureArguments else { - return - } - self.panGestureArguments = nil - - let contentOffset = scrollView?.contentOffset.y ?? 0.0 - - let translation = recognizer.translation(in: self.view).y - var velocity = recognizer.velocity(in: self.view) - - if self.isExpanded { - if contentOffset > 0.1 { - velocity = CGPoint() - } - } - - var bounds = self.bounds - if self.isExpanded { - bounds.origin.y = -max(0.0, translation - edgeTopInset) - } else { - bounds.origin.y = -translation - } - bounds.origin.y = min(0.0, bounds.origin.y) - - scrollView?.bounces = true - - let offset = currentTopInset + panOffset - let topInset: CGFloat = edgeTopInset - - var dismissing = false - if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) { - self.controller?.dismiss(animated: true, completion: nil) - dismissing = true - } else if self.isExpanded { - if velocity.y > 300.0 || offset > topInset / 2.0 { - self.isExpanded = false - if let scrollView = scrollView { - scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) - } - - let distance = topInset - offset - let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) - let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) - } else { - self.isExpanded = true - - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) - } - } else if scrollView != nil, (velocity.y < -300.0 || offset < topInset / 2.0) { - let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) - let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - self.isExpanded = true - - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) - } else { - if let scrollView = scrollView { - scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) - } - - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) - } - - if !dismissing { - var bounds = self.bounds - let previousBounds = bounds - bounds.origin.y = 0.0 - self.bounds = bounds - self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) - } - case .cancelled: - self.panGestureArguments = nil - - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut))) - default: - break - } - } - - func updateDismissOffset(_ offset: CGFloat) { - guard self.isExpanded, let layout = self.currentLayout else { - return - } - - self.dismissOffset = offset - self.containerLayoutUpdated(layout: layout, transition: .immediate) - } - - func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { - guard isExpanded != self.isExpanded else { - return - } - self.dismissOffset = nil - self.isExpanded = isExpanded - - guard let layout = self.currentLayout else { - return - } - self.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) - } - - private var currentMyBoostCount: Int32 = 0 - private var myBoostCount: Int32 = 0 - private var availableBoosts: [MyBoostStatus.Boost] = [] - private var occupiedBoosts: [MyBoostStatus.Boost] = [] - private let updatedState = Promise() - - private func updateBoostState() { - guard let controller = self.controller else { - return - } - let context = controller.context - let peerId = controller.peerId - let mode = controller.mode - let status = controller.status - let isPremium = controller.context.isPremium - let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with({ $0 })) - let canBoostAgain = premiumConfiguration.boostsPerGiftCount > 0 - let presentationData = self.presentationData - let forceDark = controller.forceDark - let boostStatusUpdated = controller.boostStatusUpdated - - if let _ = status?.nextLevelBoosts { - if let availableBoost = self.availableBoosts.first { - self.currentMyBoostCount += 1 - self.myBoostCount += 1 - - let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [availableBoost.slot]) - |> deliverOnMainQueue).startStandalone(next: { [weak self] myBoostStatus in - self?.updatedState.set(context.engine.peers.getChannelBoostStatus(peerId: peerId) - |> beforeNext { [weak self] boostStatus in - if let self, let boostStatus, let myBoostStatus { - Queue.mainQueue().async { - self.controller?.boostStatusUpdated(boostStatus, myBoostStatus) - } - } - } - |> map { status in - if let status { - return InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: Int32(status.boosts + 1)) - } else { - return nil - } - }) - }) - - let _ = (self.updatedState.get() - |> take(1) - |> deliverOnMainQueue).startStandalone(next: { [weak self] state in - guard let self, let state else { - return - } - self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) - self.updated(transition: .easeInOut(duration: 0.2)) - - self.animateSuccess() - }) - - self.availableBoosts.removeFirst() - } else if !self.occupiedBoosts.isEmpty, let myBoostStatus = controller.myBoostStatus { - if canBoostAgain { - let navigationController = controller.navigationController - let openPeer = controller.openPeer - - var dismissReplaceImpl: (() -> Void)? - let replaceController = ReplaceBoostScreen(context: context, peerId: peerId, myBoostStatus: myBoostStatus, replaceBoosts: { slots in - var sourcePeerIds = Set() - var sourcePeers: [EnginePeer] = [] - for boost in myBoostStatus.boosts { - if slots.contains(boost.slot) { - if let peer = boost.peer { - if !sourcePeerIds.contains(peer.id) { - sourcePeerIds.insert(peer.id) - sourcePeers.append(peer) - } - } - } - } - - let _ = context.engine.peers.applyChannelBoost(peerId: peerId, slots: slots).startStandalone(completed: { - let _ = combineLatest( - queue: Queue.mainQueue(), - context.engine.peers.getChannelBoostStatus(peerId: peerId), - context.engine.peers.getMyBoostStatus() - ).startStandalone(next: { boostStatus, myBoostStatus in - dismissReplaceImpl?() - - if let boostStatus, let myBoostStatus { - boostStatusUpdated(boostStatus, myBoostStatus) - } - - let levelsController = PremiumBoostLevelsScreen( - context: context, - peerId: peerId, - mode: mode, - status: boostStatus, - myBoostStatus: myBoostStatus, - replacedBoosts: (Int32(slots.count), sourcePeers), - openStats: nil, - openGift: nil, - openPeer: openPeer, - forceDark: forceDark - ) - levelsController.boostStatusUpdated = boostStatusUpdated - if let navigationController { - navigationController.pushViewController(levelsController, animated: true) - } - }) - }) - }) - - if let navigationController = controller.navigationController { - controller.dismiss(animated: true) - navigationController.pushViewController(replaceController, animated: true) - } - - dismissReplaceImpl = { [weak replaceController] in - replaceController?.dismiss(animated: true) - } - } else if let boost = self.occupiedBoosts.first, let occupiedPeer = boost.peer { - if let cooldown = boost.cooldownUntil { - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - let timeout = cooldown - currentTime - let valueText = timeIntervalString(strings: presentationData.strings, value: timeout, usage: .afterTime, preferLowerValue: false) - let alertController = textAlertController( - sharedContext: context.sharedContext, - updatedPresentationData: nil, - title: presentationData.strings.ChannelBoost_Error_BoostTooOftenTitle, - text: self.containerExternalState.isGroup ? presentationData.strings.GroupBoost_Error_BoostTooOftenText(valueText).string : presentationData.strings.ChannelBoost_Error_BoostTooOftenText(valueText).string, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) - ], - parseMarkdown: true - ) - controller.present(alertController, in: .window(.root)) - } else { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) - |> deliverOnMainQueue).start(next: { [weak controller] peer in - guard let peer, let controller else { - return - } - let replaceController = replaceBoostConfirmationController(context: context, fromPeers: [occupiedPeer], toPeer: peer, commit: { [weak self] in - self?.currentMyBoostCount += 1 - self?.myBoostCount += 1 - let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [boost.slot]) - |> deliverOnMainQueue).startStandalone(completed: { [weak self] in - guard let self else { - return - } - let _ = (self.updatedState.get() - |> take(1) - |> deliverOnMainQueue).startStandalone(next: { [weak self] state in - guard let self, let state else { - return - } - self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount) - self.updated(transition: .easeInOut(duration: 0.2)) - - self.animateSuccess() - }) - }) - }) - controller.present(replaceController, in: .window(.root)) - }) - } - } else { - controller.dismiss(animated: true, completion: nil) - } - } else { - if isPremium { - if !canBoostAgain { - controller.dismiss(animated: true, completion: nil) - } else { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) - |> deliverOnMainQueue).start(next: { [weak controller] peer in - guard let peer, let controller else { - return - } - let alertController = textAlertController( - sharedContext: context.sharedContext, - updatedPresentationData: nil, - title: presentationData.strings.ChannelBoost_MoreBoosts_Title, - text: presentationData.strings.ChannelBoost_MoreBoosts_Text(peer.compactDisplayTitle, "\(premiumConfiguration.boostsPerGiftCount)").string, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.ChannelBoost_MoreBoosts_Gift, action: { [weak controller] in - if let navigationController = controller?.navigationController { - controller?.dismiss(animated: true, completion: nil) - - Queue.mainQueue().after(0.4) { - let giftController = context.sharedContext.makePremiumGiftController(context: context, source: .channelBoost, completion: nil) - navigationController.pushViewController(giftController, animated: true) - } - } - }), - TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Close, action: {}) - ], - actionLayout: .vertical, - parseMarkdown: true - ) - controller.present(alertController, in: .window(.root)) - }) - } - } else { - let alertController = textAlertController( - sharedContext: context.sharedContext, - updatedPresentationData: nil, - title: presentationData.strings.ChannelBoost_Error_PremiumNeededTitle, - text: self.containerExternalState.isGroup ? presentationData.strings.GroupBoost_Error_PremiumNeededText : presentationData.strings.ChannelBoost_Error_PremiumNeededText, - actions: [ - TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Yes, action: { [weak controller] in - if let navigationController = controller?.navigationController { - controller?.dismiss(animated: true) - - let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .channelBoost(peerId), forceDark: forceDark, dismissed: nil) - navigationController.pushViewController(premiumController, animated: true) - } - }) - ], - parseMarkdown: true - ) - controller.present(alertController, in: .window(.root)) - } - } - } else { - controller.dismiss(animated: true) - } - } - - func buttonPressed() { - self.updateBoostState() - } - - private func animateSuccess() { - self.hapticFeedback.impact() - self.view.addSubview(ConfettiView(frame: self.view.bounds)) - - if self.isExpanded { - self.update(isExpanded: false, transition: .animated(duration: 0.4, curve: .spring)) - } - } - } - - var node: Node { - return self.displayNode as! Node - } - - private let context: AccountContext - private let peerId: EnginePeer.Id - private let mode: Mode - private let status: ChannelBoostStatus? - private let myBoostStatus: MyBoostStatus? - private let replacedBoosts: (Int32, [EnginePeer])? - private let openStats: (() -> Void)? - private let openGift: (() -> Void)? - private let openPeer: ((EnginePeer) -> Void)? - private let forceDark: Bool - - private var currentLayout: ContainerViewLayout? - - public var boostStatusUpdated: (ChannelBoostStatus, MyBoostStatus) -> Void = { _, _ in } - public var disposed: () -> Void = {} - - public init( - context: AccountContext, - peerId: EnginePeer.Id, - mode: Mode, - status: ChannelBoostStatus?, - myBoostStatus: MyBoostStatus? = nil, - replacedBoosts: (Int32, [EnginePeer])? = nil, - openStats: (() -> Void)? = nil, - openGift: (() -> Void)? = nil, - openPeer: ((EnginePeer) -> Void)? = nil, - forceDark: Bool = false - ) { - self.context = context - self.peerId = peerId - self.mode = mode - self.status = status - self.myBoostStatus = myBoostStatus - self.replacedBoosts = replacedBoosts - self.openStats = openStats - self.openGift = openGift - self.openPeer = openPeer - self.forceDark = forceDark - - super.init(navigationBarPresentationData: nil) - - self.navigationPresentation = .flatModal - self.statusBar.statusBarStyle = .Ignore - - self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) - } - - required public init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.disposed() - } - - override open func loadDisplayNode() { - self.displayNode = Node(context: self.context, controller: self) - self.displayNodeDidLoad() - - self.view.disablesInteractiveModalDismiss = true - } - - public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - self.view.endEditing(true) - if flag { - self.node.animateOut(completion: { - super.dismiss(animated: false, completion: {}) - completion?() - }) - } else { - super.dismiss(animated: false, completion: {}) - completion?() - } - } - - override open func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - self.node.updateIsVisible(isVisible: true) - } - - override open func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - self.node.updateIsVisible(isVisible: false) - } - - override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - self.currentLayout = layout - super.containerLayoutUpdated(layout, transition: transition) - - self.node.containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition)) - } -} - -private final class FooterComponent: Component { - let context: AccountContext - let theme: PresentationTheme - let title: String - let action: () -> Void - - init(context: AccountContext, theme: PresentationTheme, title: String, action: @escaping () -> Void) { - self.context = context - self.theme = theme - self.title = title - self.action = action - } - - static func ==(lhs: FooterComponent, rhs: FooterComponent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.theme !== rhs.theme { - return false - } - if lhs.title != rhs.title { - return false - } - return true - } - - final class View: UIView { - let backgroundView: BlurredBackgroundView - let separator = SimpleLayer() - - private let button = ComponentView() - - private var component: FooterComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - self.backgroundView = BlurredBackgroundView(color: nil) - - super.init(frame: frame) - - self.backgroundView.clipsToBounds = true - - self.addSubview(self.backgroundView) - self.layer.addSublayer(self.separator) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: FooterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - self.component = component - self.state = state - - let bounds = CGRect(origin: .zero, size: availableSize) - - self.backgroundView.updateColor(color: component.theme.rootController.tabBar.backgroundColor, transition: transition.containedViewLayoutTransition) - self.backgroundView.update(size: bounds.size, transition: transition.containedViewLayoutTransition) - transition.setFrame(view: self.backgroundView, frame: bounds) - - self.separator.backgroundColor = component.theme.rootController.tabBar.separatorColor.cgColor - transition.setFrame(layer: self.separator, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: UIScreenPixel))) - - let gradientColors = [ - UIColor(rgb: 0x0077ff), - UIColor(rgb: 0x6b93ff), - UIColor(rgb: 0x8878ff), - UIColor(rgb: 0xe46ace) - ] - - let buttonSize = self.button.update( - transition: .immediate, - component: AnyComponent( - SolidRoundedButtonComponent( - title: component.title, - theme: SolidRoundedButtonComponent.Theme( - backgroundColor: .black, - backgroundColors: gradientColors, - foregroundColor: .white - ), - font: .bold, - fontSize: 17.0, - height: 50.0, - cornerRadius: 10.0, - gloss: true, - iconName: "Premium/BoostChannel", - animationName: nil, - iconPosition: .left, - action: { - component.action() - } - ) - ), - environment: {}, - containerSize: CGSize(width: availableSize.width - 32.0, height: availableSize.height) - ) - - if let view = self.button.view { - if view.superview == nil { - self.addSubview(view) - } - let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: 8.0), size: buttonSize) - view.frame = buttonFrame - } - - 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) - } -} - -private struct InternalBoostState: Equatable { - let level: Int32 - let currentLevelBoosts: Int32 - let nextLevelBoosts: Int32? - let boosts: Int32 - - struct DisplayData: Equatable { - let level: Int32 - let boosts: Int32 - let currentLevelBoosts: Int32 - let nextLevelBoosts: Int32? - let myBoostCount: Int32 - } - - func displayData(myBoostCount: Int32, currentMyBoostCount: Int32, replacedBoosts: Int32? = nil) -> DisplayData { - var currentLevel = self.level - var nextLevelBoosts = self.nextLevelBoosts - var currentLevelBoosts = self.currentLevelBoosts - var boosts = self.boosts - if let replacedBoosts { - boosts = max(currentLevelBoosts, boosts - replacedBoosts) - } - - if currentMyBoostCount > 0 && self.boosts == currentLevelBoosts { - currentLevel = max(0, currentLevel - 1) - nextLevelBoosts = currentLevelBoosts - currentLevelBoosts = max(0, currentLevelBoosts - 1) - } - - return DisplayData( - level: currentLevel, - boosts: boosts, - currentLevelBoosts: currentLevelBoosts, - nextLevelBoosts: nextLevelBoosts, - myBoostCount: myBoostCount - ) - } -} diff --git a/submodules/ShareController/Sources/ShareActionButtonNode.swift b/submodules/ShareController/Sources/ShareActionButtonNode.swift index d43807b0b3..13cf0857c2 100644 --- a/submodules/ShareController/Sources/ShareActionButtonNode.swift +++ b/submodules/ShareController/Sources/ShareActionButtonNode.swift @@ -139,7 +139,7 @@ public final class ShareStartAtTimestampNode: HighlightTrackingButtonNode { self.titleTextColor = titleTextColor self.checkNodeTheme = checkNodeTheme - self.checkNode = CheckNode(theme: checkNodeTheme, content: .check) + self.checkNode = CheckNode(theme: checkNodeTheme, content: .check(isRectangle: false)) self.checkNode.isUserInteractionEnabled = false self.titleTextNode = TextNode() diff --git a/submodules/ShareItems/Sources/ShareItems.swift b/submodules/ShareItems/Sources/ShareItems.swift index 9f1b75228f..0b8208a0d2 100644 --- a/submodules/ShareItems/Sources/ShareItems.swift +++ b/submodules/ShareItems/Sources/ShareItems.swift @@ -55,7 +55,7 @@ private func preparedShareItem(postbox: Postbox, network: Network, to peerId: Pe let diminsionsSize = dimensions.cgSizeValue return .single(.preparing(false)) |> then( - standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", data: imageData, dimensions: PixelDimensions(width: Int32(diminsionsSize.width), height: Int32(diminsionsSize.height))) + standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", source: .data(imageData), dimensions: PixelDimensions(width: Int32(diminsionsSize.width), height: Int32(diminsionsSize.height))) |> mapError { _ -> PreparedShareItemError in return .generic } @@ -74,7 +74,7 @@ private func preparedShareItem(postbox: Postbox, network: Network, to peerId: Pe if let scaledImage = scalePhotoImage(image, dimensions: dimensions), let imageData = scaledImage.jpegData(compressionQuality: 0.52) { return .single(.preparing(false)) |> then( - standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", data: imageData, dimensions: PixelDimensions(width: Int32(dimensions.width), height: Int32(dimensions.height))) + standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", source: .data(imageData), dimensions: PixelDimensions(width: Int32(dimensions.width), height: Int32(dimensions.height))) |> mapError { _ -> PreparedShareItemError in return .generic } @@ -265,7 +265,7 @@ private func preparedShareItem(postbox: Postbox, network: Network, to peerId: Pe let imageData = scaledImage.jpegData(compressionQuality: 0.54)! return .single(.preparing(false)) |> then( - standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", data: imageData, dimensions: PixelDimensions(width: Int32(scaledImage.size.width), height: Int32(scaledImage.size.height))) + standaloneUploadedImage(postbox: postbox, network: network, peerId: peerId, text: "", source: .data(imageData), dimensions: PixelDimensions(width: Int32(scaledImage.size.width), height: Int32(scaledImage.size.height))) |> mapError { _ -> PreparedShareItemError in return .generic } diff --git a/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift b/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift index bdf0040936..1362d20c6e 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift @@ -52,8 +52,8 @@ private func uploadedThumbnail(network: Network, postbox: Postbox, data: Data) - } } -public func standaloneUploadedImage(postbox: Postbox, network: Network, peerId: PeerId, text: String, data: Data, thumbnailData: Data? = nil, dimensions: PixelDimensions) -> Signal { - return multipartUpload(network: network, postbox: postbox, source: .data(data), encrypt: peerId.namespace == Namespaces.Peer.SecretChat, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) +public func standaloneUploadedImage(postbox: Postbox, network: Network, peerId: PeerId, text: String, source: MultipartUploadSource, thumbnailData: Data? = nil, dimensions: PixelDimensions) -> Signal { + return multipartUpload(network: network, postbox: postbox, source: source, encrypt: peerId.namespace == Namespaces.Peer.SecretChat, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) |> mapError { _ -> StandaloneUploadMediaError in return .generic } |> mapToSignal { next -> Signal in switch next { @@ -103,7 +103,7 @@ public func standaloneUploadedImage(postbox: Postbox, network: Network, peerId: switch result { case let .encryptedFile(encryptedFileData): let (id, accessHash, size, dcId) = (encryptedFileData.id, encryptedFileData.accessHash, encryptedFileData.size, encryptedFileData.dcId) - return .single(.result(.media(.standalone(media: TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: Int64.random(in: Int64.min ... Int64.max)), representations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: SecretFileMediaResource(fileId: id, accessHash: accessHash, containerSize: size, decryptedSize: Int64(data.count), datacenterId: Int(dcId), key: key), progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: []))))) + return .single(.result(.media(.standalone(media: TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: Int64.random(in: Int64.min ... Int64.max)), representations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: SecretFileMediaResource(fileId: id, accessHash: accessHash, containerSize: size, decryptedSize: size, datacenterId: Int(dcId), key: key), progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: []))))) case .encryptedFileEmpty: return .fail(.generic) } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift index 57e82f615b..f3d8a12b95 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift @@ -187,6 +187,13 @@ public enum TelegramMediaPollKind: Equatable, PostboxCoding { encoder.encodeInt32(multipleAnswers ? 1 : 0, forKey: "m") } } + + public var multipleAnswers: Bool { + switch self { + case let .poll(multipleAnswers), let .quiz(multipleAnswers): + return multipleAnswers + } + } } public final class TelegramMediaPoll: Media, Equatable { diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift index 959a5d5e1e..a00622f832 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift @@ -50,6 +50,7 @@ public enum PresentationResourceKey: Int32 { case itemListDeleteIcon case itemListDeleteIndicatorIcon case itemListReorderIndicatorIcon + case itemListAddIndicatorIcon case itemListLinkIcon case itemListAddPersonIcon case itemListCreateGroupIcon diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesItemList.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesItemList.swift index 8ea0ea147d..10f7c5d8ad 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesItemList.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesItemList.swift @@ -146,6 +146,20 @@ public struct PresentationResourcesItemList { }) } + public static func itemListAddIndicatorIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.itemListAddIndicatorIcon.rawValue, { theme in + return generateImage(CGSize(width: 17.0, height: 15.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(theme.list.itemBlocksSeparatorColor.cgColor) + + let lineHeight = 1.0 + UIScreenPixel + context.addPath(CGPath(roundedRect: CGRect(x: 1.0, y: 7.0, width: 15.0, height: lineHeight), cornerWidth: lineHeight / 2.0, cornerHeight: lineHeight / 2.0, transform: nil)) + context.addPath(CGPath(roundedRect: CGRect(x: 8.0, y: 0.0, width: lineHeight, height: 15.0), cornerWidth: lineHeight / 2.0, cornerHeight: lineHeight / 2.0, transform: nil)) + context.fillPath() + }) + }) + } + public static func linkIcon(_ theme: PresentationTheme) -> UIImage? { return theme.image(PresentationResourceKey.itemListLinkIcon.rawValue, { theme in return generateTintedImage(image: UIImage(bundleImageName: "Contact List/LinkActionIcon"), color: theme.list.itemAccentColor) diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index 65aa5824fa..bab2f62b08 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -1780,6 +1780,8 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } else { attributedString = NSAttributedString(string: strings.Notification_CopyProtection_Request(peerName).string, font: titleFont, textColor: primaryTextColor) } + case .managedBotCreated: + attributedString = nil case .unknown: attributedString = nil } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 52e976f39d..9270210f27 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -112,7 +112,7 @@ swift_library( "//submodules/AnimatedStickerNode:AnimatedStickerNode", "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", "//submodules/ActionSheetPeerItem:ActionSheetPeerItem", - "//submodules/ComposePollUI:ComposePollUI", + "//submodules/TelegramUI/Components/ComposePollScreen", "//submodules/AlertUI:AlertUI", "//submodules/PresentationDataUtils:PresentationDataUtils", "//submodules/TouchDownGesture:TouchDownGesture", diff --git a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift index 2879bbe112..0bb0aee2da 100644 --- a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift +++ b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift @@ -524,48 +524,53 @@ public final class AttachmentFileSearchContainerNode: SearchDisplayControllerCon return messages } ) - globalMusic = .single(nil) - |> then( - context.engine.peers.resolvePeerByName(name: "lybot", referrer: nil) - |> mapToSignal { result -> Signal in - guard case let .result(result) = result else { - return .complete() + + if let data = context.currentAppConfiguration.with({ $0 }).data, let searchBot = data["music_search_username"] as? String, !searchBot.isEmpty { + globalMusic = .single(nil) + |> then( + context.engine.peers.resolvePeerByName(name: searchBot, referrer: nil) + |> mapToSignal { result -> Signal in + guard case let .result(result) = result else { + return .complete() + } + return .single(result) } - return .single(result) - } - |> mapToSignal { peer -> Signal in - guard let peer = peer else { - return .single(nil) - } - return context.engine.messages.requestChatContextResults(botId: peer.id, peerId: context.account.peerId, query: query, offset: "") - |> map { results -> ChatContextResultCollection? in - return results?.results - } - |> `catch` { error -> Signal in - return .single(nil) - } - } - |> map { contextResult in - guard let results = contextResult?.results else { - return [] - } - let peerId = context.account.peerId - var messages: [Message] = [] - let peers = SimpleDictionary() - for result in results { - switch result { - case let .internalReference(internalReference): - if let file = internalReference.file { - let stableId = UInt32(clamping: file.fileId.id % Int64(Int32.max)) - messages.append(Message(stableId: stableId, stableVersion: 0, id: MessageId(peerId: peerId, namespace: Namespaces.Message.Local, id: Int32(stableId)), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [.music], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])) - } - default: - break + |> mapToSignal { peer -> Signal in + guard let peer = peer else { + return .single(nil) + } + return context.engine.messages.requestChatContextResults(botId: peer.id, peerId: context.account.peerId, query: query, offset: "") + |> map { results -> ChatContextResultCollection? in + return results?.results + } + |> `catch` { error -> Signal in + return .single(nil) } } - return messages - } - ) + |> map { contextResult in + guard let results = contextResult?.results else { + return [] + } + let peerId = context.account.peerId + var messages: [Message] = [] + let peers = SimpleDictionary() + for result in results { + switch result { + case let .internalReference(internalReference): + if let file = internalReference.file { + let stableId = UInt32(clamping: file.fileId.id % Int64(Int32.max)) + messages.append(Message(stableId: stableId, stableVersion: 0, id: MessageId(peerId: peerId, namespace: Namespaces.Message.Local, id: Int32(stableId)), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [.music], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])) + } + default: + break + } + } + return messages + } + ) + } else { + globalMusic = .single(nil) + } } updateActivity(true) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index e5de189c31..af5cdf95c3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -131,6 +131,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ var hasSeparateCommentsButton = false var addedPriceInfo = false + var addedPollMedia = false outer: for (message, itemAttributes) in item.content { for attribute in message.attributes { @@ -283,7 +284,22 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ result.removeAll() result.append((message, ChatMessageActionBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) return (result, false, true) - } else if let _ = media as? TelegramMediaPoll { + } else if let poll = media as? TelegramMediaPoll { + if let attachedMedia = poll.attachedMedia { + if let _ = attachedMedia as? TelegramMediaImage { + result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) + } else if let file = attachedMedia as? TelegramMediaFile { + let isVideo = file.isVideo || (file.isAnimated && file.dimensions != nil) + if isVideo { + result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) + } else { + result.append((message, ChatMessageFileBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + } + } else if let _ = attachedMedia as? TelegramMediaMap { + result.append((message, ChatMessageMapBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .media, neighborSpacing: .default))) + } + addedPollMedia = true + } result.append((message, ChatMessagePollBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false } else if let _ = media as? TelegramMediaTodo { @@ -318,10 +334,12 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ isMediaInverted = updatingMedia.invertMediaAttribute != nil } else if let _ = message.attributes.first(where: { $0 is InvertMediaMessageAttribute }) { isMediaInverted = true + } else if let _ = message.media.first(where: { $0 is TelegramMediaPoll }) { + isMediaInverted = true } if isMediaInverted { - result.insert((message, ChatMessageTextBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: isFile ? .condensed : .default)), at: addedPriceInfo ? 1 : 0) + result.insert((message, ChatMessageTextBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: isFile ? .condensed : .default)), at: addedPriceInfo || addedPollMedia ? 1 : 0) } else { result.append((message, ChatMessageTextBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: isFile ? .condensed : .default))) needReactions = false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift index 30bb04bf59..0f8680dbaf 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift @@ -104,6 +104,8 @@ public class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { for media in item.message.media { if let telegramFile = media as? TelegramMediaFile { selectedFile = telegramFile + } else if let poll = media as? TelegramMediaPoll, let telegramFile = poll.attachedMedia as? TelegramMediaFile { + selectedFile = telegramFile } } if let updatingMedia = item.attributes.updatingMedia, case let .update(media) = updatingMedia.media, let file = media.media as? TelegramMediaFile { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift index 7e2591dbd9..40a156e745 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift @@ -205,6 +205,8 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { if item.presentationData.isPreview { automaticDownload = .full } + } else if let poll = media as? TelegramMediaPoll, let image = poll.attachedMedia as? TelegramMediaImage { + selectedMedia = image } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index ecbd07dc79..049213853f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -526,7 +526,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { return { context, presentationData, message, poll, option, translation, optionResult, constrainedWidth in let leftInset: CGFloat = 50.0 - let rightInset: CGFloat = 12.0 + let rightInset: CGFloat = 10.0 let incoming = message.effectivelyIncoming(context.account.peerId) @@ -559,7 +559,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let shouldHaveRadioNode = optionResult == nil let isSelectable: Bool - if shouldHaveRadioNode, case .poll(multipleAnswers: true) = poll.kind, !Namespaces.Message.allNonRegular.contains(message.id.namespace) { + if shouldHaveRadioNode, poll.kind.multipleAnswers, !Namespaces.Message.allNonRegular.contains(message.id.namespace) { isSelectable = true } else { isSelectable = false @@ -617,7 +617,13 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar } context.setFillColor(fillColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + if poll.kind.multipleAnswers { + context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 4.0).cgPath) + context.fillPath() + } else { + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + } let strokeColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground if strokeColor.alpha.isZero { @@ -689,9 +695,9 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { )) let titleNodeFrame: CGRect if titleLayout.hasRTL { - titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 11.0), size: titleLayout.size) + titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 12.0), size: titleLayout.size) } else { - titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: titleLayout.size) + titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) } if node.titleNode !== titleNode { node.titleNode = titleNode @@ -718,7 +724,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } let radioSize: CGFloat = 22.0 radioNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: CGSize(width: radioSize, height: radioSize)) - radioNode.update(isRectangle: poll.kind == .poll(multipleAnswers: true), staticColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioButton : presentationData.theme.theme.chat.message.outgoing.polls.radioButton, animatedColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioProgress : presentationData.theme.theme.chat.message.outgoing.polls.radioProgress, fillColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar, foregroundColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground, isSelectable: isSelectable, isAnimating: inProgress) + radioNode.update(isRectangle: poll.kind.multipleAnswers, staticColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioButton : presentationData.theme.theme.chat.message.outgoing.polls.radioButton, animatedColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioProgress : presentationData.theme.theme.chat.message.outgoing.polls.radioProgress, fillColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar, foregroundColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground, isSelectable: isSelectable, isAnimating: inProgress) } else if let radioNode = node.radioNode { node.radioNode = nil if animated { @@ -754,7 +760,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { node.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: contentHeight + UIScreenPixel)) } node.separatorNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.separator : presentationData.theme.theme.chat.message.outgoing.polls.separator - node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - leftInset - rightInset, height: UIScreenPixel)) if node.resultBarNode.image == nil || updatedResultIcon { var isQuiz = false @@ -1270,6 +1276,8 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } let (votersLayout, votersApply) = makeVotersLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: votersString ?? "", font: labelsFont, textColor: messageTheme.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) + + let (buttonSubmitInactiveTextLayout, buttonSubmitInactiveTextApply) = makeSubmitInactiveTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.MessagePoll_SubmitVote, font: Font.regular(17.0), textColor: messageTheme.accentControlDisabledColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) let (buttonSubmitActiveTextLayout, buttonSubmitActiveTextApply) = makeSubmitActiveTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.MessagePoll_SubmitVote, font: Font.regular(17.0), textColor: messageTheme.polls.bar), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) let (buttonViewResultsTextLayout, buttonViewResultsTextApply) = makeViewResultsTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.MessagePoll_ViewResults, font: Font.regular(17.0), textColor: messageTheme.polls.bar), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) diff --git a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift index 0433ca3db5..506638a6d4 100644 --- a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift +++ b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift @@ -225,6 +225,9 @@ private final class ChatScheduleTimeSheetContentComponent: Component { title = strings.Conversation_SetReminder_Title case .format: title = strings.Conversation_FormatDate_Title + case .poll: + //TODO:localize + title = "Deadline" } let titleSize = self.title.update( transition: transition, @@ -384,6 +387,8 @@ private final class ChatScheduleTimeSheetContentComponent: Component { var repeatValueFrame = CGRect() if case .format = component.mode { contentHeight += 8.0 + } else if case .poll = component.mode { + contentHeight += 8.0 } else { transition.setFrame(layer: self.bottomSeparator, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: UIScreenPixel))) self.bottomSeparator.backgroundColor = environment.theme.list.itemBlocksSeparatorColor.cgColor @@ -491,6 +496,9 @@ private final class ChatScheduleTimeSheetContentComponent: Component { } case .format: buttonTitle = component.currentTime != nil ? strings.Conversation_FormatDate_EditDate : strings.Conversation_FormatDate_AddDate + case .poll: + //TODO:localize + buttonTitle = "Set Deadline" } let buttonSideInset: CGFloat = 30.0 @@ -930,6 +938,7 @@ public class ChatScheduleTimeScreen: ViewControllerComponentContainer { case scheduledMessages(sendWhenOnlineAvailable: Bool) case reminders case format + case poll } public struct Result { diff --git a/submodules/ComposePollUI/BUILD b/submodules/TelegramUI/Components/ComposePollScreen/BUILD similarity index 77% rename from submodules/ComposePollUI/BUILD rename to submodules/TelegramUI/Components/ComposePollScreen/BUILD index db6a0ef318..f19c83bdf6 100644 --- a/submodules/ComposePollUI/BUILD +++ b/submodules/TelegramUI/Components/ComposePollScreen/BUILD @@ -1,8 +1,8 @@ load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") swift_library( - name = "ComposePollUI", - module_name = "ComposePollUI", + name = "ComposePollScreen", + module_name = "ComposePollScreen", srcs = glob([ "Sources/**/*.swift", ]), @@ -21,7 +21,6 @@ swift_library( "//submodules/PresentationDataUtils", "//submodules/TextFormat", "//submodules/ObjCRuntimeUtils", - "//submodules/AttachmentUI", "//submodules/TextInputMenu", "//submodules/ComponentFlow", "//submodules/Components/ComponentDisplayAdapters", @@ -43,6 +42,17 @@ swift_library( "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", "//submodules/TelegramUI/Components/ListComposePollOptionComponent", "//submodules/TelegramUI/Components/GlassBarButtonComponent", + "//submodules/LegacyComponents", + "//submodules/LegacyUI", + "//submodules/AttachmentUI", + "//submodules/MediaPickerUI", + "//submodules/TelegramUI/Components/LegacyCamera", + "//submodules/LegacyMediaPickerUI", + "//submodules/LocationUI", + "//submodules/TelegramUI/Components/AttachmentFileController", + "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", + "//submodules/TelegramUI/Components/ChatScheduleTimeController", + "//submodules/ContextUI", ], visibility = [ "//visibility:public", diff --git a/submodules/ComposePollUI/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift similarity index 70% rename from submodules/ComposePollUI/Sources/ComposePollScreen.swift rename to submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index c4c2b0ead3..e8b9918030 100644 --- a/submodules/ComposePollUI/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -6,6 +6,7 @@ import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData +import TelegramStringFormatting import ComponentFlow import ComponentDisplayAdapters import AppBundle @@ -28,6 +29,8 @@ import TextFormat import TextFieldComponent import ListComposePollOptionComponent import GlassBarButtonComponent +import ChatScheduleTimeController +import ContextUI public final class ComposedPoll { public struct Text { @@ -42,8 +45,15 @@ public final class ComposedPoll { public let publicity: TelegramMediaPollPublicity public let kind: TelegramMediaPollKind + + public let openAnswers: Bool + public let revotingDisabled: Bool + public let shuffleAnswers: Bool + public let hideResultsUntilClose: Bool public let text: Text + public let description: Text + public let media: AnyMediaReference? public let options: [TelegramMediaPollOption] public let correctAnswers: [Data]? public let results: TelegramMediaPollResults @@ -53,7 +63,13 @@ public final class ComposedPoll { public init( publicity: TelegramMediaPollPublicity, kind: TelegramMediaPollKind, + openAnswers: Bool, + revotingDisabled: Bool, + shuffleAnswers: Bool, + hideResultsUntilClose: Bool, text: Text, + description: Text, + media: AnyMediaReference?, options: [TelegramMediaPollOption], correctAnswers: [Data]?, results: TelegramMediaPollResults, @@ -62,7 +78,13 @@ public final class ComposedPoll { ) { self.publicity = publicity self.kind = kind + self.openAnswers = openAnswers + self.revotingDisabled = revotingDisabled + self.shuffleAnswers = shuffleAnswers + self.hideResultsUntilClose = hideResultsUntilClose self.text = text + self.description = description + self.media = media self.options = options self.correctAnswers = correctAnswers self.results = results @@ -101,17 +123,43 @@ final class ComposePollScreenComponent: Component { return true } + final class AttachedMedia { + var media: AnyMediaReference + var progress: CGFloat? + var uploadDisposable: Disposable? + + init(media: AnyMediaReference) { + self.media = media + } + + var requiresUpload: Bool { + if let image = self.media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations), !(largest.resource is CloudPhotoSizeMediaResource) { + return true + } + if let file = self.media.media as? TelegramMediaFile, !(file.resource is CloudDocumentMediaResource) { + return true + } + return false + } + } + private final class PollOption { let id: Int let textInputState = TextFieldComponent.ExternalState() let textFieldTag = NSObject() var resetText: String? + var media: AttachedMedia? init(id: Int) { self.id = id } } + private enum TimeLimit { + case duration(Int32) + case deadline(Int32) + } + private final class ScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) @@ -153,18 +201,29 @@ final class ComposePollScreenComponent: Component { private let pollTextFieldTag = NSObject() private var resetPollText: String? + private let pollDescriptionInputState = TextFieldComponent.ExternalState() + private let pollDescriptionFieldTag = NSObject() + private var pollDescriptionMedia: AttachedMedia? + private var quizAnswerTextInputState = TextFieldComponent.ExternalState() private let quizAnswerTextInputTag = NSObject() private var resetQuizAnswerText: String? + private var quizAnswerMedia: AttachedMedia? private var nextPollOptionId: Int = 0 private var pollOptions: [PollOption] = [] private var currentPollOptionsLimitReached: Bool = false private var isAnonymous: Bool = true - private var isMultiAnswer: Bool = false + private var isMultiAnswer: Bool? + private var canAddOptions: Bool = false + private var canRevote: Bool = false + private var shuffleOptions: Bool = false private var isQuiz: Bool = false - private var selectedQuizOptionId: Int? + private var selectedQuizOptionIds = Set() + private var limitDuration: Bool = false + private var timeLimit: TimeLimit = .duration(24 * 60 * 60) + private var hideResults: Bool = false private var currentInputMode: ListComposePollOptionComponent.InputMode = .keyboard @@ -182,6 +241,15 @@ final class ComposePollScreenComponent: Component { private var currentEditingTag: AnyObject? + private var cachedViewIcon: UIImage? + private var cachedMultipleIcon: UIImage? + private var cachedAddIcon: UIImage? + private var cachedRevoteIcon: UIImage? + private var cachedShuffleIcon: UIImage? + private var cachedQuizIcon: UIImage? + private var cachedDurationIcon: UIImage? + private var cachedEmptyIcon: UIImage? + private var reorderRecognizer: ReorderGestureRecognizer? private var reorderingItem: (id: AnyHashable, snapshotView: UIView, backgroundView: UIView, initialPosition: CGPoint, position: CGPoint)? @@ -241,6 +309,11 @@ final class ComposePollScreenComponent: Component { } deinit { + self.pollDescriptionMedia?.uploadDisposable?.dispose() + self.quizAnswerMedia?.uploadDisposable?.dispose() + for option in self.pollOptions { + option.media?.uploadDisposable?.dispose() + } self.inputMediaNodeDataDisposable?.dispose() } @@ -257,7 +330,7 @@ final class ComposePollScreenComponent: Component { for (id, itemView) in self.pollOptionsSectionContainer.itemViews { if let view = itemView.contents.view as? ListComposePollOptionComponent.View, !view.isRevealed && !view.currentText.isEmpty { let viewFrame = view.convert(view.bounds, to: self.pollOptionsSectionContainer) - let iconFrame = CGRect(origin: CGPoint(x: viewFrame.maxX - 40.0, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height)) + let iconFrame = CGRect(origin: CGPoint(x: viewFrame.minX, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height)) if iconFrame.contains(localPoint) { return (id, itemView.contents) } @@ -361,6 +434,10 @@ final class ComposePollScreenComponent: Component { } } + private var effectiveIsMultiAnswer: Bool { + return self.isMultiAnswer ?? false + } + func validatedInput() -> ComposedPoll? { if self.pollTextInputState.text.length == 0 { return nil @@ -368,20 +445,20 @@ final class ComposePollScreenComponent: Component { let mappedKind: TelegramMediaPollKind if self.isQuiz { - mappedKind = .quiz + mappedKind = .quiz(multipleAnswers: self.effectiveIsMultiAnswer) } else { - mappedKind = .poll(multipleAnswers: self.isMultiAnswer) + mappedKind = .poll(multipleAnswers: self.effectiveIsMultiAnswer) } var mappedOptions: [TelegramMediaPollOption] = [] - var selectedQuizOption: Data? + var selectedQuizOptions: [Data] = [] for pollOption in self.pollOptions { if pollOption.textInputState.text.length == 0 { continue } let optionData = "\(mappedOptions.count)".data(using: .utf8)! - if self.selectedQuizOptionId == pollOption.id { - selectedQuizOption = optionData + if self.selectedQuizOptionIds.contains(pollOption.id) { + selectedQuizOptions.append(optionData) } var entities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(pollOption.textInputState.text) { @@ -393,10 +470,15 @@ final class ComposePollScreenComponent: Component { } } + if let media = pollOption.media, media.requiresUpload { + return nil + } + mappedOptions.append(TelegramMediaPollOption( text: pollOption.textInputState.text.string, entities: entities, - opaqueIdentifier: optionData + opaqueIdentifier: optionData, + media: pollOption.media?.media.media )) } @@ -406,14 +488,14 @@ final class ComposePollScreenComponent: Component { var mappedCorrectAnswers: [Data]? if self.isQuiz { - if let selectedQuizOption { - mappedCorrectAnswers = [selectedQuizOption] + if !selectedQuizOptions.isEmpty { + mappedCorrectAnswers = selectedQuizOptions } else { return nil } } - var mappedSolution: (String, [MessageTextEntity])? + var mappedSolution: (String, [MessageTextEntity], AnyMediaReference?)? if self.isQuiz && self.quizAnswerTextInputState.text.length != 0 { var solutionTextEntities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(self.quizAnswerTextInputState.text) { @@ -425,7 +507,11 @@ final class ComposePollScreenComponent: Component { } } - mappedSolution = (self.quizAnswerTextInputState.text.string, solutionTextEntities) + if let media = self.quizAnswerMedia, media.requiresUpload { + return nil + } + + mappedSolution = (self.quizAnswerTextInputState.text.string, solutionTextEntities, self.quizAnswerMedia?.media) } var textEntities: [MessageTextEntity] = [] @@ -438,12 +524,42 @@ final class ComposePollScreenComponent: Component { } } + var descriptionEntities: [MessageTextEntity] = [] + for entity in generateChatInputTextEntities(self.pollDescriptionInputState.text) { + switch entity.type { + case .CustomEmoji: + descriptionEntities.append(entity) + default: + break + } + } + let usedCustomEmojiFiles: [Int64: TelegramMediaFile] = [:] + var deadlineTimeout: Int32? + if self.limitDuration { + switch self.timeLimit { + case let .duration(duration): + deadlineTimeout = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + duration + case let .deadline(deadline): + deadlineTimeout = deadline + } + } + + if let media = self.pollDescriptionMedia, media.requiresUpload { + return nil + } + return ComposedPoll( publicity: self.isAnonymous ? .anonymous : .public, kind: mappedKind, + openAnswers: self.canAddOptions, + revotingDisabled: !self.canRevote, + shuffleAnswers: self.shuffleOptions, + hideResultsUntilClose: self.hideResults, text: ComposedPoll.Text(string: self.pollTextInputState.text.string, entities: textEntities), + description: ComposedPoll.Text(string: self.pollDescriptionInputState.text.string, entities: descriptionEntities), + media: self.pollDescriptionMedia?.media, options: mappedOptions, correctAnswers: mappedCorrectAnswers, results: TelegramMediaPollResults( @@ -451,10 +567,14 @@ final class ComposePollScreenComponent: Component { totalVoters: nil, recentVoters: [], solution: mappedSolution.flatMap { mappedSolution in - return TelegramMediaPollResults.Solution(text: mappedSolution.0, entities: mappedSolution.1) + return TelegramMediaPollResults.Solution( + text: mappedSolution.0, + entities: mappedSolution.1, + media: mappedSolution.2?.media + ) } ), - deadlineTimeout: nil, + deadlineTimeout: deadlineTimeout, usedCustomEmojiFiles: usedCustomEmojiFiles ) } @@ -670,6 +790,253 @@ final class ComposePollScreenComponent: Component { return textInputStates } + private enum MediaAttachSubject { + case description + case quizAnswer + case pollOption(PollOption) + } + + private func openAttachedMedia(subject: MediaAttachSubject) { + guard let component = self.component else { + return + } + + guard !self.openAttachMediaMenu(subject: subject) else { + return + } + + var availableButtons: [AttachmentButtonType] + switch subject { + case .description: + availableButtons = [.gallery, .file, .location] + default: + availableButtons = [.gallery, .location] + } + + presentPollAttachmentScreen(context: component.context, updatedPresentationData: nil, availableButtons: availableButtons, present: { [weak self] c in + (self?.environment?.controller() as? ComposePollScreen)?.parentController()?.push(c) + }, completion: { [weak self] media in + guard let self else { + return + } + let attachedMedia = AttachedMedia(media: media) + switch subject { + case .description: + self.pollDescriptionMedia = attachedMedia + case .quizAnswer: + self.quizAnswerMedia = attachedMedia + case let .pollOption(pollOption): + pollOption.media = attachedMedia + } + self.uploadAttachedMediaIfNeeded(attachedMedia) + self.state?.updated(transition: .easeInOut(duration: 0.2)) + }) + } + + private func uploadAttachedMediaIfNeeded(_ media: AttachedMedia) { + guard let component = self.component, media.requiresUpload, media.uploadDisposable == nil else { + return + } + media.progress = 0.0 + + if let image = media.media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { + media.uploadDisposable = (standaloneUploadedImage( + postbox: component.context.account.postbox, + network: component.context.account.network, + peerId: component.peer.id, + text: "", + source: .resource(media.media.resourceReference(largest.resource)), + dimensions: largest.dimensions + ) + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self, let component = self.component else { + return + } + var transition: ComponentTransition = .immediate + switch value { + case let .progress(progress): + media.progress = CGFloat(progress) + case let .result(result): + switch result { + case let .media(resultMedia): + if let resultImage = resultMedia.media as? TelegramMediaImage, let resultLargest = largestImageRepresentation(resultImage.representations) { + component.context.account.postbox.mediaBox.moveResourceData(from: largest.resource.id, to: resultLargest.resource.id, synchronous: true) + } + + media.media = resultMedia + media.progress = nil + media.uploadDisposable?.dispose() + media.uploadDisposable = nil + transition = .easeInOut(duration: 0.2) + } + } + if !self.isUpdating { + self.state?.updated(transition: transition) + } + }) + } + if let file = media.media.media as? TelegramMediaFile { + media.uploadDisposable = (standaloneUploadedFile( + postbox: component.context.account.postbox, + network: component.context.account.network, + peerId: component.peer.id, + text: "", + source: .resource(media.media.resourceReference(file.resource)), + thumbnailData: file.immediateThumbnailData, + mimeType: file.mimeType, + attributes: file.attributes, + hintFileIsLarge: false + ) + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + var transition: ComponentTransition = .immediate + switch value { + case let .progress(progress): + media.progress = CGFloat(progress) + case let .result(result): + switch result { + case let .media(resultMedia): + if let resultFile = resultMedia.media as? TelegramMediaFile { + component.context.account.postbox.mediaBox.moveResourceData(from: file.resource.id, to: resultFile.resource.id, synchronous: true) + } + media.media = resultMedia + media.progress = nil + media.uploadDisposable?.dispose() + media.uploadDisposable = nil + transition = .easeInOut(duration: 0.2) + } + } + if !self.isUpdating { + self.state?.updated(transition: transition) + } + }) + } + } + + private func openAttachMediaMenu(subject: MediaAttachSubject) -> Bool { + switch subject { + case .description: + if let media = self.pollDescriptionMedia { + if let _ = media.progress { + media.uploadDisposable?.dispose() + self.pollDescriptionMedia = nil + self.state?.updated(transition: .easeInOut(duration: 0.25)) + } else { + + } + return true + } + case .quizAnswer: + if let media = self.quizAnswerMedia { + if let _ = media.progress { + media.uploadDisposable?.dispose() + self.quizAnswerMedia = nil + self.state?.updated(transition: .easeInOut(duration: 0.25)) + } else { + + } + return true + } + case let .pollOption(pollOption): + if let media = pollOption.media { + if let _ = media.progress { + media.uploadDisposable?.dispose() + pollOption.media = nil + self.state?.updated(transition: .easeInOut(duration: 0.25)) + } else { + + } + return true + } + } + return false + } + + private func presentTimeLimitOptions(sourceView: UIView) { + guard let component = self.component else { + return + } + var subItems: [ContextMenuItem] = [] + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + let presetValues: [Int32] = [ + 1 * 60 * 60, + 3 * 60 * 60, + 8 * 60 * 60, + 24 * 60 * 60, + 72 * 60 * 60 + ] + + for value in presetValues { + let optionText = timeIntervalString(strings: presentationData.strings, value: value) + subItems.append(.action(ContextMenuActionItem(text: optionText, icon: { theme in + return nil + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.timeLimit = .duration(value) + self.state?.updated() + }))) + } + + //TODO:localize + subItems.append(.action(ContextMenuActionItem(text: "Custom", icon: { theme in + return nil + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.openCustomTimePicker() + }))) + + let items: Signal = .single(ContextController.Items(content: .list(subItems))) + let source: ContextContentSource = .reference(ComposePollContextReferenceContentSource(sourceView: sourceView)) + + let contextController = makeContextController( + presentationData: presentationData, + source: source, + items: items, + gesture: nil + ) + self.environment?.controller()?.presentInGlobalOverlay(contextController) + } + + private func openCustomTimePicker() { + guard let component = self.component else { + return + } + + var currentTime: Int32? + switch self.timeLimit { + case let .duration(duration): + currentTime = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + duration + case let .deadline(deadline): + currentTime = deadline + } + + let controller = ChatScheduleTimeScreen( + context: component.context, + mode: .poll, + currentTime: currentTime, + currentRepeatPeriod: nil, + minimalTime: nil, + isDark: false, + completion: { [weak self] result in + guard let self else { + return + } + self.timeLimit = .deadline(result.time) + self.state?.updated() + } + ) + (self.environment?.controller() as? ComposePollScreen)?.parentController()?.push(controller) + } + func update(component: ComposePollScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -831,6 +1198,17 @@ final class ComposePollScreenComponent: Component { } } ) + + + self.cachedViewIcon = renderSettingsIcon(name: "Item List/Icons/View", backgroundColors: [UIColor(rgb: 0x0A84FF)]) + self.cachedMultipleIcon = renderSettingsIcon(name: "Item List/Icons/Multiple", backgroundColors: [UIColor(rgb: 0xFF9F0A)]) + self.cachedAddIcon = renderSettingsIcon(name: "Item List/Icons/Add", backgroundColors: [UIColor(rgb: 0x32ADE6)]) + self.cachedRevoteIcon = renderSettingsIcon(name: "Item List/Icons/Update", backgroundColors: [UIColor(rgb: 0x5E5CE6)]) + self.cachedShuffleIcon = renderSettingsIcon(name: "Item List/Icons/Shuffle", backgroundColors: [UIColor(rgb: 0xAF52DE)]) + self.cachedQuizIcon = renderSettingsIcon(name: "Item List/Icons/Checkbox", backgroundColors: [UIColor(rgb: 0x34C759)]) + self.cachedDurationIcon = renderSettingsIcon(name: "Item List/Icons/Timer", backgroundColors: [UIColor(rgb: 0xFF453A)]) + + self.cachedEmptyIcon = generateSingleColorImage(size: CGSize(width: 30.0, height: 30.0), color: .clear) } self.component = component @@ -864,6 +1242,53 @@ final class ComposePollScreenComponent: Component { assumeIsEditing: self.inputMediaNodeTargetTag === self.pollTextFieldTag, characterLimit: component.initialData.maxPollTextLength, emptyLineHandling: .allowed, + returnKeyAction: { [weak self] in + guard let self else { + return + } + let _ = self +// if !self.pollOptions.isEmpty { +// if let pollOptionView = self.pollOptionsSectionContainer.itemViews[self.pollOptions[0].id] { +// if let pollOptionComponentView = pollOptionView.contents.view as? ListComposePollOptionComponent.View { +// pollOptionComponentView.activateInput() +// } +// } +// } + }, + backspaceKeyAction: nil, + selection: nil, + inputMode: self.currentInputMode, + toggleInputMode: { [weak self] in + guard let self else { + return + } + switch self.currentInputMode { + case .keyboard: + self.currentInputMode = .emoji + case .emoji: + self.currentInputMode = .keyboard + } + self.state?.updated(transition: .spring(duration: 0.4)) + }, + tag: self.pollTextFieldTag + )))) + self.resetPollText = nil + + var pollDescriptionAttachment: ListComposePollOptionComponent.Attachment + pollDescriptionAttachment = .init(media: self.pollDescriptionMedia?.media, progress: self.pollDescriptionMedia?.progress, alwaysDisplayAttachButton: true) + + //TODO:localize + pollTextSectionItems.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(ListComposePollOptionComponent( + externalState: self.pollDescriptionInputState, + context: component.context, + style: .glass, + theme: theme, + strings: environment.strings, + resetText: nil, + assumeIsEditing: self.inputMediaNodeTargetTag === self.pollDescriptionFieldTag, + characterLimit: 1024, //TODO + attachment: pollDescriptionAttachment, + emptyLineHandling: .allowed, returnKeyAction: { [weak self] in guard let self else { return @@ -891,9 +1316,11 @@ final class ComposePollScreenComponent: Component { } self.state?.updated(transition: .spring(duration: 0.4)) }, - tag: self.pollTextFieldTag + attachAction: { [weak self] in + self?.openAttachedMedia(subject: .description) + }, + tag: self.pollDescriptionFieldTag )))) - self.resetPollText = nil let pollTextSectionSize = self.pollTextSection.update( transition: transition, @@ -925,6 +1352,10 @@ final class ComposePollScreenComponent: Component { if let itemView = pollTextSectionView.itemView(id: 0) as? ListComposePollOptionComponent.View { itemView.updateCustomPlaceholder(value: environment.strings.CreatePoll_TextPlaceholder, size: itemView.bounds.size, transition: .immediate) } + //TODO:localize + if let itemView = pollTextSectionView.itemView(id: 1) as? ListComposePollOptionComponent.View { + itemView.updateCustomPlaceholder(value: "Add Description (optional)", size: itemView.bounds.size, transition: .immediate) + } } contentHeight += pollTextSectionSize.height contentHeight += sectionSpacing @@ -940,13 +1371,30 @@ final class ComposePollScreenComponent: Component { var optionSelection: ListComposePollOptionComponent.Selection? if self.isQuiz { - optionSelection = ListComposePollOptionComponent.Selection(isSelected: self.selectedQuizOptionId == optionId, toggle: { [weak self] in - guard let self else { - return + optionSelection = ListComposePollOptionComponent.Selection( + isSelected: self.selectedQuizOptionIds.contains(optionId), + isMultiSelection: self.effectiveIsMultiAnswer, + toggle: { [weak self] in + guard let self else { + return + } + if self.effectiveIsMultiAnswer { + if self.selectedQuizOptionIds.contains(optionId) { + self.selectedQuizOptionIds.remove(optionId) + } else { + self.selectedQuizOptionIds.insert(optionId) + } + } else { + if self.selectedQuizOptionIds.contains(optionId) { + self.selectedQuizOptionIds.remove(optionId) + } else { + self.selectedQuizOptionIds.removeAll() + self.selectedQuizOptionIds.insert(optionId) + } + } + self.state?.updated(transition: .spring(duration: 0.35)) } - self.selectedQuizOptionId = optionId - self.state?.updated(transition: .spring(duration: 0.35)) - }) + ) } var canDelete = true @@ -954,6 +1402,9 @@ final class ComposePollScreenComponent: Component { canDelete = false } + var pollOptionAttachment: ListComposePollOptionComponent.Attachment + pollOptionAttachment = .init(media: pollOption.media?.media, progress: pollOption.media?.progress, alwaysDisplayAttachButton: false) + pollOptionsSectionItems.append(AnyComponentWithIdentity(id: pollOption.id, component: AnyComponent(ListComposePollOptionComponent( externalState: pollOption.textInputState, context: component.context, @@ -965,7 +1416,10 @@ final class ComposePollScreenComponent: Component { }, assumeIsEditing: self.inputMediaNodeTargetTag === pollOption.textFieldTag, characterLimit: component.initialData.maxPollOptionLength, + hasLeftInset: true, canReorder: true, + canAdd: i != 0 && i < component.initialData.maxPollAnswersCount, + attachment: pollOptionAttachment, emptyLineHandling: .notAllowed, returnKeyAction: { [weak self] in guard let self else { @@ -1015,6 +1469,9 @@ final class ComposePollScreenComponent: Component { } self.state?.updated(transition: .spring(duration: 0.4)) }, + attachAction: { [weak self] in + self?.openAttachedMedia(subject: .pollOption(pollOption)) + }, deleteAction: canDelete ? { [weak self] in guard let self else { return @@ -1110,12 +1567,13 @@ final class ComposePollScreenComponent: Component { transition: transition ) + //TODO:localize let sectionHeaderSideInset: CGFloat = 16.0 let pollOptionsSectionHeaderSize = self.pollOptionsSectionHeader.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_OptionsHeader, + string: "OPTIONS", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: theme.list.freeTextColor )), @@ -1235,6 +1693,7 @@ final class ComposePollScreenComponent: Component { canBePublic = false } + //TODO:localize var pollSettingsSectionItems: [AnyComponentWithIdentity] = [] if canBePublic { pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( @@ -1243,14 +1702,27 @@ final class ComposePollScreenComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_Anonymous, + string: "Show Who Voted", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: theme.list.itemPrimaryTextColor )), - maximumNumberOfLines: 1 + maximumNumberOfLines: 2 ))), - ], alignment: .left, spacing: 2.0)), - accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isAnonymous, action: { [weak self] _ in + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Display voter names on each option", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedViewIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: !self.isAnonymous, action: { [weak self] _ in guard let self else { return } @@ -1266,65 +1738,327 @@ final class ComposePollScreenComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_MultipleChoice, + string: "Allow Multiple Answers", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: theme.list.itemPrimaryTextColor )), - maximumNumberOfLines: 1 + maximumNumberOfLines: 2 ))), - ], alignment: .left, spacing: 2.0)), - accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isMultiAnswer, action: { [weak self] _ in + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Voters can select more than one option", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedMultipleIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.effectiveIsMultiAnswer, action: { [weak self] _ in guard let self else { return } - self.isMultiAnswer = !self.isMultiAnswer - if self.isMultiAnswer { - self.isQuiz = false + self.isMultiAnswer = !self.effectiveIsMultiAnswer + + if self.isMultiAnswer == false { + for option in self.pollOptions { + if self.selectedQuizOptionIds.contains(option.id) { + self.selectedQuizOptionIds.removeAll() + self.selectedQuizOptionIds.insert(option.id) + break + } + } } + self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) + + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "adding", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Allow Adding Options", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Participants can suggest new options", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedAddIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.canAddOptions, action: { [weak self] _ in + guard let self else { + return + } + self.canAddOptions = !self.canAddOptions + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "revoting", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Allow Revoting", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Voters can change their vote", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedRevoteIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.canRevote, action: { [weak self] _ in + guard let self else { + return + } + self.canRevote = !self.canRevote + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "shuffle", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Shuffle Options", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Answers appear in random order for each voter", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedShuffleIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.shuffleOptions, action: { [weak self] _ in + guard let self else { + return + } + self.shuffleOptions = !self.shuffleOptions + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "quiz", component: AnyComponent(ListActionItemComponent( theme: theme, style: .glass, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_Quiz, + string: "Set Correct Answer", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: theme.list.itemPrimaryTextColor )), - maximumNumberOfLines: 1 + maximumNumberOfLines: 2 ))), - ], alignment: .left, spacing: 2.0)), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: self.effectiveIsMultiAnswer ? "Mark one or more options as the right answer" : "Mark one option as the right answer", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedQuizIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isQuiz, action: { [weak self] _ in guard let self else { return } self.isQuiz = !self.isQuiz - if self.isQuiz { - self.isMultiAnswer = false - } self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "limitDuration", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Limit Duration", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Automatically close the poll at set time", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0), + textColor: theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedDurationIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.limitDuration, action: { [weak self] _ in + guard let self else { + return + } + self.limitDuration = !self.limitDuration + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + var pollSettingsFooter: AnyComponent? = nil + if self.limitDuration { + let title: String + let value: String + + switch self.timeLimit { + case let .duration(duration): + title = "Duration" + value = timeIntervalString(strings: environment.strings, value: duration) + case let .deadline(deadline): + title = "Poll Ends" + value = stringForMediumCompactDate(timestamp: deadline, strings: environment.strings, dateTimeFormat: environment.dateTimeFormat, withTime: true) + } + + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "duration", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedEmptyIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: value, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 1 + )))), + accessory: .expandArrows, + action: { [weak self] view in + guard let self else { + return + } + self.presentTimeLimitOptions(sourceView: view) + } + )))) + + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "hideResults", component: AnyComponent(ListActionItemComponent( + theme: theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Hide Results", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 2 + ))) + ], alignment: .left, spacing: 4.0)), + verticalAlignment: .middle, + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( + Image(image: self.cachedEmptyIcon, size: CGSize(width: 30.0, height: 30.0)) + )), false), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.hideResults, action: { [weak self] _ in + guard let self else { + return + } + self.hideResults = !self.hideResults + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + pollSettingsFooter = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "If you switch this on, results will be visible only after the poll closes.", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )) + } + + //TODO:localize let pollSettingsSectionSize = self.pollSettingsSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: theme, style: .glass, - header: nil, - footer: AnyComponent(MultilineTextComponent( + header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_QuizInfo, + string: "SETTINGS", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: theme.list.freeTextColor )), maximumNumberOfLines: 0 )), + footer: pollSettingsFooter, items: pollSettingsSectionItems )), environment: {}, @@ -1340,6 +2074,9 @@ final class ComposePollScreenComponent: Component { } contentHeight += pollSettingsSectionSize.height + var quizAnswerAttachment: ListComposePollOptionComponent.Attachment + quizAnswerAttachment = .init(media: self.quizAnswerMedia?.media, progress: self.quizAnswerMedia?.progress, alwaysDisplayAttachButton: true) + var quizAnswerSectionHeight: CGFloat = 0.0 quizAnswerSectionHeight += sectionSpacing let quizAnswerSectionSize = self.quizAnswerSection.update( @@ -1375,6 +2112,7 @@ final class ComposePollScreenComponent: Component { }, assumeIsEditing: self.inputMediaNodeTargetTag === self.quizAnswerTextInputTag, characterLimit: component.initialData.maxPollTextLength, + attachment: quizAnswerAttachment, emptyLineHandling: .allowed, returnKeyAction: { [weak self] in guard let self else { @@ -1397,6 +2135,9 @@ final class ComposePollScreenComponent: Component { } self.state?.updated(transition: .spring(duration: 0.4)) }, + attachAction: { [weak self] in + self?.openAttachedMedia(subject: .quizAnswer) + }, tag: self.quizAnswerTextInputTag ))) ] @@ -1585,6 +2326,8 @@ final class ComposePollScreenComponent: Component { } } + contentHeight += 24.0 + let combinedBottomInset: CGFloat combinedBottomInset = bottomInset + max(environment.safeInsets.bottom, 8.0 + inputHeight) contentHeight += combinedBottomInset @@ -1694,16 +2437,13 @@ final class ComposePollScreenComponent: Component { let doneButtonSize = self.doneButton.update( transition: transition, component: AnyComponent(GlassBarButtonComponent( - size: barButtonSize, + size: nil, backgroundColor: isValid ? environment.theme.list.itemCheckColors.fillColor : environment.theme.list.itemCheckColors.fillColor.desaturated().withMultipliedAlpha(0.5), isDark: environment.theme.overallDarkAppearance, state: .tintedGlass, isEnabled: isValid, component: AnyComponentWithIdentity(id: "done", component: AnyComponent( - BundleIconComponent( - name: "Navigation/Done", - tintColor: environment.theme.list.itemCheckColors.foregroundColor - ) + Text(text: environment.strings.MediaPicker_Send, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor) )), action: { [weak self] _ in guard let self, let controller = self.environment?.controller() as? ComposePollScreen else { @@ -1711,8 +2451,8 @@ final class ComposePollScreenComponent: Component { } if let input = self.validatedInput() { controller.completion(input) + controller.dismiss() } - controller.dismiss() } )), environment: {}, @@ -1952,3 +2692,15 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont return true } } + +private final class ComposePollContextReferenceContentSource: ContextReferenceContentSource { + private let sourceView: UIView + + init(sourceView: UIView) { + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0)) + } +} diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift new file mode 100644 index 0000000000..9b0d26de26 --- /dev/null +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift @@ -0,0 +1,101 @@ +import Foundation +import UIKit +import Display +import AccountContext +import TelegramCore +import Postbox +import SwiftSignalKit +import TelegramPresentationData +import LegacyComponents +import LegacyUI +import AttachmentUI +import MediaPickerUI +import LegacyCamera +import LegacyMediaPickerUI +import LocationUI +import AttachmentFileController +import ChatEntityKeyboardInputNode + +public func presentPollAttachmentScreen( + context: AccountContext, + updatedPresentationData: (initial: PresentationData, signal: Signal)?, + availableButtons: [AttachmentButtonType], + present: @escaping (ViewController) -> Void, + completion: @escaping (AnyMediaReference) -> Void +) { + let attachmentController = AttachmentController( + context: context, + updatedPresentationData: updatedPresentationData, + style: .glass, + chatLocation: nil, + isScheduledMessages: false, + buttons: availableButtons, + initialButton: .gallery, + makeEntityInputView: { + return nil + } + ) +// attachmentController.getSourceRect = { [weak self] in +// if let strongSelf = self { +// return strongSelf.chatDisplayNode.frameForAttachmentButton()?.offsetBy(dx: strongSelf.chatDisplayNode.supernode?.frame.minX ?? 0.0, dy: 0.0) +// } else { +// return nil +// } +// } + attachmentController.requestController = { [weak attachmentController] type, controllerCompletion in + switch type { + case .gallery: + let controller = MediaPickerScreenImpl( + context: context, + updatedPresentationData: updatedPresentationData, + style: .glass, + peer: nil, + threadTitle: nil, + chatLocation: nil, + enableMultiselection: false, + subject: .assets(nil, .poll(.option)) + ) + controller.getCaptionPanelView = { + return nil + } + controller.legacyCompletion = { fromGallery, signals, silently, scheduleTime, parameters, getAnimatedTransitionSource, sendCompletion in + let _ = (legacyAssetPickerEnqueueMessages(context: context, account: context.account, signals: signals) + |> deliverOnMainQueue).start(next: { items in + if let item = items.first, case let .message(_, _, _, mediaReference, _, _, _, _, _, _) = item.message, let mediaReference { + completion(mediaReference) + sendCompletion() + } + }) + } + controllerCompletion(controller, controller.mediaPickerContext) + return true + case .file: + let controller = context.sharedContext.makeAttachmentFileController(context: context, updatedPresentationData: updatedPresentationData, bannedSendMedia: nil, presentGallery: { [weak attachmentController] in + attachmentController?.dismiss(animated: true) + //self?.presentFileGallery() + }, presentFiles: { [weak attachmentController] in + attachmentController?.dismiss(animated: true) + //self?.presentICloudFileGallery() + }, presentDocumentScanner: { + //self?.presentDocumentScanner() + }, send: { mediaReference in + completion(mediaReference) + }) + guard let controller = controller as? AttachmentFileControllerImpl else { + return false + } + controllerCompletion(controller, controller.mediaPickerContext) + return true + case .location: + let controller = LocationPickerController(context: context, style: .glass, updatedPresentationData: updatedPresentationData, mode: .share(peer: nil, selfPeer: nil, hasLiveLocation: false), completion: { location, _, _, _, _ in + completion(.standalone(media: location)) + }) + controllerCompletion(controller, controller.mediaPickerContext) + return true + default: + return false + } + } + attachmentController.navigationPresentation = .flatModal + present(attachmentController) +} diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD b/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD index f10ddb8955..5ee337c4e0 100644 --- a/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD @@ -42,7 +42,7 @@ swift_library( "//submodules/ChatPresentationInterfaceState", "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", "//submodules/TelegramUI/Components/ListComposePollOptionComponent", - "//submodules/ComposePollUI", + "//submodules/TelegramUI/Components/ComposePollScreen", "//submodules/Markdown", "//submodules/TelegramUI/Components/GlassBarButtonComponent", ], diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift index 4e8a625d34..e63818f11d 100644 --- a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift @@ -1623,8 +1623,8 @@ final class ComposeTodoScreenComponent: Component { } if let input = self.validatedInput() { controller.completion(input) + controller.dismiss() } - controller.dismiss() } )), environment: {}, diff --git a/submodules/TelegramUI/Components/GlassBarButtonComponent/Sources/GlassBarButtonComponent.swift b/submodules/TelegramUI/Components/GlassBarButtonComponent/Sources/GlassBarButtonComponent.swift index 127d4f4160..3efa665988 100644 --- a/submodules/TelegramUI/Components/GlassBarButtonComponent/Sources/GlassBarButtonComponent.swift +++ b/submodules/TelegramUI/Components/GlassBarButtonComponent/Sources/GlassBarButtonComponent.swift @@ -266,7 +266,7 @@ public final class GlassBarButtonComponent: Component { transition.animateAlpha(view: glassBackgroundView, from: 0.0, to: 1.0) } - glassBackgroundView.update(size: containerSize, cornerRadius: cornerRadius, isDark: component.isDark, tintColor: .init(kind: effectiveState == .tintedGlass ? .custom(style: .default, color: backgroundColor.withMultipliedAlpha(effectiveState == .tintedGlass ? 1.0 : 0.7)) : .panel), isInteractive: true, isVisible: component.isVisible, transition: glassBackgroundTransition) + glassBackgroundView.update(size: containerSize, cornerRadius: cornerRadius, isDark: component.isDark, tintColor: .init(kind: effectiveState == .tintedGlass ? .custom(style: .default, color: backgroundColor.withMultipliedAlpha(effectiveState == .tintedGlass ? 1.0 : 0.7)) : .panel), isInteractive: component.isEnabled, isVisible: component.isVisible, transition: glassBackgroundTransition) glassBackgroundTransition.setFrame(view: glassBackgroundView, frame: bounds) } else if case .glass = component.state { let glassBackgroundView: GlassBackgroundView @@ -283,7 +283,7 @@ public final class GlassBarButtonComponent: Component { transition.animateAlpha(view: glassBackgroundView, from: 0.0, to: 1.0) } - glassBackgroundView.update(size: containerSize, cornerRadius: cornerRadius, isDark: component.isDark, tintColor: .init(kind: .panel), isInteractive: true, isVisible: component.isVisible, transition: glassBackgroundTransition) + glassBackgroundView.update(size: containerSize, cornerRadius: cornerRadius, isDark: component.isDark, tintColor: .init(kind: .panel), isInteractive: component.isEnabled, isVisible: component.isVisible, transition: glassBackgroundTransition) glassBackgroundTransition.setFrame(view: glassBackgroundView, frame: bounds) } else if let glassBackgroundView = self.glassBackgroundView { self.glassBackgroundView = nil diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 2e213c6f3e..ac0671c8e3 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -323,7 +323,7 @@ public final class ListActionItemComponent: Component { if let current = self.checkLayer { checkLayer = current } else { - checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check) + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check(isRectangle: false)) self.checkLayer = checkLayer self.layer.addSublayer(checkLayer) } diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD index a8d8cf1580..0bf741befd 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD @@ -20,9 +20,15 @@ swift_library( "//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + "//submodules/Components/BundleIconComponent", "//submodules/CheckNode", "//submodules/AccountContext", + "//submodules/TextFormat", "//submodules/PresentationDataUtils", + "//submodules/PhotoResources", + "//submodules/LocationResources", + "//submodules/SemanticStatusNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift index 0744f141da..0d6e444182 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift @@ -3,6 +3,7 @@ import UIKit import Display import AsyncDisplayKit import TelegramPresentationData +import TelegramCore import CheckNode import ListSectionComponent import ComponentFlow @@ -12,7 +13,13 @@ import MultilineTextComponent import PresentationDataUtils import LottieComponent import PlainButtonComponent +import BundleIconComponent import SwiftSignalKit +import PhotoResources +import LocationResources +import SemanticStatusNode +import EmojiTextAttachmentView +import TextFormat public final class ListComposePollOptionComponent: Component { public enum Style { @@ -34,10 +41,12 @@ public final class ListComposePollOptionComponent: Component { public final class Selection: Equatable { public let isSelected: Bool + public let isMultiSelection: Bool public let toggle: () -> Void - public init(isSelected: Bool, toggle: @escaping () -> Void) { + public init(isSelected: Bool, isMultiSelection: Bool = false, toggle: @escaping () -> Void) { self.isSelected = isSelected + self.isMultiSelection = isMultiSelection self.toggle = toggle } @@ -45,6 +54,34 @@ public final class ListComposePollOptionComponent: Component { if lhs.isSelected != rhs.isSelected { return false } + if lhs.isMultiSelection != rhs.isMultiSelection { + return false + } + return true + } + } + + public final class Attachment: Equatable { + public let media: AnyMediaReference? + public let progress: CGFloat? + public let alwaysDisplayAttachButton: Bool + + public init(media: AnyMediaReference?, progress: CGFloat?, alwaysDisplayAttachButton: Bool) { + self.media = media + self.progress = progress + self.alwaysDisplayAttachButton = alwaysDisplayAttachButton + } + + public static func ==(lhs: Attachment, rhs: Attachment) -> Bool { + if lhs.media != rhs.media { + return false + } + if lhs.progress != rhs.progress { + return false + } + if lhs.alwaysDisplayAttachButton != rhs.alwaysDisplayAttachButton { + return false + } return true } } @@ -83,8 +120,11 @@ public final class ListComposePollOptionComponent: Component { public let resetText: ResetText? public let assumeIsEditing: Bool public let characterLimit: Int? + public let hasLeftInset: Bool public let enableInlineAnimations: Bool public let canReorder: Bool + public let canAdd: Bool + public let attachment: Attachment? public let emptyLineHandling: TextFieldComponent.EmptyLineHandling public let returnKeyAction: (() -> Void)? public let backspaceKeyAction: (() -> Void)? @@ -92,6 +132,7 @@ public final class ListComposePollOptionComponent: Component { public let inputMode: InputMode? public let alwaysDisplayInputModeSelector: Bool public let toggleInputMode: (() -> Void)? + public let attachAction: (() -> Void)? public let deleteAction: (() -> Void)? public let paste: ((TextFieldComponent.PasteData) -> Void)? public let tag: AnyObject? @@ -108,7 +149,10 @@ public final class ListComposePollOptionComponent: Component { assumeIsEditing: Bool = false, characterLimit: Int, enableInlineAnimations: Bool = true, + hasLeftInset: Bool = false, canReorder: Bool = false, + canAdd: Bool = false, + attachment: Attachment? = nil, emptyLineHandling: TextFieldComponent.EmptyLineHandling, returnKeyAction: (() -> Void)?, backspaceKeyAction: (() -> Void)?, @@ -116,6 +160,7 @@ public final class ListComposePollOptionComponent: Component { inputMode: InputMode?, alwaysDisplayInputModeSelector: Bool = false, toggleInputMode: (() -> Void)?, + attachAction: (() -> Void)? = nil, deleteAction: (() -> Void)? = nil, paste: ((TextFieldComponent.PasteData) -> Void)? = nil, tag: AnyObject? = nil @@ -131,7 +176,10 @@ public final class ListComposePollOptionComponent: Component { self.assumeIsEditing = assumeIsEditing self.characterLimit = characterLimit self.enableInlineAnimations = enableInlineAnimations + self.hasLeftInset = hasLeftInset self.canReorder = canReorder + self.canAdd = canAdd + self.attachment = attachment self.emptyLineHandling = emptyLineHandling self.returnKeyAction = returnKeyAction self.backspaceKeyAction = backspaceKeyAction @@ -139,6 +187,7 @@ public final class ListComposePollOptionComponent: Component { self.inputMode = inputMode self.alwaysDisplayInputModeSelector = alwaysDisplayInputModeSelector self.toggleInputMode = toggleInputMode + self.attachAction = attachAction self.deleteAction = deleteAction self.paste = paste self.tag = tag @@ -178,9 +227,18 @@ public final class ListComposePollOptionComponent: Component { if lhs.enableInlineAnimations != rhs.enableInlineAnimations { return false } + if lhs.hasLeftInset != rhs.hasLeftInset { + return false + } if lhs.canReorder != rhs.canReorder { return false } + if lhs.canAdd != rhs.canAdd { + return false + } + if lhs.attachment != rhs.attachment { + return false + } if lhs.emptyLineHandling != rhs.emptyLineHandling { return false } @@ -202,6 +260,7 @@ public final class ListComposePollOptionComponent: Component { private final class CheckView: HighlightTrackingButton { private var checkLayer: CheckLayer? private var theme: PresentationTheme? + private var isRectangle = false var action: (() -> Void)? @@ -251,22 +310,27 @@ public final class ListComposePollOptionComponent: Component { self.action?() } - func update(size: CGSize, theme: PresentationTheme, isSelected: Bool, transition: ComponentTransition) { + func update(size: CGSize, isRectangle: Bool, theme: PresentationTheme, isSelected: Bool, transition: ComponentTransition) { let checkLayer: CheckLayer if let current = self.checkLayer { checkLayer = current } else { - checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check) + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check(isRectangle: isRectangle)) self.checkLayer = checkLayer self.layer.addSublayer(checkLayer) } - + if self.theme !== theme { self.theme = theme checkLayer.theme = CheckNodeTheme(theme: theme, style: .plain) } + if self.isRectangle != isRectangle { + self.isRectangle = isRectangle + checkLayer.content = .check(isRectangle: isRectangle) + } + checkLayer.frame = CGRect(origin: CGPoint(), size: size) checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) } @@ -385,6 +449,13 @@ public final class ListComposePollOptionComponent: Component { private var modeSelector: ComponentView? private var reorderIconView: UIImageView? + private var addIconView: UIImageView? + + private var attachButton: ComponentView? + private var imageNode: TransformImageNode? + private var statusNode: SemanticStatusNode? + private var animationLayer: InlineStickerItemLayer? + private let imageButton = HighlightTrackingButton() private var checkView: CheckView? @@ -396,6 +467,8 @@ public final class ListComposePollOptionComponent: Component { private var customPlaceholder: ComponentView? + private var appliedMedia: AnyMediaReference? + private var component: ListComposePollOptionComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false @@ -430,6 +503,25 @@ public final class ListComposePollOptionComponent: Component { public override init(frame: CGRect) { super.init(frame: CGRect()) + + self.imageButton.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if highlighted { + self.imageNode?.layer.removeAnimation(forKey: "opacity") + self.imageNode?.alpha = 0.4 + + self.animationLayer?.removeAnimation(forKey: "opacity") + self.animationLayer?.opacity = 0.4 + } else { + self.imageNode?.alpha = 1.0 + self.imageNode?.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + + self.animationLayer?.opacity = 1.0 + self.animationLayer?.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } } required public init?(coder: NSCoder) { @@ -551,6 +643,13 @@ public final class ListComposePollOptionComponent: Component { self.component?.deleteAction?() } } + + @objc private func imageButtonPressed() { + guard let component = self.component else { + return + } + component.attachAction?() + } func update(component: ListComposePollOptionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true @@ -570,6 +669,10 @@ public final class ListComposePollOptionComponent: Component { var rightInset: CGFloat = 16.0 let modeSelectorSize = CGSize(width: 32.0, height: 32.0) + if component.hasLeftInset { + leftInset += 46.0 + } + if component.selection != nil { leftInset += 34.0 } @@ -578,8 +681,8 @@ public final class ListComposePollOptionComponent: Component { rightInset += 34.0 } - if component.canReorder { - rightInset += 16.0 + if component.attachment != nil { + rightInset += 28.0 } let textFieldSize = self.textField.update( @@ -641,8 +744,89 @@ public final class ListComposePollOptionComponent: Component { ) let size = CGSize(width: availableSize.width, height: textFieldSize.height - 1.0) - let textFieldFrame = CGRect(origin: CGPoint(x: leftInset - 16.0 + self.revealOffset, y: 0.0), size: textFieldSize) + var hasReorderIcon = false + if component.canReorder, let externalState = component.externalState, externalState.hasText { + var reorderIconTransition = transition + let reorderIconView: UIImageView + if let current = self.reorderIconView { + reorderIconView = current + } else { + reorderIconTransition = reorderIconTransition.withAnimation(.none) + reorderIconView = UIImageView() + self.reorderIconView = reorderIconView + self.addSubview(reorderIconView) + + if !transition.animation.isImmediate { + transition.animateAlpha(view: reorderIconView, from: 0.0, to: 1.0) + transition.animateScale(view: reorderIconView, from: 0.001, to: 1.0) + } + } + reorderIconView.image = PresentationResourcesItemList.itemListReorderIndicatorIcon(component.theme) + + var reorderIconSize = CGSize() + if let icon = reorderIconView.image { + reorderIconSize = icon.size + } + + let reorderIconFrame = CGRect(origin: CGPoint(x: 22.0 + self.revealOffset, y: floor((size.height - reorderIconSize.height) * 0.5)), size: reorderIconSize) + reorderIconTransition.setPosition(view: reorderIconView, position: reorderIconFrame.center) + reorderIconTransition.setBounds(view: reorderIconView, bounds: CGRect(origin: CGPoint(), size: reorderIconFrame.size)) + + hasReorderIcon = true + } else if let reorderIconView = self.reorderIconView { + self.reorderIconView = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: reorderIconView, alpha: 0.0, completion: { [weak reorderIconView] _ in + reorderIconView?.removeFromSuperview() + }) + alphaTransition.setScale(view: reorderIconView, scale: 0.001) + } else { + reorderIconView.removeFromSuperview() + } + } + + if component.canAdd, !hasReorderIcon { + var addIconTransition = transition + let addIconView: UIImageView + if let current = self.addIconView { + addIconView = current + } else { + addIconTransition = addIconTransition.withAnimation(.none) + addIconView = UIImageView() + self.addIconView = addIconView + self.addSubview(addIconView) + + if !transition.animation.isImmediate { + transition.animateAlpha(view: addIconView, from: 0.0, to: 1.0) + transition.animateScale(view: addIconView, from: 0.001, to: 1.0) + } + } + addIconView.image = PresentationResourcesItemList.itemListAddIndicatorIcon(component.theme) + + var addIconSize = CGSize() + if let icon = addIconView.image { + addIconSize = icon.size + } + + let addIconFrame = CGRect(origin: CGPoint(x: 22.0 + self.revealOffset, y: floor((size.height - addIconSize.height) * 0.5)), size: addIconSize) + addIconTransition.setPosition(view: addIconView, position: addIconFrame.center) + addIconTransition.setBounds(view: addIconView, bounds: CGRect(origin: CGPoint(), size: addIconFrame.size)) + } else if let addIconView = self.addIconView { + self.addIconView = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: addIconView, alpha: 0.0, completion: { [weak addIconView] _ in + addIconView?.removeFromSuperview() + }) + alphaTransition.setScale(view: addIconView, scale: 0.001) + } else { + addIconView.removeFromSuperview() + } + } + + let textFieldFrame = CGRect(origin: CGPoint(x: leftInset - 16.0 + self.revealOffset, y: 0.0), size: textFieldSize) if let textFieldView = self.textField.view { if textFieldView.superview == nil { self.addSubview(textFieldView) @@ -673,17 +857,17 @@ public final class ListComposePollOptionComponent: Component { } } let checkSize = CGSize(width: 22.0, height: 22.0) - let checkFrame = CGRect(origin: CGPoint(x: floor((leftInset - checkSize.width) * 0.5) + self.revealOffset, y: floor((size.height - checkSize.height) * 0.5)), size: checkSize) + let checkFrame = CGRect(origin: CGPoint(x: leftInset - checkSize.width - 20.0 + self.revealOffset, y: floor((size.height - checkSize.height) * 0.5)), size: checkSize) if animateIn { checkView.frame = CGRect(origin: CGPoint(x: -checkSize.width, y: self.bounds.height == 0.0 ? checkFrame.minY : floor((self.bounds.height - checkSize.height) * 0.5)), size: checkFrame.size) transition.setPosition(view: checkView, position: checkFrame.center) transition.setBounds(view: checkView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) - checkView.update(size: checkFrame.size, theme: component.theme, isSelected: selection.isSelected, transition: .immediate) + checkView.update(size: checkFrame.size, isRectangle: selection.isMultiSelection, theme: component.theme, isSelected: selection.isSelected, transition: .immediate) } else { transition.setPosition(view: checkView, position: checkFrame.center) transition.setBounds(view: checkView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) - checkView.update(size: checkFrame.size, theme: component.theme, isSelected: selection.isSelected, transition: transition) + checkView.update(size: checkFrame.size, isRectangle: selection.isMultiSelection, theme: component.theme, isSelected: selection.isSelected, transition: transition) } } else if let checkView = self.checkView { self.checkView = nil @@ -692,40 +876,237 @@ public final class ListComposePollOptionComponent: Component { }) } - var rightIconsInset: CGFloat = 0.0 - if component.canReorder, let externalState = component.externalState, externalState.hasText { - var reorderIconTransition = transition - let reorderIconView: UIImageView - if let current = self.reorderIconView { - reorderIconView = current + var rightIconsInset: CGFloat = 16.0 + let minHeight: CGFloat = 52.0 + + if let attachment = component.attachment, attachment.alwaysDisplayAttachButton || component.externalState?.hasText == true { + var attachButtonTransition = transition + let attachButton: ComponentView + if let current = self.attachButton { + attachButton = current } else { - reorderIconTransition = reorderIconTransition.withAnimation(.none) - reorderIconView = UIImageView() - self.reorderIconView = reorderIconView - self.addSubview(reorderIconView) + attachButtonTransition = attachButtonTransition.withAnimation(.none) + attachButton = ComponentView() + self.attachButton = attachButton } - reorderIconView.image = PresentationResourcesItemList.itemListReorderIndicatorIcon(component.theme) - - var reorderIconSize = CGSize() - if let icon = reorderIconView.image { - reorderIconSize = icon.size + + let attachButtonSize = attachButton.update( + transition: attachButtonTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Text/IconAttachment", + tintColor: component.theme.chat.inputPanel.inputControlColor.blitOver(component.theme.list.itemBlocksBackgroundColor, alpha: 1.0), + maxSize: CGSize(width: 25.0, height: 25.0) + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.attachAction?() + }, + animateScale: false + )), + environment: {}, + containerSize: availableSize + ) + let attachButtonFrame = CGRect(origin: CGPoint(x: size.width - rightIconsInset - 7.0 - attachButtonSize.width + self.revealOffset, y: size.height - minHeight + floor((minHeight - attachButtonSize.height) * 0.5)), size: attachButtonSize) + if let attachButtonView = attachButton.view as? PlainButtonComponent.View { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + + if attachButtonView.superview == nil { + self.addSubview(attachButtonView) + ComponentTransition.immediate.setAlpha(view: attachButtonView, alpha: 0.0) + ComponentTransition.immediate.setScale(view: attachButtonView, scale: 0.001) + } + + attachButtonTransition.setPosition(view: attachButtonView, position: attachButtonFrame.center) + attachButtonTransition.setBounds(view: attachButtonView, bounds: CGRect(origin: CGPoint(), size: attachButtonFrame.size)) + + let displaySelector = attachment.media == nil + alphaTransition.setAlpha(view: attachButtonView, alpha: displaySelector ? 1.0 : 0.0) + alphaTransition.setScale(view: attachButtonView, scale: displaySelector ? 1.0 : 0.001) } - let reorderIconFrame = CGRect(origin: CGPoint(x: size.width - 14.0 - reorderIconSize.width + self.revealOffset, y: floor((size.height - reorderIconSize.height) * 0.5)), size: reorderIconSize) - reorderIconTransition.setPosition(view: reorderIconView, position: reorderIconFrame.center) - reorderIconTransition.setBounds(view: reorderIconView, bounds: CGRect(origin: CGPoint(), size: reorderIconFrame.size)) + rightIconsInset += 42.0 + } else if let attachButton = self.attachButton { + self.attachButton = nil + if let attachButtonView = attachButton.view { + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: attachButtonView, alpha: 0.0, completion: { [weak attachButtonView] _ in + attachButtonView?.removeFromSuperview() + }) + alphaTransition.setScale(view: attachButtonView, scale: 0.001) + } else { + attachButtonView.removeFromSuperview() + } + } + } + + let imageNodeSize = CGSize(width: 40.0, height: 40.0) + let imageNodeFrame = CGRect(origin: CGPoint(x: size.width - 16.0 - imageNodeSize.width + self.revealOffset, y: size.height - minHeight + floor((minHeight - imageNodeSize.height) * 0.5)), size: imageNodeSize) + + var isSticker = false + if let attachment = component.attachment, let file = attachment.media?.media as? TelegramMediaFile, file.isSticker { + isSticker = true - rightIconsInset += 36.0 - } else if let reorderIconView = self.reorderIconView { - self.reorderIconView = nil + let animationSize = CGSize(width: 40.0, height: 40.0) + let animationLayer: InlineStickerItemLayer + if let current = self.animationLayer { + animationLayer = current + } else { + if let animationLayer = self.animationLayer { + self.animationLayer = nil + animationLayer.removeFromSuperlayer() + } + animationLayer = InlineStickerItemLayer( + context: component.context, + userLocation: .other, + attemptSynchronousLoad: true, + emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file, custom: nil, enableAnimation: true), + file: file, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + unique: false, + placeholderColor: component.theme.list.mediaPlaceholderColor, + pointSize: CGSize(width: animationSize.width * 2.0, height: animationSize.height * 2.0), + dynamicColor: nil, + loopCount: 2 + ) + self.animationLayer = animationLayer + self.layer.addSublayer(animationLayer) + } + animationLayer.frame = imageNodeFrame + + if self.imageButton.superview == nil { + self.imageButton.addTarget(self, action: #selector(self.imageButtonPressed), for: .touchUpInside) + self.addSubview(self.imageButton) + } + self.imageButton.frame = imageNodeFrame + } else if let animationLayer = self.animationLayer { + self.imageNode = nil if !transition.animation.isImmediate { let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) - alphaTransition.setAlpha(view: reorderIconView, alpha: 0.0, completion: { [weak reorderIconView] _ in - reorderIconView?.removeFromSuperview() + alphaTransition.setAlpha(layer: animationLayer, alpha: 0.0, completion: { [weak animationLayer] _ in + animationLayer?.removeFromSuperlayer() }) - alphaTransition.setScale(view: reorderIconView, scale: 0.001) + alphaTransition.setScale(layer: animationLayer, scale: 0.001) } else { - reorderIconView.removeFromSuperview() + animationLayer.removeFromSuperlayer() + } + self.imageButton.removeFromSuperview() + } + + if let attachment = component.attachment, let media = attachment.media, !isSticker { + var imageNodeTransition = transition + let imageNode: TransformImageNode + if let current = self.imageNode { + imageNode = current + } else { + imageNodeTransition = imageNodeTransition.withAnimation(.none) + imageNode = TransformImageNode() + imageNode.isUserInteractionEnabled = false + self.imageNode = imageNode + self.addSubview(imageNode.view) + } + + imageNodeTransition.setPosition(view: imageNode.view, position: imageNodeFrame.center) + imageNodeTransition.setBounds(view: imageNode.view, bounds: CGRect(origin: CGPoint(), size: imageNodeFrame.size)) + + var imageSize = imageNodeSize + var updateMedia = false + if self.appliedMedia != media { + self.appliedMedia = media + updateMedia = true + } + if let image = media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations), let photoReference = media.concrete(TelegramMediaImage.self) { + imageSize = largest.dimensions.cgSize.aspectFilled(imageNodeSize) + + if updateMedia { + imageNode.setSignal(chatMessagePhoto(postbox: component.context.account.postbox, userLocation: .other, photoReference: photoReference)) + } + } else if let file = media.media as? TelegramMediaFile, let fileReference = media.concrete(TelegramMediaFile.self) { + if let dimensions = file.dimensions { + imageSize = dimensions.cgSize.aspectFilled(imageNodeSize) + } + if file.mimeType.hasPrefix("image/") { + if updateMedia { + imageNode.setSignal(instantPageImageFile(account: component.context.account, userLocation: .other, fileReference: fileReference, fetched: true)) + } + } else { + if updateMedia { + imageNode.setSignal(chatMessageVideo(postbox: component.context.account.postbox, userLocation: .other, videoReference: fileReference)) + } + } + } else if let map = media.media as? TelegramMediaMap { + imageSize = CGSize(width: 40.0, height: 40.0) + if updateMedia { + let resource = MapSnapshotMediaResource(latitude: map.latitude, longitude: map.longitude, width: Int32(imageSize.width), height: Int32(imageSize.height)) + imageNode.setSignal(chatMapSnapshotImage(engine: component.context.engine, resource: resource)) + } + } + + let cornerRadius: CGFloat = 10.0 + let makeLayout = imageNode.asyncLayout() + let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: cornerRadius), imageSize: imageSize, boundingSize: imageNodeSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor)) + apply() + + if self.imageButton.superview == nil { + self.imageButton.addTarget(self, action: #selector(self.imageButtonPressed), for: .touchUpInside) + self.addSubview(self.imageButton) + } + self.imageButton.frame = imageNodeFrame + + if let progress = attachment.progress { + let statusNode: SemanticStatusNode + if let current = self.statusNode { + statusNode = current + } else { + statusNode = SemanticStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5), foregroundNodeColor: .white) + self.statusNode = statusNode + self.addSubview(statusNode.view) + } + + let progressFrame = imageNodeFrame.insetBy(dx: 6.0, dy: 6.0) + statusNode.frame = progressFrame + statusNode.transitionToState(.progress(value: max(0.027, min(1.0, progress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 1.0 + UIScreenPixel), animateRotation: false), updateCutout: false) + } else if let statusNode = self.statusNode { + self.statusNode = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: statusNode.view, alpha: 0.0, completion: { [weak statusNode] _ in + statusNode?.view.removeFromSuperview() + }) + alphaTransition.setScale(view: statusNode.view, scale: 0.001) + } else { + statusNode.view.removeFromSuperview() + } + } + } else if let imageNode = self.imageNode { + self.imageNode = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: imageNode.view, alpha: 0.0, completion: { [weak imageNode] _ in + imageNode?.view.removeFromSuperview() + }) + alphaTransition.setScale(view: imageNode.view, scale: 0.001) + } else { + imageNode.view.removeFromSuperview() + } + self.imageButton.removeFromSuperview() + + if let statusNode = self.statusNode { + self.statusNode = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: statusNode.view, alpha: 0.0, completion: { [weak statusNode] _ in + statusNode?.view.removeFromSuperview() + }) + alphaTransition.setScale(view: statusNode.view, scale: 0.001) + } else { + statusNode.view.removeFromSuperview() + } } } @@ -775,7 +1156,7 @@ public final class ListComposePollOptionComponent: Component { environment: {}, containerSize: modeSelectorSize ) - let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - rightIconsInset - 4.0 - modeSelectorSize.width + self.revealOffset, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize) + let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - rightIconsInset - 4.0 - modeSelectorSize.width + self.revealOffset, y: size.height - minHeight + floor((minHeight - modeSelectorSize.height) * 0.5)), size: modeSelectorSize) if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View { let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) @@ -881,6 +1262,10 @@ public final class ListComposePollOptionComponent: Component { var leftInset: CGFloat = 16.0 let rightInset: CGFloat = 16.0 + if component.hasLeftInset { + leftInset += 46.0 + } + if component.selection != nil { leftInset += 34.0 } diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift index 111bf64dec..6d5c9bb67a 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift @@ -136,6 +136,8 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon self.customTitle = self.presentationData.strings.RequestPeer_ChooseGroupTitle case .channel: self.customTitle = self.presentationData.strings.RequestPeer_ChooseChannelTitle + case .createBot: + break } } else { self.customTitle = self.presentationData.strings.ChatImport_Title diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index dfa91ea3a3..2eadeee068 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -1210,6 +1210,10 @@ final class PeerSelectionControllerNode: ASDisplayNode { emptyText = "" } emptyButtonText = self.presentationData.strings.RequestPeer_CreateNewGroup + case .createBot: + emptyTitle = "" + emptyText = "" + emptyButtonText = "" } self.emptyTitleNode.attributedText = NSAttributedString(string: emptyTitle, font: Font.semibold(15.0), textColor: self.presentationData.theme.list.itemPrimaryTextColor) @@ -1863,6 +1867,8 @@ private func stringForRequestPeerType(strings: PresentationStrings, peerType: Re append(rightsString) } } + case .createBot: + break } if lines.isEmpty { return nil diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 797719a48d..3dfa33e15c 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -34,7 +34,6 @@ swift_library( "//submodules/TelegramUI/Components/ChatTimerScreen", "//submodules/TextFormat", "//submodules/PhoneNumberFormat", - "//submodules/ComposePollUI", "//submodules/TelegramIntents", "//submodules/LegacyUI", "//submodules/WebSearchUI", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index cdf8d57c88..ec54cec618 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -15,7 +15,6 @@ import ChatEntityKeyboardInputNode import ChatScheduleTimeController import TextFormat import PhoneNumberFormat -import ComposePollUI import TelegramIntents import LegacyUI import WebSearchUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift index e7be83595c..89b1b723d9 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift index 7ea7d6cb91..b08831c0d5 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift index ce7646faef..0aa529ccfb 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift index 4657f19078..f23de4f234 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenViewOnceMediaMessage.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerReport.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerReport.swift index f399aa2d11..2f485b8010 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerReport.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerReport.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerThemeManagement.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerThemeManagement.swift index 3005a84de5..a444092c18 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerThemeManagement.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerThemeManagement.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index ddd63f9677..2c4ea64530 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -21,8 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI -import ComposeTodoScreen import AlertUI import PresentationDataUtils import UndoUI @@ -4625,6 +4623,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } createNewGroupImpl = { [weak controller] in switch peerType { + case .createBot: + break case .user: break case let .group(group): diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index 23151e97f8..d296940063 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -34,7 +34,7 @@ import MediaEditorScreen import CameraScreen import ShareController import ComposeTodoScreen -import ComposePollUI +import ComposePollScreen import Photos import AttachmentFileController @@ -1291,26 +1291,6 @@ extension ChatControllerImpl { self?.openCamera(cameraView: nil) } } - controller.presentWebSearch = { [weak self, weak controller] mediaGroups, activateOnDisplay in - self?.presentWebSearch(editingMessage: false, attachment: true, activateOnDisplay: activateOnDisplay, present: { [weak controller] c, a in - controller?.present(c, in: .current) - if let webSearchController = c as? WebSearchController { - webSearchController.searchingUpdated = { [weak mediaGroups] searching in - if let mediaGroups = mediaGroups, mediaGroups.isNodeLoaded { - let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) - transition.updateAlpha(node: mediaGroups.displayNode, alpha: searching ? 0.0 : 1.0) - mediaGroups.displayNode.isUserInteractionEnabled = !searching - } - } - webSearchController.present(mediaGroups, in: .current) - webSearchController.dismissed = { - updateMediaPickerContext(mediaPickerContext) - } - controller?.webSearchController = webSearchController - updateMediaPickerContext(webSearchController.mediaPickerContext) - } - }) - } controller.presentSchedulePicker = { [weak self] media, done in if let strongSelf = self { strongSelf.presentScheduleTimePicker(style: media ? .media : .default, completion: { [weak self] time, repeatPeriod in @@ -2049,9 +2029,15 @@ extension ChatControllerImpl { }) } }, nil) + + var attributes: [MessageAttribute] = [] + if !poll.description.entities.isEmpty { + attributes.append(TextEntitiesMessageAttribute(entities: poll.description.entities)) + } + let message: EnqueueMessage = .message( - text: "", - attributes: [], + text: poll.description.string, + attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaPoll( pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min...Int64.max)), @@ -2063,7 +2049,12 @@ extension ChatControllerImpl { correctAnswers: poll.correctAnswers, results: poll.results, isClosed: false, - deadlineTimeout: poll.deadlineTimeout + deadlineTimeout: poll.deadlineTimeout, + openAnswers: poll.openAnswers, + revotingDisabled: poll.revotingDisabled, + shuffleAnswers: poll.shuffleAnswers, + hideResultsUntilClose: poll.hideResultsUntilClose, + attachedMedia: poll.media?.media )), threadId: self.chatLocation.threadId, replyToMessageId: nil, diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index db6c25aa35..798844b9d7 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1589,7 +1589,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState hasSelected = true } } - if hasSelected, case .poll = activePoll.kind { + if hasSelected, !activePoll.revotingDisabled { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_UnvotePoll, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Unvote"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in