diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index f1071b2adc..c8d8e8d1a0 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -15,6 +15,16 @@ private final class TextNodeStrikethrough { } } +private final class TextNodeSpoiler { + let range: NSRange + let frame: CGRect + + init(range: NSRange, frame: CGRect) { + self.range = range + self.frame = frame + } +} + public struct TextRangeRectEdge: Equatable { public var x: CGFloat public var y: CGFloat @@ -33,13 +43,15 @@ private final class TextNodeLine { let range: NSRange let isRTL: Bool let strikethroughs: [TextNodeStrikethrough] + let spoilers: [TextNodeSpoiler] - init(line: CTLine, frame: CGRect, range: NSRange, isRTL: Bool, strikethroughs: [TextNodeStrikethrough]) { + init(line: CTLine, frame: CGRect, range: NSRange, isRTL: Bool, strikethroughs: [TextNodeStrikethrough], spoilers: [TextNodeSpoiler]) { self.line = line self.frame = frame self.range = range self.isRTL = isRTL self.strikethroughs = strikethroughs + self.spoilers = spoilers } } @@ -117,8 +129,9 @@ public final class TextNodeLayoutArguments { public let lineColor: UIColor? public let textShadowColor: UIColor? public let textStroke: (UIColor, CGFloat)? + public let displaySpoilers: Bool - public init(attributedString: NSAttributedString?, backgroundColor: UIColor? = nil, minimumNumberOfLines: Int = 0, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment = .natural, verticalAlignment: TextVerticalAlignment = .top, lineSpacing: CGFloat = 0.12, cutout: TextNodeCutout? = nil, insets: UIEdgeInsets = UIEdgeInsets(), lineColor: UIColor? = nil, textShadowColor: UIColor? = nil, textStroke: (UIColor, CGFloat)? = nil) { + public init(attributedString: NSAttributedString?, backgroundColor: UIColor? = nil, minimumNumberOfLines: Int = 0, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment = .natural, verticalAlignment: TextVerticalAlignment = .top, lineSpacing: CGFloat = 0.12, cutout: TextNodeCutout? = nil, insets: UIEdgeInsets = UIEdgeInsets(), lineColor: UIColor? = nil, textShadowColor: UIColor? = nil, textStroke: (UIColor, CGFloat)? = nil, displaySpoilers: Bool = false) { self.attributedString = attributedString self.backgroundColor = backgroundColor self.minimumNumberOfLines = minimumNumberOfLines @@ -133,6 +146,7 @@ public final class TextNodeLayoutArguments { self.lineColor = lineColor self.textShadowColor = textShadowColor self.textStroke = textStroke + self.displaySpoilers = displaySpoilers } } @@ -157,9 +171,11 @@ public final class TextNodeLayout: NSObject { fileprivate let lineColor: UIColor? fileprivate let textShadowColor: UIColor? fileprivate let textStroke: (UIColor, CGFloat)? + fileprivate let displaySpoilers: Bool public let hasRTL: Bool + public let spoilers: [CGRect] - fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, explicitAlignment: NSTextAlignment, resolvedAlignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, rawTextSize: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], blockQuotes: [TextNodeBlockQuote], backgroundColor: UIColor?, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?) { + fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, explicitAlignment: NSTextAlignment, resolvedAlignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, rawTextSize: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], blockQuotes: [TextNodeBlockQuote], backgroundColor: UIColor?, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) { self.attributedString = attributedString self.maximumNumberOfLines = maximumNumberOfLines self.truncationType = truncationType @@ -180,13 +196,17 @@ public final class TextNodeLayout: NSObject { self.lineColor = lineColor self.textShadowColor = textShadowColor self.textStroke = textStroke + self.displaySpoilers = displaySpoilers var hasRTL = false + var spoilers: [CGRect] = [] for line in lines { if line.isRTL { hasRTL = true } + spoilers.append(contentsOf: line.spoilers.map { $0.frame.offsetBy(dx: line.frame.minX, dy: line.frame.minY) }) } self.hasRTL = hasRTL + self.spoilers = spoilers } public func areLinesEqual(to other: TextNodeLayout) -> Bool { @@ -851,7 +871,7 @@ public class TextNode: ASDisplayNode { } } - private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?) -> TextNodeLayout { + private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { if let attributedString = attributedString { let stringLength = attributedString.length @@ -890,7 +910,7 @@ public class TextNode: ASDisplayNode { var maybeTypesetter: CTTypesetter? maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) if maybeTypesetter == nil { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } let typesetter = maybeTypesetter! @@ -931,6 +951,7 @@ public class TextNode: ASDisplayNode { var first = true while true { var strikethroughs: [TextNodeStrikethrough] = [] + var spoilers: [TextNodeSpoiler] = [] var lineConstrainedWidth = constrainedSize.width var lineConstrainedWidthDelta: CGFloat = 0.0 @@ -996,7 +1017,32 @@ public class TextNode: ASDisplayNode { var headIndent: CGFloat = 0.0 attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in - if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + if let _ = attributes[NSAttributedString.Key.init(rawValue: "TelegramSpoiler")] { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + (attributedString.string as NSString).enumerateSubstrings(in: range, options: .byWords) { _, range, _, _ in + let startIndex = range.location + let endIndex = range.location + range.length + + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(coreTextLine, startIndex, &secondaryLeftOffset) + var leftOffset = ceil(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = ceil(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRighOffset = CTLineGetOffsetForStringIndex(coreTextLine, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRighOffset) + if !rawRighOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + } + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX @@ -1025,7 +1071,7 @@ public class TextNode: ASDisplayNode { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs)) + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers)) break } else { if lineCharacterCount > 0 { @@ -1048,7 +1094,32 @@ public class TextNode: ASDisplayNode { var headIndent: CGFloat = 0.0 attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in - if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + if let _ = attributes[NSAttributedString.Key.init(rawValue: "TelegramSpoiler")] { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + (attributedString.string as NSString).enumerateSubstrings(in: range, options: .byWords) { _, range, _, _ in + let startIndex = range.location + let endIndex = range.location + range.length + + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(coreTextLine, startIndex, &secondaryLeftOffset) + var leftOffset = ceil(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = ceil(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRighOffset = CTLineGetOffsetForStringIndex(coreTextLine, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRighOffset) + if !rawRighOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + } + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX @@ -1076,7 +1147,7 @@ public class TextNode: ASDisplayNode { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs)) + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers)) } else { if !lines.isEmpty { layoutSize.height += fontLineSpacing @@ -1109,9 +1180,9 @@ public class TextNode: ASDisplayNode { } } - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } else { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } } @@ -1137,6 +1208,7 @@ public class TextNode: ASDisplayNode { context.setAllowsFontSubpixelQuantization(true) context.setShouldSubpixelQuantizeFonts(true) + var clearRects: [CGRect] = [] if let layout = parameters as? TextNodeLayout { if !isRasterizing || layout.backgroundColor != nil { context.setBlendMode(.copy) @@ -1189,6 +1261,15 @@ public class TextNode: ASDisplayNode { } context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY) + if layout.displaySpoilers && !line.spoilers.isEmpty { + context.saveGState() + var clipRects: [CGRect] = [] + for spoiler in line.spoilers { + clipRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) + } + context.clip(to: clipRects) + } + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray if glyphRuns.count != 0 { for run in glyphRuns { @@ -1213,6 +1294,16 @@ public class TextNode: ASDisplayNode { context.fill(CGRect(x: frame.minX, y: frame.minY - 5.0, width: frame.width, height: 1.0)) } } + + if !line.spoilers.isEmpty { + if layout.displaySpoilers { + context.restoreGState() + } else { + for spoiler in line.spoilers { + clearRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) + } + } + } } var blockQuoteFrames: [CGRect] = [] @@ -1248,6 +1339,10 @@ public class TextNode: ASDisplayNode { } context.setBlendMode(.normal) + + for rect in clearRects { + context.clear(rect) + } } public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode) { @@ -1282,11 +1377,11 @@ public class TextNode: ASDisplayNode { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) updated = true } diff --git a/submodules/InvisibleInkDustNode/BUILD b/submodules/InvisibleInkDustNode/BUILD new file mode 100644 index 0000000000..684ce8c618 --- /dev/null +++ b/submodules/InvisibleInkDustNode/BUILD @@ -0,0 +1,21 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "InvisibleInkDustNode", + module_name = "InvisibleInkDustNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AppBundle:AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift new file mode 100644 index 0000000000..a90a3df2ad --- /dev/null +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -0,0 +1,249 @@ +import Foundation +import UIKit +import UIKit.UIGestureRecognizerSubclass +import SwiftSignalKit +import AsyncDisplayKit +import Display +import AppBundle + +private func createEmitterBehavior(type: String) -> NSObject { + let selector = ["behaviorWith", "Type:"].joined(separator: "") + let behaviorClass = NSClassFromString(["CA", "Emitter", "Behavior"].joined(separator: "")) as! NSObject.Type + let behaviorWithType = behaviorClass.method(for: NSSelectorFromString(selector))! + let castedBehaviorWithType = unsafeBitCast(behaviorWithType, to:(@convention(c)(Any?, Selector, Any?) -> NSObject).self) + return castedBehaviorWithType(behaviorClass, NSSelectorFromString(selector), type) +} + +private let textMaskImage: UIImage = { + return generateImage(CGSize(width: 60.0, height: 60.0), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + var locations: [CGFloat] = [0.0, 0.7, 0.95, 1.0] + let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: size.width / 2.0, options: .drawsAfterEndLocation) + })! +}() + +private let emitterMaskImage: UIImage = { + return generateImage(CGSize(width: 120.0, height: 120.0), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + var locations: [CGFloat] = [0.0, 0.7, 0.95, 1.0] + let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: size.width / 10.0, options: .drawsAfterEndLocation) + })! +}() + +public class InvisibleInkDustNode: ASDisplayNode { + private var currentParams: (size: CGSize, color: UIColor, rects: [CGRect])? + + private weak var textNode: TextNode? + private let textMaskNode: ASDisplayNode + private let textSpotNode: ASImageNode + + private var emitterNode: ASDisplayNode + private var emitter: CAEmitterCell? + private var emitterLayer: CAEmitterLayer? + private let emitterMaskNode: ASDisplayNode + private let emitterSpotNode: ASImageNode + private let emitterMaskFillNode: ASDisplayNode + + public var isRevealedUpdated: (Bool) -> Void = { _ in } + + public init(textNode: TextNode) { + self.textNode = textNode + + self.emitterNode = ASDisplayNode() + self.emitterNode.clipsToBounds = true + + self.textMaskNode = ASDisplayNode() + self.textSpotNode = ASImageNode() + let img = textMaskImage + self.textSpotNode.image = img + + self.emitterMaskNode = ASDisplayNode() + self.emitterSpotNode = ASImageNode() + let simg = emitterMaskImage + self.emitterSpotNode.image = simg + + self.emitterMaskFillNode = ASDisplayNode() + self.emitterMaskFillNode.backgroundColor = .white + + super.init() + + self.addSubnode(self.emitterNode) + + self.textMaskNode.addSubnode(self.textSpotNode) + self.emitterMaskNode.addSubnode(self.emitterSpotNode) + self.emitterMaskNode.addSubnode(self.emitterMaskFillNode) + } + + public override func didLoad() { + super.didLoad() + + let emitter = CAEmitterCell() + emitter.contents = UIImage(bundleImageName: "Components/TextSpeckle")?.cgImage + emitter.setValue(1.8, forKey: "contentsScale") + emitter.emissionRange = .pi * 2.0 + emitter.setValue(3.0, forKey: "mass") + emitter.setValue(2.0, forKey: "massRange") + emitter.lifetime = 1.0 + emitter.scale = 0.5 + emitter.velocityRange = 20.0 + emitter.name = "dustCell" + emitter.setValue("point", forKey: "particleType") + emitter.color = UIColor.white.withAlphaComponent(0.0).cgColor + emitter.alphaRange = 1.0 + self.emitter = emitter + + let fingerAttractor = createEmitterBehavior(type: "simpleAttractor") + fingerAttractor.setValue("fingerAttractor", forKey: "name") + + let alphaBehavior = createEmitterBehavior(type: "valueOverLife") + alphaBehavior.setValue("alphaBehavior", forKey: "name") + alphaBehavior.setValue("color.alpha", forKey: "keyPath") + alphaBehavior.setValue([0.0, 0.0, 1.0, 0.0, -1.0], forKey: "values") + alphaBehavior.setValue(true, forKey: "additive") + + let behaviors = [fingerAttractor, alphaBehavior] + + let emitterLayer = CAEmitterLayer() + emitterLayer.masksToBounds = true + emitterLayer.allowsGroupOpacity = true + emitterLayer.lifetime = 1 + emitterLayer.emitterCells = [emitter] + emitterLayer.setValue(behaviors, forKey: "emitterBehaviors") + emitterLayer.emitterPosition = CGPoint(x: 0, y: 0) + emitterLayer.seed = arc4random() + emitterLayer.setValue("rectangles", forKey: "emitterShape") + emitterLayer.emitterSize = CGSize(width: 1, height: 1) + emitterLayer.setValue(0.0322, forKey: "updateInterval") + + emitterLayer.setValue(4.0, forKeyPath: "emitterBehaviors.fingerAttractor.stiffness") + emitterLayer.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + + self.emitterLayer = emitterLayer + + self.emitterNode.layer.addSublayer(emitterLayer) + + self.updateEmitter() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap(_:)))) + } + + private var revealed = false + @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { + guard let (size, _, _) = self.currentParams, !self.revealed else { + return + } + + self.revealed = true + + let position = gestureRecognizer.location(in: self.view) + self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") + + Queue.mainQueue().after(0.1 * UIView.animationDurationFactor()) { + self.textNode?.view.mask = self.textMaskNode.view + self.textNode?.alpha = 1.0 + + let radius = max(size.width, size.height) + self.textSpotNode.frame = CGRect(x: position.x - radius / 2.0, y: position.y - radius / 2.0, width: radius, height: radius) + + self.textSpotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + self.textSpotNode.layer.animateScale(from: 0.1, to: 3.5, duration: 0.71, removeOnCompletion: false, completion: { [weak self] _ in + self?.textNode?.view.mask = nil + }) + + self.emitterNode.view.mask = self.emitterMaskNode.view + let emitterSide = radius * 5.0 + self.emitterSpotNode.frame = CGRect(x: position.x - emitterSide / 2.0, y: position.y - emitterSide / 2.0, width: emitterSide, height: emitterSide) + self.emitterSpotNode.layer.animateScale(from: 0.1, to: 3.0, duration: 0.71, removeOnCompletion: false, completion: { [weak self] _ in + self?.alpha = 0.0 + self?.emitterNode.view.mask = nil + }) + self.emitterMaskFillNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + +// let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) +// transition.updateAlpha(node: self, alpha: 0.0) + + self.isRevealedUpdated(true) + } + + Queue.mainQueue().after(0.8 * UIView.animationDurationFactor()) { + self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.textSpotNode.layer.removeAllAnimations() + + self.emitterSpotNode.layer.removeAllAnimations() + self.emitterMaskFillNode.layer.removeAllAnimations() + } + + Queue.mainQueue().after(4.0 * UIView.animationDurationFactor()) { + self.revealed = false + self.isRevealedUpdated(false) + + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) + transition.updateAlpha(node: self, alpha: 1.0) + if let textNode = self.textNode { + transition.updateAlpha(node: textNode, alpha: 0.0) + } + } + } + + private func updateEmitter() { + guard let (size, color, rects) = self.currentParams else { + return + } + + self.emitter?.color = color.cgColor + self.emitterLayer?.setValue(rects, forKey: "emitterRects") + self.emitterLayer?.frame = CGRect(origin: CGPoint(), size: size) + + let radius = max(size.width, size.height) + self.emitterLayer?.setValue(max(size.width, size.height), forKeyPath: "emitterBehaviors.fingerAttractor.radius") + self.emitterLayer?.setValue(radius * -0.5, forKeyPath: "emitterBehaviors.fingerAttractor.falloff") + + var square: Float = 0.0 + for rect in rects { + square += Float(rect.width * rect.height) + } + + self.emitter?.birthRate = square * 0.3 + } + + public func update(size: CGSize, color: UIColor, rects: [CGRect]) { + self.currentParams = (size, color, rects) + + self.emitterNode.frame = CGRect(origin: CGPoint(), size: size) + self.emitterMaskNode.frame = self.emitterNode.bounds + self.emitterMaskFillNode.frame = self.emitterNode.bounds + self.textMaskNode.frame = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: size) + + if self.isNodeLoaded { + self.updateEmitter() + } + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if let (_, _, rects) = self.currentParams { + for rect in rects { + if rect.contains(point) { + return true + } + } + return false + } else { + return false + } + } +} diff --git a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift index decd4d028a..909cb6c91b 100644 --- a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift +++ b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift @@ -444,7 +444,7 @@ final class LocalizationListControllerNode: ViewControllerTracingNode { self.presentationData = presentationData self.presentationDataValue.set(.single(presentationData)) self.backgroundColor = presentationData.theme.list.blocksBackgroundColor - self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.chatList.backgroundColor, direction: true) + self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) self.searchDisplayController?.updatePresentationData(presentationData) self.leftOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor self.rightOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index e56a7b583f..ec5812a2c6 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -248,6 +248,7 @@ swift_library( "//submodules/DirectMediaImageCache:DirectMediaImageCache", "//submodules/CodeInputView:CodeInputView", "//submodules/Components/ReactionButtonListComponent:ReactionButtonListComponent", + "//submodules/InvisibleInkDustNode:InvisibleInkDustNode", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json new file mode 100644 index 0000000000..c87dc0fd29 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "textSpeckle_Normal.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png new file mode 100644 index 0000000000..f9ee87547e Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png differ diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 7d0d81efd2..1802dd006f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -8,6 +8,7 @@ import TextFormat import UrlEscaping import TelegramUniversalVideoContent import TextSelectionNode +import InvisibleInkDustNode private final class CachedChatMessageText { let text: String @@ -37,6 +38,9 @@ private final class CachedChatMessageText { class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let textNode: TextNode + private var spoilerTextNode: TextNode? + private var dustNode: InvisibleInkDustNode? + private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode private let statusNode: ChatMessageDateAndStatusNode private var linkHighlightingNode: LinkHighlightingNode? @@ -80,6 +84,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))) { let textLayout = TextNode.asyncLayout(self.textNode) + let spoilerTextLayout = TextNode.asyncLayout(self.spoilerTextNode) let statusLayout = self.statusNode.asyncLayout() let currentCachedChatMessageText = self.cachedChatMessageText @@ -273,7 +278,14 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor)) - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? + let spoilerTextLayoutAndApply: (TextNodeLayout, () -> TextNode)? + if !textLayout.spoilers.isEmpty { + spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true)) + } else { + spoilerTextLayoutAndApply = nil + } + + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false if case .replyThread = item.chatLocation { @@ -353,8 +365,39 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.textNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync let _ = textApply() - animation.animator.updateFrame(layer: strongSelf.textNode.layer, frame: textFrame, completion: nil) + + if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { + let spoilerTextNode = spoilerTextApply() + if strongSelf.spoilerTextNode == nil { + spoilerTextNode.isUserInteractionEnabled = false + spoilerTextNode.contentMode = .topLeft + spoilerTextNode.contentsScale = UIScreenScale + spoilerTextNode.displaysAsynchronously = false + strongSelf.insertSubnode(spoilerTextNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) + + strongSelf.spoilerTextNode = spoilerTextNode + } + + strongSelf.spoilerTextNode?.frame = textFrame + strongSelf.spoilerTextNode?.isHidden = false + strongSelf.spoilerTextNode?.alpha = 0.0 + + let dustNode: InvisibleInkDustNode + if let current = strongSelf.dustNode { + dustNode = current + } else { + dustNode = InvisibleInkDustNode(textNode: spoilerTextNode) + strongSelf.dustNode = dustNode + strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) + } + dustNode.update(size: textFrame.size, color: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) }) + dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) + } else if let spoilerTextNode = strongSelf.spoilerTextNode { + strongSelf.spoilerTextNode = nil + spoilerTextNode.removeFromSupernode() + } + if let textSelectionNode = strongSelf.textSelectionNode { let shouldUpdateLayout = textSelectionNode.frame.size != textFrame.size textSelectionNode.frame = textFrame diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index cfe7903628..622f252f7d 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -137,7 +137,8 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti } string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention), value: nsString!.substring(with: range), range: range) case .Strikethrough: - string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) + string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler), value: true as NSNumber, range: range) +// string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) case .Underline: string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) case let .TextMention(peerId): diff --git a/submodules/TextFormat/Sources/TelegramAttributes.swift b/submodules/TextFormat/Sources/TelegramAttributes.swift index 7ce2749909..fb93ec60a2 100644 --- a/submodules/TextFormat/Sources/TelegramAttributes.swift +++ b/submodules/TextFormat/Sources/TelegramAttributes.swift @@ -41,4 +41,5 @@ public struct TelegramTextAttributes { public static let Timecode = "TelegramTimecode" public static let BlockQuote = "TelegramBlockQuote" public static let Pre = "TelegramPre" + public static let Spoiler = "TelegramSpoiler" }