Interactive story reactions

This commit is contained in:
Ali 2023-08-25 22:54:07 +04:00
parent db9e286857
commit f33537509f
8 changed files with 368 additions and 99 deletions

View File

@ -1242,6 +1242,8 @@ private final class DrawingScreenComponent: CombinedComponent {
nextStyle = .regular
case .stroke:
nextStyle = .regular
case .blur:
nextStyle = .regular
}
textEntity.style = nextStyle
updateEntityView.invoke((textEntity.uuid, false))
@ -3515,6 +3517,8 @@ public final class DrawingToolsInteraction {
nextStyle = .regular
case .stroke:
nextStyle = .regular
case .blur:
nextStyle = .regular
}
textEntity.style = nextStyle
entityView.update()

View File

@ -89,6 +89,7 @@ swift_library(
"//submodules/TelegramUI/Components/NavigationSearchComponent",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/OptionButtonComponent",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
],
visibility = [
"//visibility:public",

View File

@ -30,18 +30,22 @@ final class StoryItemContentComponent: Component {
let strings: PresentationStrings
let peer: EnginePeer
let item: EngineStoryItem
let availableReactions: StoryAvailableReactions?
let audioMode: StoryContentItem.AudioMode
let isVideoBuffering: Bool
let isCurrent: Bool
let activateReaction: (UIView, MessageReaction.Reaction) -> Void
init(context: AccountContext, strings: PresentationStrings, peer: EnginePeer, item: EngineStoryItem, audioMode: StoryContentItem.AudioMode, isVideoBuffering: Bool, isCurrent: Bool) {
init(context: AccountContext, strings: PresentationStrings, peer: EnginePeer, item: EngineStoryItem, availableReactions: StoryAvailableReactions?, audioMode: StoryContentItem.AudioMode, isVideoBuffering: Bool, isCurrent: Bool, activateReaction: @escaping (UIView, MessageReaction.Reaction) -> Void) {
self.context = context
self.strings = strings
self.peer = peer
self.item = item
self.availableReactions = availableReactions
self.audioMode = audioMode
self.isVideoBuffering = isVideoBuffering
self.isCurrent = isCurrent
self.activateReaction = activateReaction
}
static func ==(lhs: StoryItemContentComponent, rhs: StoryItemContentComponent) -> Bool {
@ -57,6 +61,9 @@ final class StoryItemContentComponent: Component {
if lhs.item != rhs.item {
return false
}
if lhs.availableReactions != rhs.availableReactions {
return false
}
if lhs.isVideoBuffering != rhs.isVideoBuffering {
return false
}
@ -68,6 +75,7 @@ final class StoryItemContentComponent: Component {
final class View: StoryContentItem.View {
private let imageView: StoryItemImageView
private let overlaysView: StoryItemOverlaysView
private var videoNode: UniversalVideoNode?
private var loadingEffectView: StoryItemLoadingEffectView?
@ -107,12 +115,14 @@ final class StoryItemContentComponent: Component {
override init(frame: CGRect) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
self.imageView = StoryItemImageView()
self.overlaysView = StoryItemOverlaysView()
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.addSubview(self.imageView)
self.addSubview(self.overlaysView)
self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in
guard let self else {
@ -120,6 +130,13 @@ final class StoryItemContentComponent: Component {
}
self.updateProgressMode(update: true)
}
self.overlaysView.activate = { [weak self] view, reaction in
guard let self, let component = self.component else {
return
}
component.activateReaction(view, reaction)
}
}
required init?(coder: NSCoder) {
@ -449,6 +466,9 @@ final class StoryItemContentComponent: Component {
return result
}
}
if let result = self.overlaysView.hitTest(self.convert(point, to: self.overlaysView), with: event) {
return result
}
return nil
}
@ -579,11 +599,23 @@ final class StoryItemContentComponent: Component {
attemptSynchronous: synchronousLoad,
transition: transition
)
self.overlaysView.update(
context: component.context,
strings: component.strings,
peer: component.peer,
story: component.item,
availableReactions: component.availableReactions,
size: availableSize,
isCaptureProtected: component.item.isForwardingDisabled,
attemptSynchronous: synchronousLoad,
transition: transition
)
applyState = true
if self.imageView.isContentLoaded {
self.contentLoaded = true
}
transition.setFrame(view: self.imageView, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setFrame(view: self.overlaysView, frame: CGRect(origin: CGPoint(), size: availableSize))
var dimensions: CGSize?
switch messageMedia {

View File

@ -0,0 +1,205 @@
import Foundation
import UIKit
import AccountContext
import TelegramCore
import Postbox
import SwiftSignalKit
import ComponentFlow
import TinyThumbnail
import ImageBlur
import MediaResources
import Display
import TelegramPresentationData
import BundleIconComponent
import MultilineTextComponent
import AppBundle
import EmojiTextAttachmentView
import TextFormat
final class StoryItemOverlaysView: UIView {
private static let coverImage: UIImage = {
return UIImage(bundleImageName: "Stories/ReactionOutline")!
}()
private final class ItemView: HighlightTrackingButton {
private let coverView: UIImageView
private var stickerView: EmojiTextAttachmentView?
private var file: TelegramMediaFile?
private var reaction: MessageReaction.Reaction?
var activate: ((UIView, MessageReaction.Reaction) -> Void)?
override init(frame: CGRect) {
self.coverView = UIImageView(image: StoryItemOverlaysView.coverImage)
super.init(frame: frame)
self.addSubview(self.coverView)
self.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut))
transition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(0.9, 0.9, 1.0))
} else {
let transition: Transition = .immediate
transition.setSublayerTransform(view: self, transform: CATransform3DIdentity)
self.layer.animateSpring(from: 0.9 as NSNumber, to: 1.0 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.4)
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let activate = self.activate, let reaction = self.reaction else {
return
}
activate(self, reaction)
}
func update(
context: AccountContext,
reaction: MessageReaction.Reaction,
availableReactions: StoryAvailableReactions?,
synchronous: Bool,
size: CGSize
) {
self.reaction = reaction
let insets = UIEdgeInsets(top: -0.08, left: -0.05, bottom: -0.01, right: -0.02)
self.coverView.frame = CGRect(origin: CGPoint(x: size.width * insets.left, y: size.height * insets.top), size: CGSize(width: size.width - size.width * insets.left - size.width * insets.right, height: size.height - size.height * insets.top - size.height * insets.bottom))
let minSide = floor(min(200.0, min(size.width, size.height)) * 0.65)
let itemSize = CGSize(width: minSide, height: minSide)
var file: TelegramMediaFile? = self.file
if self.file == nil {
switch reaction {
case .builtin:
if let availableReactions {
for reactionItem in availableReactions.reactionItems {
if reactionItem.reaction.rawValue == reaction {
file = reactionItem.stillAnimation
break
}
}
}
case let .custom(fileId):
let _ = fileId
}
}
if self.file?.fileId != file?.fileId, let file {
self.file = file
let stickerView: EmojiTextAttachmentView
if let current = self.stickerView {
stickerView = current
} else {
stickerView = EmojiTextAttachmentView(
context: context,
userLocation: .other,
emoji: ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file.fileId.id,
file: file
),
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
placeholderColor: UIColor(white: 0.0, alpha: 0.1),
pointSize: CGSize(width: itemSize.width, height: itemSize.height)
)
stickerView.isUserInteractionEnabled = false
self.stickerView = stickerView
self.addSubview(stickerView)
}
stickerView.frame = itemSize.centered(around: CGPoint(x: size.width * 0.5, y: size.height * 0.47))
}
}
}
private var itemViews: [Int: ItemView] = [:]
var activate: ((UIView, MessageReaction.Reaction) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for (_, itemView) in self.itemViews {
if let result = itemView.hitTest(self.convert(point, to: itemView), with: event) {
return result
}
}
return nil
}
func update(
context: AccountContext,
strings: PresentationStrings,
peer: EnginePeer,
story: EngineStoryItem,
availableReactions: StoryAvailableReactions?,
size: CGSize,
isCaptureProtected: Bool,
attemptSynchronous: Bool,
transition: Transition
) {
var nextId = 0
for mediaArea in story.mediaAreas {
switch mediaArea {
case let .reaction(coordinates, reaction):
let referenceSize = size
let areaSize = CGSize(width: coordinates.width / 100.0 * referenceSize.width, height: coordinates.height / 100.0 * referenceSize.height)
let targetFrame = CGRect(x: coordinates.x / 100.0 * referenceSize.width - areaSize.width * 0.5, y: coordinates.y / 100.0 * referenceSize.height - areaSize.height * 0.5, width: areaSize.width, height: areaSize.height)
if targetFrame.width < 5.0 || targetFrame.height < 5.0 {
continue
}
let itemView: ItemView
let itemId = nextId
if let current = self.itemViews[itemId] {
itemView = current
} else {
itemView = ItemView(frame: CGRect())
itemView.activate = { [weak self] view, reaction in
self?.activate?(view, reaction)
}
self.itemViews[itemId] = itemView
self.addSubview(itemView)
}
transition.setPosition(view: itemView, position: targetFrame.center)
transition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: targetFrame.size))
transition.setTransform(view: itemView, transform: CATransform3DMakeRotation(coordinates.rotation * (CGFloat.pi / 180.0), 0.0, 0.0, 1.0))
itemView.update(
context: context,
reaction: reaction,
availableReactions: availableReactions,
synchronous: attemptSynchronous,
size: targetFrame.size
)
nextId += 1
default:
break
}
}
}
}

View File

@ -920,7 +920,11 @@ public final class StoryItemSetContainerComponent: Component {
}
for area in component.slice.item.storyItem.mediaAreas {
if isPoint(point, in: area) {
if case .reaction = area {
continue
}
if isPoint(point, in: area) {
selectedMediaArea = area
break
}
@ -1483,9 +1487,16 @@ public final class StoryItemSetContainerComponent: Component {
strings: component.strings,
peer: component.slice.peer,
item: item.storyItem,
availableReactions: component.availableReactions,
audioMode: component.audioMode,
isVideoBuffering: visibleItem.isBuffering,
isCurrent: index == centralIndex
isCurrent: index == centralIndex,
activateReaction: { [weak self] reactionView, reaction in
guard let self else {
return
}
self.sendMessageContext.activateInlineReaction(view: self, reactionView: reactionView, reaction: reaction)
}
)),
environment: {
itemEnvironment

View File

@ -3268,102 +3268,7 @@ final class StoryItemSetContainerSendMessage {
}
controller?.push(locationController)
}))
case let .reaction(_, reaction):
if component.slice.peer.id != component.context.account.peerId {
let animateWithReactionItem: (ReactionItem) -> Void = { [weak self, weak view] reactionItem in
guard let self, let view else {
return
}
self.performWithPossibleStealthModeConfirmation(view: view, action: { [weak view] in
guard let view, let component = view.component else {
return
}
let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: reaction).start()
let referenceSize = view.controlsContainerView.frame.size
let size = CGSize(width: 16.0, height: mediaArea.coordinates.height / 100.0 * referenceSize.height * 1.1)
var targetFrame = CGRect(x: mediaArea.coordinates.x / 100.0 * referenceSize.width - size.width / 2.0, y: mediaArea.coordinates.y / 100.0 * referenceSize.height - size.height / 2.0, width: size.width, height: size.height)
let maxSide = min(300.0, max(targetFrame.width, targetFrame.height))
targetFrame = CGSize(width: maxSide, height: maxSide).centered(around: targetFrame.center)
//targetFrame = targetFrame.insetBy(dx: -50.0, dy: -50.0)
targetFrame = view.controlsContainerView.convert(targetFrame, to: view)
let targetView = UIView(frame: targetFrame)
targetView.isUserInteractionEnabled = false
view.addSubview(targetView)
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: nil, useDirectRendering: false)
view.componentContainerView.addSubview(standaloneReactionAnimation.view)
if let standaloneReactionAnimation = view.standaloneReactionAnimation {
view.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
view.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = view.bounds
standaloneReactionAnimation.animateReactionSelection(
context: component.context,
theme: component.theme,
animationCache: component.context.animationCache,
reaction: reactionItem,
avatarPeers: [],
playHaptic: true,
isLarge: false,
hideCenterAnimation: true,
targetView: targetView,
addStandaloneReactionAnimation: { [weak view] standaloneReactionAnimation in
guard let view else {
return
}
if let standaloneReactionAnimation = view.standaloneReactionAnimation {
view.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
view.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = view.bounds
view.componentContainerView.addSubview(standaloneReactionAnimation.view)
},
completion: { [weak targetView, weak standaloneReactionAnimation] in
targetView?.removeFromSuperview()
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
})
}
switch reaction {
case .builtin:
if let availableReactions = component.availableReactions {
for reactionItem in availableReactions.reactionItems {
if reactionItem.reaction.rawValue == reaction {
animateWithReactionItem(reactionItem)
break
}
}
}
case let .custom(fileId):
let _ = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> deliverOnMainQueue).start(next: { files in
if let itemFile = files[fileId] {
let reactionItem = ReactionItem(
reaction: ReactionItem.Reaction(rawValue: .custom(itemFile.fileId.id)),
appearAnimation: itemFile,
stillAnimation: itemFile,
listAnimation: itemFile,
largeListAnimation: itemFile,
applicationAnimation: nil,
largeApplicationAnimation: nil,
isCustom: true
)
animateWithReactionItem(reactionItem)
}
})
}
}
case .reaction:
return
}
@ -3397,4 +3302,100 @@ final class StoryItemSetContainerSendMessage {
self.menuController = menuController
view.updateIsProgressPaused()
}
func activateInlineReaction(view: StoryItemSetContainerComponent.View, reactionView: UIView, reaction: MessageReaction.Reaction) {
guard let component = view.component else {
return
}
if component.slice.peer.id != component.context.account.peerId {
let animateWithReactionItem: (ReactionItem) -> Void = { [weak self, weak view] reactionItem in
guard let self, let view else {
return
}
self.performWithPossibleStealthModeConfirmation(view: view, action: { [weak view] in
guard let view, let component = view.component else {
return
}
let _ = component.context.engine.messages.setStoryReaction(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id, reaction: reaction).start()
let targetFrame = reactionView.convert(reactionView.bounds, to: view)
let targetView = UIView(frame: targetFrame)
targetView.isUserInteractionEnabled = false
view.addSubview(targetView)
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: nil, useDirectRendering: false)
view.componentContainerView.addSubview(standaloneReactionAnimation.view)
if let standaloneReactionAnimation = view.standaloneReactionAnimation {
view.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
view.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = view.bounds
standaloneReactionAnimation.animateReactionSelection(
context: component.context,
theme: component.theme,
animationCache: component.context.animationCache,
reaction: reactionItem,
avatarPeers: [],
playHaptic: true,
isLarge: false,
hideCenterAnimation: true,
targetView: targetView,
addStandaloneReactionAnimation: { [weak view] standaloneReactionAnimation in
guard let view else {
return
}
if let standaloneReactionAnimation = view.standaloneReactionAnimation {
view.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
view.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = view.bounds
view.componentContainerView.addSubview(standaloneReactionAnimation.view)
},
completion: { [weak targetView, weak standaloneReactionAnimation] in
targetView?.removeFromSuperview()
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
})
}
switch reaction {
case .builtin:
if let availableReactions = component.availableReactions {
for reactionItem in availableReactions.reactionItems {
if reactionItem.reaction.rawValue == reaction {
animateWithReactionItem(reactionItem)
break
}
}
}
case let .custom(fileId):
let _ = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> deliverOnMainQueue).start(next: { files in
if let itemFile = files[fileId] {
let reactionItem = ReactionItem(
reaction: ReactionItem.Reaction(rawValue: .custom(itemFile.fileId.id)),
appearAnimation: itemFile,
stillAnimation: itemFile,
listAnimation: itemFile,
largeListAnimation: itemFile,
applicationAnimation: nil,
largeApplicationAnimation: nil,
isCustom: true
)
animateWithReactionItem(reactionItem)
}
})
}
}
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "ReactionOutline.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}