diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift index 8243efd2bb..8dcdf78175 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift @@ -120,6 +120,7 @@ final class ReactionContextBackgroundNode: ASDisplayNode { isMinimized: Bool, isCoveredByInput: Bool, displayTail: Bool, + forceTailToRight: Bool, transition: ContainedViewLayoutTransition ) { let shadowInset: CGFloat = 15.0 @@ -171,7 +172,10 @@ final class ReactionContextBackgroundNode: ASDisplayNode { let largeCircleFrame: CGRect let smallCircleFrame: CGRect - if isLeftAligned { + if forceTailToRight { + largeCircleFrame = CGRect(origin: CGPoint(x: cloudSourcePoint - floor(largeCircleSize / 2.0), y: size.height - largeCircleSize / 2.0), size: CGSize(width: largeCircleSize, height: largeCircleSize)) + smallCircleFrame = CGRect(origin: CGPoint(x: largeCircleFrame.maxX - 3.0, y: largeCircleFrame.maxY + 2.0), size: CGSize(width: smallCircleSize, height: smallCircleSize)) + } else if isLeftAligned { largeCircleFrame = CGRect(origin: CGPoint(x: cloudSourcePoint - floor(largeCircleSize / 2.0), y: size.height - largeCircleSize / 2.0), size: CGSize(width: largeCircleSize, height: largeCircleSize)) smallCircleFrame = CGRect(origin: CGPoint(x: largeCircleFrame.maxX - 3.0, y: largeCircleFrame.maxY + 2.0), size: CGSize(width: smallCircleSize, height: smallCircleSize)) } else { diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index b2324d045d..9b1b05b968 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -233,6 +233,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { private var animationHideNode: Bool = false public var displayTail: Bool = true + public var forceTailToRight: Bool = true private var didAnimateIn: Bool = false public private(set) var isAnimatingOut: Bool = false @@ -636,12 +637,20 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { contentSize.width = max(46.0, contentSize.width) contentSize.height = self.currentContentHeight - let sideInset: CGFloat = 11.0 + insets.left + let sideInset: CGFloat + if self.forceTailToRight { + sideInset = insets.left + } else { + sideInset = 11.0 + insets.left + } let backgroundOffset: CGPoint = CGPoint(x: 22.0, y: -7.0) var rect: CGRect let isLeftAligned: Bool - if anchorRect.minX < containerSize.width - anchorRect.maxX { + if self.forceTailToRight { + rect = CGRect(origin: CGPoint(x: anchorRect.minX - backgroundOffset.x - 4.0, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) + isLeftAligned = false + } else if anchorRect.minX < containerSize.width - anchorRect.maxX { rect = CGRect(origin: CGPoint(x: anchorRect.maxX - contentSize.width + backgroundOffset.x, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) isLeftAligned = true } else { @@ -665,7 +674,9 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { } let cloudSourcePoint: CGFloat - if isLeftAligned { + if self.forceTailToRight { + cloudSourcePoint = min(rect.maxX - 46.0 / 2.0, anchorRect.maxX - 4.0) + } else if isLeftAligned { cloudSourcePoint = min(rect.maxX - 46.0 / 2.0, anchorRect.maxX - 4.0) } else { cloudSourcePoint = max(rect.minX + 46.0 / 2.0, anchorRect.minX) @@ -1190,6 +1201,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { isMinimized: self.highlightedReaction != nil && !self.highlightedByHover, isCoveredByInput: isCoveredByInput, displayTail: self.displayTail, + forceTailToRight: self.forceTailToRight, transition: transition ) @@ -1779,10 +1791,6 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { public func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, forceSwitchToInlineImmediately: Bool = false, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { self.isAnimatingOutToReaction = true - #if DEBUG - let hideNode = true - #endif - var foundItemNode: ReactionNode? for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { diff --git a/submodules/TelegramNotices/Sources/Notices.swift b/submodules/TelegramNotices/Sources/Notices.swift index 4a914141bc..360ddd9f2c 100644 --- a/submodules/TelegramNotices/Sources/Notices.swift +++ b/submodules/TelegramNotices/Sources/Notices.swift @@ -177,6 +177,7 @@ private enum ApplicationSpecificGlobalNotice: Int32 { case storiesCameraTooltip = 43 case storiesDualCameraTooltip = 44 case displayChatListArchiveTooltip = 45 + case displayStoryReactionTooltip = 46 var key: ValueBoxKey { let v = ValueBoxKey(length: 4) @@ -414,6 +415,10 @@ private struct ApplicationSpecificNoticeKeys { static func displayChatListArchiveTooltip() -> NoticeEntryKey { return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.displayChatListArchiveTooltip.key) } + + static func displayStoryReactionTooltip() -> NoticeEntryKey { + return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.displayStoryReactionTooltip.key) + } } public struct ApplicationSpecificNotice { @@ -1546,6 +1551,27 @@ public struct ApplicationSpecificNotice { |> take(1) } + public static func setDisplayStoryReactionTooltip(accountManager: AccountManager) -> Signal { + return accountManager.transaction { transaction -> Void in + if let entry = CodableEntry(ApplicationSpecificBoolNotice()) { + transaction.setNotice(ApplicationSpecificNoticeKeys.displayStoryReactionTooltip(), entry) + } + } + |> ignoreValues + } + + public static func displayStoryReactionTooltip(accountManager: AccountManager) -> Signal { + return accountManager.noticeEntry(key: ApplicationSpecificNoticeKeys.displayStoryReactionTooltip()) + |> map { view -> Bool in + if let _ = view.value?.get(ApplicationSpecificBoolNotice.self) { + return true + } else { + return false + } + } + |> take(1) + } + public static func setDisplayChatListArchiveTooltip(accountManager: AccountManager) -> Signal { return accountManager.transaction { transaction -> Void in if let entry = CodableEntry(ApplicationSpecificBoolNotice()) { diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift index 5a4b1a7e2b..36ea09ad07 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift @@ -21,12 +21,8 @@ private extension MessageInputActionButtonComponent.Mode { return "Chat/Input/Text/IconAttachment" case .forward: return "Chat/Input/Text/IconForwardSend" - case let .like(reaction, _, _): - if reaction == nil { - return "Stories/InputLikeOff" - } else { - return nil - } + case .like: + return "Stories/InputLikeOff" default: return nil } @@ -220,6 +216,11 @@ public final class MessageInputActionButtonComponent: Component { let themeUpdated = previousComponent?.theme !== component.theme + var transition = transition + if transition.animation.isImmediate, let previousComponent, case .like = previousComponent.mode, case .like = component.mode, previousComponent.mode != component.mode { + transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) + } + self.containerNode.isUserInteractionEnabled = component.longPressAction != nil if self.micButton == nil { @@ -319,8 +320,14 @@ public final class MessageInputActionButtonComponent: Component { switch component.mode { case .none: break - case .send, .apply, .attach, .delete, .forward, .like: + case .send, .apply, .attach, .delete, .forward: sendAlpha = 1.0 + case let .like(reaction, _, _): + if reaction != nil { + sendAlpha = 0.0 + } else { + sendAlpha = 1.0 + } case .more: moreAlpha = 1.0 case .videoInput, .voiceInput: @@ -333,8 +340,6 @@ public final class MessageInputActionButtonComponent: Component { if let iconName = component.mode.iconName { let tintColor: UIColor = .white self.sendIconView.image = generateTintedImage(image: UIImage(bundleImageName: iconName), color: tintColor) - } else if case let .like(reaction, _, _) = component.mode, reaction != nil { - self.sendIconView.image = nil } else if case .apply = component.mode { self.sendIconView.image = generateImage(CGSize(width: 33.0, height: 33.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -392,7 +397,7 @@ public final class MessageInputActionButtonComponent: Component { } if case let .like(reactionValue, reactionFile, animationFileId) = component.mode, let reaction = reactionValue { - let reactionIconFrame = CGRect(origin: .zero, size: availableSize).insetBy(dx: 3.0, dy: 3.0) + let reactionIconFrame = CGRect(origin: .zero, size: CGSize(width: 32.0, height: 32.0)).insetBy(dx: 2.0, dy: 2.0) let reactionIconView: ReactionIconView if let current = self.reactionIconView { @@ -402,6 +407,11 @@ public final class MessageInputActionButtonComponent: Component { reactionIconView.isUserInteractionEnabled = false self.reactionIconView = reactionIconView self.addSubview(reactionIconView) + + if previousComponent != nil { + reactionIconView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + reactionIconView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25) + } } transition.setFrame(view: reactionIconView, frame: reactionIconFrame) reactionIconView.update( @@ -418,7 +428,10 @@ public final class MessageInputActionButtonComponent: Component { ) } else if let reactionIconView = self.reactionIconView { self.reactionIconView = nil - reactionIconView.removeFromSuperview() + reactionIconView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak reactionIconView] _ in + reactionIconView?.removeFromSuperview() + }) + reactionIconView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) } transition.setFrame(view: self.button.view, frame: CGRect(origin: .zero, size: availableSize)) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index d825a85f43..34aeb9a3f1 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -20,6 +20,7 @@ import VolumeButtons import TooltipUI import ChatEntityKeyboardInputNode import notify +import TelegramNotices func hasFirstResponder(_ view: UIView) -> Bool { if view.isFirstResponder { @@ -382,6 +383,8 @@ private final class StoryContainerScreenComponent: Component { private var pendingNavigationToItemId: (peerId: EnginePeer.Id, id: Int32)? + private var didDisplayReactionTooltip: Bool = false + override init(frame: CGRect) { self.backgroundLayer = SimpleLayer() self.backgroundLayer.backgroundColor = UIColor.black.cgColor @@ -911,6 +914,30 @@ private final class StoryContainerScreenComponent: Component { self?.layer.allowsGroupOpacity = false }) } + + Queue.mainQueue().after(0.4, { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = (ApplicationSpecificNotice.displayStoryReactionTooltip(accountManager: component.context.sharedContext.accountManager) + |> delay(1.0, queue: .mainQueue()) + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + if !value { + if let component = self.component, let stateValue = component.content.stateValue, let slice = stateValue.slice, let itemSetView = self.visibleItemSetViews[slice.peer.id], let currentItemView = itemSetView.view.view as? StoryItemSetContainerComponent.View { + currentItemView.maybeDisplayReactionTooltip() + } + } + + self.didDisplayReactionTooltip = true + #if !DEBUG + let _ = ApplicationSpecificNotice.setDisplayStoryReactionTooltip(accountManager: component.context.sharedContext.accountManager).start() + #endif + }) + }) } func animateOut(completion: @escaping () -> Void) { @@ -1094,6 +1121,7 @@ private final class StoryContainerScreenComponent: Component { } } }) + update = true } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index ae12847873..e6b584db75 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -950,10 +950,21 @@ public final class StoryItemSetContainerComponent: Component { } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + + if self.displayLikeReactions, let reactionContextNode = self.reactionContextNode { + if let result, result.isDescendant(of: reactionContextNode.view) { + return result + } else { + return self.itemsContainerView + } + } + if let inputView = self.inputPanel.view, let inputViewHitTest = inputView.hitTest(self.convert(point, to: inputView), with: event) { return inputViewHitTest } - guard let result = super.hitTest(point, with: event) else { + + guard let result else { return nil } @@ -1823,6 +1834,67 @@ public final class StoryItemSetContainerComponent: Component { }) } + func maybeDisplayReactionTooltip() { + if "".isEmpty { + return + } + guard let component = self.component else { + return + } + guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View, let likeButtonView = inputPanelView.likeButtonView else { + return + } + if inputPanelView.isHidden || inputPanelView.alpha == 0.0 { + return + } + if !likeButtonView.isDescendant(of: self) { + return + } + + let rect = likeButtonView.convert(likeButtonView.bounds, to: nil) + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + //TODO:localize + let text = "Long tap for more reactions" + let controller = TooltipController(content: .text(text), baseFontSize: presentationData.listsFontSize.baseDisplaySize, padding: 2.0) + controller.dismissed = { [weak self] _ in + if let self { + self.voiceMessagesRestrictedTooltipController = nil + self.updateIsProgressPaused() + } + } + component.presentController(controller, TooltipControllerPresentationArguments(sourceViewAndRect: { [weak self] in + if let self { + return (self, rect) + } + return nil + })) + self.voiceMessagesRestrictedTooltipController = controller + self.updateIsProgressPaused() + + //TODO:localize + /*let tooltipScreen = TooltipScreen( + account: component.context.account, + sharedContext: component.context.sharedContext, + text: .markdown(text: "Long tap for more reactions"), + balancedTextLayout: true, + style: .default, + location: TooltipScreen.Location.point(likeButtonView.convert(likeButtonView.bounds, to: nil).offsetBy(dx: 0.0, dy: 0.0), .bottom), displayDuration: .infinite, shouldDismissOnTouch: { _, _ in + return .dismiss(consume: true) + } + ) + tooltipScreen.willBecomeDismissed = { [weak self] _ in + guard let self else { + return + } + self.sendMessageContext.tooltipScreen = nil + self.updateIsProgressPaused() + } + self.sendMessageContext.tooltipScreen?.dismiss() + self.sendMessageContext.tooltipScreen = tooltipScreen + self.updateIsProgressPaused() + component.controller()?.present(tooltipScreen, in: .current)*/ + } + func update(component: StoryItemSetContainerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let isFirstTime = self.component == nil @@ -3260,8 +3332,9 @@ public final class StoryItemSetContainerComponent: Component { let reactionsAnchorRect: CGRect if self.displayLikeReactions, let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View, let likeButtonView = inputPanelView.likeButtonView { var likeRect = likeButtonView.convert(likeButtonView.bounds, to: self) - likeRect.origin.y -= 14.0 - likeRect.size.height += 14.0 + likeRect.origin.y -= 15.0 + likeRect.size.height += 15.0 + likeRect.origin.x -= 30.0 reactionsAnchorRect = likeRect } else { reactionsAnchorRect = CGRect(origin: CGPoint(x: inputPanelFrame.maxX - 40.0, y: inputPanelFrame.minY + 9.0), size: CGSize(width: 32.0, height: 32.0)).insetBy(dx: -4.0, dy: -4.0) @@ -3306,7 +3379,7 @@ public final class StoryItemSetContainerComponent: Component { animationCache: component.context.animationCache, presentationData: component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme), items: reactionItems.map(ReactionContextItem.reaction), - selectedItems: Set(), + selectedItems: component.slice.item.storyItem.myReaction.flatMap { Set([$0]) } ?? Set(), getEmojiContent: { [weak self] animationCache, animationRenderer in guard let self, let component = self.component else { preconditionFailure() @@ -3353,6 +3426,7 @@ public final class StoryItemSetContainerComponent: Component { } ) reactionContextNode.displayTail = self.displayLikeReactions + reactionContextNode.forceTailToRight = self.displayLikeReactions self.reactionContextNode = reactionContextNode reactionContextNode.reactionSelected = { [weak self] updateReaction, _ in @@ -3360,12 +3434,45 @@ public final class StoryItemSetContainerComponent: Component { return } - if component.slice.item.storyItem.myReaction == updateReaction.reaction { - let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: nil).start() - self.displayLikeReactions = false - self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + if self.displayLikeReactions { + if component.slice.item.storyItem.myReaction == updateReaction.reaction { + let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: nil).start() + self.displayLikeReactions = false + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } else { + self.waitingForReactionAnimateOutToLike = updateReaction.reaction + let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: updateReaction.reaction).start() + } } else { - self.waitingForReactionAnimateOutToLike = updateReaction.reaction + let targetView = UIView(frame: CGRect(origin: CGPoint(x: floor((self.bounds.width - 100.0) * 0.5), y: floor((self.bounds.height - 100.0) * 0.5)), size: CGSize(width: 100.0, height: 100.0))) + targetView.isUserInteractionEnabled = false + self.addSubview(targetView) + + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.willAnimateOutToReaction(value: updateReaction.reaction) + reactionContextNode.animateOutToReaction(value: updateReaction.reaction, targetView: targetView, hideNode: false, animateTargetContainer: nil, addStandaloneReactionAnimation: "".isEmpty ? nil : { [weak self] standaloneReactionAnimation in + guard let self else { + return + } + standaloneReactionAnimation.frame = self.bounds + self.addSubview(standaloneReactionAnimation.view) + }, completion: { [weak targetView, weak reactionContextNode] in + targetView?.removeFromSuperview() + if let reactionContextNode { + reactionContextNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, removeOnCompletion: false) + reactionContextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak reactionContextNode] _ in + reactionContextNode?.view.removeFromSuperview() + }) + } + }) + } + + if hasFirstResponder(self) { + self.sendMessageContext.currentInputMode = .text + self.endEditing(true) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: updateReaction.reaction).start() } }