diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index 6f662b6b91..d6ebdb4ef5 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -135,6 +135,7 @@ public final class BrowserBookmarksScreen: ViewController { }, dismissReplyMarkupMessage: { _ in }, openMessagePollResults: { _, _ in }, openPollCreation: { _ in + }, openPollMedia: { _, _ in }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in }, displayDiceTooltip: { _ in diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index d2ecbb720b..beeefe26d2 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -61,27 +61,59 @@ public enum CheckNodeContent: Equatable { case counter(Int) } +private extension CheckNodeContent { + var rectangleProgressValue: CGFloat { + if case .check(isRectangle: true) = self { + return 1.0 + } else { + return 0.0 + } + } + + var renderedContent: CheckNodeRenderedContent { + switch self { + case .check: + return .check + case let .counter(value): + return .counter(value) + } + } +} + +private enum CheckNodeRenderedContent { + case check + case counter(Int) +} + private final class CheckNodeParameters: NSObject { - let isRectangle: Bool let theme: CheckNodeTheme - let content: CheckNodeContent + let content: CheckNodeRenderedContent let animationProgress: CGFloat let selected: Bool let animatingOut: Bool + let rectangleProgress: CGFloat - init(isRectangle: Bool, theme: CheckNodeTheme, content: CheckNodeContent, animationProgress: CGFloat, selected: Bool, animatingOut: Bool) { - self.isRectangle = isRectangle + init( + theme: CheckNodeTheme, + content: CheckNodeRenderedContent, + animationProgress: CGFloat, + selected: Bool, + animatingOut: Bool, + rectangleProgress: CGFloat + ) { self.theme = theme self.content = content self.animationProgress = animationProgress self.selected = selected self.animatingOut = animatingOut + self.rectangleProgress = rectangleProgress } } public class CheckNode: ASDisplayNode { private var animatingOut = false private var animationProgress: CGFloat = 0.0 + private var rectangleProgress: CGFloat public var theme: CheckNodeTheme { didSet { self.setNeedsDisplay() @@ -91,6 +123,7 @@ public class CheckNode: ASDisplayNode { public init(theme: CheckNodeTheme, content: CheckNodeContent = .check(isRectangle: false)) { self.theme = theme self.content = content + self.rectangleProgress = content.rectangleProgressValue super.init() @@ -99,7 +132,34 @@ public class CheckNode: ASDisplayNode { public var content: CheckNodeContent { didSet { - self.setNeedsDisplay() + if oldValue == self.content { + return + } + + let targetProgress = self.content.rectangleProgressValue + if oldValue.rectangleProgressValue != targetProgress { + let animation = POPBasicAnimation() + animation.property = (POPAnimatableProperty.property(withName: "rectangleProgress", initializer: { property in + property?.readBlock = { node, values in + values?.pointee = (node as! CheckNode).rectangleProgress + } + property?.writeBlock = { node, values in + let node = node as! CheckNode + node.rectangleProgress = values!.pointee + node.setNeedsDisplay() + } + property?.threshold = 0.01 + }) as! POPAnimatableProperty) + animation.fromValue = NSNumber(value: Double(self.rectangleProgress)) + animation.toValue = NSNumber(value: Double(targetProgress)) + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + animation.duration = 0.2 + self.pop_add(animation, forKey: "rectangleProgress") + } else { + self.pop_removeAnimation(forKey: "rectangleProgress") + self.rectangleProgress = targetProgress + self.setNeedsDisplay() + } } } @@ -152,7 +212,7 @@ public class CheckNode: ASDisplayNode { }) } } else { - self.pop_removeAllAnimations() + self.pop_removeAnimation(forKey: "progress") self.animatingOut = false self.animationProgress = selected ? 1.0 : 0.0 self.setNeedsDisplay() @@ -163,7 +223,7 @@ public class CheckNode: ASDisplayNode { } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return CheckNodeParameters(isRectangle: self.content == .check(isRectangle: true), theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + return CheckNodeParameters(theme: self.theme, content: self.content.renderedContent, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut, rectangleProgress: self.rectangleProgress) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { @@ -244,6 +304,8 @@ public class InteractiveCheckNode: CheckNode { public class CheckLayer: CALayer { private var animatingOut = false private var animationProgress: CGFloat = 0.0 + private var rectangleProgress: CGFloat = 0.0 + public var theme: CheckNodeTheme { didSet { self.setNeedsDisplay() @@ -266,6 +328,9 @@ public class CheckLayer: CALayer { self.theme = layer.theme self.content = layer.content + self.animatingOut = layer.animatingOut + self.animationProgress = layer.animationProgress + self.rectangleProgress = layer.rectangleProgress super.init(layer: layer) @@ -275,6 +340,7 @@ public class CheckLayer: CALayer { public init(theme: CheckNodeTheme, content: CheckNodeContent = .check(isRectangle: false)) { self.theme = theme self.content = content + self.rectangleProgress = content.rectangleProgressValue super.init() @@ -291,7 +357,36 @@ public class CheckLayer: CALayer { public var content: CheckNodeContent { didSet { - self.setNeedsDisplay() + if oldValue != self.content { + let targetProgress = self.content.rectangleProgressValue + + if oldValue.rectangleProgressValue != targetProgress { + let animation = POPBasicAnimation() + animation.property = (POPAnimatableProperty.property(withName: "rectangleProgress", initializer: { property in + property?.readBlock = { node, values in + values?.pointee = (node as! CheckLayer).rectangleProgress + } + property?.writeBlock = { node, values in + let layer = node as! CheckLayer + layer.rectangleProgress = values!.pointee + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.display() + CATransaction.commit() + } + property?.threshold = 0.01 + }) as! POPAnimatableProperty) + animation.fromValue = NSNumber(value: Double(self.rectangleProgress)) + animation.toValue = NSNumber(value: Double(targetProgress)) + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + animation.duration = 0.2 + self.pop_add(animation, forKey: "rectangleProgress") + } else { + self.pop_removeAnimation(forKey: "rectangleProgress") + self.rectangleProgress = targetProgress + self.setNeedsDisplay() + } + } } } @@ -348,7 +443,7 @@ public class CheckLayer: CALayer { } } } else { - self.pop_removeAllAnimations() + self.pop_removeAnimation(forKey: "progress") self.animatingOut = false self.animationProgress = selected ? 1.0 : 0.0 self.setNeedsDisplay() @@ -362,13 +457,14 @@ public class CheckLayer: CALayer { if self.bounds.isEmpty { return } - self.contents = generateImage(self.bounds.size, rotatedContext: { size, context in + let image = generateImage(self.bounds.size, rotatedContext: { size, context in CheckLayer.drawContents( context: context, size: size, - parameters: CheckNodeParameters(isRectangle: self.content == .check(isRectangle: true), theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + parameters: CheckNodeParameters(theme: self.theme, content: self.content.renderedContent, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut, rectangleProgress: self.rectangleProgress) ) - })?.cgImage + }) + self.contents = image?.cgImage } fileprivate static func drawContents(context: CGContext, size: CGSize, parameters: CheckNodeParameters) { @@ -402,14 +498,19 @@ public class CheckLayer: CALayer { context.setAlpha(parameters.animationProgress) } } - + + let rectProgress = parameters.rectangleProgress + let cornerRadius = self.cornerRadius(for: size, progress: rectProgress, minCornerRadius: ceil(size.width * 0.318)) + let innerCornerRadius = self.cornerRadius(for: size, progress: rectProgress, minCornerRadius: ceil(size.width * 0.318) - 1.0) + if !parameters.theme.filledBorder && !parameters.theme.hasShadow && !parameters.theme.overlayBorder { if parameters.theme.isDottedBorder { checkProgress = 0.0 let borderInset = borderWidth / 2.0 + inset let borderFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: borderInset, dy: borderInset) context.setLineDash(phase: -6.4, lengths: [4.0, 4.0]) - context.strokeEllipse(in: borderFrame) + context.addPath(self.roundedRectPath(in: borderFrame, cornerRadius: self.cornerRadius(for: borderFrame.size, progress: rectProgress, minCornerRadius: 7.0))) + context.strokePath() } else { checkProgress = parameters.animationProgress @@ -417,23 +518,18 @@ public class CheckLayer: CALayer { context.setFillColor(parameters.theme.backgroundColor.mixedWith(parameters.theme.borderColor, alpha: 1.0 - fillProgress).cgColor) - 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)) - } + context.addPath(self.roundedRectPath(in: CGRect(origin: .zero, size: size), cornerRadius: cornerRadius)) + context.fillPath() + let innerDiameter: CGFloat = (fillProgress * 0.0) + (1.0 - fillProgress) * (size.width - borderWidth * 2.0) context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) - 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.addPath(self.roundedRectPath(in: CGRect(origin: CGPoint(x: (size.width - innerDiameter) * 0.5, y: (size.height - innerDiameter) * 0.5), size: CGSize(width: innerDiameter, height: innerDiameter)), cornerRadius: innerCornerRadius)) + context.fillPath() + context.setBlendMode(.normal) } } else { @@ -454,7 +550,9 @@ public class CheckLayer: CALayer { context.setShadow(offset: CGSize(), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor) } - context.strokeEllipse(in: borderFrame.insetBy(dx: borderFrame.width * (1.0 - borderProgress), dy: borderFrame.height * (1.0 - borderProgress))) + let borderRect = borderFrame.insetBy(dx: borderFrame.width * (1.0 - borderProgress), dy: borderFrame.height * (1.0 - borderProgress)) + context.addPath(self.roundedRectPath(in: borderRect, cornerRadius: self.cornerRadius(for: borderRect.size, progress: rectProgress, minCornerRadius: 7.0))) + context.strokePath() context.restoreGState() if !parameters.theme.filledBorder { @@ -465,7 +563,9 @@ public class CheckLayer: CALayer { let fillInset = parameters.theme.overlayBorder ? borderWidth + inset : inset let fillFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: fillInset, dy: fillInset) - context.fillEllipse(in: fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress))) + let fillRect = fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress)) + context.addPath(self.roundedRectPath(in: fillRect, cornerRadius: self.cornerRadius(for: fillRect.size, progress: rectProgress, minCornerRadius: 6.0))) + context.fillPath() } switch parameters.content { @@ -514,4 +614,40 @@ public class CheckLayer: CALayer { text.draw(at: CGPoint(x: textRect.minX + floorToScreenPixels((size.width - textRect.width) * 0.5), y: textRect.minY + floorToScreenPixels((size.height - textRect.height) * 0.5))) } } + + private static func cornerRadius(for size: CGSize, progress: CGFloat, minCornerRadius: CGFloat) -> CGFloat { + let maxCornerRadius = min(size.width, size.height) / 2.0 + let minCornerRadius = min(minCornerRadius, maxCornerRadius) + return maxCornerRadius - (maxCornerRadius - minCornerRadius) * progress + } + + private static func roundedRectPath(in rect: CGRect, cornerRadius: CGFloat) -> CGPath { + let path = CGMutablePath() + guard !rect.isEmpty else { + return path + } + + let radius = min(max(cornerRadius, 0.0), min(rect.width, rect.height) / 2.0) + if radius <= 0.0 { + path.addRect(rect) + return path + } + + let minX = rect.minX + let maxX = rect.maxX + let minY = rect.minY + let maxY = rect.maxY + + path.move(to: CGPoint(x: minX + radius, y: minY)) + path.addLine(to: CGPoint(x: maxX - radius, y: minY)) + path.addArc(center: CGPoint(x: maxX - radius, y: minY + radius), radius: radius, startAngle: -.pi / 2.0, endAngle: 0.0, clockwise: false) + path.addLine(to: CGPoint(x: maxX, y: maxY - radius)) + path.addArc(center: CGPoint(x: maxX - radius, y: maxY - radius), radius: radius, startAngle: 0.0, endAngle: .pi / 2.0, clockwise: false) + path.addLine(to: CGPoint(x: minX + radius, y: maxY)) + path.addArc(center: CGPoint(x: minX + radius, y: maxY - radius), radius: radius, startAngle: .pi / 2.0, endAngle: .pi, clockwise: false) + path.addLine(to: CGPoint(x: minX, y: minY + radius)) + path.addArc(center: CGPoint(x: minX + radius, y: minY + radius), radius: radius, startAngle: .pi, endAngle: 3.0 * .pi / 2.0, clockwise: false) + path.closeSubpath() + return path + } } diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 4dfdbc6985..0ee779aa09 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -1912,6 +1912,8 @@ public class GalleryController: ViewController, StandalonePresentableController, self.galleryNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) if !self.adjustedForInitialPreviewingLayout && self.isPresentedInPreviewingContext() { + self.navigationBar?.isHidden = true + self.adjustedForInitialPreviewingLayout = true self.galleryNode.setControlsHidden(true, animated: false) if let centralItemNode = self.galleryNode.pager.centralItemNode(), let itemSize = centralItemNode.contentSize() { diff --git a/submodules/LegacyComponents/Sources/TGIconSwitchView.m b/submodules/LegacyComponents/Sources/TGIconSwitchView.m index dc184934e8..c93256d074 100644 --- a/submodules/LegacyComponents/Sources/TGIconSwitchView.m +++ b/submodules/LegacyComponents/Sources/TGIconSwitchView.m @@ -104,7 +104,7 @@ static const void *positionChangedKey = &positionChangedKey; offset = CGPointMake(-7.0, -3.0); } - if (_isLocked.boolValue) { + if (_isLocked.boolValue || [_positiveContentColor isEqual:[UIColor clearColor]]) { _offIconView.frame = CGRectOffset(_offIconView.bounds, TGScreenPixelFloor(21.5f) + offset.x, TGScreenPixelFloor(12.5f) + offset.y); } else { _offIconView.frame = CGRectOffset(_offIconView.bounds, TGScreenPixelFloor(21.5f) + offset.x, TGScreenPixelFloor(14.5f) + offset.y); @@ -150,10 +150,11 @@ static const void *positionChangedKey = &positionChangedKey; self.backgroundColor = nil; if (_isLocked.boolValue) { - _offIconView.hidden = false; + _offIconView.alpha = 1.0; } else { - _offIconView.hidden = true; + _offIconView.alpha = 0.0; } + _onIconView.hidden = true; } else { self.tintColor = [UIColor redColor]; self.backgroundColor = [UIColor redColor]; @@ -162,12 +163,13 @@ static const void *positionChangedKey = &positionChangedKey; - (void)setNegativeContentColor:(UIColor *)color { _negativeContentColor = color; - if (_isLocked.boolValue) { + if (_isLocked.boolValue || [_positiveContentColor isEqual:[UIColor clearColor]]) { _offIconView.image = TGTintedImage(TGComponentsImageNamed(@"Item List/SwitchLockIcon"), color); } else { _offIconView.image = TGTintedImage(TGComponentsImageNamed(@"PermissionSwitchOff.png"), color); } _offIconView.frame = CGRectMake(_offIconView.frame.origin.x, _offIconView.frame.origin.y, _offIconView.image.size.width, _offIconView.image.size.height); + [self updateIconFrame]; } - (void)updateIsLocked:(bool)isLocked { @@ -176,12 +178,17 @@ static const void *positionChangedKey = &positionChangedKey; if ([_positiveContentColor isEqual:[UIColor clearColor]]) { if (!isLocked) { - _offIconView.hidden = true; + [UIView animateWithDuration:0.2 animations:^{ + _offIconView.alpha = 0.0; + }]; } else { - _offIconView.hidden = false; + [UIView animateWithDuration:0.2 animations:^{ + _offIconView.alpha = 1.0; + }]; } _onIconView.hidden = true; } else { + _offIconView.alpha = 1.0; _offIconView.hidden = false; _onIconView.hidden = false; } diff --git a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift index 521b478841..2621570cd5 100644 --- a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift +++ b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift @@ -525,8 +525,8 @@ private final class StickerPackContainer: ASDisplayNode { if let strongSelf = self { let _ = (strongSelf.context.engine.stickers.toggleStickerSaved(file: item.file._parse(), saved: !isStarred) |> deliverOnMainQueue).start(next: { [weak self] result in - if let self, let contorller = self.controller { - contorller.present(UndoOverlayController(presentationData: self.presentationData, content: .sticker(context: context, file: item.file._parse(), loop: true, title: nil, text: !isStarred ? self.presentationData.strings.Conversation_StickerAddedToFavorites : self.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .window(.root)) + if let self, let controller = self.controller { + controller.present(UndoOverlayController(presentationData: self.presentationData, content: .sticker(context: context, file: item.file._parse(), loop: true, title: nil, text: !isStarred ? self.presentationData.strings.Conversation_StickerAddedToFavorites : self.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .window(.root)) } }) } diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift index 1f56e6e48a..29c4126a9c 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift @@ -1414,9 +1414,9 @@ public struct PresentationResourcesChat { return theme.image(PresentationResourceKey.chatPollAddIcon.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) + context.setFillColor(UIColor.white.cgColor) - let lineHeight = 1.0 + UIScreenPixel + let lineHeight = 2.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() diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift index 43e58acc9d..53dfc4e926 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift @@ -206,10 +206,33 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { automaticDownload = .full } } else if let poll = media as? TelegramMediaPoll { - if let image = poll.attachedMedia as? TelegramMediaImage { - selectedMedia = image - } else if let file = poll.attachedMedia as? TelegramMediaFile { - selectedMedia = file + if let telegramImage = poll.attachedMedia as? TelegramMediaImage { + selectedMedia = telegramImage + if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramImage) { + automaticDownload = .full + } + + if let _ = telegramImage.video { + automaticPlayback = true + } + } else if let telegramFile = poll.attachedMedia as? TelegramMediaFile { + selectedMedia = telegramFile + if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramFile) { + automaticDownload = .full + } else if shouldPredownloadMedia(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, media: telegramFile) { + automaticDownload = .prefetch + } + if (telegramFile.isVideo && !telegramFile.isAnimated) && item.context.sharedContext.energyUsageSettings.autoplayVideo { + if let _ = telegramFile.videoCover { + automaticPlayback = false + } else if NativeVideoContent.isHLSVideo(file: telegramFile) { + automaticPlayback = true + } else if case .full = automaticDownload { + automaticPlayback = true + } else { + automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil + } + } } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD index f3a6012df1..2199a250f0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD @@ -33,7 +33,7 @@ swift_library( "//submodules/PhotoResources", "//submodules/LocationResources", "//submodules/TelegramUI/Components/ChatControllerInteraction", - "//submodules/SemanticStatusNode", + "//submodules/RadialStatusNode", "//submodules/TelegramUI/Components/ComposePollScreen", ], visibility = [ diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index e8b3a58ccb..03172fdb41 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -22,8 +22,11 @@ import TextNodeWithEntities import ShimmeringLinkNode import EmojiTextAttachmentView import ChatControllerInteraction -import SemanticStatusNode +import RadialStatusNode import ComposePollScreen +import ComponentFlow +import PlainButtonComponent +import LottieComponent private final class ChatMessagePollOptionRadioNodeParameters: NSObject { let timestamp: Double @@ -85,9 +88,13 @@ private final class ChatMessagePollOptionRadioNode: ASDisplayNode { func updateIsChecked(_ value: Bool, animated: Bool) { if let previousValue = self.isChecked, previousValue != value { - self.checkTransition = ChatMessagePollOptionRadioNodeCheckTransition(startTime: CACurrentMediaTime(), duration: 0.15, previousValue: previousValue, updatedValue: value) + if animated { + self.checkTransition = ChatMessagePollOptionRadioNodeCheckTransition(startTime: CACurrentMediaTime(), duration: 0.15, previousValue: previousValue, updatedValue: value) + } self.isChecked = value - self.updateAnimating() + if animated { + self.updateAnimating() + } self.setNeedsDisplay() } } @@ -410,8 +417,9 @@ private func generateCountImage(presentationData: ChatPresentationData, incoming return generateImage(imageSize, rotatedContext: { size, context in UIGraphicsPushContext(context) context.clear(CGRect(origin: CGPoint(), size: size)) + let string = NSAttributedString( - string: "\(value)", + string: value == 0 ? "" : compactNumericCountString(Int(value), decimalSeparator: presentationData.dateTimeFormat.decimalSeparator), font: countFont, textColor: incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor, paragraphAlignment: .right @@ -478,7 +486,10 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { private(set) var mediaFrame: CGRect? private var currentMedia: Media? private var currentRecentVoterPeerIds: [PeerId] = [] + private var fetchDisposable = MetaDisposable() + var option: TelegramMediaPollOption? + var forceSelected: Bool? private(set) var currentResult: ChatMessagePollOptionResult? private(set) var currentSelection: ChatMessagePollOptionSelection? var pressed: (() -> Void)? @@ -699,6 +710,10 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } } + deinit { + self.fetchDisposable.dispose() + } + override func didLoad() { super.didLoad() @@ -711,6 +726,10 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { self.ignoreNextTap = false return } + + guard self.forceSelected == nil else { + return + } if let radioNode = self.radioNode, let isChecked = radioNode.isChecked { radioNode.updateIsChecked(!isChecked, animated: true) @@ -743,13 +762,13 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { }) } - static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) { + static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) { let makeTitleLayout = TextNodeWithEntities.asyncLayout(maybeNode?.titleNode) let currentResult = maybeNode?.currentResult let currentSelection = maybeNode?.currentSelection let currentTheme = maybeNode?.theme - return { context, presentationData, presentationContext, message, poll, option, translation, optionResult, hasAnyMedia, constrainedWidth in + return { context, presentationData, presentationContext, message, poll, option, translation, optionResult, forceSelected, hasAnyMedia, constrainedWidth in let leftInset: CGFloat = 50.0 let media = option.media let mediaInset: CGFloat @@ -786,7 +805,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let shouldHaveRadioNode = optionResult == nil let isSelectable: Bool - if shouldHaveRadioNode, poll.kind.multipleAnswers, !Namespaces.Message.allNonRegular.contains(message.id.namespace) { + if shouldHaveRadioNode, poll.kind.multipleAnswers, forceSelected == nil, !Namespaces.Message.allNonRegular.contains(message.id.namespace) { isSelectable = true } else { isSelectable = false @@ -817,7 +836,8 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: optionAttributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: max(1.0, constrainedWidth - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude), alignment: .left, cutout: nil, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0))) - let contentHeight: CGFloat = max(52.0, titleLayout.size.height + 28.0) + let contentLayoutHeight: CGFloat = max(52.0, titleLayout.size.height + 28.0) + let contentHeight: CGFloat = contentLayoutHeight + (optionResult != nil ? 7.0 : 0.0) var resultIcon: UIImage? var updatedResultIcon = false @@ -904,6 +924,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } node.option = option + node.forceSelected = forceSelected node.context = context node.message = message let previousMedia = node.currentMedia @@ -977,7 +998,14 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } let radioSize: CGFloat = 22.0 radioNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 15.0), size: CGSize(width: radioSize, height: radioSize)) - 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) + 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 || forceSelected != nil, isAnimating: inProgress) + + if let forceSelected { + radioNode.updateIsChecked(forceSelected, animated: false) + radioNode.isUserInteractionEnabled = false + } else { + radioNode.isUserInteractionEnabled = true + } } else if let radioNode = node.radioNode { node.radioNode = nil if animated { @@ -1024,7 +1052,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let mediaFrame: CGRect? if let _ = media { - mediaFrame = CGRect(origin: CGPoint(x: width - ChatMessagePollOptionNode.mediaRightInset - ChatMessagePollOptionNode.mediaSize.width, y: floor((contentHeight - ChatMessagePollOptionNode.mediaSize.height) * 0.5)), size: ChatMessagePollOptionNode.mediaSize) + mediaFrame = CGRect(origin: CGPoint(x: width - ChatMessagePollOptionNode.mediaRightInset - ChatMessagePollOptionNode.mediaSize.width, y: floor((contentLayoutHeight - ChatMessagePollOptionNode.mediaSize.height) * 0.5)), size: ChatMessagePollOptionNode.mediaSize) trailingOriginX = mediaFrame!.minX - 12.0 } else { mediaFrame = nil @@ -1032,7 +1060,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { node.mediaFrame = mediaFrame if !recentVoterPeers.isEmpty { - let avatarsFrame = CGRect(origin: CGPoint(x: trailingOriginX + 15.0 - ChatMessagePollOptionNode.avatarsSize.width, y: floor((contentHeight - ChatMessagePollOptionNode.avatarsSize.height) * 0.5)), size: ChatMessagePollOptionNode.avatarsSize) + let avatarsFrame = CGRect(origin: CGPoint(x: trailingOriginX + 15.0 - ChatMessagePollOptionNode.avatarsSize.width, y: floor((contentLayoutHeight - ChatMessagePollOptionNode.avatarsSize.height) * 0.5)), size: ChatMessagePollOptionNode.avatarsSize) node.avatarsNode.frame = avatarsFrame node.avatarsNode.updateLayout(size: avatarsFrame.size) node.avatarsNode.update(context: context, peers: recentVoterPeers, synchronousLoad: attemptSynchronous, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth) @@ -1044,7 +1072,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } if let image = node.countImage, displayCount { - let countFrame = CGRect(origin: CGPoint(x: trailingOriginX - image.size.width, y: floor((contentHeight - image.size.height) * 0.5)), size: image.size) + let countFrame = CGRect(origin: CGPoint(x: trailingOriginX - image.size.width, y: floor((contentLayoutHeight - image.size.height) * 0.5)), size: image.size) node.countNode.frame = countFrame if animated && previousResult?.count != optionResult?.count { let countDuration = 0.27 @@ -1060,8 +1088,13 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } var isSticker = false - if let media, let mediaFrame, let file = media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { + if let media, var mediaFrame, let file = media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { isSticker = true + + if let dimensions = file.dimensions { + let mediaSize = dimensions.cgSize.aspectFitted(mediaFrame.size) + mediaFrame = CGRect(origin: CGPoint(x: mediaFrame.midX - mediaSize.width * 0.5, y: mediaFrame.midY - mediaSize.height * 0.5), size: mediaSize) + } let stickerLayer: InlineStickerItemLayer if let current = node.stickerMediaLayer, previousMedia?.isEqual(to: media) == true { @@ -1097,6 +1130,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } } + var updatedFetchSignal: Signal? if let media, let mediaFrame, !isSticker { let mediaNode: TransformImageNode if let current = node.mediaNode { @@ -1111,13 +1145,14 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { mediaNode.isHidden = false mediaNode.frame = mediaFrame - let mediaReference = AnyMediaReference.standalone(media: media) + let mediaReference = AnyMediaReference.message(message: MessageReference(message), media: media) var imageSize = ChatMessagePollOptionNode.mediaSize var isVideo = false if let image = media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { imageSize = largest.dimensions.cgSize.aspectFilled(ChatMessagePollOptionNode.mediaSize) if previousMedia?.isEqual(to: media) != true, let photoReference = mediaReference.concrete(TelegramMediaImage.self) { - mediaNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: .other, photoReference: photoReference)) + mediaNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), photoReference: photoReference)) + updatedFetchSignal = messageMediaImageInteractiveFetched(context: context, message: message, image: image, resource: largest.resource, storeToDownloadsPeerId: nil) } } else if let file = media as? TelegramMediaFile { if let dimensions = file.dimensions { @@ -1125,9 +1160,9 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } if let fileReference = mediaReference.concrete(TelegramMediaFile.self), previousMedia?.isEqual(to: media) != true { if file.mimeType.hasPrefix("image/") { - mediaNode.setSignal(instantPageImageFile(account: context.account, userLocation: .other, fileReference: fileReference, fetched: true)) + mediaNode.setSignal(instantPageImageFile(account: context.account, userLocation: .peer(message.id.peerId), fileReference: fileReference, fetched: true)) } else { - mediaNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: .other, videoReference: fileReference)) + mediaNode.setSignal(mediaGridMessageVideo(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), videoReference: fileReference, autoFetchFullSizeThumbnail: true)) } } isVideo = file.isVideo @@ -1178,13 +1213,17 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { node.mediaVideoIconNode = nil } node.setMediaHidden(node.mediaHidden) + + if let updatedFetchSignal { + node.fetchDisposable.set(updatedFetchSignal.start()) + } node.buttonNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: CGSize(width: width - 2.0, height: contentHeight)) if node.highlightedBackgroundNode.supernode == node { 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 - 10.0, height: UIScreenPixel)) + node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - UIScreenPixel), size: CGSize(width: width - leftInset - 10.0, height: UIScreenPixel)) node.containerNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight)) node.contextSourceNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight)) node.contextSourceNode.contentRect = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight)) @@ -1229,8 +1268,8 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let minBarWidth: CGFloat = 6.0 let maxBarWidth = width - leftInset - rightInset let resultBarWidth = minBarWidth + floor((width - leftInset - rightInset - minBarWidth) * (optionResult?.normalized ?? 0.0)) - let barFrame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - 6.0 - 1.0), size: CGSize(width: resultBarWidth, height: 4.0)) - node.resultBarBackgroundNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - 6.0 - 1.0), size: CGSize(width: maxBarWidth, height: 4.0)) + let barFrame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - 6.0 - 1.0), size: CGSize(width: resultBarWidth, height: 4.0)) + node.resultBarBackgroundNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - 6.0 - 1.0), size: CGSize(width: maxBarWidth, height: 4.0)) node.resultBarNode.frame = barFrame node.resultBarIconNode.frame = CGRect(origin: CGPoint(x: barFrame.minX - 6.0 - 16.0, y: barFrame.minY + floor((barFrame.height - 16.0) / 2.0)), size: CGSize(width: 16.0, height: 16.0)) node.resultBarBackgroundNode.alpha = optionResult != nil ? 1.0 : 0.0 @@ -1286,16 +1325,19 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN let progress: CGFloat? } - private let textNode: EditableTextNode + fileprivate let textNode: EditableTextNode private let placeholderNode: ImmediateTextNode private let measureTextNode: ImmediateTextNode + private let leftAccessoryButton: HighlightableButtonNode + private let addIconNode: ASImageNode let separatorNode: ASDisplayNode private let imageButton: HighlightTrackingButtonNode private var attachButton: HighlightableButtonNode? + private var modeSelector: ComponentView? private var imageNode: TransformImageNode? private var animationLayer: InlineStickerItemLayer? - private var statusNode: SemanticStatusNode? + private var statusNode: RadialStatusNode? private var videoIconView: UIImageView? private var appliedMedia: AnyMediaReference? @@ -1306,15 +1348,19 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN private var currentTintColor: UIColor? private var currentMeasuredHeight: CGFloat? private var currentTextWidth: CGFloat = 0.0 + private var currentSize: CGSize = .zero + private var currentIsEditing = false private var currentAttachment: Attachment? private var currentContext: AccountContext? private var currentTheme: PresentationTheme? + private var currentIncoming = false var textUpdated: ((String) -> Void)? var heightUpdated: (() -> Void)? var focusUpdated: ((Bool) -> Void)? var attachPressed: (() -> Void)? var mediaPressed: (() -> Void)? + var modeSelectorPressed: (() -> Void)? static let characterLimit = 100 private static let leftInset: CGFloat = 50.0 @@ -1338,15 +1384,24 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN self.measureTextNode.isUserInteractionEnabled = false self.measureTextNode.lineSpacing = 0.1 + self.leftAccessoryButton = HighlightableButtonNode() + + self.addIconNode = ASImageNode() + self.addIconNode.displaysAsynchronously = false + self.addIconNode.isUserInteractionEnabled = false + self.separatorNode = ASDisplayNode() self.imageButton = HighlightTrackingButtonNode() super.init() + self.addSubnode(self.leftAccessoryButton) + self.addSubnode(self.addIconNode) self.addSubnode(self.textNode) self.addSubnode(self.placeholderNode) self.addSubnode(self.separatorNode) + self.leftAccessoryButton.addTarget(self, action: #selector(self.leftAccessoryPressed), forControlEvents: .touchUpInside) self.imageButton.isExclusiveTouch = true self.imageButton.addTarget(self, action: #selector(self.imageButtonPressed), forControlEvents: .touchUpInside) @@ -1399,10 +1454,14 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN } func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { + self.currentIsEditing = true + self.updateModeSelectorLayout(size: self.currentSize, theme: self.currentTheme, animated: true) self.focusUpdated?(true) } func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { + self.currentIsEditing = false + self.updateModeSelectorLayout(size: self.currentSize, theme: self.currentTheme, animated: true) self.focusUpdated?(false) } @@ -1472,6 +1531,9 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN node.currentPlaceholderColor = placeholderColor node.placeholderNode.attributedText = NSAttributedString(string: strings.CreatePoll_AddOption, font: font, textColor: placeholderColor) } + if node.currentTheme !== presentationData.theme.theme || node.addIconNode.image == nil { + node.addIconNode.image = generateTintedImage(image: PresentationResourcesChat.chatPollAddIcon(presentationData.theme.theme), color: secondaryTextColor.withMultipliedAlpha(0.7)) + } if node.text != text, let textColor = node.currentTextColor, let font = node.currentFont { node.textNode.attributedText = NSAttributedString(string: text, font: font, textColor: textColor) @@ -1479,15 +1541,20 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN node.placeholderNode.isHidden = !text.isEmpty node.currentTextWidth = textWidth node.currentMeasuredHeight = measureSize.height + node.currentSize = size + node.currentIsEditing = node.textNode.textView.isFirstResponder node.currentAttachment = attachment node.currentContext = context node.currentTheme = presentationData.theme.theme + node.currentIncoming = incoming let placeholderSize = node.placeholderNode.updateLayout(CGSize(width: textWidth, height: .greatestFiniteMagnitude)) + node.leftAccessoryButton.frame = CGRect(origin: .zero, size: CGSize(width: ChatMessagePollAddOptionNode.leftInset, height: contentHeight)) node.placeholderNode.frame = CGRect(origin: CGPoint(x: ChatMessagePollAddOptionNode.leftInset, y: ChatMessagePollAddOptionNode.verticalInset), size: placeholderSize) node.textNode.frame = CGRect(origin: CGPoint(x: ChatMessagePollAddOptionNode.leftInset, y: ChatMessagePollAddOptionNode.verticalInset), size: CGSize(width: textWidth, height: max(contentHeight, 1000.0))) node.separatorNode.frame = CGRect(origin: CGPoint(x: ChatMessagePollAddOptionNode.leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - ChatMessagePollAddOptionNode.leftInset - 10.0, height: UIScreenPixel)) node.separatorNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.separator : presentationData.theme.theme.chat.message.outgoing.polls.separator + node.updateModeSelectorLayout(size: size, theme: presentationData.theme.theme, animated: false) node.updateAttachmentLayout(size: size, tintColor: secondaryTextColor) return node @@ -1496,6 +1563,10 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN } } + @objc private func leftAccessoryPressed() { + self.modeSelectorPressed?() + } + @objc private func imageButtonPressed() { self.mediaPressed?() } @@ -1504,6 +1575,62 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN self.attachPressed?() } + private func updateModeSelectorLayout(size: CGSize, theme: PresentationTheme?, animated: Bool) { + guard !size.width.isZero, !size.height.isZero, let theme = self.currentTheme else { + return + } + let secondaryTextColor = self.currentIncoming ? theme.chat.message.incoming.secondaryTextColor : theme.chat.message.outgoing.secondaryTextColor + + let addIconSize = self.addIconNode.image?.size ?? .zero + self.addIconNode.frame = CGRect(origin: CGPoint(x: floor((ChatMessagePollAddOptionNode.leftInset - addIconSize.width) * 0.5) - 2.0, y: floor((size.height - addIconSize.height) * 0.5)), size: addIconSize) + + let displaySelector = self.currentIsEditing + self.addIconNode.isHidden = displaySelector + + if displaySelector { + let modeSelectorSize = CGSize(width: 32.0, height: 32.0) + let modeSelectorFrame = CGRect(origin: CGPoint(x: floor((ChatMessagePollAddOptionNode.leftInset - modeSelectorSize.width) * 0.5) - 2.0, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize) + + let modeSelector: ComponentView + if let current = self.modeSelector { + modeSelector = current + } else { + modeSelector = ComponentView() + self.modeSelector = modeSelector + } + + let _ = modeSelector.update( + transition: animated ? .easeInOut(duration: 0.2) : .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "input_anim_keyToSmile"), + color: secondaryTextColor, + size: modeSelectorSize + )), + effectAlignment: .center, + action: { [weak self] in + self?.leftAccessoryPressed() + }, + animateScale: false + )), + environment: {}, + containerSize: modeSelectorSize + ) + + if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View { + if modeSelectorView.superview == nil { + self.view.addSubview(modeSelectorView) + } + modeSelectorView.frame = modeSelectorFrame + modeSelectorView.alpha = 1.0 + modeSelectorView.transform = .identity + } + } else if let modeSelector = self.modeSelector { + self.modeSelector = nil + modeSelector.view?.removeFromSuperview() + } + } + private func updateAttachmentLayout(size: CGSize, tintColor: UIColor) { let imageNodeSize = CGSize(width: 40.0, height: 40.0) let imageNodeFrame = CGRect(origin: CGPoint(x: size.width - 10.0 - imageNodeSize.width, y: size.height - ChatMessagePollAddOptionNode.minHeight + floor((ChatMessagePollAddOptionNode.minHeight - imageNodeSize.height) * 0.5)), size: imageNodeSize) @@ -1625,17 +1752,17 @@ private final class ChatMessagePollAddOptionNode: ASDisplayNode, ASEditableTextN self.imageButton.isHidden = false if let progress = attachment.progress { - let statusNode: SemanticStatusNode + let statusNode: RadialStatusNode if let current = self.statusNode { statusNode = current } else { - let current = SemanticStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5), foregroundNodeColor: .white) + let current = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5)) self.statusNode = current self.addSubnode(current) statusNode = current } - statusNode.frame = imageNodeFrame.insetBy(dx: 6.0, dy: 6.0) - statusNode.transitionToState(.progress(value: max(0.027, min(1.0, progress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: false), updateCutout: false) + statusNode.frame = imageNodeFrame.insetBy(dx: 4.0, dy: 4.0) + statusNode.transitionToState(.progress(color: .white, lineWidth: 2.0, value: max(0.027, min(1.0, progress)), cancelEnabled: true, animateRotation: false)) isVideo = false } else if let statusNode = self.statusNode { self.statusNode = nil @@ -1751,7 +1878,6 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { private let statusNode: ChatMessageDateAndStatusNode private var optionNodes: [ChatMessagePollOptionNode] = [] private var addOptionNode: ChatMessagePollAddOptionNode? - private var shuffledOptionOpaqueIdentifiers: [Data]? private var shimmeringNodes: [ShimmeringLinkNode] = [] private let temporaryHiddenMediaDisposable = MetaDisposable() @@ -1760,7 +1886,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { private var currentNewOptionMedia: AttachedMedia? private var pendingNewOptionSubmissionText: String? private var pendingNewOptionOptionCount: Int? - private var pollOptionsDisabledForAddOptionFocusMessageId: MessageId? + private var newOptionIsFocused = false private var isPreviewingResults = false @@ -1924,7 +2050,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { guard let item = self.item else { return } - let arePollOptionsDisabled = self.pollOptionsDisabledForAddOptionFocusMessageId == item.message.id + let arePollOptionsDisabled = self.newOptionIsFocused let isPollActionInProgress = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil for optionNode in self.optionNodes { @@ -1937,11 +2063,6 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } - private func setPollOptionsDisabledForAddOptionFocus(messageId: MessageId?, animated: Bool) { - self.pollOptionsDisabledForAddOptionFocusMessageId = messageId - self.updatePollOptionsInteraction(animated: animated) - } - private func requestNewOptionLayoutUpdate() { guard let item = self.item else { return @@ -1949,34 +2070,29 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { item.controllerInteraction.requestMessageUpdate(item.message.id, false) } - private func updateFocusedPollAddOptionMessageId(_ messageId: MessageId?) { + private func updatePollAddOptionFocused(_ focus: Bool) { guard let item = self.item else { return } + self.newOptionIsFocused = focus item.controllerInteraction.updatePresentationState { state in - if state.focusedPollAddOptionMessageId == messageId { - return state + if focus { + if state.focusedPollAddOptionMessageId == item.message.id { + return state + } + return state.updatedFocusedPollAddOptionMessageId(item.message.id) + } else { + if state.focusedPollAddOptionMessageId != item.message.id { + return state + } + return state.updatedFocusedPollAddOptionMessageId(nil) } - return state.updatedFocusedPollAddOptionMessageId(messageId) } + self.updatePollOptionsInteraction(animated: true) } - private func clearFocusedPollAddOptionMessageIdIfNeeded(for messageId: MessageId) { - guard let item = self.item else { - return - } - item.controllerInteraction.updatePresentationState { state in - if state.focusedPollAddOptionMessageId != messageId { - return state - } - return state.updatedFocusedPollAddOptionMessageId(nil) - } - } - - private func clearOpenAnswerInput() { - if let item = self.item { - self.clearFocusedPollAddOptionMessageIdIfNeeded(for: item.message.id) - } + private func clearNewOptionInput() { + self.updatePollAddOptionFocused(false) self.currentNewOptionText = "" self.currentNewOptionMedia?.uploadDisposable?.dispose() self.currentNewOptionMedia = nil @@ -1986,8 +2102,23 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { self.addOptionNode?.resignInput() self.requestNewOptionLayoutUpdate() } + + private func toggleNewOptionInputMode() { + guard let item = self.item else { + return + } + item.controllerInteraction.updatePresentationState { state in + return state.updatedInputMode({ inputMode in + if case .media = inputMode { + return .text + } else { + return .media(mode: .other, expanded: .none, focused: true) + } + }) + } + } - private func openOpenAnswerAttachment() { + private func openNewOptionAttachment() { guard let item = self.item else { return } @@ -2094,100 +2225,6 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } - private func openOptionMedia(option: TelegramMediaPollOption) { - guard let item = self.item, let media = option.media else { - return - } - - var attributes = item.message.attributes - attributes.removeAll(where: { $0 is TextEntitiesMessageAttribute }) - if !option.entities.isEmpty { - attributes.append(TextEntitiesMessageAttribute(entities: option.entities)) - } - - let message = item.message.withUpdatedText(option.text).withUpdatedAttributes(attributes).withUpdatedMedia([media]) - let _ = item.context.sharedContext.openChatMessage(OpenChatMessageParams( - context: item.context, - updatedPresentationData: item.controllerInteraction.updatedPresentationData, - chatLocation: item.chatLocation, - chatFilterTag: nil, - chatLocationContextHolder: nil, - message: message, - mediaIndex: 0, - standalone: true, - reverseMessageGalleryOrder: false, - navigationController: item.controllerInteraction.navigationController(), - dismissInput: { - item.controllerInteraction.dismissTextInput() - }, - present: { controller, arguments, presentationContextType in - switch presentationContextType { - case .current: - item.controllerInteraction.presentControllerInCurrent(controller, arguments) - default: - item.controllerInteraction.presentController(controller, arguments) - } - }, - transitionNode: { [weak self] messageId, media, adjustRect in - guard let self else { - return nil - } - return self.transitionNode(messageId: messageId, media: media, adjustRect: adjustRect) - }, - addToTransitionSurface: { [weak self] view in - guard let self else { - return - } - if let superview = self.itemNode?.view.superview?.superview?.superview { - superview.addSubview(view) - } else { - self.view.addSubview(view) - } - }, - openUrl: { url in - item.controllerInteraction.openUrl(.init(url: url, concealed: false, progress: Promise())) - }, - openPeer: { peer, navigation in - item.controllerInteraction.openPeer(EnginePeer(peer), navigation, nil, .default) - }, - callPeer: { peerId, isVideo in - item.controllerInteraction.callPeer(peerId, isVideo) - }, - openConferenceCall: { message in - item.controllerInteraction.openConferenceCall(message) - }, - enqueueMessage: { _ in - }, - sendSticker: { fileReference, sourceNode, sourceRect in - item.controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) - }, - sendEmoji: { text, attribute in - item.controllerInteraction.sendEmoji(text, attribute, false) - }, - setupTemporaryHiddenMedia: { [weak self] signal, _, galleryMedia in - guard let self else { - return - } - self.temporaryHiddenMediaDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] entry in - guard let self, let item = self.item else { - return - } - var hiddenMedia = item.controllerInteraction.hiddenMedia - if entry != nil { - hiddenMedia[item.message.id] = [galleryMedia] - } else { - hiddenMedia.removeValue(forKey: item.message.id) - } - item.controllerInteraction.hiddenMedia = hiddenMedia - self.itemNode?.updateHiddenMedia() - })) - }, - chatAvatarHiddenMedia: { _, _ in - }, - gallerySource: .standaloneMessage(message, 0) - )) - } - @objc private func buttonPressed() { guard let item = self.item, let poll = self.poll, let pollId = poll.id else { return @@ -2262,7 +2299,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { var previousPoll: TelegramMediaPoll? let currentNewOptionText = self.currentNewOptionText let currentNewOptionAttachment = ChatMessagePollAddOptionNode.Attachment(media: self.currentNewOptionMedia?.media, progress: self.currentNewOptionMedia?.progress) - let pendingOpenAnswerSubmissionText = self.pendingNewOptionSubmissionText + let pendingNewOptionSubmissionText = self.pendingNewOptionSubmissionText if let item = self.item { for media in item.message.media { if let media = media as? TelegramMediaPoll { @@ -2271,7 +2308,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } - var previousOptionNodeLayouts: [Data: (_ contet: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)))] = [:] + var previousOptionNodeLayouts: [Data: (_ contet: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)))] = [:] for optionNode in self.optionNodes { if let option = optionNode.option { previousOptionNodeLayouts[option.opaqueIdentifier] = ChatMessagePollOptionNode.asyncLayout(optionNode) @@ -2564,14 +2601,11 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { isClosed = false } - var pollOptionsFinalizeLayouts: [(CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)] = [] + var pollOptionsFinalizeLayouts: [(hasResult: Bool, layout: (CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))] = [] var addOptionFinalizeLayout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode))? var orderedPollOptions: [(Int, TelegramMediaPollOption)] = [] - var shuffledOptionOpaqueIdentifiers: [Data]? if let poll = poll { - let resolvedOptionOrder = resolvedOptionOrder(for: item) - orderedPollOptions = resolvedOptionOrder.orderedOptions - shuffledOptionOpaqueIdentifiers = resolvedOptionOrder.shuffledOpaqueIdentifiers + orderedPollOptions = resolvedOptionOrder(for: item) var optionVoterCount: [Int: Int32] = [:] var maxOptionVoterCount: Int32 = 0 @@ -2582,11 +2616,13 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } else { voters = poll.results.voters } + var votedFor = Set() if let voters = voters, let totalVoters = poll.results.totalVoters { var didVote = false for voter in voters { if voter.selected { didVote = true + votedFor.insert(voter.opaqueIdentifier) } } totalVoterCount = totalVoters @@ -2614,7 +2650,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let hasAnyOptionMedia = orderedPollOptions.contains(where: { $0.1.media != nil }) for (i, option) in orderedPollOptions { - let makeLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) + let makeLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) if let previous = previousOptionNodeLayouts[option.opaqueIdentifier] { makeLayout = previous } else { @@ -2642,10 +2678,15 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if !pollOptions.isEmpty && i < pollOptions.count { translation = pollOptions[i] } + + var forceSelected: Bool? + if !votedFor.isEmpty && optionResult == nil { + forceSelected = votedFor.contains(option.opaqueIdentifier) + } - let result = makeLayout(item.context, item.presentationData, item.controllerInteraction.presentationContext, item.message, poll, option, translation, optionResult, hasAnyOptionMedia, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0) + let result = makeLayout(item.context, item.presentationData, item.controllerInteraction.presentationContext, item.message, poll, option, translation, optionResult, forceSelected, hasAnyOptionMedia, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0) boundingSize.width = max(boundingSize.width, result.minimumWidth + layoutConstants.bubble.borderInset * 2.0) - pollOptionsFinalizeLayouts.append(result.1) + pollOptionsFinalizeLayouts.append((optionResult != nil, result.1)) } let displayAddOption = poll.openAnswers && !isClosed && !hasVoted && poll.pollId.namespace == Namespaces.Media.CloudPoll @@ -2676,9 +2717,13 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { var optionNodesSizesAndApply: [(CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)] = [] for finalizeLayout in pollOptionsFinalizeLayouts { - let result = finalizeLayout(boundingWidth - layoutConstants.bubble.borderInset * 2.0) + let result = finalizeLayout.layout(boundingWidth - layoutConstants.bubble.borderInset * 2.0) resultSize.width = max(resultSize.width, result.0.width + layoutConstants.bubble.borderInset * 2.0) - resultSize.height += result.0.height + if finalizeLayout.hasResult { + resultSize.height += result.0.height - 7.0 + } else { + resultSize.height += result.0.height + } optionNodesSizesAndApply.append(result) } var addOptionSizeAndApply: (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode)? @@ -2788,13 +2833,19 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { animation.animator.updateFrame(layer: optionNode.layer, frame: optionNodeFrame, completion: nil) } - verticalOffset += size.height + if optionNode.currentResult != nil { + verticalOffset += size.height - 7.0 + } else { + verticalOffset += size.height + } updatedOptionNodes.append(optionNode) - optionNode.isUserInteractionEnabled = strongSelf.pollOptionsDisabledForAddOptionFocusMessageId != item.message.id && item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] == nil - optionNode.alpha = strongSelf.pollOptionsDisabledForAddOptionFocusMessageId == item.message.id ? 0.5 : 1.0 + optionNode.isUserInteractionEnabled = !strongSelf.newOptionIsFocused && item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] == nil + optionNode.alpha = strongSelf.newOptionIsFocused ? 0.5 : 1.0 if i > 0 { optionNode.previousOptionNode = updatedOptionNodes[i - 1] + } else { + optionNode.previousOptionNode = nil } } for optionNode in strongSelf.optionNodes { @@ -2803,7 +2854,6 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } strongSelf.optionNodes = updatedOptionNodes - strongSelf.shuffledOptionOpaqueIdentifiers = shuffledOptionOpaqueIdentifiers strongSelf.updatePollOptionsInteraction(animated: animation.isAnimated) if let (size, apply) = addOptionSizeAndApply { @@ -2824,37 +2874,30 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { self?.requestNewOptionLayoutUpdate() } addOptionNode.attachPressed = { [weak self] in - self?.openOpenAnswerAttachment() + self?.openNewOptionAttachment() } addOptionNode.mediaPressed = { [weak self] in - self?.openOpenAnswerAttachment() + self?.openNewOptionAttachment() + } + addOptionNode.modeSelectorPressed = { [weak self] in + self?.toggleNewOptionInputMode() } - let messageId = item.message.id addOptionNode.focusUpdated = { [weak self] focused in guard let self else { return } - if focused { - self.setPollOptionsDisabledForAddOptionFocus(messageId: messageId, animated: true) - self.updateFocusedPollAddOptionMessageId(messageId) - } else { - self.setPollOptionsDisabledForAddOptionFocus(messageId: nil, animated: true) - self.clearFocusedPollAddOptionMessageIdIfNeeded(for: messageId) - } + self.updatePollAddOptionFocused(focused) } strongSelf.addOptionNode = addOptionNode verticalOffset += size.height } else if let addOptionNode = strongSelf.addOptionNode { - if let item = strongSelf.item { - strongSelf.setPollOptionsDisabledForAddOptionFocus(messageId: nil, animated: animation.isAnimated) - strongSelf.clearFocusedPollAddOptionMessageIdIfNeeded(for: item.message.id) - } + strongSelf.updatePollAddOptionFocused(false) strongSelf.addOptionNode = nil addOptionNode.removeFromSupernode() } - if let poll = poll, let pendingOpenAnswerSubmissionText, let pendingOpenAnswerOptionCount = strongSelf.pendingNewOptionOptionCount, poll.options.count > pendingOpenAnswerOptionCount, poll.options.contains(where: { $0.text == pendingOpenAnswerSubmissionText }) { - strongSelf.clearOpenAnswerInput() + if let poll = poll, let pendingNewOptionSubmissionText, let pendingNewOptionOptionCount = strongSelf.pendingNewOptionOptionCount, poll.options.count > pendingNewOptionOptionCount, poll.options.contains(where: { $0.text == pendingNewOptionSubmissionText }) { + strongSelf.clearNewOptionInput() } if textLayout.hasRTL { @@ -2873,7 +2916,11 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if let voters = poll.results.voters { for voter in voters { if voter.selected { - displayDeadlineTimer = false + if case .quiz = poll.kind { + displayDeadlineTimer = false + } else { + displayDeadlineTimer = !poll.isClosed + } hasSelected = true break } @@ -2983,7 +3030,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } timerNode.update(size: resultSize, color: messageTheme.secondaryTextColor, deadlineTimeout: endDate, resultsHidden: poll.hideResultsUntilClose) - timerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset + 30.0), size: CGSize(width: resultSize.width, height: 20.0)) + timerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset + 31.0), size: CGSize(width: resultSize.width, height: 20.0)) statusOffset += 6.0 } else if let timerNode = strongSelf.deadlineTimerNode { strongSelf.deadlineTimerNode = nil @@ -3172,7 +3219,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let isClosed = isPollEffectivelyClosed(message: item.message, poll: poll) var canAlwaysViewResults = false - if let peer = item.message.peers[item.message.id.peerId] { + if !poll.hideResultsUntilClose, let peer = item.message.peers[item.message.id.peerId] { if let group = peer as? TelegramGroup { switch group.role { case .creator, .admin: @@ -3199,14 +3246,14 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if let voters = poll.results.voters { for voter in voters { if voter.selected { - hasResults = true + hasResults = voter.count != nil break } } } } } - + if !disableAllActions && hasSelection && !hasResults && (!canAlwaysViewResults || hasSelectedOptions) && poll.pollId.namespace == Namespaces.Media.CloudPoll { self.votersNode.isHidden = true self.buttonViewResultsTextNode.isHidden = true @@ -3253,10 +3300,9 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { self.buttonSubmitActiveTextNode.isHidden = true } - let canDisplayOpenAnswer = poll.openAnswers && !isClosed && !hasVoted && poll.pollId.namespace == Namespaces.Media.CloudPoll - let hasDraftOpenAnswer = !self.trimmedNewOptionText.isEmpty - let canSubmitOpenAnswer = hasDraftOpenAnswer && !(self.currentNewOptionMedia?.requiresUpload ?? false) - if canDisplayOpenAnswer && canSubmitOpenAnswer { + let canDisplayNewOption = poll.openAnswers && !isClosed && !hasVoted && poll.pollId.namespace == Namespaces.Media.CloudPoll + let canSubmitNewOption = !self.trimmedNewOptionText.isEmpty && !(self.currentNewOptionMedia?.requiresUpload ?? false) + if canDisplayNewOption && canSubmitNewOption { self.votersNode.isHidden = true self.buttonSubmitInactiveTextNode.isHidden = true self.buttonSubmitActiveTextNode.isHidden = true @@ -3314,12 +3360,14 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { for optionNode in self.optionNodes { if optionNode.frame.contains(point), case .tap = gesture { - if self.pollOptionsDisabledForAddOptionFocusMessageId == self.item?.message.id { + if self.newOptionIsFocused { return ChatMessageBubbleContentTapAction(content: .none) } if let mediaFrame = optionNode.mediaFrame, mediaFrame.offsetBy(dx: optionNode.frame.minX, dy: optionNode.frame.minY).contains(point), let option = optionNode.option, let _ = option.media { return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in - self?.openOptionMedia(option: option) + if let item = self?.item { + item.controllerInteraction.openPollMedia(item.message, .option(option)) + } })) } if optionNode.isUserInteractionEnabled { @@ -3402,6 +3450,10 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { transition.updateSublayerTransformScale(node: self.solutionButtonNode, scale: displaySolutionButton ? 1.0 : 0.1) } } + + public func newOptionInputTextView() -> UITextView? { + return self.addOptionNode?.textNode.textView + } override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { if !self.statusNode.isHidden { @@ -3553,12 +3605,13 @@ private class DeadlineTimerNode: ASDisplayNode { func update(size: CGSize, color: UIColor, deadlineTimeout: Int32, resultsHidden: Bool) { self.params = (size, color, deadlineTimeout, resultsHidden) + //TODO:localize let prefix: String = resultsHidden ? "results in" : "ends in" let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) let duration = max(0, deadlineTimeout - currentTime) - if duration < 60 * 60 * 24 { + if duration > 0 && duration < 60 * 60 * 24 { if self.timer == nil { self.timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { [weak self] in guard let self, let params = self.params else { @@ -3573,21 +3626,25 @@ private class DeadlineTimerNode: ASDisplayNode { self.timer = nil } - let text = prefix + " " + stringForRemainingTime(duration) + let text: String + if duration == 0 { + text = "" + } else { + text = prefix + " " + stringForRemainingTime(duration) + } self.textNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 11.0, traits: .monospacedNumbers), textColor: color) let textSize = self.textNode.updateLayout(size) self.textNode.frame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: 0.0), size: textSize) } } -private func resolvedOptionOrder(for item: ChatMessageBubbleContentItem) -> (orderedOptions: [(Int, TelegramMediaPollOption)], shuffledOpaqueIdentifiers: [Data]?) { +private func resolvedOptionOrder(for item: ChatMessageBubbleContentItem) -> [(Int, TelegramMediaPollOption)] { guard let poll = item.message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll else { - return ([], nil) + return [] } - let defaultOrderedOptions = Array(poll.options.enumerated()).map { ($0.offset, $0.element) } guard poll.shuffleAnswers else { - return (defaultOrderedOptions, nil) + return defaultOrderedOptions } let currentOpaqueIdentifiers = poll.options.map(\.opaqueIdentifier) @@ -3608,8 +3665,8 @@ private func resolvedOptionOrder(for item: ChatMessageBubbleContentItem) -> (ord } if orderedOptions.count == poll.options.count { - return (orderedOptions, resolvedOpaqueIdentifiers) + return orderedOptions } else { - return (defaultOrderedOptions, currentOpaqueIdentifiers) + return defaultOrderedOptions } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessageQuizAnswerBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessageQuizAnswerBubbleContentNode.swift index 0454adb379..478d10691c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessageQuizAnswerBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessageQuizAnswerBubbleContentNode.swift @@ -28,6 +28,8 @@ public final class ChatMessageQuizAnswerBubbleContentNode: ChatMessageBubbleCont super.init() + self.clipsToBounds = true + self.addSubnode(self.contentNode) } @@ -39,99 +41,6 @@ public final class ChatMessageQuizAnswerBubbleContentNode: ChatMessageBubbleCont self.temporaryHiddenMediaDisposable.dispose() } - func openSolutionMedia() { - guard let item = self.item, let poll = item.message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, let solution = poll.results.solution, let media = poll.results.solution?.media else { - return - } - var attributes = item.message.attributes - attributes.removeAll(where: { $0 is TextEntitiesMessageAttribute }) - if !solution.entities.isEmpty { - attributes.append(TextEntitiesMessageAttribute(entities: solution.entities)) - } - - let message = item.message.withUpdatedText(solution.text).withUpdatedAttributes(attributes).withUpdatedMedia([media]) - let _ = item.context.sharedContext.openChatMessage(OpenChatMessageParams( - context: item.context, - updatedPresentationData: item.controllerInteraction.updatedPresentationData, - chatLocation: item.chatLocation, - chatFilterTag: nil, - chatLocationContextHolder: nil, - message: message, - mediaIndex: 0, - standalone: true, - reverseMessageGalleryOrder: false, - navigationController: item.controllerInteraction.navigationController(), - dismissInput: { - item.controllerInteraction.dismissTextInput() - }, - present: { controller, arguments, presentationContextType in - switch presentationContextType { - case .current: - item.controllerInteraction.presentControllerInCurrent(controller, arguments) - default: - item.controllerInteraction.presentController(controller, arguments) - } - }, - transitionNode: { [weak self] messageId, media, adjustRect in - guard let self else { - return nil - } - return self.transitionNode(messageId: messageId, media: media, adjustRect: adjustRect) - }, - addToTransitionSurface: { [weak self] view in - guard let self else { - return - } - if let superview = self.itemNode?.view.superview?.superview?.superview { - superview.addSubview(view) - } else { - self.view.addSubview(view) - } - }, - openUrl: { url in - item.controllerInteraction.openUrl(.init(url: url, concealed: false, progress: Promise())) - }, - openPeer: { peer, navigation in - item.controllerInteraction.openPeer(EnginePeer(peer), navigation, nil, .default) - }, - callPeer: { peerId, isVideo in - item.controllerInteraction.callPeer(peerId, isVideo) - }, - openConferenceCall: { message in - item.controllerInteraction.openConferenceCall(message) - }, - enqueueMessage: { _ in - }, - sendSticker: { fileReference, sourceNode, sourceRect in - item.controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) - }, - sendEmoji: { text, attribute in - item.controllerInteraction.sendEmoji(text, attribute, false) - }, - setupTemporaryHiddenMedia: { [weak self] signal, _, galleryMedia in - guard let self else { - return - } - self.temporaryHiddenMediaDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] entry in - guard let self, let item = self.item else { - return - } - var hiddenMedia = item.controllerInteraction.hiddenMedia - if entry != nil { - hiddenMedia[item.message.id] = [galleryMedia] - } else { - hiddenMedia.removeValue(forKey: item.message.id) - } - item.controllerInteraction.hiddenMedia = hiddenMedia - self.itemNode?.updateHiddenMedia() - })) - }, - chatAvatarHiddenMedia: { _, _ in - }, - gallerySource: .standaloneMessage(message, 0) - )) - } - override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let contentNodeLayout = self.contentNode.asyncLayout() @@ -141,10 +50,12 @@ public final class ChatMessageQuizAnswerBubbleContentNode: ChatMessageBubbleCont var text: String = "" var entities: [MessageTextEntity] = [] var mediaAndFlags: ([Media], ChatMessageAttachedContentNodeMediaFlags)? = nil - if let poll = item.message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, let solution = poll.results.solution { - text = solution.text - entities = solution.entities - mediaAndFlags = solution.media.flatMap { ([$0], []) } + var solution: TelegramMediaPollResults.Solution? + if let poll = item.message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, let solutionValue = poll.results.solution { + text = solutionValue.text + entities = solutionValue.entities + mediaAndFlags = solutionValue.media.flatMap { ([$0], []) } + solution = solutionValue } let (initialWidth, continueLayout) = contentNodeLayout(item.presentationData, item.controllerInteraction.automaticMediaDownloadSettings, item.associatedData, item.attributes, item.context, item.controllerInteraction, item.message, true, .peer(id: item.message.id.peerId), title, nil, nil, text, entities, mediaAndFlags, nil, nil, nil, true, layoutConstants, preparePosition, constrainedSize, item.controllerInteraction.presentationContext.animationCache, item.controllerInteraction.presentationContext.animationRenderer) @@ -166,7 +77,9 @@ public final class ChatMessageQuizAnswerBubbleContentNode: ChatMessageBubbleCont strongSelf.contentNode.frame = CGRect(origin: CGPoint(), size: size) strongSelf.contentNode.openMedia = { [weak self] _ in - self?.openSolutionMedia() + if let item = self?.item, let solution { + item.controllerInteraction.openPollMedia(item.message, .solution(solution)) + } } } }) @@ -187,7 +100,15 @@ public final class ChatMessageQuizAnswerBubbleContentNode: ChatMessageBubbleCont self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } + override public func animateRemovalFromBubble(_ duration: Double, completion: @escaping () -> Void) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + completion() + }) + self.layer.animateBounds(from: self.bounds, to: CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: 0.0)), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + override public func animateInsertionIntoBubble(_ duration: Double) { + self.layer.animateBounds(from: CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: 0.0)), to: self.bounds, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring) self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageReplyInfoNode/Sources/ChatMessageReplyInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageReplyInfoNode/Sources/ChatMessageReplyInfoNode.swift index b747a1cfc3..23357feb71 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageReplyInfoNode/Sources/ChatMessageReplyInfoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageReplyInfoNode/Sources/ChatMessageReplyInfoNode.swift @@ -446,9 +446,14 @@ public class ChatMessageReplyInfoNode: ASDisplayNode { var textLeftInset: CGFloat = 0.0 var messageText: NSAttributedString var todoItemCompleted: Bool? + var checkIsRectangle = false if case let .pollOption(optionId) = arguments.innerSubject, let poll = arguments.message?.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, let pollOption = poll.options.first(where: { $0.opaqueIdentifier == optionId }) { messageText = stringWithAppliedEntities(pollOption.text, entities: pollOption.entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil) + textLeftInset += 16.0 + + todoItemCompleted = true + checkIsRectangle = poll.kind.multipleAnswers } else if case let .todoItem(todoItemId) = arguments.innerSubject, let todo = arguments.message?.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo, let todoItem = todo.items.first(where: { $0.id == todoItemId }) { messageText = stringWithAppliedEntities(todoItem.text, entities: todoItem.entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil) textLeftInset += 16.0 @@ -925,7 +930,7 @@ public class ChatMessageReplyInfoNode: ASDisplayNode { checkLayer.setSelected(todoItemCompleted, animated: true) animation.animator.updateFrame(layer: checkLayer, frame: checkLayerFrame, completion: nil) } else { - checkLayer = CheckLayer(theme: checkTheme) + checkLayer = CheckLayer(theme: checkTheme, content: .check(isRectangle: checkIsRectangle)) node.checkLayer = checkLayer node.contentNode.layer.addSublayer(checkLayer) diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index ef18da7013..c1bd8e2f31 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -605,6 +605,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, dismissReplyMarkupMessage: { _ in }, openMessagePollResults: { _, _ in }, openPollCreation: { _ in + }, openPollMedia: { _, _ in }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in }, displayDiceTooltip: { _ in diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index b1a415e5a5..880ec7ae2b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -459,6 +459,7 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, dismissReplyMarkupMessage: { _ in }, openMessagePollResults: { _, _ in }, openPollCreation: { _ in + }, openPollMedia: { _, _ in }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in }, displayDiceTooltip: { _ in diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index f132d2f7ae..3ac112d322 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -173,6 +173,11 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol } } + public enum PollMediaSubject { + case option(TelegramMediaPollOption) + case solution(TelegramMediaPollResults.Solution) + } + public let openMessage: (Message, OpenMessageParams) -> Bool public let openPeer: (EnginePeer, ChatControllerInteractionNavigateToPeer, MessageReference?, OpenPeerSource) -> Void public let openPeerMention: (String, Promise?) -> Void @@ -243,6 +248,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let dismissReplyMarkupMessage: (Message) -> Void public let openMessagePollResults: (MessageId, Data) -> Void public let openPollCreation: (Bool?) -> Void + public let openPollMedia: (Message, PollMediaSubject) -> Void public let displayPollSolution: (TelegramMediaPollResults.Solution?, ASDisplayNode?) -> Void public let displayPsa: (String, ASDisplayNode) -> Void public let displayDiceTooltip: (TelegramMediaDice) -> Void @@ -417,6 +423,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol dismissReplyMarkupMessage: @escaping (Message) -> Void, openMessagePollResults: @escaping (MessageId, Data) -> Void, openPollCreation: @escaping (Bool?) -> Void, + openPollMedia: @escaping (Message, PollMediaSubject) -> Void, displayPollSolution: @escaping (TelegramMediaPollResults.Solution?, ASDisplayNode?) -> Void, displayPsa: @escaping (String, ASDisplayNode) -> Void, displayDiceTooltip: @escaping (TelegramMediaDice) -> Void, @@ -533,6 +540,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.requestAddMessagePollOption = requestAddMessagePollOption self.requestOpenMessagePollResults = requestOpenMessagePollResults self.openPollCreation = openPollCreation + self.openPollMedia = openPollMedia self.displayPollSolution = displayPollSolution self.displayPsa = displayPsa self.openAppStorePage = openAppStorePage diff --git a/submodules/TelegramUI/Components/ComposePollScreen/BUILD b/submodules/TelegramUI/Components/ComposePollScreen/BUILD index e00eaabbc9..e215598c3c 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/BUILD +++ b/submodules/TelegramUI/Components/ComposePollScreen/BUILD @@ -56,6 +56,7 @@ swift_library( "//submodules/Components/PagerComponent", "//submodules/FeaturedStickersScreen", "//submodules/TelegramNotices", + "//submodules/StickerPeekUI:StickerPeekUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index 0e854a3ede..b3269b15db 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -31,6 +31,7 @@ import ListComposePollOptionComponent import GlassBarButtonComponent import ChatScheduleTimeController import ContextUI +import StickerPeekUI public final class ComposedPoll { public struct Text { @@ -781,6 +782,9 @@ final class ComposePollScreenComponent: Component { if let textInputView = self.pollTextSection.findTaggedView(tag: self.pollTextFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, self.pollTextInputState)) } + if let textInputView = self.pollTextSection.findTaggedView(tag: self.pollDescriptionFieldTag) as? ListComposePollOptionComponent.View { + textInputStates.append((textInputView, self.pollDescriptionInputState)) + } for pollOption in self.pollOptions { if let textInputView = findTaggedComponentViewImpl(view: self.pollOptionsSectionContainer, tag: pollOption.textFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, pollOption.textInputState)) @@ -800,13 +804,35 @@ final class ComposePollScreenComponent: Component { case quizAnswer case pollOption(PollOption) } + + private func attachedMedia(for subject: MediaAttachSubject) -> AttachedMedia? { + switch subject { + case .description: + return self.pollDescriptionMedia + case .quizAnswer: + return self.quizAnswerMedia + case let .pollOption(pollOption): + return pollOption.media + } + } + + private func setAttachedMedia(_ media: AttachedMedia?, for subject: MediaAttachSubject) { + switch subject { + case .description: + self.pollDescriptionMedia = media + case .quizAnswer: + self.quizAnswerMedia = media + case let .pollOption(pollOption): + pollOption.media = media + } + } - private func openAttachedMedia(subject: MediaAttachSubject) { + private func openAttachedMedia(subject: MediaAttachSubject, replace: Bool = false) { guard let component = self.component else { return } - guard !self.openAttachMediaMenu(subject: subject) else { + guard replace || !self.openAttachMediaContextMenu(subject: subject) else { return } @@ -825,14 +851,7 @@ final class ComposePollScreenComponent: Component { 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.setAttachedMedia(attachedMedia, for: subject) self.uploadAttachedMediaIfNeeded(attachedMedia) self.state?.updated(transition: .easeInOut(duration: 0.2)) }) @@ -920,53 +939,111 @@ final class ComposePollScreenComponent: Component { } } - 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 { - self.pollDescriptionMedia = nil - self.state?.updated(transition: .easeInOut(duration: 0.25)) - } - 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 { - self.quizAnswerMedia = nil - self.state?.updated(transition: .easeInOut(duration: 0.25)) - } - self.state?.updated(transition: .easeInOut(duration: 0.25)) - 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 { - pollOption.media = nil - self.state?.updated(transition: .easeInOut(duration: 0.25)) - } - return true + private func openAttachMediaContextMenu(subject: MediaAttachSubject) -> Bool { + guard let component = self.component, let media = self.attachedMedia(for: subject) else { + return false + } + if media.progress != nil { + media.uploadDisposable?.dispose() + self.setAttachedMedia(nil, for: subject) + self.state?.updated(transition: .easeInOut(duration: 0.25)) + } else { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + if let file = media.media.media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { + var items: [ContextMenuItem] = [] + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Replace", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Replace"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.openAttachedMedia(subject: subject, replace: true) + }))) + + items.append(.action(ContextMenuActionItem(text: "Delete", textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.setAttachedMedia(nil, for: subject) + self.state?.updated(transition: .easeInOut(duration: 0.2)) + }))) + + let peekController = makePeekController( + presentationData: presentationData, + content: StickerPreviewPeekContent( + context: component.context, + theme: presentationData.theme, + strings: presentationData.strings, + item: .pack(file), + isCreating: false, + menu: items, + openPremiumIntro: {} + ), + sourceView: { + return nil + }, + activateImmediately: true + ) + self.environment?.controller()?.presentInGlobalOverlay(peekController) + } else { + var items: [ContextMenuItem] = [] + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Replace", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Replace"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.openAttachedMedia(subject: subject, replace: true) + }))) + + items.append(.action(ContextMenuActionItem(text: "Delete", textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak self] _, f in + f(.default) + guard let self else { + return + } + self.setAttachedMedia(nil, for: subject) + self.state?.updated(transition: .easeInOut(duration: 0.2)) + }))) + + let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value(0)), namespace: Namespaces.Message.Local, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [media.media.media], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) + let gallery = component.context.sharedContext.makeGalleryController(context: component.context, source: .standaloneMessage(message, nil), streamSingleVideo: true, isPreview: true) + + let source: ContextContentSource = .controller(ComposePollContextControllerContentSource(controller: gallery, sourceView: nil, sourceRect: .zero)) + + let contextController = makeContextController( + presentationData: presentationData, + source: source, + items: .single(ContextController.Items(content: .list(items))), + gesture: nil + ) + self.environment?.controller()?.presentInGlobalOverlay(contextController) } } - return false + return true } private func presentTimeLimitOptions(sourceView: UIView) { guard let component = self.component else { return } + + var sourceView = sourceView + if let itemView = sourceView as? ListActionItemComponent.View, let iconView = itemView.iconView { + sourceView = iconView + } + var subItems: [ContextMenuItem] = [] let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } @@ -1063,8 +1140,24 @@ final class ComposePollScreenComponent: Component { let theme = environment.theme.withModalBlocksBackground() + var isChannel = false + if case let .channel(channel) = component.peer, case .broadcast = channel.info { + isChannel = true + } + if self.component == nil { self.isQuiz = component.isQuiz ?? false + if !self.isQuiz { + self.isMultiAnswer = true + } + if !isChannel { + self.isAnonymous = false + if !self.isQuiz { + self.canAddOptions = true + } + } + self.canRevote = true + self.shuffleOptions = true self.pollOptions.append(ComposePollScreenComponent.PollOption( id: self.nextPollOptionId @@ -1300,18 +1393,7 @@ final class ComposePollScreenComponent: Component { characterLimit: 1024, //TODO attachment: pollDescriptionAttachment, emptyLineHandling: .allowed, - returnKeyAction: { [weak self] in - guard let self else { - return - } - 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() - } - } - } - }, + returnKeyType: .default, backspaceKeyAction: nil, selection: nil, inputMode: self.currentInputMode, @@ -1699,15 +1781,10 @@ final class ComposePollScreenComponent: Component { } contentHeight += pollOptionsSectionFooterSize.height contentHeight += sectionSpacing - - var canBePublic = true - if case let .channel(channel) = component.peer, case .broadcast = channel.info { - canBePublic = false - } - + //TODO:localize var pollSettingsSectionItems: [AnyComponentWithIdentity] = [] - if canBePublic { + if !isChannel { pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( theme: theme, style: .glass, @@ -1791,44 +1868,46 @@ final class ComposePollScreenComponent: Component { 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: .lock(isLocked: self.isQuiz), isOn: self.canAddOptions, action: { [weak self] _ in - guard let self else { - return - } - if !self.canAddOptions && self.isAnonymous { - self.isAnonymous = false - } - self.canAddOptions = !self.canAddOptions - self.state?.updated(transition: .spring(duration: 0.4)) - })), - action: nil - )))) + if !isChannel { + 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: .lock(isLocked: self.isQuiz), isOn: self.canAddOptions, isInteractive: !self.isQuiz, action: { [weak self] _ in + guard let self else { + return + } + if !self.canAddOptions && self.isAnonymous { + self.isAnonymous = false + } + self.canAddOptions = !self.canAddOptions + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + } pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "revoting", component: AnyComponent(ListActionItemComponent( theme: theme, @@ -1933,8 +2012,11 @@ final class ComposePollScreenComponent: Component { return } self.isQuiz = !self.isQuiz - if self.isQuiz && self.canAddOptions { - self.canAddOptions = false + if self.isQuiz { + if self.canAddOptions { + self.canAddOptions = false + } + self.canRevote = false } self.state?.updated(transition: .spring(duration: 0.4)) })), @@ -1973,6 +2055,10 @@ final class ComposePollScreenComponent: Component { } self.limitDuration = !self.limitDuration self.state?.updated(transition: .spring(duration: 0.4)) + + if self.limitDuration { + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.scrollView.contentSize.height - self.scrollView.bounds.size.height), animated: true) + } })), action: nil )))) @@ -2722,3 +2808,37 @@ private final class ComposePollContextReferenceContentSource: ContextReferenceCo return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0)) } } + +private final class ComposePollContextControllerContentSource: ContextControllerContentSource { + let controller: ViewController + weak var sourceView: UIView? + let sourceRect: CGRect + + let navigationController: NavigationController? = nil + + let passthroughTouches: Bool = false + + init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect) { + self.controller = controller + self.sourceView = sourceView + self.sourceRect = sourceRect + } + + func transitionInfo() -> ContextControllerTakeControllerInfo? { + let sourceView = self.sourceView + let sourceRect = self.sourceRect + return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in + if let sourceView = sourceView { + return (sourceView, sourceRect) + } else { + return nil + } + }) + } + + func animatedIn() { + if let controller = self.controller as? GalleryControllerProtocol { + controller.viewDidAppear(false) + } + } +} diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift index 62a772ed9b..b94a6e02b5 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift @@ -197,7 +197,7 @@ final class StickerAttachmentScreenComponent: Component { return } component.completion(fileReference) - (self.environment?.controller() as? StickerAttachmentScreen)?.parentController()?.dismiss() + (self.environment?.controller() as? StickerAttachmentScreen)?.dismiss(animated: true) } func updateContent() { diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 328585abba..5dd8687f81 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -363,7 +363,7 @@ final class GiftOptionsScreenComponent: Component { mainController.present(controller, in: .current) return } - if gift.flags.contains(.requiresPremium) && !component.context.isPremium { + if gift.flags.contains(.requiresPremium) && !component.context.isPremium && !((gift.availability?.resale ?? 0) > 0) { let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .premiumGift(gift.file), forceDark: false, dismissed: nil) mainController.push(controller) return diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index 7606c529d8..36637edf47 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -1449,7 +1449,7 @@ private final class GiftSetupScreenComponent: Component { switch component.subject { case let .premium(product): let balance = component.context.starsContext?.currentState?.balance.value ?? 0 - if let starsPrice = product.starsPrice, balance >= starsPrice { + if let starsPrice = product.starsPrice { //}, balance >= starsPrice { let balanceString = presentationStringsFormattedNumber(Int32(balance), environment.dateTimeFormat.groupingSeparator) let starsFooterRawString = environment.strings.Gift_Send_PayWithStars_Info("# \(balanceString)").string diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 360492f3f6..f9ef531350 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -467,7 +467,7 @@ public final class ListActionItemComponent: Component { case .expandArrows: contentRightInset = 36.0 case .toggle: - contentRightInset = 76.0 + contentRightInset = 82.0 case .activity: contentRightInset = 76.0 case let .custom(customAccessory): diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD index 0bf741befd..ea35796f5f 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD @@ -28,10 +28,9 @@ swift_library( "//submodules/PresentationDataUtils", "//submodules/PhotoResources", "//submodules/LocationResources", - "//submodules/SemanticStatusNode", + "//submodules/RadialStatusNode", ], visibility = [ "//visibility:public", ], ) - diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift index d4054d07cf..32c79512f0 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift @@ -17,7 +17,7 @@ import BundleIconComponent import SwiftSignalKit import PhotoResources import LocationResources -import SemanticStatusNode +import RadialStatusNode import EmojiTextAttachmentView import TextFormat @@ -131,6 +131,7 @@ public final class ListComposePollOptionComponent: Component { public let canAdd: Bool public let attachment: Attachment? public let emptyLineHandling: TextFieldComponent.EmptyLineHandling + public let returnKeyType: UIReturnKeyType public let returnKeyAction: (() -> Void)? public let backspaceKeyAction: (() -> Void)? public let selection: Selection? @@ -159,7 +160,8 @@ public final class ListComposePollOptionComponent: Component { canAdd: Bool = false, attachment: Attachment? = nil, emptyLineHandling: TextFieldComponent.EmptyLineHandling, - returnKeyAction: (() -> Void)?, + returnKeyType: UIReturnKeyType = .next, + returnKeyAction: (() -> Void)? = nil, backspaceKeyAction: (() -> Void)?, selection: Selection?, inputMode: InputMode?, @@ -186,6 +188,7 @@ public final class ListComposePollOptionComponent: Component { self.canAdd = canAdd self.attachment = attachment self.emptyLineHandling = emptyLineHandling + self.returnKeyType = returnKeyType self.returnKeyAction = returnKeyAction self.backspaceKeyAction = backspaceKeyAction self.selection = selection @@ -247,6 +250,9 @@ public final class ListComposePollOptionComponent: Component { if lhs.emptyLineHandling != rhs.emptyLineHandling { return false } + if lhs.returnKeyType != rhs.returnKeyType { + return false + } if lhs.selection != rhs.selection { return false } @@ -466,7 +472,7 @@ public final class ListComposePollOptionComponent: Component { private var attachButton: ComponentView? private var imageNode: TransformImageNode? - private var statusNode: SemanticStatusNode? + private var statusNode: RadialStatusNode? private var animationLayer: InlineStickerItemLayer? private var videoIconView: UIImageView? private let imageButton = HighlightTrackingButton() @@ -727,7 +733,7 @@ public final class ListComposePollOptionComponent: Component { emptyLineHandling: component.emptyLineHandling, externalHandlingForMultilinePaste: true, formatMenuAvailability: .none, - returnKeyType: .next, + returnKeyType: component.returnKeyType, lockedFormatAction: { }, present: { _ in @@ -853,11 +859,13 @@ public final class ListComposePollOptionComponent: Component { } if let selection = component.selection { + var checkTransition = transition let checkView: CheckView var animateIn = false if let current = self.checkView { checkView = current } else { + checkTransition = .immediate animateIn = true checkView = CheckView() self.checkView = checkView @@ -873,21 +881,22 @@ public final class ListComposePollOptionComponent: Component { let checkSize = CGSize(width: 22.0, height: 22.0) let checkFrame = CGRect(origin: CGPoint(x: leftInset - checkSize.width - 20.0 + self.revealOffset, y: floor((size.height - checkSize.height) * 0.5)), size: checkSize) + checkTransition.setPosition(view: checkView, position: checkFrame.center) + checkTransition.setBounds(view: checkView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) + + checkView.update(size: checkFrame.size, isRectangle: selection.isMultiSelection, isQuiz: selection.isQuiz, theme: component.theme, isSelected: selection.isSelected, transition: .immediate) + 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, isRectangle: selection.isMultiSelection, isQuiz: selection.isQuiz, 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, isRectangle: selection.isMultiSelection, isQuiz: selection.isQuiz, theme: component.theme, isSelected: selection.isSelected, transition: transition) + transition.animateAlpha(view: checkView, from: 0.0, to: 1.0) + transition.animateScale(view: checkView, from: 0.01, to: 1.0) } } else if let checkView = self.checkView { self.checkView = nil - transition.setPosition(view: checkView, position: CGPoint(x: -checkView.bounds.width * 0.5, y: size.height * 0.5), completion: { [weak checkView] _ in + + transition.setAlpha(view: checkView, alpha: 0.0, completion: { [weak checkView] _ in checkView?.removeFromSuperview() }) + transition.setScale(view: checkView, scale: 0.01) } var rightIconsInset: CGFloat = 16.0 @@ -964,9 +973,19 @@ public final class ListComposePollOptionComponent: Component { if let attachment = component.attachment, let file = attachment.media?.media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { isSticker = true - let animationSize = CGSize(width: 40.0, height: 40.0) + var updateMedia = false + if self.appliedMedia != attachment.media { + self.appliedMedia = attachment.media + updateMedia = true + } + + + var animationSize = CGSize(width: 40.0, height: 40.0) + if let dimensions = file.dimensions { + animationSize = dimensions.cgSize.aspectFitted(animationSize) + } let animationLayer: InlineStickerItemLayer - if let current = self.animationLayer { + if let current = self.animationLayer, !updateMedia { animationLayer = current } else { if let animationLayer = self.animationLayer { @@ -991,7 +1010,7 @@ public final class ListComposePollOptionComponent: Component { self.animationLayer = animationLayer self.layer.addSublayer(animationLayer) } - animationLayer.frame = imageNodeFrame + animationLayer.frame = CGRect(origin: CGPoint(x: imageNodeFrame.midX - animationSize.width * 0.5, y: imageNodeFrame.midY - animationSize.height * 0.5), size: animationSize) if self.imageButton.superview == nil { self.imageButton.addTarget(self, action: #selector(self.imageButtonPressed), for: .touchUpInside) @@ -999,7 +1018,7 @@ public final class ListComposePollOptionComponent: Component { } self.imageButton.frame = imageNodeFrame } else if let animationLayer = self.animationLayer { - self.imageNode = nil + self.animationLayer = nil if !transition.animation.isImmediate { let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) alphaTransition.setAlpha(layer: animationLayer, alpha: 0.0, completion: { [weak animationLayer] _ in @@ -1094,18 +1113,18 @@ public final class ListComposePollOptionComponent: Component { self.imageButton.frame = imageNodeFrame if let progress = attachment.progress { - let statusNode: SemanticStatusNode + let statusNode: RadialStatusNode if let current = self.statusNode { statusNode = current } else { - statusNode = SemanticStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5), foregroundNodeColor: .white) + statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5)) self.statusNode = statusNode self.addSubview(statusNode.view) } - let progressFrame = imageNodeFrame.insetBy(dx: 6.0, dy: 6.0) + let progressFrame = imageNodeFrame.insetBy(dx: 4.0, dy: 4.0) statusNode.frame = progressFrame - statusNode.transitionToState(.progress(value: max(0.027, min(1.0, progress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: false), updateCutout: false) + statusNode.transitionToState(.progress(color: .white, lineWidth: 2.0 - UIScreenPixel, value: max(0.027, min(1.0, progress)), cancelEnabled: true, animateRotation: true)) isVideo = false } else if let statusNode = self.statusNode { @@ -1130,6 +1149,12 @@ public final class ListComposePollOptionComponent: Component { videoIconView.tintColor = .white self.addSubview(videoIconView) self.videoIconView = videoIconView + + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.animateAlpha(view: videoIconView, from: 0.0, to: 1.0) + alphaTransition.animateScale(view: videoIconView, from: 0.01, to: 1.0) + } } let videoIconFrame = CGRect(origin: CGPoint(x: imageNodeFrame.center.x - 15.0, y: imageNodeFrame.center.y - 15.0), size: CGSize(width: 30.0, height: 30.0)) videoIconView.frame = videoIconFrame @@ -1158,6 +1183,19 @@ public final class ListComposePollOptionComponent: Component { } self.imageButton.removeFromSuperview() + if let videoIconView = self.videoIconView { + self.videoIconView = nil + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: videoIconView, alpha: 0.0, completion: { [weak videoIconView] _ in + videoIconView?.removeFromSuperview() + }) + alphaTransition.setScale(view: videoIconView, scale: 0.001) + } else { + videoIconView.removeFromSuperview() + } + } + if let statusNode = self.statusNode { self.statusNode = nil if !transition.animation.isImmediate { diff --git a/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift b/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift index 9d00e1054b..bf2eaacaa4 100644 --- a/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift +++ b/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift @@ -1038,7 +1038,7 @@ public final class NavigationBarImpl: ASDisplayNode, NavigationBar { titleViewTransition = .immediate } - let titleSize = titleView.updateLayout(availableSize: CGSize(width: size.width - leftTitleInset - rightTitleInset, height: nominalHeight), transition: titleViewTransition) + let titleSize = titleView.updateLayout(availableSize: CGSize(width: size.width - max(leftTitleInset, rightTitleInset) * 2.0, height: nominalHeight), transition: titleViewTransition) var titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) * 0.5), y: contentVerticalOrigin + floorToScreenPixels((nominalHeight - titleSize.height) / 2.0)), size: titleSize) if titleFrame.origin.x + titleFrame.width > size.width - rightTitleInset { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 54a25750d4..5591349c6b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1195,6 +1195,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, dismissReplyMarkupMessage: { _ in }, openMessagePollResults: { _, _ in }, openPollCreation: { _ in + }, openPollMedia: { _, _ in }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in }, displayDiceTooltip: { _ in diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollContextMenu.swift index 1362fb73f6..e927ecc76e 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollContextMenu.swift @@ -10,7 +10,6 @@ import UndoUI import AccountContext import ChatMessageItemView import ChatMessageItemCommon -import AvatarNode import ChatControllerInteraction import Pasteboard import TelegramStringFormatting diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollOptionMedia.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollOptionMedia.swift new file mode 100644 index 0000000000..f2570c4f4c --- /dev/null +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPollOptionMedia.swift @@ -0,0 +1,231 @@ +import Foundation +import UIKit +import SwiftSignalKit +import Postbox +import TelegramCore +import AsyncDisplayKit +import Display +import ContextUI +import UndoUI +import AccountContext +import ChatMessageItemView +import ChatMessageItemCommon +import ChatControllerInteraction +import TelegramStringFormatting +import TelegramPresentationData +import StickerPeekUI +import StickerPackPreviewUI + +extension ChatControllerImpl { + func openPollMedia(message: Message, subject: ChatControllerInteraction.PollMediaSubject) -> Void { + var media: Media? + var text: String? + var entities: [MessageTextEntity] = [] + switch subject { + case let .option(option): + text = option.text + entities = option.entities + media = option.media + case let .solution(solution): + text = solution.text + entities = solution.entities + media = solution.media + } + + guard let text, let media else { + return + } + + if let file = media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { + let _ = (self.context.engine.stickers.isStickerSaved(id: file.fileId) + |> deliverOnMainQueue).start(next: { [weak self] isStarred in + guard let self else { + return + } + + var items: [ContextMenuItem] = [] + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: isStarred ? self.presentationData.strings.Stickers_RemoveFromFavorites : self.presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: isStarred ? UIImage(bundleImageName: "Chat/Context Menu/Unfave") : UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + let _ = (self.context.engine.stickers.toggleStickerSaved(file: file, saved: !isStarred) + |> deliverOnMainQueue).start(next: { [weak self] result in + if let self { + self.present(UndoOverlayController(presentationData: self.presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: !isStarred ? self.presentationData.strings.Conversation_StickerAddedToFavorites : self.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .current) + } + }) + }))) + + var packReference: StickerPackReference? + for attribute in file.attributes { + switch attribute { + case let .Sticker(_, packReferenceValue, _): + packReference = packReferenceValue + break + default: + break + } + } + if let packReference { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in + f(.default) + + guard let self, let controllerInteraction = self.controllerInteraction else { + return + } + + let controller = StickerPackScreen(context: self.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: controllerInteraction.navigationController(), sendSticker: { [weak self] file, sourceNode, sourceRect in + if let self, let controllerInteraction = self.controllerInteraction { + return controllerInteraction.sendSticker(file, false, false, nil, true, sourceNode, sourceRect, nil, []) + } else { + return false + } + }) + + controllerInteraction.navigationController()?.view.window?.endEditing(true) + controllerInteraction.presentController(controller, nil) + }))) + } + + let peekController = makePeekController( + presentationData: self.presentationData, + content: StickerPreviewPeekContent( + context: self.context, + theme: self.presentationData.theme, + strings: self.presentationData.strings, + item: .pack(file), + isCreating: false, + menu: items, + openPremiumIntro: {} + ), + sourceView: { + return nil + }, + activateImmediately: true + ) + self.presentInGlobalOverlay(peekController) + + }) + } else { + var attributes = message.attributes + attributes.removeAll(where: { $0 is TextEntitiesMessageAttribute }) + if !entities.isEmpty { + attributes.append(TextEntitiesMessageAttribute(entities: entities)) + } + + let message = message.withUpdatedText(text).withUpdatedAttributes(attributes).withUpdatedMedia([media]) + let _ = self.context.sharedContext.openChatMessage(OpenChatMessageParams( + context: self.context, + updatedPresentationData: self.controllerInteraction?.updatedPresentationData, + chatLocation: self.chatLocation, + chatFilterTag: nil, + chatLocationContextHolder: nil, + message: message, + mediaIndex: 0, + standalone: true, + reverseMessageGalleryOrder: false, + navigationController: self.controllerInteraction?.navigationController(), + dismissInput: { [weak self] in + guard let self else { + return + } + self.controllerInteraction?.dismissTextInput() + }, + present: { [weak self] controller, arguments, presentationContextType in + guard let self else { + return + } + switch presentationContextType { + case .current: + self.controllerInteraction?.presentControllerInCurrent(controller, arguments) + default: + self.controllerInteraction?.presentController(controller, arguments) + } + }, + transitionNode: { [weak self] messageId, media, adjustRect in + guard let self else { + return nil + } + let _ = self + return nil + //return self.transitionNode(messageId: messageId, media: media, adjustRect: adjustRect) + }, + addToTransitionSurface: { [weak self] view in + guard let self else { + return + } + let _ = self +// if let superview = self.itemNode?.view.superview?.superview?.superview { +// superview.addSubview(view) +// } else { +// self.view.addSubview(view) +// } + }, + openUrl: { [weak self] url in + guard let self else { + return + } + self.controllerInteraction?.openUrl(.init(url: url, concealed: false, progress: Promise())) + }, + openPeer: { [weak self] peer, navigation in + guard let self else { + return + } + self.controllerInteraction?.openPeer(EnginePeer(peer), navigation, nil, .default) + }, + callPeer: { [weak self] peerId, isVideo in + guard let self else { + return + } + self.controllerInteraction?.callPeer(peerId, isVideo) + }, + openConferenceCall: { [weak self] message in + guard let self else { + return + } + self.controllerInteraction?.openConferenceCall(message) + }, + enqueueMessage: { _ in + }, + sendSticker: { [weak self] fileReference, sourceNode, sourceRect in + guard let self else { + return false + } + return self.controllerInteraction?.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) ?? false + }, + sendEmoji: { [weak self] text, attribute in + guard let self else { + return + } + self.controllerInteraction?.sendEmoji(text, attribute, false) + }, + setupTemporaryHiddenMedia: { [weak self] signal, _, galleryMedia in + guard let self else { + return + } + let _ = self +// self.temporaryHiddenMediaDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] entry in +// guard let self, let item = self.item else { +// return +// } +// var hiddenMedia = item.controllerInteraction.hiddenMedia +// if entry != nil { +// hiddenMedia[item.message.id] = [galleryMedia] +// } else { +// hiddenMedia.removeValue(forKey: item.message.id) +// } +// item.controllerInteraction.hiddenMedia = hiddenMedia +// self.itemNode?.updateHiddenMedia() +// })) + }, + chatAvatarHiddenMedia: { _, _ in + }, + gallerySource: .standaloneMessage(message, 0) + )) + } + } +} diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index d1e8b56615..43b88955a4 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -4140,14 +4140,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) }) }, openPollCreation: { [weak self] isQuiz in - guard let strongSelf = self else { + guard let self else { return } - let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { - if let controller = strongSelf.configurePollCreation(isQuiz: isQuiz) { - strongSelf.effectiveNavigationController?.pushViewController(controller) + let _ = self.presentVoiceMessageDiscardAlert(action: { + if let controller = self.configurePollCreation(isQuiz: isQuiz) { + self.effectiveNavigationController?.pushViewController(controller) } }) + }, openPollMedia: { [weak self] message, subject in + guard let self else { + return + } + self.openPollMedia(message: message, subject: subject) }, displayPollSolution: { [weak self] solution, sourceNode in guard let self else { return diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 2725882410..505142b864 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -34,6 +34,7 @@ import TextSelectionNode import ReplyAccessoryPanelNode import SuggestPostAccessoryPanelNode import ChatMessageItemView +import ChatMessageBubbleItemNode import ChatMessageSelectionNode import ManagedDiceAnimationNode import ChatMessageTransitionNode @@ -51,6 +52,7 @@ import ChatThemeScreen import ChatTextInputPanelNode import ChatInputAccessoryPanel import ChatMessageTextBubbleContentNode +import ChatMessagePollBubbleContentNode import HeaderPanelContainerComponent import MediaPlaybackHeaderPanelComponent import LiveLocationHeaderPanelComponent @@ -3506,6 +3508,22 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } + func chatPresentationInterfaceStateTextInputView(_ state: ChatPresentationInterfaceState) -> UITextView? { + var result: UITextView? + if let focusedPollAddOptionMessageId = state.focusedPollAddOptionMessageId { + self.historyNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMessageBubbleItemNode, itemNode.item?.message.id == focusedPollAddOptionMessageId { + for contentNode in itemNode.contentNodes { + if let contentNode = contentNode as? ChatMessagePollBubbleContentNode { + result = contentNode.newOptionInputTextView() + } + } + } + } + } + return result + } + func updateChatPresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, transition: ContainedViewLayoutTransition, interactive: Bool, forceLayout: Bool, completion: @escaping (ContainedViewLayoutTransition) -> Void) { self.selectedMessages = chatPresentationInterfaceState.interfaceState.selectionState?.selectedIds @@ -3659,7 +3677,14 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } var waitForKeyboardLayout = false - if let textView = self.textInputPanelNode?.textInputNode?.textView { + var effectiveTextView: UITextView? + let customTextView = self.chatPresentationInterfaceStateTextInputView(chatPresentationInterfaceState) + if let customTextView { + effectiveTextView = customTextView + } else if let mainTextView = self.textInputPanelNode?.textInputNode?.textView { + effectiveTextView = mainTextView + } + if let textView = effectiveTextView { let updatedInputView = self.chatPresentationInterfaceStateInputView(chatPresentationInterfaceState) if textView.inputView !== updatedInputView { textView.inputView = updatedInputView @@ -3682,9 +3707,15 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } if self.chatPresentationInterfaceStateRequiresInputFocus(chatPresentationInterfaceState) { - self.ensureInputViewFocused() + if let customTextView { + customTextView.becomeFirstResponder() + } else { + self.ensureInputViewFocused() + } } else { - if let inputPanelNode = self.inputPanelNode as? ChatTextInputPanelNode { + if let customTextView, customTextView.isFirstResponder { + self.context.sharedContext.mainWindow?.simulateKeyboardDismiss(transition: .animated(duration: 0.5, curve: .spring)) + } else if let inputPanelNode = self.inputPanelNode as? ChatTextInputPanelNode { if inputPanelNode.isFocused { inputPanelNode.skipPresentationInterfaceStateUpdate = true self.context.sharedContext.mainWindow?.simulateKeyboardDismiss(transition: .animated(duration: 0.5, curve: .spring)) diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index 5a3e0365f4..e82106b81e 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -179,6 +179,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, dismissReplyMarkupMessage: { _ in }, openMessagePollResults: { _, _ in }, openPollCreation: { _ in + }, openPollMedia: { _, _ in }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in }, displayDiceTooltip: { _ in diff --git a/submodules/TelegramUI/Sources/PollResultsController.swift b/submodules/TelegramUI/Sources/PollResultsController.swift index ae6a433669..7650c459da 100644 --- a/submodules/TelegramUI/Sources/PollResultsController.swift +++ b/submodules/TelegramUI/Sources/PollResultsController.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AsyncDisplayKit import TelegramCore import SwiftSignalKit import TelegramPresentationData @@ -456,7 +457,7 @@ public func pollResultsController(context: AccountContext, messageId: EngineMess }) let previousWasEmpty = Atomic(value: nil) - + let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, statePromise.get(), @@ -498,7 +499,7 @@ public func pollResultsController(context: AccountContext, messageId: EngineMess } } } - + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .textWithSubtitle(presentationData.strings.PollResults_Title, presentationData.strings.MessagePoll_VotedCount(totalVoters)), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, focusItemTag: nil, emptyStateItem: nil, initialScrollToItem: initialScrollToItem, crossfadeState: previousWasEmptyValue != nil && previousWasEmptyValue == true && isEmpty == false, animateChanges: false) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 5987fb0ef1..3a666067ed 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2469,6 +2469,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, openPollCreation: { _ in }, + openPollMedia: { _, _ in + }, displayPollSolution: { _, _ in }, displayPsa: { _, _ in