From 950d99eea7bc45cc308a5af208facaeed371fdc4 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 13 Mar 2026 14:11:19 +0100 Subject: [PATCH] [WIP] Polls --- .../Sources/AttachmentController.swift | 18 + .../Sources/AttachmentPanel.swift | 12 + .../ChatMessageMapBubbleContentNode.swift | 2 + .../ChatMessagePollBubbleContentNode/BUILD | 3 + .../ChatMessagePollBubbleContentNode.swift | 289 +++- .../Components/ComposePollScreen/BUILD | 3 + .../Sources/ComposePollScreen.swift | 14 +- .../Sources/PollAttachmentScreen.swift | 27 + .../Sources/StickerAttachmentScreen.swift | 1170 +++++++++++++++++ .../ListComposePollOptionComponent.swift | 32 +- 10 files changed, 1548 insertions(+), 22 deletions(-) create mode 100644 submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index 49fa0329f3..d0374a005e 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -30,6 +30,8 @@ public enum AttachmentButtonType: Equatable { case poll case app(AttachMenuBot) case gift + case sticker + case emoji case standalone public var key: String { @@ -52,6 +54,10 @@ public enum AttachmentButtonType: Equatable { return "app_\(bot.shortName)" case .gift: return "gift" + case .sticker: + return "sticker" + case .emoji: + return "emoji" case .standalone: return "standalone" } @@ -113,6 +119,18 @@ public enum AttachmentButtonType: Equatable { } else { return false } + case .sticker: + if case .sticker = rhs { + return true + } else { + return false + } + case .emoji: + if case .emoji = rhs { + return true + } else { + return false + } case .standalone: if case .standalone = rhs { return true diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 4bb1b98337..df96d89e41 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -229,6 +229,13 @@ private final class AttachButtonComponent: CombinedComponent { case .gift: name = strings.Attachment_Gift imageName = "Chat/Attach Menu/Gift" + case .sticker: + //TODO:localize + name = "Sticker" + imageName = "Chat/Attach Menu/Gift" + case .emoji: + name = "Emoji" + imageName = "Chat/Attach Menu/Reply" case let .app(bot): botPeer = bot.peer name = bot.shortName @@ -1816,6 +1823,11 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate, ASGestureRecog accessibilityTitle = self.presentationData.strings.Attachment_Poll case .gift: accessibilityTitle = self.presentationData.strings.Attachment_Gift + case .sticker: + //TODO:localize + accessibilityTitle = "Sticker" + case .emoji: + accessibilityTitle = "Emoji" case let .app(bot): accessibilityTitle = bot.shortName case .standalone: diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift index ad82af9b7e..367234a0e1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift @@ -89,6 +89,8 @@ public class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { activeLiveBroadcastingTimeout = liveBroadcastingTimeout } } + } else if let poll = media as? TelegramMediaPoll, let telegramMap = poll.attachedMedia as? TelegramMediaMap { + selectedMedia = telegramMap } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD index 4ed8c14938..3cc089c040 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD @@ -28,6 +28,9 @@ swift_library( "//submodules/TelegramUI/Components/Chat/MergedAvatarsNode", "//submodules/TelegramUI/Components/TextNodeWithEntities", "//submodules/TelegramUI/Components/Chat/ShimmeringLinkNode", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + "//submodules/PhotoResources", + "//submodules/LocationResources", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 049213853f..026511a184 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -10,6 +10,8 @@ import SwiftSignalKit import AccountContext import AvatarNode import TelegramPresentationData +import PhotoResources +import LocationResources import ChatMessageBackground import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode @@ -18,6 +20,7 @@ import PollBubbleTimerNode import MergedAvatarsNode import TextNodeWithEntities import ShimmeringLinkNode +import EmojiTextAttachmentView private final class ChatMessagePollOptionRadioNodeParameters: NSObject { let timestamp: Double @@ -408,6 +411,10 @@ private struct ChatMessagePollOptionSelection: Equatable { } private final class ChatMessagePollOptionNode: ASDisplayNode { + private static let mediaSize = CGSize(width: 40.0, height: 40.0) + private static let mediaSpacing: CGFloat = 2.0 + private static let mediaRightInset: CGFloat = 10.0 + private let highlightedBackgroundNode: ASDisplayNode private(set) var radioNode: ChatMessagePollOptionRadioNode? private let percentageNode: ASDisplayNode @@ -417,6 +424,11 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let separatorNode: ASDisplayNode private let resultBarNode: ASImageNode private let resultBarIconNode: ASImageNode + private var mediaNode: TransformImageNode? + private var stickerMediaLayer: InlineStickerItemLayer? + private var mediaHidden = false + private(set) var mediaFrame: CGRect? + private var currentMedia: Media? var option: TelegramMediaPollOption? private(set) var currentResult: ChatMessagePollOptionResult? private(set) var currentSelection: ChatMessagePollOptionSelection? @@ -518,6 +530,28 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } } + private func updateMediaVisibility() { + let alpha: CGFloat = self.mediaHidden ? 0.0 : 1.0 + self.mediaNode?.alpha = alpha + self.stickerMediaLayer?.opacity = Float(alpha) + } + + func setMediaHidden(_ hidden: Bool) { + if self.mediaHidden != hidden { + self.mediaHidden = hidden + self.updateMediaVisibility() + } + } + + func transitionNode(media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + guard let currentMedia = self.currentMedia, currentMedia.isEqual(to: media), let mediaNode = self.mediaNode, !mediaNode.isHidden else { + return nil + } + return (mediaNode, mediaNode.bounds, { [weak mediaNode] in + return (mediaNode?.view.snapshotContentTree(unhide: true), nil) + }) + } + static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) { let makeTitleLayout = TextNodeWithEntities.asyncLayout(maybeNode?.titleNode) let currentResult = maybeNode?.currentResult @@ -526,7 +560,15 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { return { context, presentationData, message, poll, option, translation, optionResult, constrainedWidth in let leftInset: CGFloat = 50.0 - let rightInset: CGFloat = 10.0 + let media = option.media + let hasMedia = media != nil + let mediaInset: CGFloat + if hasMedia { + mediaInset = ChatMessagePollOptionNode.mediaSize.width + ChatMessagePollOptionNode.mediaSpacing + ChatMessagePollOptionNode.mediaRightInset + } else { + mediaInset = 0.0 + } + let rightInset: CGFloat = 10.0 + mediaInset let incoming = message.effectivelyIncoming(context.account.peerId) @@ -555,7 +597,7 @@ 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(46.0, titleLayout.size.height + 22.0) + let contentHeight: CGFloat = max(52.0, titleLayout.size.height + 28.0) let shouldHaveRadioNode = optionResult == nil let isSelectable: Bool @@ -658,6 +700,8 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } node.option = option + let previousMedia = node.currentMedia + node.currentMedia = media let previousResult = node.currentResult node.currentResult = optionResult node.currentSelection = selection @@ -695,9 +739,9 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { )) let titleNodeFrame: CGRect if titleLayout.hasRTL { - titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 12.0), size: titleLayout.size) + titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 15.0), size: titleLayout.size) } else { - titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 15.0), size: titleLayout.size) } if node.titleNode !== titleNode { node.titleNode = titleNode @@ -723,7 +767,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } } let radioSize: CGFloat = 22.0 - radioNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: CGSize(width: radioSize, height: radioSize)) + radioNode.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) } else if let radioNode = node.radioNode { node.radioNode = nil @@ -741,7 +785,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { node.percentageImage = updatedPercentageImage } if let image = node.percentageImage { - node.percentageNode.frame = CGRect(origin: CGPoint(x: leftInset - 7.0 - image.size.width, y: 12.0), size: image.size) + node.percentageNode.frame = CGRect(origin: CGPoint(x: leftInset - 7.0 - image.size.width, y: 15.0), size: image.size) if animated && previousResult?.percent != optionResult?.percent { let percentageDuration = 0.27 let images = generatePercentageAnimationImages(presentationData: presentationData, incoming: incoming, from: previousResult?.percent ?? 0, to: optionResult?.percent ?? 0, duration: percentageDuration) @@ -755,12 +799,112 @@ 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) + } else { + mediaFrame = nil + } + node.mediaFrame = mediaFrame + + var isSticker = false + if let media, let mediaFrame, let file = media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji { + isSticker = true + + let stickerLayer: InlineStickerItemLayer + if let current = node.stickerMediaLayer, previousMedia?.isEqual(to: media) == true { + stickerLayer = current + } else { + node.stickerMediaLayer?.removeFromSuperlayer() + let emoji = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file, custom: nil, enableAnimation: true) + stickerLayer = InlineStickerItemLayer( + context: context, + userLocation: .other, + attemptSynchronousLoad: attemptSynchronous, + emoji: emoji, + file: file, + cache: context.animationCache, + renderer: context.animationRenderer, + unique: false, + placeholderColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + pointSize: CGSize(width: mediaFrame.width * 2.0, height: mediaFrame.height * 2.0), + dynamicColor: nil, + loopCount: nil + ) + node.layer.addSublayer(stickerLayer) + node.stickerMediaLayer = stickerLayer + } + stickerLayer.frame = mediaFrame + stickerLayer.isVisibleForAnimations = true + node.mediaNode?.removeFromSupernode() + node.mediaNode = nil + } else { + if let stickerMediaLayer = node.stickerMediaLayer { + stickerMediaLayer.removeFromSuperlayer() + node.stickerMediaLayer = nil + } + } + + if let media, let mediaFrame, !isSticker { + let mediaNode: TransformImageNode + if let current = node.mediaNode { + mediaNode = current + } else { + let current = TransformImageNode() + current.contentAnimations = [.subsequentUpdates] + node.mediaNode = current + node.addSubnode(current) + mediaNode = current + } + mediaNode.isHidden = false + mediaNode.frame = mediaFrame + + let mediaReference = AnyMediaReference.standalone(media: media) + var imageSize = ChatMessagePollOptionNode.mediaSize + 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)) + } + } else if let file = media as? TelegramMediaFile { + if let dimensions = file.dimensions { + imageSize = dimensions.cgSize.aspectFilled(ChatMessagePollOptionNode.mediaSize) + } + 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)) + } else { + mediaNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: .other, videoReference: fileReference)) + } + } + } else if let map = media as? TelegramMediaMap { + if previousMedia?.isEqual(to: media) != true { + let resource = MapSnapshotMediaResource(latitude: map.latitude, longitude: map.longitude, width: Int32(ChatMessagePollOptionNode.mediaSize.width), height: Int32(ChatMessagePollOptionNode.mediaSize.height)) + mediaNode.setSignal(chatMapSnapshotImage(engine: context.engine, resource: resource)) + } + } + + let makeLayout = mediaNode.asyncLayout() + let apply = makeLayout(TransformImageArguments( + corners: ImageCorners(radius: 10.0), + imageSize: imageSize, + boundingSize: ChatMessagePollOptionNode.mediaSize, + intrinsicInsets: UIEdgeInsets(), + emptyColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor + )) + apply() + } else if let mediaNode = node.mediaNode { + mediaNode.removeFromSupernode() + node.mediaNode = nil + } + node.setMediaHidden(node.mediaHidden) + 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 - rightInset, height: UIScreenPixel)) + node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - leftInset - 10.0, height: UIScreenPixel)) if node.resultBarNode.image == nil || updatedResultIcon { var isQuiz = false @@ -788,7 +932,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar } - node.resultBarNode.image = generateStretchableFilledCircleImage(diameter: 6.0, color: fillColor) + node.resultBarNode.image = generateStretchableFilledCircleImage(diameter: 4.0, color: fillColor) } if updatedResultIcon { @@ -797,7 +941,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { let minBarWidth: CGFloat = 6.0 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: 6.0)) + let barFrame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - 6.0 - 1.0), size: CGSize(width: resultBarWidth, 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.resultBarNode.alpha = optionResult != nil ? 1.0 : 0.0 @@ -888,6 +1032,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { private let statusNode: ChatMessageDateAndStatusNode private var optionNodes: [ChatMessagePollOptionNode] = [] private var shimmeringNodes: [ShimmeringLinkNode] = [] + private let temporaryHiddenMediaDisposable = MetaDisposable() private var poll: TelegramMediaPoll? @@ -1012,6 +1157,105 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { fatalError("init(coder:) has not been implemented") } + private func optionNodeForMedia(_ media: Media) -> ChatMessagePollOptionNode? { + for optionNode in self.optionNodes { + if let optionMedia = optionNode.option?.media, optionMedia.isEqual(to: media) { + return optionNode + } + } + return nil + } + + private func openOptionMedia(_ media: Media) { + guard let item = self.item else { + return + } + + let _ = item + +// let message = item.message.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 itemNode = self.itemNode as? ASDisplayNode, let superview = itemNode.view.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, let itemNode = self.itemNode as? ChatMessageItemView 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 +// 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 @@ -1872,6 +2116,11 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { for optionNode in self.optionNodes { if optionNode.frame.contains(point), case .tap = gesture { + if let mediaFrame = optionNode.mediaFrame, mediaFrame.offsetBy(dx: optionNode.frame.minX, dy: optionNode.frame.minY).contains(point), let media = optionNode.option?.media { + return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in + self?.openOptionMedia(media) + })) + } if optionNode.isUserInteractionEnabled { return ChatMessageBubbleContentTapAction(content: .ignore) } else if let result = optionNode.currentResult, let item = self.item, !Namespaces.Message.allNonRegular.contains(item.message.id.namespace), let poll = self.poll, let option = optionNode.option, !isBotChat { @@ -1926,6 +2175,28 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } + override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + guard let item = self.item, item.message.id == messageId, let optionNode = self.optionNodeForMedia(media), let transitionNode = optionNode.transitionNode(media: media) else { + return nil + } + return (transitionNode.0, transitionNode.1, transitionNode.2) + } + + override public func updateHiddenMedia(_ media: [Media]?) -> Bool { + var updated = false + for optionNode in self.optionNodes { + let shouldHide = media?.contains(where: { hiddenMedia in + guard let optionMedia = optionNode.option?.media else { + return false + } + return optionMedia.isEqual(to: hiddenMedia) + }) ?? false + optionNode.setMediaHidden(shouldHide) + updated = updated || shouldHide + } + return updated + } + public func updatePollTooltipMessageState(animated: Bool) { guard let item = self.item else { return diff --git a/submodules/TelegramUI/Components/ComposePollScreen/BUILD b/submodules/TelegramUI/Components/ComposePollScreen/BUILD index f19c83bdf6..e00eaabbc9 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/BUILD +++ b/submodules/TelegramUI/Components/ComposePollScreen/BUILD @@ -53,6 +53,9 @@ swift_library( "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", "//submodules/TelegramUI/Components/ChatScheduleTimeController", "//submodules/ContextUI", + "//submodules/Components/PagerComponent", + "//submodules/FeaturedStickersScreen", + "//submodules/TelegramNotices", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index e8b9918030..44649743d0 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -810,10 +810,10 @@ final class ComposePollScreenComponent: Component { case .description: availableButtons = [.gallery, .file, .location] default: - availableButtons = [.gallery, .location] + availableButtons = [.gallery, .sticker, .emoji, .location] } - presentPollAttachmentScreen(context: component.context, updatedPresentationData: nil, availableButtons: availableButtons, present: { [weak self] c in + presentPollAttachmentScreen(context: component.context, updatedPresentationData: nil, availableButtons: availableButtons, inputMediaNodeData: self.inputMediaNodeDataPromise.get() |> map(Optional.init), present: { [weak self] c in (self?.environment?.controller() as? ComposePollScreen)?.parentController()?.push(c) }, completion: { [weak self] media in guard let self else { @@ -1073,7 +1073,7 @@ final class ComposePollScreenComponent: Component { areCustomEmojiEnabled: true, hasTrending: false, hasSearch: true, - hasStickers: false, + hasStickers: true, hasGifs: false, hideBackground: true, maskEdge: .fade, @@ -1085,7 +1085,9 @@ final class ComposePollScreenComponent: Component { guard let self else { return } - self.inputMediaNodeData = value + var inputData = value + inputData.stickers = nil + self.inputMediaNodeData = inputData }) self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( @@ -1374,6 +1376,7 @@ final class ComposePollScreenComponent: Component { optionSelection = ListComposePollOptionComponent.Selection( isSelected: self.selectedQuizOptionIds.contains(optionId), isMultiSelection: self.effectiveIsMultiAnswer, + isQuiz: self.isQuiz, toggle: { [weak self] in guard let self else { return @@ -1809,6 +1812,9 @@ final class ComposePollScreenComponent: Component { 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)) })), diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift index 9b0d26de26..2ffc4a954c 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/PollAttachmentScreen.swift @@ -20,6 +20,7 @@ public func presentPollAttachmentScreen( context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, availableButtons: [AttachmentButtonType], + inputMediaNodeData: Signal = .single(nil), present: @escaping (ViewController) -> Void, completion: @escaping (AnyMediaReference) -> Void ) { @@ -92,6 +93,32 @@ public func presentPollAttachmentScreen( }) controllerCompletion(controller, controller.mediaPickerContext) return true + case .sticker: + let _ = (inputMediaNodeData + |> take(1) + |> deliverOnMainQueue).start(next: { content in + guard let content = content?.stickers else { + return + } + let controller = StickerAttachmentScreen(context: context, mode: .stickers(content), completion: { sticker in + completion(sticker) + }) + controllerCompletion(controller, controller.mediaPickerContext) + }) + return true + case .emoji: + let _ = (inputMediaNodeData + |> take(1) + |> deliverOnMainQueue).start(next: { content in + guard let content = content?.emoji else { + return + } + let controller = StickerAttachmentScreen(context: context, mode: .emoji(content), completion: { sticker in + completion(sticker) + }) + controllerCompletion(controller, controller.mediaPickerContext) + }) + return true default: return false } diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift new file mode 100644 index 0000000000..62a772ed9b --- /dev/null +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift @@ -0,0 +1,1170 @@ +import Foundation +import UIKit +import Display +import AccountContext +import TelegramCore +import Postbox +import SwiftSignalKit +import TelegramPresentationData +import ComponentFlow +import ViewControllerComponent +import AttachmentUI +import EntityKeyboard +import ChatEntityKeyboardInputNode +import ChatPresentationInterfaceState +import PagerComponent +import FeaturedStickersScreen +import TelegramNotices + +final class StickerAttachmentScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let mode: StickerAttachmentScreen.Mode + let completion: (AnyMediaReference) -> Void + + init( + context: AccountContext, + mode: StickerAttachmentScreen.Mode, + completion: @escaping (AnyMediaReference) -> Void + ) { + self.context = context + self.mode = mode + self.completion = completion + } + + static func ==(lhs: StickerAttachmentScreenComponent, rhs: StickerAttachmentScreenComponent) -> Bool { + return true + } + + final class KeyboardClippingView: UIView { + var hitEdgeInsets: UIEdgeInsets = .zero + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let bounds = self.bounds.inset(by: self.hitEdgeInsets) + return bounds.contains(point) + } + } + + final class View: UIView, UIScrollViewDelegate { + fileprivate let keyboardView: ComponentView + private let keyboardClippingView: KeyboardClippingView + private let panelHostView: PagerExternalTopPanelContainer + private let panelSeparatorView: UIView + + private var component: StickerAttachmentScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var interaction: ChatEntityKeyboardInputNode.Interaction? + private var inputNodeInteraction: ChatMediaInputNodeInteraction? + + private var searchVisible = false + private var forceUpdate = false + + private var ignoreNextZeroScrollingOffset = false + private var topPanelScrollingOffset: CGFloat = 0.0 + private var keyboardContentId: AnyHashable? + + private let contentDisposable = MetaDisposable() + + private var emojiContent: EmojiPagerContentComponent? + private var stickerContent: EmojiPagerContentComponent? + + private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? + + private struct EmojiSearchResult { + var groups: [EmojiPagerContentComponent.ItemGroup] + var id: AnyHashable + var version: Int + var isPreset: Bool + } + + private struct EmojiSearchState { + var result: EmojiSearchResult? + var isSearching: Bool + + init(result: EmojiSearchResult?, isSearching: Bool) { + self.result = result + self.isSearching = isSearching + } + } + + private let emojiSearchDisposable = MetaDisposable() + private let emojiSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) + private var emojiSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { + didSet { + self.emojiSearchState.set(.single(self.emojiSearchStateValue)) + } + } + + private let stickerSearchDisposable = MetaDisposable() + private let stickerSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) + private var stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { + didSet { + self.stickerSearchState.set(.single(self.stickerSearchStateValue)) + } + } + + override init(frame: CGRect) { + self.keyboardView = ComponentView() + self.keyboardClippingView = KeyboardClippingView() + self.panelHostView = PagerExternalTopPanelContainer() + self.panelSeparatorView = UIView() + + super.init(frame: frame) + + self.addSubview(self.keyboardClippingView) + self.addSubview(self.panelSeparatorView) + self.addSubview(self.panelHostView) + + self.interaction = ChatEntityKeyboardInputNode.Interaction( + sendSticker: { [weak self] file, _, _, _, _, _, _, _, _ in + if let self { + self.complete(file.abstract) + } + return false + }, + sendEmoji: { _, _, _ in + }, + sendGif: { _, _, _, _, _ in + return false + }, + sendBotContextResultAsGif: { _, _, _, _, _, _ in + return false + }, + editGif: { _, _ in + }, + updateChoosingSticker: { _ in }, + switchToTextInput: {}, + dismissTextInput: {}, + insertText: { _ in + }, + backwardsDeleteText: {}, + openStickerEditor: {}, + presentController: { [weak self] c, a in + if let self, let controller = self.environment?.controller() { + controller.present(c, in: .window(.root), with: a) + } + }, + presentGlobalOverlayController: { [weak self] c, a in + if let self, let controller = self.environment?.controller() { + controller.presentInGlobalOverlay(c, with: a) + } + }, + getNavigationController: { + return nil + }, + requestLayout: { transition in + let _ = transition + } + ) + + self.inputNodeInteraction = ChatMediaInputNodeInteraction( + navigateToCollectionId: { _ in + }, + navigateBackToStickers: { + }, + setGifMode: { _ in + }, + openSettings: { + }, + openTrending: { _ in + }, + dismissTrendingPacks: { _ in + }, + toggleSearch: { _, _, _ in + }, + openPeerSpecificSettings: { + }, + dismissPeerSpecificSettings: { + }, + clearRecentlyUsedStickers: { + } + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.contentDisposable.dispose() + } + + func complete(_ fileReference: AnyMediaReference) { + guard let component = self.component else { + return + } + component.completion(fileReference) + (self.environment?.controller() as? StickerAttachmentScreen)?.parentController()?.dismiss() + } + + func updateContent() { + guard let component = self.component else { + return + } + self.emojiContent?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( + performItemAction: { [weak self] groupId, item, _, _, _, _ in + guard let self, let component = self.component else { + return + } + let context = component.context + if groupId == AnyHashable("featuredTop"), let file = item.itemFile { + let _ = ( + combineLatest( + ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: context.account.peerId, premiumIfSavedMessages: true), + ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: context.account.peerId, premiumIfSavedMessages: false) + ) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] hasPremium, hasGlobalPremium in + guard let self else { + return + } + + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) + let _ = (combineLatest( + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), + context.account.postbox.combinedView(keys: [viewKey]) + ) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] emojiPacksView, views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + guard let self else { + return + } + + var installedCollectionIds = Set() + for (id, _, _) in emojiPacksView.collectionInfos { + installedCollectionIds.insert(id) + } + + let stickerPacks = view.items.map({ $0.contents.get(FeaturedStickerPackItem.self)! }).filter({ + !installedCollectionIds.contains($0.info.id) + }) + + for featuredStickerPack in stickerPacks { + if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { + if let pagerView = self.keyboardView.view as? EntityKeyboardComponent.View, let emojiInputInteraction = self.emojiContent?.inputInteractionHolder.inputInteraction { + pagerView.openCustomSearch(content: EmojiSearchContent( + context: context, + forceTheme: nil, + items: stickerPacks, + initialFocusId: featuredStickerPack.info.id, + hasPremiumForUse: hasPremium, + hasPremiumForInstallation: hasGlobalPremium, + parentInputInteraction: emojiInputInteraction + )) + } + break + } + } + }) + }) + } else if let file = item.itemFile?._parse() { + self.complete(.standalone(media: file)) + } + }, + deleteBackwards: nil, + openStickerSettings: nil, + openFeatured: nil, + openSearch: { + }, + addGroupAction: { [weak self] groupId, isPremiumLocked, _ in + guard let self, let component = self.component, let collectionId = groupId.base as? ItemCollectionId else { + return + } + let context = component.context + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + if featuredStickerPack.info.id == collectionId { + let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: featuredStickerPack.info.id.id, accessHash: featuredStickerPack.info.accessHash), forceActualized: false) + |> mapToSignal { result -> Signal in + switch result { + case let .result(info, items, installed): + if installed { + return .complete() + } else { + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + } + case .fetching: + break + case .none: + break + } + return .complete() + } + |> deliverOnMainQueue).start(completed: { + }) + + break + } + } + }) + }, + clearGroup: { [weak self] groupId in + guard let self, let component = self.component else { + return + } + let context = component.context + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + if groupId == AnyHashable("recent") { + let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) + var items: [ActionSheetItem] = [] + items.append(ActionSheetButtonItem(title: presentationData.strings.Emoji_ClearRecent, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + let _ = context.engine.stickers.clearRecentlyUsedEmoji().start() + })) + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) + } else if groupId == AnyHashable("popular") { + let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) + var items: [ActionSheetItem] = [] + items.append(ActionSheetTextItem(title: presentationData.strings.Chat_ClearReactionsAlertText, parseMarkdown: true)) + items.append(ActionSheetButtonItem(title: presentationData.strings.Chat_ClearReactionsAlertAction, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + guard let self else { + return + } + + self.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupRemoved(id: "popular")) + let _ = context.engine.stickers.clearRecentlyUsedReactions().start() + })) + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) + } + }, + editAction: { _ in }, + pushController: { c in + }, + presentController: { c in + }, + presentGlobalOverlayController: { c in + }, + navigationController: { [weak self] in + return self?.environment?.controller()?.navigationController as? NavigationController + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + if !transition.animation.isImmediate { + self.state?.updated(transition: transition) + } + }, + updateSearchQuery: { [weak self] query in + guard let self, let component = self.component else { + return + } + let context = component.context + + switch query { + case .none: + self.emojiSearchDisposable.set(nil) + self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) + case let .text(rawQuery, languageCode): + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + + if query.isEmpty { + self.emojiSearchDisposable.set(nil) + self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) + } else { + var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false) + if !languageCode.lowercased().hasPrefix("en") { + signal = signal + |> mapToSignal { keywords in + return .single(keywords) + |> then( + context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3) + |> map { englishKeywords in + return keywords + englishKeywords + } + ) + } + } + + let hasPremium: Signal = .single(true) + let resultSignal = combineLatest( + signal, + hasPremium + ) + |> mapToSignal { keywords, hasPremium -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in + var allEmoticons: [String: String] = [:] + for keyword in keywords { + for emoticon in keyword.emoticons { + allEmoticons[emoticon] = keyword.keyword + } + } + let remoteSignal: Signal<(items: [TelegramMediaFile], isFinalResult: Bool), NoError> + if hasPremium { + remoteSignal = context.engine.stickers.searchEmoji(query: query, emoticon: Array(allEmoticons.keys), inputLanguageCode: languageCode) + } else { + remoteSignal = .single(([], true)) + } + return remoteSignal + |> mapToSignal { foundEmoji -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in + if foundEmoji.items.isEmpty && !foundEmoji.isFinalResult { + return .complete() + } + var items: [EmojiPagerContentComponent.Item] = [] + + let appendUnicodeEmoji = { + for (_, list) in EmojiPagerContentComponent.staticEmojiMapping { + for emojiString in list { + if allEmoticons[emojiString] != nil { + let item = EmojiPagerContentComponent.Item( + animationData: nil, + content: .staticEmoji(emojiString), + itemFile: nil, + subgroupId: nil, + icon: .none, + tintMode: .none + ) + items.append(item) + } + } + } + } + + if !hasPremium { + appendUnicodeEmoji() + } + + var existingIds = Set() + for itemFile in foundEmoji.items { + if existingIds.contains(itemFile.fileId) { + continue + } + existingIds.insert(itemFile.fileId) + if itemFile.isPremiumEmoji && !hasPremium { + continue + } + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(itemFile)) + let item = EmojiPagerContentComponent.Item( + animationData: animationData, + content: .animation(animationData), + itemFile: TelegramMediaFile.Accessor(itemFile), + subgroupId: nil, + icon: .none, + tintMode: animationData.isTemplate ? .primary : .none + ) + items.append(item) + } + + if hasPremium { + appendUnicodeEmoji() + } + + return .single([EmojiPagerContentComponent.ItemGroup( + supergroupId: "search", + groupId: "search", + title: nil, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: nil, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: false, + items: items + )]) + } + } + + var version = 0 + self.emojiSearchStateValue.isSearching = true + self.emojiSearchDisposable.set((resultSignal + |> delay(0.15, queue: .mainQueue()) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + + self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false) + version += 1 + })) + } + case let .category(value): + let resultSignal = context.engine.stickers.searchEmoji(category: value) + |> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in + var items: [EmojiPagerContentComponent.Item] = [] + + var existingIds = Set() + for itemFile in files { + if existingIds.contains(itemFile.fileId) { + continue + } + existingIds.insert(itemFile.fileId) + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(itemFile)) + let item = EmojiPagerContentComponent.Item( + animationData: animationData, + content: .animation(animationData), + itemFile: TelegramMediaFile.Accessor(itemFile), + subgroupId: nil, + icon: .none, + tintMode: animationData.isTemplate ? .primary : .none + ) + items.append(item) + } + + return .single(([EmojiPagerContentComponent.ItemGroup( + supergroupId: "search", + groupId: "search", + title: nil, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: nil, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: false, + items: items + )], isFinalResult)) + } + + var version = 0 + self.emojiSearchDisposable.set((resultSignal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + + guard let group = result.items.first else { + return + } + if group.items.isEmpty && !result.isFinalResult { + self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [ + EmojiPagerContentComponent.ItemGroup( + supergroupId: "search", + groupId: "search", + title: nil, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: nil, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: true, + items: [] + ) + ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + return + } + self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + version += 1 + })) + } + }, + updateScrollingToItemGroup: { // [weak self] in +// if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { +// componentView.scrolledToItemGroup() +// } +// self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) + }, + onScroll: {}, + chatPeerId: nil, + peekBehavior: nil, + customLayout: nil, + externalBackground: nil, + externalExpansionView: nil, + customContentView: nil, + useOpaqueTheme: false, + hideBackground: true, + stateContext: nil, + addImage: nil + ) + + var stickerPeekBehavior: EmojiContentPeekBehaviorImpl? + stickerPeekBehavior = EmojiContentPeekBehaviorImpl( + context: component.context, + forceTheme: nil, + interaction: nil, + chatPeerId: nil, + present: { [weak self] c, a in + self?.environment?.controller()?.presentInGlobalOverlay(c, with: a) + } + ) + + self.stickerContent?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( + performItemAction: { [weak self] groupId, item, _, _, _, _ in + guard let self, let component = self.component, let file = item.itemFile?._parse() else { + return + } + let context = component.context + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + if groupId == AnyHashable("featuredTop") { + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] views in + guard let self, let controller = self.environment?.controller(), let view = views.views[viewKey] as? OrderedItemListView else { + return + } + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { + controller.push(FeaturedStickersScreen( + context: context, + highlightedPackId: featuredStickerPack.info.id, + forceTheme: nil, + stickerActionTitle: presentationData.strings.StickerPack_AddSticker, + sendSticker: { [weak self] fileReference, _, _ in + if let self { + self.complete(fileReference.abstract) + } + return true + } + )) + + break + } + } + }) + } else { + let reference: FileMediaReference + if groupId == AnyHashable("saved") { + reference = .savedSticker(media: file) + } else if groupId == AnyHashable("recent") { + reference = .recentSticker(media: file) + } else { + reference = .standalone(media: file) + } + self.complete(reference.abstract) + } + }, + deleteBackwards: nil, + openStickerSettings: nil, + openFeatured: nil, + openSearch: { [weak self] in + guard let self else { + return + } + if let pagerView = self.keyboardView.view as? EntityKeyboardComponent.View { + pagerView.openSearch() + } + }, + addGroupAction: { [weak self] groupId, isPremiumLocked, _ in + guard let strongSelf = self, let component = strongSelf.component, let collectionId = groupId.base as? ItemCollectionId else { + return + } + let context = component.context + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + if featuredStickerPack.info.id == collectionId { + let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: featuredStickerPack.info.id.id, accessHash: featuredStickerPack.info.accessHash), forceActualized: false) + |> mapToSignal { result -> Signal in + switch result { + case let .result(info, items, installed): + if installed { + return .complete() + } else { + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + } + case .fetching: + break + case .none: + break + } + return .complete() + } + |> deliverOnMainQueue).start(completed: { + }) + + break + } + } + }) + }, + clearGroup: { [weak self] groupId in + guard let strongSelf = self, let component = strongSelf.component else { + return + } + let context = component.context + if groupId == AnyHashable("recent") { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) + var items: [ActionSheetItem] = [] + items.append(ActionSheetButtonItem(title: presentationData.strings.Stickers_ClearRecent, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + let _ = context.engine.stickers.clearRecentlyUsedStickers().start() + })) + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) + } else if groupId == AnyHashable("featuredTop") { + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + var stickerPackIds: [Int64] = [] + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + stickerPackIds.append(featuredStickerPack.info.id.id) + } + let _ = ApplicationSpecificNotice.setDismissedTrendingStickerPacks(accountManager: context.sharedContext.accountManager, values: stickerPackIds).start() + }) + } else if groupId == AnyHashable("peerSpecific") { + } + }, + editAction: { _ in }, + pushController: { c in + }, + presentController: { c in + }, + presentGlobalOverlayController: { c in + }, + navigationController: { [weak self] in + return self?.environment?.controller()?.navigationController as? NavigationController + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + if !transition.animation.isImmediate { + self.state?.updated(transition: transition) + } + }, + updateSearchQuery: { [weak self] query in + guard let self = self, let component = self.component else { + return + } + let context = component.context + + switch query { + case .none: + self.stickerSearchDisposable.set(nil) + self.stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) + case .text: + self.stickerSearchDisposable.set(nil) + self.stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) + case let .category(value): + let resultSignal = context.engine.stickers.searchStickers(category: value, scope: [.installed, .remote]) + |> mapToSignal { files -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in + var items: [EmojiPagerContentComponent.Item] = [] + + var existingIds = Set() + for item in files.items { + let itemFile = item.file + if existingIds.contains(itemFile.fileId) { + continue + } + existingIds.insert(itemFile.fileId) + let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(itemFile)) + let item = EmojiPagerContentComponent.Item( + animationData: animationData, + content: .animation(animationData), + itemFile: TelegramMediaFile.Accessor(itemFile), + subgroupId: nil, + icon: .none, + tintMode: animationData.isTemplate ? .primary : .none + ) + items.append(item) + } + + return .single(([EmojiPagerContentComponent.ItemGroup( + supergroupId: "search", + groupId: "search", + title: nil, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: nil, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: false, + items: items + )], files.isFinalResult)) + } + + var version = 0 + self.stickerSearchDisposable.set((resultSignal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let strongSelf = self else { + return + } + guard let group = result.items.first else { + return + } + if group.items.isEmpty && !result.isFinalResult { + strongSelf.stickerSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [ + EmojiPagerContentComponent.ItemGroup( + supergroupId: "search", + groupId: "search", + title: nil, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: nil, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: true, + items: [] + ) + ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + return + } + strongSelf.stickerSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + version += 1 + })) + } + }, + updateScrollingToItemGroup: { +// if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { +// componentView.scrolledToItemGroup() +// } +// self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) + }, + onScroll: {}, + chatPeerId: nil, + peekBehavior: stickerPeekBehavior, + customLayout: nil, + externalBackground: nil, + externalExpansionView: nil, + customContentView: nil, + useOpaqueTheme: false, + hideBackground: true, + stateContext: nil, + addImage: nil + ) + } + + func update(component: StickerAttachmentScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let environment = environment[EnvironmentType.self].value + self.environment = environment + + self.backgroundColor = environment.theme.list.plainBackgroundColor + self.panelSeparatorView.backgroundColor = .clear + + if self.component == nil { + let data = combineLatest( + queue: Queue.mainQueue(), + self.stickerSearchState.get(), + self.emojiSearchState.get() + ) + self.contentDisposable.set(data.start(next: { [weak self] stickerSearchState, emojiSearchState in + guard let self else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + switch component.mode { + case var .emoji(emojiContent): + if let emojiSearchResult = emojiSearchState.result { + var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults? + if !emojiSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) { + emptySearchResults = EmojiPagerContentComponent.EmptySearchResults( + text: presentationData.strings.EmojiSearch_SearchEmojiEmptyResult, + iconFile: nil + ) + } + + let defaultSearchState: EmojiPagerContentComponent.SearchState = emojiSearchResult.isPreset ? .active : .empty(hasResults: true) + emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : defaultSearchState) + } else if emojiSearchState.isSearching { + emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiContent.contentItemGroups, itemContentUniqueId: emojiContent.itemContentUniqueId, emptySearchResults: emojiContent.emptySearchResults, searchState: .searching) + } + self.emojiContent = emojiContent + case var .stickers(stickerContent): + if let stickerSearchResult = stickerSearchState.result { + var stickerSearchResults: EmojiPagerContentComponent.EmptySearchResults? + if !stickerSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) { + stickerSearchResults = EmojiPagerContentComponent.EmptySearchResults( + text: presentationData.strings.EmojiSearch_SearchStickersEmptyResult, + iconFile: nil + ) + } + + let defaultSearchState: EmojiPagerContentComponent.SearchState = stickerSearchResult.isPreset ? .active : .empty(hasResults: true) + stickerContent = stickerContent.withUpdatedItemGroups(panelItemGroups: stickerContent.panelItemGroups, contentItemGroups: stickerSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: stickerSearchResult.id, version: stickerSearchResult.version), emptySearchResults: stickerSearchResults, searchState: stickerSearchState.isSearching ? .searching : defaultSearchState) + } else if stickerSearchState.isSearching { + stickerContent = stickerContent.withUpdatedItemGroups(panelItemGroups: stickerContent.panelItemGroups, contentItemGroups: stickerContent.contentItemGroups, itemContentUniqueId: stickerContent.itemContentUniqueId, emptySearchResults: stickerContent.emptySearchResults, searchState: .searching) + } + self.stickerContent = stickerContent + } + Queue.mainQueue().justDispatch { + self.updateContent() + self.state?.updated() + } + })) + } + + self.component = component + self.state = state + + let topPanelHeight: CGFloat = 42.0 + let topInset: CGFloat = 64.0 //component.topInset + + let context = component.context + let stickerPeekBehavior = EmojiContentPeekBehaviorImpl( + context: context, + forceTheme: nil, + interaction: nil, + chatPeerId: nil, + present: { c, a in + } + ) + + let keyboardSize = self.keyboardView.update( + transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)), + component: AnyComponent(EntityKeyboardComponent( + theme: environment.theme, + strings: environment.strings, + isContentInFocus: true, + containerInsets: UIEdgeInsets(top: topPanelHeight - 34.0 + topInset, left: 0.0, bottom: 0.0, right: 0.0), + topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), + emojiContent: self.emojiContent, + stickerContent: self.stickerContent, + maskContent: nil, + gifContent: nil, + hasRecentGifs: false, + availableGifSearchEmojies: [], + defaultToEmojiTab: emojiContent != nil, + externalTopPanelContainer: self.panelHostView, + externalBottomPanelContainer: nil, + externalTintMaskContainer: nil, + displayTopPanelBackground: .blur, + topPanelExtensionUpdated: { _, _ in + }, + topPanelScrollingOffset: { [weak self] offset, transition in + if let self { + if self.ignoreNextZeroScrollingOffset && offset == 0.0 { + } else { + self.ignoreNextZeroScrollingOffset = false + self.topPanelScrollingOffset = offset + } + } + }, + hideInputUpdated: { [weak self] _, searchVisible, transition in + guard let self else { + return + } + self.forceUpdate = true + self.searchVisible = searchVisible + self.state?.updated(transition: transition) + }, + hideTopPanelUpdated: { _, _ in + }, + switchToTextInput: {}, + switchToGifSubject: { _ in }, + reorderItems: { _, _ in }, + makeSearchContainerNode: { [weak self] content in + guard let self, let interaction = self.interaction, let inputNodeInteraction = self.inputNodeInteraction else { + return nil + } + + let mappedMode: ChatMediaInputSearchMode + switch content { + case .stickers: + mappedMode = .sticker + case .gifs: + mappedMode = .gif + } + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let searchContainerNode = PaneSearchContainerNode( + context: context, + theme: presentationData.theme, + strings: presentationData.strings, + interaction: interaction, + inputNodeInteraction: inputNodeInteraction, + mode: mappedMode, + batchVideoRenderingContext: nil, + stickerActionTitle: presentationData.strings.StickerPack_AddSticker, + trendingGifsPromise: Promise(nil), + cancel: { + }, + peekBehavior: stickerPeekBehavior + ) + return searchContainerNode + }, + contentIdUpdated: { [weak self] id in + guard let self else { + return + } + self.keyboardContentId = id + }, + deviceMetrics: environment.deviceMetrics, + hiddenInputHeight: 0.0, + inputHeight: 0.0, + displayBottomPanel: false, + isExpanded: true, + clipContentToTopPanel: false, + useExternalSearchContainer: false + )), + environment: {}, + forceUpdate: self.forceUpdate, + containerSize: availableSize + ) + self.forceUpdate = false + if let keyboardComponentView = self.keyboardView.view { + if keyboardComponentView.superview == nil { + self.keyboardClippingView.addSubview(keyboardComponentView) + } + +// if panelBackgroundColor.alpha < 0.01 { +// self.keyboardClippingView.clipsToBounds = true +// } else { + self.keyboardClippingView.clipsToBounds = false +// } + + transition.setFrame(view: self.keyboardClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelHeight - topInset))) + self.keyboardClippingView.hitEdgeInsets = UIEdgeInsets(top: -topPanelHeight - topInset, left: 0.0, bottom: 0.0, right: 0.0) + + transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topPanelHeight - topInset), size: keyboardSize)) + transition.setFrame(view: self.panelHostView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset - 34.0), size: CGSize(width: keyboardSize.width, height: 0.0))) + + let topPanelAlpha: CGFloat + if self.searchVisible || self.keyboardContentId == AnyHashable("gifs") { + topPanelAlpha = 0.0 + } else { + topPanelAlpha = max(0.0, min(1.0, (self.topPanelScrollingOffset / 20.0))) + } + + transition.setAlpha(view: self.panelSeparatorView, alpha: topPanelAlpha) + + transition.setFrame(view: self.panelSeparatorView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset - UIScreenPixel), size: CGSize(width: keyboardSize.width, height: UIScreenPixel))) + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class StickerAttachmentScreen: ViewControllerComponentContainer, AttachmentContainable { + enum Mode { + case stickers(EmojiPagerContentComponent) + case emoji(EmojiPagerContentComponent) + } + + private let context: AccountContext + private let mode: Mode + private let completion: (AnyMediaReference) -> Void + + init(context: AccountContext, mode: Mode, completion: @escaping (AnyMediaReference) -> Void) { + self.context = context + self.mode = mode + self.completion = completion + + super.init(context: context, component: StickerAttachmentScreenComponent( + context: context, + mode: mode, + completion: completion + ), navigationBarAppearance: .default, theme: .default) + + self._hasGlassStyle = true + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public var isMinimized: Bool = false + + public var requestAttachmentMenuExpansion: () -> Void = { + } + public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in + } + public var parentController: () -> ViewController? = { + return nil + } + public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in + } + public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in + } + public var cancelPanGesture: () -> Void = { + } + public var isContainerPanning: () -> Bool = { + return false + } + public var isContainerExpanded: () -> Bool = { + return false + } + public var mediaPickerContext: AttachmentMediaPickerContext? + + public var isPanGestureEnabled: (() -> Bool)? { + return { + return true +// guard let self, let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.View else { +// return true +// } +// return componentView.isPanGestureEnabled() + } + } + + public func isContainerPanningUpdated(_ panning: Bool) { + } + + public func resetForReuse() { + } + + public func prepareForReuse() { + } + + public func requestDismiss(completion: @escaping () -> Void) { + completion() + } + + public func shouldDismissImmediately() -> Bool { + return true + } +} diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift index 0d6e444182..1eb6d50f38 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift @@ -42,11 +42,13 @@ public final class ListComposePollOptionComponent: Component { public final class Selection: Equatable { public let isSelected: Bool public let isMultiSelection: Bool + public let isQuiz: Bool public let toggle: () -> Void - public init(isSelected: Bool, isMultiSelection: Bool = false, toggle: @escaping () -> Void) { + public init(isSelected: Bool, isMultiSelection: Bool = false, isQuiz: Bool = false, toggle: @escaping () -> Void) { self.isSelected = isSelected self.isMultiSelection = isMultiSelection + self.isQuiz = isQuiz self.toggle = toggle } @@ -57,6 +59,9 @@ public final class ListComposePollOptionComponent: Component { if lhs.isMultiSelection != rhs.isMultiSelection { return false } + if lhs.isQuiz != rhs.isQuiz { + return false + } return true } } @@ -261,6 +266,7 @@ public final class ListComposePollOptionComponent: Component { private var checkLayer: CheckLayer? private var theme: PresentationTheme? private var isRectangle = false + private var isQuiz = false var action: (() -> Void)? @@ -310,7 +316,7 @@ public final class ListComposePollOptionComponent: Component { self.action?() } - func update(size: CGSize, isRectangle: Bool, theme: PresentationTheme, isSelected: Bool, transition: ComponentTransition) { + func update(size: CGSize, isRectangle: Bool, isQuiz: Bool, theme: PresentationTheme, isSelected: Bool, transition: ComponentTransition) { let checkLayer: CheckLayer if let current = self.checkLayer { checkLayer = current @@ -320,10 +326,17 @@ public final class ListComposePollOptionComponent: Component { self.layer.addSublayer(checkLayer) } - if self.theme !== theme { + if self.theme !== theme || self.isQuiz != isQuiz { self.theme = theme + self.isQuiz = isQuiz - checkLayer.theme = CheckNodeTheme(theme: theme, style: .plain) + let checkTheme: CheckNodeTheme + if isQuiz { + checkTheme = CheckNodeTheme(backgroundColor: theme.chat.message.incoming.polls.barPositive, strokeColor: theme.list.itemCheckColors.foregroundColor, borderColor: theme.list.itemCheckColors.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false) + } else { + checkTheme = CheckNodeTheme(theme: theme, style: .plain) + } + checkLayer.theme = checkTheme } if self.isRectangle != isRectangle { @@ -863,11 +876,11 @@ public final class ListComposePollOptionComponent: Component { 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, theme: component.theme, isSelected: selection.isSelected, transition: .immediate) + 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, theme: component.theme, isSelected: selection.isSelected, transition: transition) + checkView.update(size: checkFrame.size, isRectangle: selection.isMultiSelection, isQuiz: selection.isQuiz, theme: component.theme, isSelected: selection.isSelected, transition: transition) } } else if let checkView = self.checkView { self.checkView = nil @@ -948,7 +961,7 @@ public final class ListComposePollOptionComponent: Component { let imageNodeFrame = CGRect(origin: CGPoint(x: size.width - 16.0 - imageNodeSize.width + self.revealOffset, y: size.height - minHeight + floor((minHeight - imageNodeSize.height) * 0.5)), size: imageNodeSize) var isSticker = false - if let attachment = component.attachment, let file = attachment.media?.media as? TelegramMediaFile, file.isSticker { + 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) @@ -972,8 +985,9 @@ public final class ListComposePollOptionComponent: Component { placeholderColor: component.theme.list.mediaPlaceholderColor, pointSize: CGSize(width: animationSize.width * 2.0, height: animationSize.height * 2.0), dynamicColor: nil, - loopCount: 2 + loopCount: nil ) + animationLayer.isVisibleForAnimations = true self.animationLayer = animationLayer self.layer.addSublayer(animationLayer) } @@ -1070,7 +1084,7 @@ public final class ListComposePollOptionComponent: Component { let progressFrame = imageNodeFrame.insetBy(dx: 6.0, dy: 6.0) statusNode.frame = progressFrame - statusNode.transitionToState(.progress(value: max(0.027, min(1.0, progress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 1.0 + UIScreenPixel), animateRotation: false), updateCutout: false) + 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) } else if let statusNode = self.statusNode { self.statusNode = nil if !transition.animation.isImmediate {