[WIP] Polls

This commit is contained in:
Ilya Laktyushin
2026-03-13 14:11:19 +01:00
parent cbc5a49507
commit 950d99eea7
10 changed files with 1548 additions and 22 deletions
@@ -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
@@ -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:
@@ -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
}
}
@@ -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",
@@ -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
@@ -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",
@@ -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))
})),
@@ -20,6 +20,7 @@ public func presentPollAttachmentScreen(
context: AccountContext,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
availableButtons: [AttachmentButtonType],
inputMediaNodeData: Signal<ChatEntityKeyboardInputNode.InputData?, NoError> = .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
}
@@ -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 {