diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index e427b5cb88..6e32852eaf 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -107,6 +107,7 @@ "PUSH_MESSAGE_THEME" = "%1$@|changed chat theme to %2$@"; "PUSH_MESSAGE_NOTHEME" = "%1$@|disabled chat theme"; "PUSH_MESSAGE_RECURRING_PAY" = "%1$@|You were charged %2$@"; +"CHAT_MESSAGE_RECURRING_PAY" = "%1$@|You were charged %2$@"; "PUSH_CHANNEL_MESSAGE_TEXT" = "%1$@|%2$@"; "PUSH_CHANNEL_MESSAGE_NOTEXT" = "%1$@|posted a message"; diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/ImageDCT.h b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/ImageDCT.h index 4f9a855d4f..236cdd2f9f 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/ImageDCT.h +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/ImageDCT.h @@ -5,9 +5,18 @@ #import +@interface ImageDCTTable : NSObject + +- (instancetype _Nonnull)initWithQuality:(NSInteger)quality isChroma:(bool)isChroma; +- (instancetype _Nullable)initWithData:(NSData * _Nonnull)data; + +- (NSData * _Nonnull)serializedData; + +@end + @interface ImageDCT : NSObject -- (instancetype _Nonnull)initWithQuality:(NSInteger)quality; +- (instancetype _Nonnull)initWithTable:(ImageDCTTable * _Nonnull)table; - (void)forwardWithPixels:(uint8_t const * _Nonnull)pixels coefficients:(int16_t * _Nonnull)coefficients width:(NSInteger)width height:(NSInteger)height bytesPerRow:(NSInteger)bytesPerRow __attribute__((objc_direct)); - (void)inverseWithCoefficients:(int16_t const * _Nonnull)coefficients pixels:(uint8_t * _Nonnull)pixels width:(NSInteger)width height:(NSInteger)height coefficientsPerRow:(NSInteger)coefficientsPerRow bytesPerRow:(NSInteger)bytesPerRow __attribute__((objc_direct)); diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.cpp b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.cpp index 49e464e764..fe67756534 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.cpp +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.cpp @@ -118,6 +118,16 @@ static DCTELEM std_luminance_quant_tbl[DCTSIZE2] = { 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99 }; +static DCTELEM std_chrominance_quant_tbl[DCTSIZE2] = { + 17, 18, 24, 47, 99, 99, 99, 99, + 18, 21, 26, 66, 99, 99, 99, 99, + 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99 +}; int jpeg_quality_scaling(int quality) /* Convert a user-specified quality rating to a percentage scaling factor @@ -143,7 +153,7 @@ int jpeg_quality_scaling(int quality) return quality; } -void jpeg_add_quant_table(DCTELEM *qtable, DCTELEM *basicTable, int scale_factor, bool forceBaseline) +void jpeg_add_quant_table(DCTELEM *qtable, DCTELEM const *basicTable, int scale_factor, bool forceBaseline) /* Define a quantization table equal to the basic_table times * a scale factor (given as a percentage). * If force_baseline is TRUE, the computed quantization table entries @@ -164,7 +174,7 @@ void jpeg_add_quant_table(DCTELEM *qtable, DCTELEM *basicTable, int scale_factor } } -void jpeg_set_quality(DCTELEM *qtable, int quality) +void jpeg_set_quality(DCTELEM *qtable, DCTELEM const *basicTable, int quality) /* Set or change the 'quality' (quantization) setting, using default tables. * This is the standard quality-adjusting entry point for typical user * interfaces; only those who want detailed control over quantization tables @@ -175,10 +185,10 @@ void jpeg_set_quality(DCTELEM *qtable, int quality) quality = jpeg_quality_scaling(quality); /* Set up standard quality tables */ - jpeg_add_quant_table(qtable, std_luminance_quant_tbl, quality, false); + jpeg_add_quant_table(qtable, basicTable, quality, false); } -void getDivisors(DCTELEM *dtbl, DCTELEM *qtable) { +void getDivisors(DCTELEM *dtbl, DCTELEM const *qtable) { #define CONST_BITS 14 #define RIGHT_SHIFT(x, shft) ((x) >> (shft)) @@ -234,22 +244,15 @@ void quantize(JCOEFPTR coef_block, DCTELEM *divisors, DCTELEM *workspace) } } -void generateForwardDctData(int quality, std::vector &data) { +void generateForwardDctData(DCTELEM const *qtable, std::vector &data) { data.resize(DCTSIZE2 * 4 * sizeof(DCTELEM)); - - DCTELEM qtable[DCTSIZE2]; - jpeg_set_quality(qtable, quality); - getDivisors((DCTELEM *)data.data(), qtable); } -void generateInverseDctData(int quality, std::vector &data) { +void generateInverseDctData(DCTELEM const *qtable, std::vector &data) { data.resize(DCTSIZE2 * sizeof(IFAST_MULT_TYPE)); IFAST_MULT_TYPE *ifmtbl = (IFAST_MULT_TYPE *)data.data(); - DCTELEM qtable[DCTSIZE2]; - jpeg_set_quality(qtable, quality); - #define CONST_BITS 14 static const int16_t aanscales[DCTSIZE2] = { /* precomputed values scaled up by 14 bits */ @@ -338,13 +341,32 @@ void performInverseDct(int16_t const * coefficients, uint8_t *pixels, int width, namespace dct { +DCTTable DCTTable::generate(int quality, bool isChroma) { + DCTTable result; + result.table.resize(DCTSIZE2); + + if (isChroma) { + jpeg_set_quality(result.table.data(), std_chrominance_quant_tbl, quality); + } else { + jpeg_set_quality(result.table.data(), std_luminance_quant_tbl, quality); + } + + return result; +} + +DCTTable DCTTable::initializeEmpty() { + DCTTable result; + result.table.resize(DCTSIZE2); + return result; +} + class DCTInternal { public: - DCTInternal(int quality) { + DCTInternal(DCTTable const &dctTable) { auxiliaryData = createDctAuxiliaryData(); - generateForwardDctData(quality, forwardDctData); - generateInverseDctData(quality, inverseDctData); + generateForwardDctData(dctTable.table.data(), forwardDctData); + generateInverseDctData(dctTable.table.data(), inverseDctData); } ~DCTInternal() { @@ -357,8 +379,8 @@ public: std::vector inverseDctData; }; -DCT::DCT(int quality) { - _internal = new DCTInternal(quality); +DCT::DCT(DCTTable const &dctTable) { + _internal = new DCTInternal(dctTable); } DCT::~DCT() { diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.h b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.h index 3b0ca4e772..67031caa80 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.h +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/DCT.h @@ -3,15 +3,23 @@ #include "DCTCommon.h" +#include #include namespace dct { class DCTInternal; +struct DCTTable { + static DCTTable generate(int quality, bool isChroma); + static DCTTable initializeEmpty(); + + std::vector table; +}; + class DCT { public: - DCT(int quality); + DCT(DCTTable const &dctTable); ~DCT(); void forward(uint8_t const *pixels, int16_t *coefficients, int width, int height, int bytesPerRow); diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/ImageDCT.mm b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/ImageDCT.mm index 3d39105531..8a50b55c4b 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/ImageDCT.mm +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/ImageDCT.mm @@ -4,6 +4,41 @@ #include "DCT.h" +@interface ImageDCTTable () { +@public + dct::DCTTable _table; +} + +@end + +@implementation ImageDCTTable + +- (instancetype _Nonnull)initWithQuality:(NSInteger)quality isChroma:(bool)isChroma { + self = [super init]; + if (self != nil) { + _table = dct::DCTTable::generate((int)quality, isChroma); + } + return self; +} + +- (instancetype _Nullable)initWithData:(NSData * _Nonnull)data { + self = [super init]; + if (self != nil) { + _table = dct::DCTTable::initializeEmpty(); + if (data.length != _table.table.size() * 2) { + return nil; + } + memcpy(_table.table.data(), data.bytes, data.length); + } + return self; +} + +- (NSData * _Nonnull)serializedData { + return [[NSData alloc] initWithBytes:_table.table.data() length:_table.table.size() * 2]; +} + +@end + @interface ImageDCT () { std::unique_ptr _dct; } @@ -12,10 +47,10 @@ @implementation ImageDCT -- (instancetype _Nonnull)initWithQuality:(NSInteger)quality { +- (instancetype _Nonnull)initWithTable:(ImageDCTTable * _Nonnull)table { self = [super init]; if (self != nil) { - _dct = std::unique_ptr(new dct::DCT((int)quality)); + _dct = std::unique_ptr(new dct::DCT(table->_table)); } return self; } diff --git a/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift b/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift index 5e6e58c568..8093e31732 100644 --- a/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift +++ b/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift @@ -294,10 +294,10 @@ private final class AnimationCacheItemWriterInternal { } let dctData: DctData - if let current = self.currentDctData, current.quality == self.dctQuality { + if let current = self.currentDctData { dctData = current } else { - dctData = DctData(quality: self.dctQuality) + dctData = DctData(generatingTablesAtQuality: self.dctQuality) self.currentDctData = dctData } @@ -310,11 +310,18 @@ private final class AnimationCacheItemWriterInternal { yuvaSurface.dct(dctData: dctData, target: dctCoefficients) if isFirstFrame { - self.file.write(3 as UInt32) + self.file.write(4 as UInt32) self.file.write(UInt32(dctCoefficients.yPlane.width)) self.file.write(UInt32(dctCoefficients.yPlane.height)) - self.file.write(UInt32(dctData.quality)) + + let lumaDctTable = dctData.lumaTable.serializedData() + self.file.write(UInt32(lumaDctTable.count)) + let _ = self.file.write(lumaDctTable) + + let chromaDctTable = dctData.chromaTable.serializedData() + self.file.write(UInt32(chromaDctTable.count)) + let _ = self.file.write(chromaDctTable) self.contentLengthOffset = Int(self.file.position()) self.file.write(0 as UInt32) @@ -501,10 +508,10 @@ private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { } let dctData: DctData - if let current = self.currentDctData, current.quality == self.dctQuality { + if let current = self.currentDctData { dctData = current } else { - dctData = DctData(quality: self.dctQuality) + dctData = DctData(generatingTablesAtQuality: self.dctQuality) self.currentDctData = dctData } @@ -526,11 +533,18 @@ private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { yuvaSurface.dct(dctData: dctData, target: dctCoefficients) if isFirstFrame { - file.write(3 as UInt32) + file.write(4 as UInt32) file.write(UInt32(dctCoefficients.yPlane.width)) file.write(UInt32(dctCoefficients.yPlane.height)) - file.write(UInt32(dctData.quality)) + + let lumaDctTable = dctData.lumaTable.serializedData() + file.write(UInt32(lumaDctTable.count)) + let _ = file.write(lumaDctTable) + + let chromaDctTable = dctData.chromaTable.serializedData() + file.write(UInt32(chromaDctTable.count)) + let _ = file.write(chromaDctTable) self.contentLengthOffset = Int(file.position()) file.write(0 as UInt32) @@ -652,10 +666,10 @@ private final class AnimationCacheItemAccessor { private var currentFrame: CurrentFrame? private var currentYUVASurface: ImageYUVA420? - private var currentDctData: DctData + private let currentDctData: DctData private var sharedDctCoefficients: DctCoefficientsYUVA420? - init(data: Data, range: Range, frameMapping: [FrameInfo], width: Int, height: Int, dctQuality: Int) { + init(data: Data, range: Range, frameMapping: [FrameInfo], width: Int, height: Int, dctData: DctData) { self.data = data self.range = range self.width = width @@ -673,7 +687,7 @@ private final class AnimationCacheItemAccessor { self.frameMapping = resultFrameMapping self.durationMapping = durationMapping - self.currentDctData = DctData(quality: dctQuality) + self.currentDctData = dctData } private func loadNextFrame() { @@ -835,6 +849,16 @@ private final class AnimationCacheItemAccessor { } } +private func readData(data: Data, offset: Int, count: Int) -> Data { + var result = Data(count: count) + result.withUnsafeMutableBytes { bytes -> Void in + data.withUnsafeBytes { dataBytes -> Void in + memcpy(bytes.baseAddress!, dataBytes.baseAddress!.advanced(by: offset), count) + } + } + return result +} + private func readUInt32(data: Data, offset: Int) -> UInt32 { var value: UInt32 = 0 withUnsafeMutableBytes(of: &value, { bytes -> Void in @@ -1071,7 +1095,7 @@ private func loadItem(path: String) throws -> AnimationCacheItem { } let formatVersion = readUInt32(data: compressedData, offset: offset) offset += 4 - if formatVersion != 3 { + if formatVersion != 4 { throw LoadItemError.dataError } @@ -1090,9 +1114,27 @@ private func loadItem(path: String) throws -> AnimationCacheItem { if offset + 4 > dataLength { throw LoadItemError.dataError } - let dctQuality = readUInt32(data: compressedData, offset: offset) + let dctLumaTableLength = readUInt32(data: compressedData, offset: offset) offset += 4 + if offset + Int(dctLumaTableLength) > dataLength { + throw LoadItemError.dataError + } + let dctLumaData = readData(data: compressedData, offset: offset, count: Int(dctLumaTableLength)) + offset += Int(dctLumaTableLength) + + if offset + 4 > dataLength { + throw LoadItemError.dataError + } + let dctChromaTableLength = readUInt32(data: compressedData, offset: offset) + offset += 4 + + if offset + Int(dctChromaTableLength) > dataLength { + throw LoadItemError.dataError + } + let dctChromaData = readData(data: compressedData, offset: offset, count: Int(dctChromaTableLength)) + offset += Int(dctChromaTableLength) + if offset + 4 > dataLength { throw LoadItemError.dataError } @@ -1119,7 +1161,11 @@ private func loadItem(path: String) throws -> AnimationCacheItem { frameMapping.append(AnimationCacheItemAccessor.FrameInfo(duration: Double(frameDuration))) } - let itemAccessor = AnimationCacheItemAccessor(data: compressedData, range: compressedFrameDataRange, frameMapping: frameMapping, width: Int(width), height: Int(height), dctQuality: Int(dctQuality)) + guard let dctData = DctData(lumaTable: dctLumaData, chromaTable: dctChromaData) else { + throw LoadItemError.dataError + } + + let itemAccessor = AnimationCacheItemAccessor(data: compressedData, range: compressedFrameDataRange, frameMapping: frameMapping, width: Int(width), height: Int(height), dctData: dctData) return AnimationCacheItem(numFrames: frameMapping.count, advanceImpl: { advance, requestedFormat in return itemAccessor.advance(advance: advance, requestedFormat: requestedFormat) @@ -1397,7 +1443,9 @@ public final class AnimationCacheImpl: AnimationCache { let itemFirstFramePath = "\(itemDirectoryPath)/\(sourceIdPath.fileName)-f" if FileManager.default.fileExists(atPath: itemFirstFramePath) { - return try? loadItem(path: itemFirstFramePath) + if let item = try? loadItem(path: itemFirstFramePath) { + return item + } } if let adaptationItemPath = findHigherResolutionFileForAdaptation(itemDirectoryPath: itemDirectoryPath, baseName: "\(hashString)_", baseSuffix: "-f", width: Int(size.width), height: Int(size.height)) { diff --git a/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift b/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift index 6a31392a13..1c833fc60e 100644 --- a/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift +++ b/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift @@ -152,12 +152,33 @@ extension ImageYUVA420 { } final class DctData { - let quality: Int - let dct: ImageDCT + let lumaTable: ImageDCTTable + let lumaDct: ImageDCT - init(quality: Int) { - self.quality = quality - self.dct = ImageDCT(quality: quality) + let chromaTable: ImageDCTTable + let chromaDct: ImageDCT + + init?(lumaTable: Data, chromaTable: Data) { + guard let lumaTableData = ImageDCTTable(data: lumaTable) else { + return nil + } + guard let chromaTableData = ImageDCTTable(data: chromaTable) else { + return nil + } + + self.lumaTable = lumaTableData + self.lumaDct = ImageDCT(table: lumaTableData) + + self.chromaTable = chromaTableData + self.chromaDct = ImageDCT(table: chromaTableData) + } + + init(generatingTablesAtQuality quality: Int) { + self.lumaTable = ImageDCTTable(quality: quality, isChroma: false) + self.lumaDct = ImageDCT(table: self.lumaTable) + + self.chromaTable = ImageDCTTable(quality: quality, isChroma: true) + self.chromaDct = ImageDCT(table: self.chromaTable) } } @@ -168,19 +189,24 @@ extension ImageYUVA420 { for i in 0 ..< 4 { let sourcePlane: ImagePlane let targetPlane: DctCoefficientPlane + let isChroma: Bool switch i { case 0: sourcePlane = self.yPlane targetPlane = target.yPlane + isChroma = false case 1: sourcePlane = self.uPlane targetPlane = target.uPlane + isChroma = true case 2: sourcePlane = self.vPlane targetPlane = target.vPlane + isChroma = true case 3: sourcePlane = self.aPlane targetPlane = target.aPlane + isChroma = false default: preconditionFailure() } @@ -191,7 +217,8 @@ extension ImageYUVA420 { targetPlane.data.withUnsafeMutableBytes { bytes in let coefficients = bytes.baseAddress!.assumingMemoryBound(to: Int16.self) - dctData.dct.forward(withPixels: sourcePixels, coefficients: coefficients, width: sourcePlane.width, height: sourcePlane.height, bytesPerRow: sourcePlane.bytesPerRow) + let dct = isChroma ? dctData.chromaDct : dctData.lumaDct + dct.forward(withPixels: sourcePixels, coefficients: coefficients, width: sourcePlane.width, height: sourcePlane.height, bytesPerRow: sourcePlane.bytesPerRow) } } } @@ -211,19 +238,24 @@ extension DctCoefficientsYUVA420 { for i in 0 ..< 4 { let sourcePlane: DctCoefficientPlane let targetPlane: ImagePlane + let isChroma: Bool switch i { case 0: sourcePlane = self.yPlane targetPlane = target.yPlane + isChroma = false case 1: sourcePlane = self.uPlane targetPlane = target.uPlane + isChroma = true case 2: sourcePlane = self.vPlane targetPlane = target.vPlane + isChroma = true case 3: sourcePlane = self.aPlane targetPlane = target.aPlane + isChroma = false default: preconditionFailure() } @@ -234,7 +266,8 @@ extension DctCoefficientsYUVA420 { targetPlane.data.withUnsafeMutableBytes { bytes in let pixels = bytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - dctData.dct.inverse(withCoefficients: coefficients, pixels: pixels, width: sourcePlane.width, height: sourcePlane.height, coefficientsPerRow: targetPlane.width, bytesPerRow: targetPlane.bytesPerRow) + let dct = isChroma ? dctData.chromaDct : dctData.lumaDct + dct.inverse(withCoefficients: coefficients, pixels: pixels, width: sourcePlane.width, height: sourcePlane.height, coefficientsPerRow: targetPlane.width, bytesPerRow: targetPlane.bytesPerRow) } } } diff --git a/submodules/TelegramUI/Components/ChatInputPanelContainer/Sources/ChatInputPanelContainer.swift b/submodules/TelegramUI/Components/ChatInputPanelContainer/Sources/ChatInputPanelContainer.swift index 609dee148f..a333939a18 100644 --- a/submodules/TelegramUI/Components/ChatInputPanelContainer/Sources/ChatInputPanelContainer.swift +++ b/submodules/TelegramUI/Components/ChatInputPanelContainer/Sources/ChatInputPanelContainer.swift @@ -24,6 +24,16 @@ private func traceScrollView(view: UIView, point: CGPoint) -> (UIScrollView?, Bo return (nil, true) } +private func traceScrollViewUp(view: UIView) -> UIScrollView? { + if let scrollView = view as? UIScrollView { + return scrollView + } else if let superview = view.superview { + return traceScrollViewUp(view: superview) + } else { + return nil + } +} + private final class ExpansionPanRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { enum LockDirection { case up @@ -87,8 +97,8 @@ private final class ExpansionPanRecognizer: UIGestureRecognizer, UIGestureRecogn if let _ = hitView as? UIButton { } else if let hitView = hitView, hitView.asyncdisplaykit_node is ASButtonNode { } else { - if let scrollView = traceScrollView(view: view, point: point).0 { - if scrollView is ListViewScroller || scrollView is GridNodeScrollerView { + if let scrollView = traceScrollView(view: view, point: point).0 ?? hitView.flatMap(traceScrollViewUp) { + if scrollView is ListViewScroller || scrollView is GridNodeScrollerView || scrollView.asyncdisplaykit_node is ASScrollNode { found = false } else { found = true diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 7fe1ebdf8f..fb1133e422 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -93,41 +93,173 @@ private final class PremiumBadgeView: UIView { } } -private final class GroupHeaderLayer: SimpleLayer { +private final class GroupHeaderActionButton: UIButton { + private var currentTextLayout: (string: String, color: UIColor, constrainedWidth: CGFloat, size: CGSize)? + private let backgroundLayer: SimpleLayer private let textLayer: SimpleLayer + private let pressed: () -> Void + + init(pressed: @escaping () -> Void) { + self.pressed = pressed + + self.backgroundLayer = SimpleLayer() + self.backgroundLayer.masksToBounds = true + + self.textLayer = SimpleLayer() + + super.init(frame: CGRect()) + + self.layer.addSublayer(self.backgroundLayer) + self.layer.addSublayer(self.textLayer) + + self.addTarget(self, action: #selector(self.onPressed), for: .touchUpInside) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + @objc private func onPressed() { + self.pressed() + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + self.alpha = 0.6 + + return super.beginTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.cancelTracking(with: event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.touchesCancelled(touches, with: event) + } + + func update(theme: PresentationTheme, title: String) -> CGSize { + let textConstrainedWidth: CGFloat = 100.0 + let color = theme.list.itemCheckColors.foregroundColor + + self.backgroundLayer.backgroundColor = theme.list.itemCheckColors.fillColor.cgColor + + let textSize: CGSize + if let currentTextLayout = self.currentTextLayout, currentTextLayout.string == title, currentTextLayout.color == color, currentTextLayout.constrainedWidth == textConstrainedWidth { + textSize = currentTextLayout.size + } else { + let font: UIFont = Font.semibold(15.0) + let string = NSAttributedString(string: title.uppercased(), font: font, textColor: color) + let stringBounds = string.boundingRect(with: CGSize(width: textConstrainedWidth, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + textSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height)) + self.textLayer.contents = generateImage(textSize, opaque: false, scale: 0.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + string.draw(in: stringBounds) + + UIGraphicsPopContext() + })?.cgImage + self.currentTextLayout = (title, color, textConstrainedWidth, textSize) + } + + let size = CGSize(width: textSize.width + 16.0 * 2.0, height: 28.0) + + let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: floor((size.height - textSize.height) / 2.0)), size: textSize) + self.textLayer.frame = textFrame + + self.backgroundLayer.frame = CGRect(origin: CGPoint(), size: size) + self.backgroundLayer.cornerRadius = min(size.width, size.height) / 2.0 + + return size + } +} + +private final class GroupHeaderLayer: UIView { + private let actionPressed: () -> Void + private let performItemAction: (EmojiPagerContentComponent.Item, UIView, CGRect, CALayer) -> Void + + private let textLayer: SimpleLayer + private var subtitleLayer: SimpleLayer? private var lockIconLayer: SimpleLayer? private(set) var clearIconLayer: SimpleLayer? + private var separatorLayer: SimpleLayer? + private var actionButton: GroupHeaderActionButton? + + private var groupEmbeddedView: GroupEmbeddedView? private var theme: PresentationTheme? private var currentTextLayout: (string: String, color: UIColor, constrainedWidth: CGFloat, size: CGSize)? + private var currentSubtitleLayout: (string: String, color: UIColor, constrainedWidth: CGFloat, size: CGSize)? - override init() { + init(actionPressed: @escaping () -> Void, performItemAction: @escaping (EmojiPagerContentComponent.Item, UIView, CGRect, CALayer) -> Void) { + self.actionPressed = actionPressed + self.performItemAction = performItemAction + self.textLayer = SimpleLayer() - super.init() + super.init(frame: CGRect()) - self.addSublayer(self.textLayer) - } - - override init(layer: Any) { - self.textLayer = SimpleLayer() - - super.init(layer: layer) + self.layer.addSublayer(self.textLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func update(theme: PresentationTheme, title: String, isPremiumLocked: Bool, hasClear: Bool, constrainedWidth: CGFloat) -> CGSize { + func update( + context: AccountContext, + theme: PresentationTheme, + layoutType: EmojiPagerContentComponent.ItemLayoutType, + hasTopSeparator: Bool, + actionButtonTitle: String?, + title: String, + subtitle: String?, + isPremiumLocked: Bool, + hasClear: Bool, + embeddedItems: [EmojiPagerContentComponent.Item]?, + constrainedSize: CGSize, + insets: UIEdgeInsets, + cache: AnimationCache, + renderer: MultiAnimationRenderer, + attemptSynchronousLoad: Bool + ) -> CGSize { var themeUpdated = false if self.theme !== theme { self.theme = theme themeUpdated = true } - let color = theme.chat.inputMediaPanel.stickersSectionTextColor + let textOffsetY: CGFloat + if hasTopSeparator { + textOffsetY = 9.0 + } else { + textOffsetY = 0.0 + } + + let color: UIColor + if subtitle != nil { + color = theme.chat.inputPanel.primaryTextColor + } else { + color = theme.chat.inputMediaPanel.stickersSectionTextColor + } + let subtitleColor = theme.chat.inputMediaPanel.stickersSectionTextColor let titleHorizontalOffset: CGFloat if isPremiumLocked { @@ -137,7 +269,7 @@ private final class GroupHeaderLayer: SimpleLayer { } else { lockIconLayer = SimpleLayer() self.lockIconLayer = lockIconLayer - self.addSublayer(lockIconLayer) + self.layer.addSublayer(lockIconLayer) } if let image = PresentationResourcesChat.chatEntityKeyboardLock(theme) { let imageSize = image.size.aspectFitted(CGSize(width: 16.0, height: 16.0)) @@ -156,13 +288,44 @@ private final class GroupHeaderLayer: SimpleLayer { titleHorizontalOffset = 0.0 } - let textConstrainedWidth = constrainedWidth - titleHorizontalOffset - 10.0 + var actionButtonSize: CGSize? + if let actionButtonTitle = actionButtonTitle { + let actionButton: GroupHeaderActionButton + if let current = self.actionButton { + actionButton = current + } else { + actionButton = GroupHeaderActionButton(pressed: self.actionPressed) + self.actionButton = actionButton + self.addSubview(actionButton) + } + + actionButtonSize = actionButton.update(theme: theme, title: actionButtonTitle) + } else { + if let actionButton = self.actionButton { + self.actionButton = nil + actionButton.removeFromSuperview() + } + } + + var textConstrainedWidth = constrainedSize.width - titleHorizontalOffset - 10.0 + if let actionButtonSize = actionButtonSize { + textConstrainedWidth -= actionButtonSize.width - 8.0 + } let textSize: CGSize if let currentTextLayout = self.currentTextLayout, currentTextLayout.string == title, currentTextLayout.color == color, currentTextLayout.constrainedWidth == textConstrainedWidth { textSize = currentTextLayout.size } else { - let string = NSAttributedString(string: title.uppercased(), font: Font.medium(12.0), textColor: color) + let font: UIFont + let stringValue: String + if subtitle == nil { + font = Font.medium(12.0) + stringValue = title.uppercased() + } else { + font = Font.semibold(16.0) + stringValue = title + } + let string = NSAttributedString(string: stringValue, font: font, textColor: color) let stringBounds = string.boundingRect(with: CGSize(width: textConstrainedWidth, height: 100.0), options: .usesLineFragmentOrigin, context: nil) textSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height)) self.textLayer.contents = generateImage(textSize, opaque: false, scale: 0.0, rotatedContext: { size, context in @@ -176,7 +339,51 @@ private final class GroupHeaderLayer: SimpleLayer { self.currentTextLayout = (title, color, textConstrainedWidth, textSize) } - self.textLayer.frame = CGRect(origin: CGPoint(x: titleHorizontalOffset, y: 0.0), size: textSize) + let textFrame = CGRect(origin: CGPoint(x: titleHorizontalOffset, y: textOffsetY), size: textSize) + self.textLayer.frame = textFrame + + let subtitleSize: CGSize + if let subtitle = subtitle { + var updateSubtitleContents: UIImage? + if let currentSubtitleLayout = self.currentSubtitleLayout, currentSubtitleLayout.string == subtitle, currentSubtitleLayout.color == subtitleColor, currentSubtitleLayout.constrainedWidth == textConstrainedWidth { + subtitleSize = currentSubtitleLayout.size + } else { + let string = NSAttributedString(string: subtitle, font: Font.regular(15.0), textColor: subtitleColor) + let stringBounds = string.boundingRect(with: CGSize(width: textConstrainedWidth, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + subtitleSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height)) + updateSubtitleContents = generateImage(subtitleSize, opaque: false, scale: 0.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + string.draw(in: stringBounds) + + UIGraphicsPopContext() + }) + self.currentSubtitleLayout = (subtitle, subtitleColor, textConstrainedWidth, subtitleSize) + } + + let subtitleLayer: SimpleLayer + if let current = self.subtitleLayer { + subtitleLayer = current + } else { + subtitleLayer = SimpleLayer() + self.subtitleLayer = subtitleLayer + self.layer.addSublayer(subtitleLayer) + } + + if let updateSubtitleContents = updateSubtitleContents { + subtitleLayer.contents = updateSubtitleContents.cgImage + } + + let subtitleFrame = CGRect(origin: CGPoint(x: 0.0, y: textFrame.maxY + 1.0), size: subtitleSize) + subtitleLayer.frame = subtitleFrame + } else { + subtitleSize = CGSize() + if let subtitleLayer = self.subtitleLayer { + self.subtitleLayer = nil + subtitleLayer.removeFromSuperlayer() + } + } var clearWidth: CGFloat = 0.0 if hasClear { @@ -188,7 +395,7 @@ private final class GroupHeaderLayer: SimpleLayer { updateImage = true clearIconLayer = SimpleLayer() self.clearIconLayer = clearIconLayer - self.addSublayer(clearIconLayer) + self.layer.addSublayer(clearIconLayer) } var clearSize = clearIconLayer.bounds.size if updateImage, let image = PresentationResourcesChat.chatInputMediaPanelGridDismissImage(theme) { @@ -198,19 +405,393 @@ private final class GroupHeaderLayer: SimpleLayer { clearIconLayer.contents = image.cgImage } - clearIconLayer.frame = CGRect(origin: CGPoint(x: titleHorizontalOffset + textSize.width + 4.0, y: floorToScreenPixels((textSize.height - clearSize.height) / 2.0)), size: clearSize) + switch layoutType { + case .compact: + clearIconLayer.frame = CGRect(origin: CGPoint(x: titleHorizontalOffset + textSize.width + 4.0, y: floorToScreenPixels((textSize.height - clearSize.height) / 2.0)), size: clearSize) + case .detailed: + clearIconLayer.frame = CGRect(origin: CGPoint(x: constrainedSize.width - clearSize.width, y: floorToScreenPixels((textSize.height - clearSize.height) / 2.0)), size: clearSize) + } clearWidth = 4.0 + clearSize.width } - return CGSize(width: titleHorizontalOffset + textSize.width + clearWidth, height: textSize.height) + var size: CGSize + switch layoutType { + case .compact: + size = CGSize(width: titleHorizontalOffset + textSize.width + clearWidth, height: constrainedSize.height) + case .detailed: + size = CGSize(width: constrainedSize.width, height: constrainedSize.height) + } + + if let embeddedItems = embeddedItems { + let groupEmbeddedView: GroupEmbeddedView + if let current = self.groupEmbeddedView { + groupEmbeddedView = current + } else { + groupEmbeddedView = GroupEmbeddedView(performItemAction: self.performItemAction) + self.groupEmbeddedView = groupEmbeddedView + self.addSubview(groupEmbeddedView) + } + + let groupEmbeddedViewSize = CGSize(width: constrainedSize.width + insets.left + insets.right, height: 36.0) + groupEmbeddedView.frame = CGRect(origin: CGPoint(x: -insets.left, y: size.height - groupEmbeddedViewSize.height), size: groupEmbeddedViewSize) + groupEmbeddedView.update( + context: context, + theme: theme, + insets: insets, + size: groupEmbeddedViewSize, + items: embeddedItems, + cache: cache, + renderer: renderer, + attemptSynchronousLoad: attemptSynchronousLoad + ) + } else { + if let groupEmbeddedView = self.groupEmbeddedView { + self.groupEmbeddedView = nil + groupEmbeddedView.removeFromSuperview() + } + } + + if let actionButtonSize = actionButtonSize, let actionButton = self.actionButton { + actionButton.frame = CGRect(origin: CGPoint(x: size.width - actionButtonSize.width, y: textFrame.minY + 3.0), size: actionButtonSize) + } + + if hasTopSeparator { + let separatorLayer: SimpleLayer + if let current = self.separatorLayer { + separatorLayer = current + } else { + separatorLayer = SimpleLayer() + self.separatorLayer = separatorLayer + self.layer.addSublayer(separatorLayer) + } + separatorLayer.backgroundColor = theme.chat.inputMediaPanel.stickersSectionTextColor.withAlphaComponent(0.3).cgColor + separatorLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: UIScreenPixel)) + } else { + if let separatorLayer = self.separatorLayer { + self.separatorLayer = separatorLayer + separatorLayer.removeFromSuperlayer() + } + } + + //self.backgroundColor = UIColor.lightGray.cgColor + + return size + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + func tapGesture(_ recognizer: UITapGestureRecognizer) -> Bool { + if let groupEmbeddedView = self.groupEmbeddedView { + return groupEmbeddedView.tapGesture(recognizer) + } else { + return false + } + } +} + +private final class GroupEmbeddedView: UIScrollView, UIScrollViewDelegate, PagerExpandableScrollView { + private struct ItemLayout { + var itemSize: CGFloat + var itemSpacing: CGFloat + var sideInset: CGFloat + var itemCount: Int + var contentSize: CGSize + + init(height: CGFloat, sideInset: CGFloat, itemCount: Int) { + self.itemSize = 30.0 + self.itemSpacing = 20.0 + self.sideInset = sideInset + self.itemCount = itemCount + + self.contentSize = CGSize(width: self.sideInset * 2.0 + CGFloat(self.itemCount) * self.itemSize + CGFloat(self.itemCount - 1) * self.itemSpacing, height: height) + } + + func frame(at index: Int) -> CGRect { + return CGRect(origin: CGPoint(x: sideInset + CGFloat(index) * (self.itemSize + self.itemSpacing), y: floor((self.contentSize.height - self.itemSize) / 2.0)), size: CGSize(width: self.itemSize, height: self.itemSize)) + } + + func visibleItems(for rect: CGRect) -> Range? { + let offsetRect = rect.offsetBy(dx: -self.sideInset, dy: 0.0) + var minVisibleIndex = Int(floor((offsetRect.minX - self.itemSpacing) / (self.itemSize + self.itemSpacing))) + minVisibleIndex = max(0, minVisibleIndex) + var maxVisibleIndex = Int(ceil((offsetRect.maxX - self.itemSpacing) / (self.itemSize + self.itemSpacing))) + maxVisibleIndex = min(maxVisibleIndex, self.itemCount - 1) + + if minVisibleIndex <= maxVisibleIndex { + return minVisibleIndex ..< (maxVisibleIndex + 1) + } else { + return nil + } + } + } + + private let performItemAction: (EmojiPagerContentComponent.Item, UIView, CGRect, CALayer) -> Void + + private var visibleItemLayers: [EmojiPagerContentComponent.View.ItemLayer.Key: EmojiPagerContentComponent.View.ItemLayer] = [:] + private var ignoreScrolling: Bool = false + + private var context: AccountContext? + private var theme: PresentationTheme? + private var cache: AnimationCache? + private var renderer: MultiAnimationRenderer? + private var currentInsets: UIEdgeInsets? + private var currentSize: CGSize? + private var items: [EmojiPagerContentComponent.Item]? + + private var itemLayout: ItemLayout? + + init(performItemAction: @escaping (EmojiPagerContentComponent.Item, UIView, CGRect, CALayer) -> Void) { + self.performItemAction = performItemAction + + super.init(frame: CGRect()) + + self.delaysContentTouches = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.automaticallyAdjustsScrollIndicatorInsets = false + } + self.showsVerticalScrollIndicator = true + self.showsHorizontalScrollIndicator = false + self.delegate = self + self.clipsToBounds = false + self.scrollsToTop = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func tapGesture(_ recognizer: UITapGestureRecognizer) -> Bool { + guard let itemLayout = self.itemLayout else { + return false + } + + if case .ended = recognizer.state { + let point = recognizer.location(in: self) + for (_, itemLayer) in self.visibleItemLayers { + if itemLayer.frame.inset(by: UIEdgeInsets(top: 6.0, left: itemLayout.itemSpacing, bottom: 6.0, right: itemLayout.itemSpacing)).contains(point) { + self.performItemAction(itemLayer.item, self, itemLayer.frame, itemLayer) + return true + } + } + } + + return false + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateVisibleItems(transition: .immediate, attemptSynchronousLoad: false) + } + } + + private func updateVisibleItems(transition: Transition, attemptSynchronousLoad: Bool) { + guard let context = self.context, let itemLayout = self.itemLayout, let items = self.items, let cache = self.cache, let renderer = self.renderer else { + return + } + + var validIds = Set() + if let itemRange = itemLayout.visibleItems(for: self.bounds) { + for index in itemRange.lowerBound ..< itemRange.upperBound { + let item = items[index] + let itemId = EmojiPagerContentComponent.View.ItemLayer.Key(groupId: AnyHashable(0), fileId: item.file?.fileId, staticEmoji: item.staticEmoji) + validIds.insert(itemId) + + let itemLayer: EmojiPagerContentComponent.View.ItemLayer + if let current = self.visibleItemLayers[itemId] { + itemLayer = current + } else { + itemLayer = EmojiPagerContentComponent.View.ItemLayer( + item: item, + context: context, + attemptSynchronousLoad: attemptSynchronousLoad, + file: item.file, + staticEmoji: item.staticEmoji, + cache: cache, + renderer: renderer, + placeholderColor: .clear, + blurredBadgeColor: .clear, + pointSize: CGSize(width: 32.0, height: 32.0), + onUpdateDisplayPlaceholder: { _, _ in + } + ) + self.visibleItemLayers[itemId] = itemLayer + self.layer.addSublayer(itemLayer) + } + + let itemFrame = itemLayout.frame(at: index) + itemLayer.frame = itemFrame + + itemLayer.isVisibleForAnimations = true + } + } + + var removedIds: [EmojiPagerContentComponent.View.ItemLayer.Key] = [] + for (id, itemLayer) in self.visibleItemLayers { + if !validIds.contains(id) { + removedIds.append(id) + itemLayer.removeFromSuperlayer() + } + } + for id in removedIds { + self.visibleItemLayers.removeValue(forKey: id) + } + } + + func update( + context: AccountContext, + theme: PresentationTheme, + insets: UIEdgeInsets, + size: CGSize, + items: [EmojiPagerContentComponent.Item], + cache: AnimationCache, + renderer: MultiAnimationRenderer, + attemptSynchronousLoad: Bool + ) { + if self.theme === theme && self.currentInsets == insets && self.currentSize == size && self.items == items { + return + } + + self.context = context + self.theme = theme + self.currentInsets = insets + self.currentSize = size + self.items = items + self.cache = cache + self.renderer = renderer + + let itemLayout = ItemLayout(height: size.height, sideInset: insets.left, itemCount: items.count) + self.itemLayout = itemLayout + + self.ignoreScrolling = true + if itemLayout.contentSize != self.contentSize { + self.contentSize = itemLayout.contentSize + } + self.ignoreScrolling = false + + self.updateVisibleItems(transition: .immediate, attemptSynchronousLoad: attemptSynchronousLoad) + } +} + +private final class GroupExpandActionButton: UIButton { + private var currentTextLayout: (string: String, color: UIColor, constrainedWidth: CGFloat, size: CGSize)? + private let backgroundLayer: SimpleLayer + private let textLayer: SimpleLayer + private let pressed: () -> Void + + init(pressed: @escaping () -> Void) { + self.pressed = pressed + + self.backgroundLayer = SimpleLayer() + self.backgroundLayer.masksToBounds = true + + self.textLayer = SimpleLayer() + + super.init(frame: CGRect()) + + self.layer.addSublayer(self.backgroundLayer) + self.layer.addSublayer(self.textLayer) + + self.addTarget(self, action: #selector(self.onPressed), for: .touchUpInside) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + @objc private func onPressed() { + self.pressed() + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + self.alpha = 0.6 + + return super.beginTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.cancelTracking(with: event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + let alpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: alpha, to: 1.0, duration: 0.25) + + super.touchesCancelled(touches, with: event) + } + + func update(theme: PresentationTheme, title: String) -> CGSize { + let textConstrainedWidth: CGFloat = 100.0 + let color = theme.list.itemCheckColors.foregroundColor + + self.backgroundLayer.backgroundColor = theme.chat.inputPanel.primaryTextColor.withMultipliedAlpha(0.1).cgColor + + let textSize: CGSize + if let currentTextLayout = self.currentTextLayout, currentTextLayout.string == title, currentTextLayout.color == color, currentTextLayout.constrainedWidth == textConstrainedWidth { + textSize = currentTextLayout.size + } else { + let font: UIFont = Font.semibold(13.0) + let string = NSAttributedString(string: title.uppercased(), font: font, textColor: color) + let stringBounds = string.boundingRect(with: CGSize(width: textConstrainedWidth, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + textSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height)) + self.textLayer.contents = generateImage(textSize, opaque: false, scale: 0.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + string.draw(in: stringBounds) + + UIGraphicsPopContext() + })?.cgImage + self.currentTextLayout = (title, color, textConstrainedWidth, textSize) + } + + let size = CGSize(width: textSize.width + 10.0 * 2.0, height: 28.0) + + let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: floor((size.height - textSize.height) / 2.0)), size: textSize) + self.textLayer.frame = textFrame + + self.backgroundLayer.frame = CGRect(origin: CGPoint(), size: size) + self.backgroundLayer.cornerRadius = min(size.width, size.height) / 2.0 + + return size } } public final class EmojiPagerContentComponent: Component { public typealias EnvironmentType = (EntityKeyboardChildEnvironment, PagerComponentChildEnvironment) + public final class ContentAnimation { + public enum AnimationType { + case generic + case groupExpanded(id: AnyHashable) + } + + public let type: AnimationType + + public init(type: AnimationType) { + self.type = type + } + } + public final class InputInteraction { - public let performItemAction: (Item, UIView, CGRect, CALayer) -> Void + public let performItemAction: (AnyHashable, Item, UIView, CGRect, CALayer) -> Void public let deleteBackwards: () -> Void public let openStickerSettings: () -> Void public let addGroupAction: (AnyHashable, Bool) -> Void @@ -223,7 +804,7 @@ public final class EmojiPagerContentComponent: Component { public let chatPeerId: PeerId? public init( - performItemAction: @escaping (Item, UIView, CGRect, CALayer) -> Void, + performItemAction: @escaping (AnyHashable, Item, UIView, CGRect, CALayer) -> Void, deleteBackwards: @escaping () -> Void, openStickerSettings: @escaping () -> Void, addGroupAction: @escaping (AnyHashable, Bool) -> Void, @@ -297,9 +878,13 @@ public final class EmojiPagerContentComponent: Component { public let supergroupId: AnyHashable public let groupId: AnyHashable public let title: String? + public let subtitle: String? + public let actionButtonTitle: String? public let isFeatured: Bool public let isPremiumLocked: Bool + public let isEmbedded: Bool public let hasClear: Bool + public let isExpandable: Bool public let displayPremiumBadges: Bool public let items: [Item] @@ -307,18 +892,26 @@ public final class EmojiPagerContentComponent: Component { supergroupId: AnyHashable, groupId: AnyHashable, title: String?, + subtitle: String?, + actionButtonTitle: String?, isFeatured: Bool, isPremiumLocked: Bool, + isEmbedded: Bool, hasClear: Bool, + isExpandable: Bool, displayPremiumBadges: Bool, items: [Item] ) { self.supergroupId = supergroupId self.groupId = groupId self.title = title + self.subtitle = subtitle + self.actionButtonTitle = actionButtonTitle self.isFeatured = isFeatured self.isPremiumLocked = isPremiumLocked + self.isEmbedded = isEmbedded self.hasClear = hasClear + self.isExpandable = isExpandable self.displayPremiumBadges = displayPremiumBadges self.items = items } @@ -336,15 +929,27 @@ public final class EmojiPagerContentComponent: Component { if lhs.title != rhs.title { return false } + if lhs.subtitle != rhs.subtitle { + return false + } + if lhs.actionButtonTitle != rhs.actionButtonTitle { + return false + } if lhs.isFeatured != rhs.isFeatured { return false } if lhs.isPremiumLocked != rhs.isPremiumLocked { return false } + if lhs.isEmbedded != rhs.isEmbedded { + return false + } if lhs.hasClear != rhs.hasClear { return false } + if lhs.isExpandable != rhs.isExpandable { + return false + } if lhs.displayPremiumBadges != rhs.displayPremiumBadges { return false } @@ -387,6 +992,9 @@ public final class EmojiPagerContentComponent: Component { } public static func ==(lhs: EmojiPagerContentComponent, rhs: EmojiPagerContentComponent) -> Bool { + if lhs === rhs { + return true + } if lhs.id != rhs.id { return false } @@ -428,56 +1036,75 @@ public final class EmojiPagerContentComponent: Component { let isPremiumLocked: Bool let isFeatured: Bool let itemCount: Int + let isEmbedded: Bool + let isExpandable: Bool } private struct ItemGroupLayout: Equatable { let frame: CGRect let supergroupId: AnyHashable let groupId: AnyHashable + let headerHeight: CGFloat let itemTopOffset: CGFloat let itemCount: Int + let collapsedItemIndex: Int? + let collapsedItemText: String? } private struct ItemLayout: Equatable { + var layoutType: ItemLayoutType var width: CGFloat - var containerInsets: UIEdgeInsets + var headerInsets: UIEdgeInsets + var itemInsets: UIEdgeInsets var itemGroupLayouts: [ItemGroupLayout] + var itemDefaultHeaderHeight: CGFloat + var itemFeaturedHeaderHeight: CGFloat var nativeItemSize: CGFloat let visibleItemSize: CGFloat var horizontalSpacing: CGFloat var verticalSpacing: CGFloat - var verticalGroupSpacing: CGFloat + var verticalGroupDefaultSpacing: CGFloat + var verticalGroupFeaturedSpacing: CGFloat var itemsPerRow: Int var contentSize: CGSize var premiumButtonInset: CGFloat var premiumButtonHeight: CGFloat - init(width: CGFloat, containerInsets: UIEdgeInsets, itemGroups: [ItemGroupDescription], itemLayoutType: ItemLayoutType) { + init(layoutType: ItemLayoutType, width: CGFloat, containerInsets: UIEdgeInsets, itemGroups: [ItemGroupDescription], expandedGroupIds: Set) { + self.layoutType = layoutType self.width = width - self.containerInsets = containerInsets self.premiumButtonInset = 6.0 self.premiumButtonHeight = 50.0 let minItemsPerRow: Int let minSpacing: CGFloat - switch itemLayoutType { + switch layoutType { case .compact: minItemsPerRow = 8 self.nativeItemSize = 36.0 self.verticalSpacing = 9.0 minSpacing = 9.0 + self.itemDefaultHeaderHeight = 24.0 + self.itemFeaturedHeaderHeight = self.itemDefaultHeaderHeight + self.itemInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left + 12.0, bottom: containerInsets.bottom, right: containerInsets.right + 12.0) + self.headerInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left + 12.0, bottom: containerInsets.bottom, right: containerInsets.right + 12.0) case .detailed: minItemsPerRow = 5 - self.nativeItemSize = 76.0 + self.nativeItemSize = 71.0 self.verticalSpacing = 2.0 - minSpacing = 2.0 + minSpacing = 12.0 + self.itemDefaultHeaderHeight = 24.0 + self.itemFeaturedHeaderHeight = 60.0 + self.itemInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left + 10.0, bottom: containerInsets.bottom, right: containerInsets.right + 10.0) + self.headerInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left + 16.0, bottom: containerInsets.bottom, right: containerInsets.right + 16.0) } - self.verticalGroupSpacing = 18.0 + self.verticalGroupDefaultSpacing = 18.0 + self.verticalGroupFeaturedSpacing = 15.0 - let itemHorizontalSpace = width - self.containerInsets.left - self.containerInsets.right + let itemHorizontalSpace = width - self.itemInsets.left - self.itemInsets.right self.itemsPerRow = max(minItemsPerRow, Int((itemHorizontalSpace + minSpacing) / (self.nativeItemSize + minSpacing))) @@ -485,29 +1112,78 @@ public final class EmojiPagerContentComponent: Component { self.horizontalSpacing = floor((itemHorizontalSpace - self.visibleItemSize * CGFloat(self.itemsPerRow)) / CGFloat(self.itemsPerRow - 1)) - var verticalGroupOrigin: CGFloat = self.containerInsets.top + var verticalGroupOrigin: CGFloat = self.itemInsets.top self.itemGroupLayouts = [] for itemGroup in itemGroups { var itemTopOffset: CGFloat = 0.0 + var headerHeight: CGFloat = 0.0 + var groupSpacing = self.verticalGroupDefaultSpacing if itemGroup.hasTitle { - itemTopOffset += 24.0 + if itemGroup.isFeatured { + headerHeight = self.itemFeaturedHeaderHeight + groupSpacing = self.verticalGroupFeaturedSpacing + } else { + headerHeight = self.itemDefaultHeaderHeight + } + } + if itemGroup.isEmbedded { + headerHeight += 32.0 + groupSpacing -= 4.0 + } + itemTopOffset += headerHeight + + var numRowsInGroup: Int + if itemGroup.isEmbedded { + numRowsInGroup = 0 + } else { + numRowsInGroup = (itemGroup.itemCount + (self.itemsPerRow - 1)) / self.itemsPerRow + } + + var collapsedItemIndex: Int? + var collapsedItemText: String? + let visibleItemCount: Int + if itemGroup.isEmbedded { + visibleItemCount = 0 + } else if itemGroup.isExpandable && !expandedGroupIds.contains(itemGroup.groupId) { + let maxLines: Int + #if DEBUG + maxLines = 2 + #else + maxLines = 3 + #endif + if numRowsInGroup > maxLines { + visibleItemCount = self.itemsPerRow * maxLines - 1 + collapsedItemIndex = visibleItemCount + collapsedItemText = "+\(itemGroup.itemCount - visibleItemCount)" + } else { + visibleItemCount = itemGroup.itemCount + } + } else { + visibleItemCount = itemGroup.itemCount + } + + if !itemGroup.isEmbedded { + numRowsInGroup = (visibleItemCount + (self.itemsPerRow - 1)) / self.itemsPerRow } - let numRowsInGroup = (itemGroup.itemCount + (self.itemsPerRow - 1)) / self.itemsPerRow var groupContentSize = CGSize(width: width, height: itemTopOffset + CGFloat(numRowsInGroup) * self.visibleItemSize + CGFloat(max(0, numRowsInGroup - 1)) * self.verticalSpacing) - if itemGroup.isPremiumLocked || itemGroup.isFeatured { + if (itemGroup.isPremiumLocked || itemGroup.isFeatured), case .compact = layoutType { groupContentSize.height += self.premiumButtonInset + self.premiumButtonHeight } + self.itemGroupLayouts.append(ItemGroupLayout( frame: CGRect(origin: CGPoint(x: 0.0, y: verticalGroupOrigin), size: groupContentSize), supergroupId: itemGroup.supergroupId, groupId: itemGroup.groupId, + headerHeight: headerHeight, itemTopOffset: itemTopOffset, - itemCount: itemGroup.itemCount + itemCount: visibleItemCount, + collapsedItemIndex: collapsedItemIndex, + collapsedItemText: collapsedItemText )) - verticalGroupOrigin += groupContentSize.height + self.verticalGroupSpacing + verticalGroupOrigin += groupContentSize.height + groupSpacing } - verticalGroupOrigin += self.containerInsets.bottom + verticalGroupOrigin += self.itemInsets.bottom self.contentSize = CGSize(width: width, height: verticalGroupOrigin) } @@ -519,7 +1195,7 @@ public final class EmojiPagerContentComponent: Component { return CGRect( origin: CGPoint( - x: self.containerInsets.left + CGFloat(column) * (self.visibleItemSize + self.horizontalSpacing), + x: self.itemInsets.left + CGFloat(column) * (self.visibleItemSize + self.horizontalSpacing), y: groupLayout.frame.minY + groupLayout.itemTopOffset + CGFloat(row) * (self.visibleItemSize + self.verticalSpacing) ), size: CGSize( @@ -538,7 +1214,7 @@ public final class EmojiPagerContentComponent: Component { if !rect.intersects(group.frame) { continue } - let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: -group.frame.minY - group.itemTopOffset) + let offsetRect = rect.offsetBy(dx: -self.itemInsets.left, dy: -group.frame.minY - group.itemTopOffset) var minVisibleRow = Int(floor((offsetRect.minY - self.verticalSpacing) / (self.visibleItemSize + self.verticalSpacing))) minVisibleRow = max(0, minVisibleRow) let maxVisibleRow = Int(ceil((offsetRect.maxY - self.verticalSpacing) / (self.visibleItemSize + self.verticalSpacing))) @@ -740,11 +1416,11 @@ public final class EmojiPagerContentComponent: Component { } }) } - } else if let dimensions = file.dimensions { + } else if let _ = file.dimensions { let isSmall: Bool = false self.disposable = (chatMessageSticker(account: context.account, file: file, small: isSmall, synchronousLoad: attemptSynchronousLoad)).start(next: { [weak self] resultTransform in let boundingSize = CGSize(width: 93.0, height: 93.0) - let imageSize = dimensions.cgSize.aspectFilled(boundingSize) + let imageSize = boundingSize//dimensions.cgSize.aspectFitted(boundingSize) if let image = resultTransform(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), resizeMode: .fill(.clear)))?.generateImage() { Queue.mainQueue().async { @@ -911,6 +1587,8 @@ public final class EmojiPagerContentComponent: Component { private var visibleGroupHeaders: [AnyHashable: GroupHeaderLayer] = [:] private var visibleGroupBorders: [AnyHashable: GroupBorderLayer] = [:] private var visibleGroupPremiumButtons: [AnyHashable: ComponentView] = [:] + private var visibleGroupExpandActionButtons: [AnyHashable: GroupExpandActionButton] = [:] + private var expandedGroupIds: Set = Set() private var ignoreScrolling: Bool = false private var keepTopPanelVisibleUntilScrollingInput: Bool = false @@ -956,6 +1634,7 @@ public final class EmojiPagerContentComponent: Component { self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.delegate = self self.scrollView.clipsToBounds = false + self.scrollView.scrollsToTop = false self.addSubview(self.scrollView) self.scrollView.addSubview(self.placeholdersContainerView) @@ -1168,7 +1847,7 @@ public final class EmojiPagerContentComponent: Component { anchorFrame = group.frame } - var scrollPosition = anchorFrame.minY + floor(-itemLayout.verticalGroupSpacing / 2.0) - pagerEnvironment.containerInsets.top + var scrollPosition = anchorFrame.minY + floor(-itemLayout.verticalGroupDefaultSpacing / 2.0) - pagerEnvironment.containerInsets.top if scrollPosition > self.scrollView.contentSize.height - self.scrollView.bounds.height { scrollPosition = self.scrollView.contentSize.height - self.scrollView.bounds.height } @@ -1186,12 +1865,12 @@ public final class EmojiPagerContentComponent: Component { for (id, view) in self.visibleItemPlaceholderViews { previousVisiblePlaceholderViews[id] = (view, view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY)) } - var previousVisibleGroupHeaders: [AnyHashable: (CALayer, CGRect)] = [:] - for (id, layer) in self.visibleGroupHeaders { - if !self.scrollView.bounds.intersects(layer.frame) { + var previousVisibleGroupHeaders: [AnyHashable: (UIView, CGRect)] = [:] + for (id, view) in self.visibleGroupHeaders { + if !self.scrollView.bounds.intersects(view.frame) { continue } - previousVisibleGroupHeaders[id] = (layer, layer.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY)) + previousVisibleGroupHeaders[id] = (view, view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY)) } var previousVisibleGroupBorders: [AnyHashable: (CALayer, CGRect)] = [:] for (id, layer) in self.visibleGroupBorders { @@ -1203,11 +1882,15 @@ public final class EmojiPagerContentComponent: Component { previousVisibleGroupPremiumButtons[id] = (view, view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY)) } } + var previousVisibleGroupExpandActionButtons: [AnyHashable: (UIView, CGRect)] = [:] + for (id, view) in self.visibleGroupExpandActionButtons { + previousVisibleGroupExpandActionButtons[id] = (view, view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY)) + } self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: scrollPosition), size: self.scrollView.bounds.size) self.ignoreScrolling = wasIgnoringScrollingEvents - self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: true) + self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: true, previousItemPositions: nil, updatedItemPositions: nil) var commonItemOffset: CGFloat? var previousVisibleBoundingRect: CGRect? @@ -1244,9 +1927,9 @@ public final class EmojiPagerContentComponent: Component { } for (id, layerAndFrame) in previousVisibleGroupHeaders { - if let layer = self.visibleGroupHeaders[id] { - if commonItemOffset == nil, self.scrollView.bounds.intersects(layer.frame) { - let visibleFrame = layer.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + if let view = self.visibleGroupHeaders[id] { + if commonItemOffset == nil, self.scrollView.bounds.intersects(view.frame) { + let visibleFrame = view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) commonItemOffset = layerAndFrame.1.minY - visibleFrame.minY } break @@ -1291,6 +1974,22 @@ public final class EmojiPagerContentComponent: Component { } } + for (id, viewAndFrame) in previousVisibleGroupExpandActionButtons { + if let view = self.visibleGroupExpandActionButtons[id], self.scrollView.bounds.intersects(view.frame) { + if commonItemOffset == nil { + let visibleFrame = view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + commonItemOffset = viewAndFrame.1.minY - visibleFrame.minY + } + break + } else { + if let previousVisibleBoundingRectValue = previousVisibleBoundingRect { + previousVisibleBoundingRect = viewAndFrame.1.union(previousVisibleBoundingRectValue) + } else { + previousVisibleBoundingRect = viewAndFrame.1 + } + } + } + let duration = 0.4 let timingFunction = kCAMediaTimingFunctionSpring @@ -1323,17 +2022,17 @@ public final class EmojiPagerContentComponent: Component { }) } - for (_, layer) in self.visibleGroupHeaders { - layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) + for (_, view) in self.visibleGroupHeaders { + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) } - for (id, layerAndFrame) in previousVisibleGroupHeaders { + for (id, viewAndFrame) in previousVisibleGroupHeaders { if self.visibleGroupHeaders[id] != nil { continue } - let layer = layerAndFrame.0 - self.scrollView.layer.addSublayer(layer) - layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak layer] _ in - layer?.removeFromSuperlayer() + let view = viewAndFrame.0 + self.scrollView.addSubview(view) + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak view] _ in + view?.removeFromSuperview() }) } @@ -1366,6 +2065,20 @@ public final class EmojiPagerContentComponent: Component { view?.removeFromSuperview() }) } + + for (_, view) in self.visibleGroupExpandActionButtons { + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) + } + for (id, viewAndFrame) in previousVisibleGroupExpandActionButtons { + if self.visibleGroupExpandActionButtons[id] != nil { + continue + } + let view = viewAndFrame.0 + self.scrollView.addSubview(view) + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } } else if let previousVisibleBoundingRect = previousVisibleBoundingRect { var updatedVisibleBoundingRect: CGRect? @@ -1385,11 +2098,11 @@ public final class EmojiPagerContentComponent: Component { updatedVisibleBoundingRect = frame } } - for (_, layer) in self.visibleGroupHeaders { - if !self.scrollView.bounds.intersects(layer.frame) { + for (_, view) in self.visibleGroupHeaders { + if !self.scrollView.bounds.intersects(view.frame) { continue } - let frame = layer.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + let frame = view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) if let updatedVisibleBoundingRectValue = updatedVisibleBoundingRect { updatedVisibleBoundingRect = frame.union(updatedVisibleBoundingRectValue) } else { @@ -1410,6 +2123,18 @@ public final class EmojiPagerContentComponent: Component { } } } + for (_, view) in self.visibleGroupExpandActionButtons { + if !self.scrollView.bounds.intersects(view.frame) { + continue + } + + let frame = view.frame.offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + if let updatedVisibleBoundingRectValue = updatedVisibleBoundingRect { + updatedVisibleBoundingRect = frame.union(updatedVisibleBoundingRectValue) + } else { + updatedVisibleBoundingRect = frame + } + } if let updatedVisibleBoundingRect = updatedVisibleBoundingRect { var commonItemOffset = updatedVisibleBoundingRect.height * offsetDirectionSign @@ -1452,21 +2177,21 @@ public final class EmojiPagerContentComponent: Component { }) } - for (_, layer) in self.visibleGroupHeaders { - if !self.scrollView.bounds.intersects(layer.frame) { + for (_, view) in self.visibleGroupHeaders { + if !self.scrollView.bounds.intersects(view.frame) { continue } - layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) } - for (id, layerAndFrame) in previousVisibleGroupHeaders { + for (id, viewAndFrame) in previousVisibleGroupHeaders { if self.visibleGroupHeaders[id] != nil { continue } - let layer = layerAndFrame.0 - layer.frame = layerAndFrame.1.offsetBy(dx: 0.0, dy: self.scrollView.bounds.minY) - self.scrollView.layer.addSublayer(layer) - layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -commonItemOffset), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak layer] _ in - layer?.removeFromSuperlayer() + let view = viewAndFrame.0 + view.frame = viewAndFrame.1.offsetBy(dx: 0.0, dy: self.scrollView.bounds.minY) + self.scrollView.addSubview(view) + view.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -commonItemOffset), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak view] _ in + view?.removeFromSuperview() }) } @@ -1504,6 +2229,21 @@ public final class EmojiPagerContentComponent: Component { view?.removeFromSuperview() }) } + + for (_, view) in self.visibleGroupExpandActionButtons { + view.layer.animatePosition(from: CGPoint(x: 0.0, y: commonItemOffset), to: CGPoint(), duration: duration, timingFunction: timingFunction, additive: true) + } + for (id, viewAndFrame) in previousVisibleGroupExpandActionButtons { + if self.visibleGroupExpandActionButtons[id] != nil { + continue + } + let view = viewAndFrame.0 + view.frame = viewAndFrame.1.offsetBy(dx: 0.0, dy: self.scrollView.bounds.minY) + self.scrollView.addSubview(view) + view.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -commonItemOffset), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } } } } @@ -1518,28 +2258,37 @@ public final class EmojiPagerContentComponent: Component { let locationInScrollView = recognizer.location(in: self.scrollView) outer: for (id, groupHeader) in self.visibleGroupHeaders { if groupHeader.frame.insetBy(dx: -10.0, dy: -6.0).contains(locationInScrollView) { - let groupHeaderPoint = self.scrollView.layer.convert(locationInScrollView, to: groupHeader) + let groupHeaderPoint = self.scrollView.convert(locationInScrollView, to: groupHeader) if let clearIconLayer = groupHeader.clearIconLayer, clearIconLayer.frame.insetBy(dx: -4.0, dy: -4.0).contains(groupHeaderPoint) { component.inputInteraction.clearGroup(id) + } else { + if groupHeader.tapGesture(recognizer) { + return + } } } } + var foundItem = false var foundExactItem = false if let (item, itemKey) = self.item(atPoint: recognizer.location(in: self)), let itemLayer = self.visibleItemLayers[itemKey] { foundExactItem = true + foundItem = true if !itemLayer.displayPlaceholder { - component.inputInteraction.performItemAction(item, self, self.scrollView.convert(itemLayer.frame, to: self), itemLayer) + component.inputInteraction.performItemAction(itemKey.groupId, item, self, self.scrollView.convert(itemLayer.frame, to: self), itemLayer) } } if !foundExactItem { if let (item, itemKey) = self.item(atPoint: recognizer.location(in: self), extendedHitRange: true), let itemLayer = self.visibleItemLayers[itemKey] { + foundItem = true if !itemLayer.displayPlaceholder { - component.inputInteraction.performItemAction(item, self, self.scrollView.convert(itemLayer.frame, to: self), itemLayer) + component.inputInteraction.performItemAction(itemKey.groupId, item, self, self.scrollView.convert(itemLayer.frame, to: self), itemLayer) } } } + + let _ = foundItem } } @@ -1604,7 +2353,7 @@ public final class EmojiPagerContentComponent: Component { return } - self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false) + self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false, previousItemPositions: nil, updatedItemPositions: nil) self.updateScrollingOffset(isReset: false, transition: .immediate) } @@ -1678,7 +2427,7 @@ public final class EmojiPagerContentComponent: Component { self.updateScrollingOffset(isReset: false, transition: transition) } - private func updateVisibleItems(transition: Transition, attemptSynchronousLoads: Bool) { + private func updateVisibleItems(transition: Transition, attemptSynchronousLoads: Bool, previousItemPositions: [ItemLayer.Key: CGPoint]?, updatedItemPositions: [ItemLayer.Key: CGPoint]?) { guard let component = self.component, let pagerEnvironment = self.pagerEnvironment, let theme = self.theme, let itemLayout = self.itemLayout else { return } @@ -1690,10 +2439,13 @@ public final class EmojiPagerContentComponent: Component { var validGroupHeaderIds = Set() var validGroupBorderIds = Set() var validGroupPremiumButtonIds = Set() + var validGroupExpandActionButtons = Set() let effectiveVisibleBounds = CGRect(origin: self.scrollView.bounds.origin, size: self.effectiveVisibleSize) let topVisibleDetectionBounds = effectiveVisibleBounds.offsetBy(dx: 0.0, dy: pagerEnvironment.containerInsets.top) + let contentAnimation = transition.userData(ContentAnimation.self) + for groupItems in itemLayout.visibleItems(for: effectiveVisibleBounds) { let itemGroup = component.itemGroups[groupItems.groupIndex] let itemGroupLayout = itemLayout.itemGroupLayouts[groupItems.groupIndex] @@ -1708,31 +2460,72 @@ public final class EmojiPagerContentComponent: Component { var headerSizeUpdated = false if let title = itemGroup.title { validGroupHeaderIds.insert(itemGroup.groupId) - let groupHeaderLayer: GroupHeaderLayer + let groupHeaderView: GroupHeaderLayer var groupHeaderTransition = transition if let current = self.visibleGroupHeaders[itemGroup.groupId] { - groupHeaderLayer = current + groupHeaderView = current } else { groupHeaderTransition = .immediate - groupHeaderLayer = GroupHeaderLayer() - self.visibleGroupHeaders[itemGroup.groupId] = groupHeaderLayer - self.scrollView.layer.addSublayer(groupHeaderLayer) + let groupId = itemGroup.groupId + groupHeaderView = GroupHeaderLayer( + actionPressed: { [weak self] in + guard let strongSelf = self, let component = strongSelf.component else { + return + } + component.inputInteraction.addGroupAction(groupId, false) + }, + performItemAction: { [weak self] item, view, rect, layer in + guard let strongSelf = self, let component = strongSelf.component else { + return + } + component.inputInteraction.performItemAction(groupId, item, view, rect, layer) + } + ) + self.visibleGroupHeaders[itemGroup.groupId] = groupHeaderView + self.scrollView.addSubview(groupHeaderView) } - let groupHeaderSize = groupHeaderLayer.update(theme: theme, title: title, isPremiumLocked: itemGroup.isPremiumLocked, hasClear: itemGroup.hasClear, constrainedWidth: itemLayout.contentSize.width - itemLayout.containerInsets.left - itemLayout.containerInsets.right) - if groupHeaderLayer.bounds.size != groupHeaderSize { + var actionButtonTitle: String? + if case .detailed = itemLayout.layoutType, itemGroup.isFeatured { + actionButtonTitle = itemGroup.actionButtonTitle + } + + var hasTopSeparator = false + if case .detailed = itemLayout.layoutType, itemGroup.isFeatured, groupItems.groupIndex != 0 { + hasTopSeparator = true + } + + let groupHeaderSize = groupHeaderView.update( + context: component.context, + theme: theme, + layoutType: itemLayout.layoutType, + hasTopSeparator: hasTopSeparator, + actionButtonTitle: actionButtonTitle, + title: title, + subtitle: itemGroup.subtitle, + isPremiumLocked: itemGroup.isPremiumLocked, + hasClear: itemGroup.hasClear, + embeddedItems: itemGroup.isEmbedded ? itemGroup.items : nil, + constrainedSize: CGSize(width: itemLayout.contentSize.width - itemLayout.headerInsets.left - itemLayout.headerInsets.right, height: itemGroupLayout.headerHeight), + insets: itemLayout.headerInsets, + cache: component.animationCache, + renderer: component.animationRenderer, + attemptSynchronousLoad: attemptSynchronousLoads + ) + + if groupHeaderView.bounds.size != groupHeaderSize { headerSizeUpdated = true } let groupHeaderFrame = CGRect(origin: CGPoint(x: floor((itemLayout.contentSize.width - groupHeaderSize.width) / 2.0), y: itemGroupLayout.frame.minY + 1.0), size: groupHeaderSize) - groupHeaderLayer.bounds = CGRect(origin: CGPoint(), size: groupHeaderFrame.size) - groupHeaderTransition.setPosition(layer: groupHeaderLayer, position: CGPoint(x: groupHeaderFrame.midX, y: groupHeaderFrame.midY)) + groupHeaderView.bounds = CGRect(origin: CGPoint(), size: groupHeaderFrame.size) + groupHeaderTransition.setPosition(view: groupHeaderView, position: CGPoint(x: groupHeaderFrame.midX, y: groupHeaderFrame.midY)) headerSize = CGSize(width: groupHeaderSize.width, height: groupHeaderSize.height) } let groupBorderRadius: CGFloat = 16.0 - if itemGroup.isPremiumLocked && !itemGroup.isFeatured { + if itemGroup.isPremiumLocked && !itemGroup.isFeatured && !itemGroup.isEmbedded { validGroupBorderIds.insert(itemGroup.groupId) let groupBorderLayer: GroupBorderLayer var groupBorderTransition = transition @@ -1750,7 +2543,7 @@ public final class EmojiPagerContentComponent: Component { groupBorderLayer.fillColor = nil } - let groupBorderHorizontalInset: CGFloat = itemLayout.containerInsets.left - 4.0 + let groupBorderHorizontalInset: CGFloat = itemLayout.itemInsets.left - 4.0 let groupBorderVerticalTopOffset: CGFloat = 8.0 let groupBorderVerticalInset: CGFloat = 6.0 @@ -1792,8 +2585,8 @@ public final class EmojiPagerContentComponent: Component { groupBorderTransition.setFrame(layer: groupBorderLayer, frame: groupBorderFrame) } - if itemGroup.isPremiumLocked || itemGroup.isFeatured { - let groupPremiumButtonMeasuringFrame = CGRect(origin: CGPoint(x: itemLayout.containerInsets.left, y: itemGroupLayout.frame.maxY - 50.0 + 1.0), size: CGSize(width: 100.0, height: 50.0)) + if (itemGroup.isPremiumLocked || itemGroup.isFeatured), !itemGroup.isEmbedded, case .compact = itemLayout.layoutType { + let groupPremiumButtonMeasuringFrame = CGRect(origin: CGPoint(x: itemLayout.itemInsets.left, y: itemGroupLayout.frame.maxY - 50.0 + 1.0), size: CGSize(width: 100.0, height: 50.0)) if effectiveVisibleBounds.intersects(groupPremiumButtonMeasuringFrame) { validGroupPremiumButtonIds.insert(itemGroup.groupId) @@ -1864,13 +2657,12 @@ public final class EmojiPagerContentComponent: Component { } )), environment: {}, - containerSize: CGSize(width: itemLayout.width - itemLayout.containerInsets.left - itemLayout.containerInsets.right, height: itemLayout.premiumButtonHeight) + containerSize: CGSize(width: itemLayout.width - itemLayout.itemInsets.left - itemLayout.itemInsets.right, height: itemLayout.premiumButtonHeight) ) - let groupPremiumButtonFrame = CGRect(origin: CGPoint(x: itemLayout.containerInsets.left, y: itemGroupLayout.frame.maxY - groupPremiumButtonSize.height + 1.0), size: groupPremiumButtonSize) + let groupPremiumButtonFrame = CGRect(origin: CGPoint(x: itemLayout.itemInsets.left, y: itemGroupLayout.frame.maxY - groupPremiumButtonSize.height + 1.0), size: groupPremiumButtonSize) if let view = groupPremiumButton.view { var animateIn = false if view.superview == nil { - view.layer.anchorPoint = CGPoint(x: 0.5, y: 0.0) animateIn = true self.scrollView.addSubview(view) } @@ -1883,7 +2675,33 @@ public final class EmojiPagerContentComponent: Component { } } - if let groupItemRange = groupItems.groupItems { + if !itemGroup.isEmbedded, let collapsedItemIndex = itemGroupLayout.collapsedItemIndex, let collapsedItemText = itemGroupLayout.collapsedItemText { + validGroupExpandActionButtons.insert(itemGroup.groupId) + let groupId = itemGroup.groupId + + var groupExpandActionButtonTransition = transition + let groupExpandActionButton: GroupExpandActionButton + if let current = self.visibleGroupExpandActionButtons[itemGroup.groupId] { + groupExpandActionButton = current + } else { + groupExpandActionButtonTransition = .immediate + groupExpandActionButton = GroupExpandActionButton(pressed: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.expandGroup(groupId: groupId) + }) + self.visibleGroupExpandActionButtons[itemGroup.groupId] = groupExpandActionButton + self.scrollView.addSubview(groupExpandActionButton) + } + + let baseItemFrame = itemLayout.frame(groupIndex: groupItems.groupIndex, itemIndex: collapsedItemIndex) + let buttonSize = groupExpandActionButton.update(theme: theme, title: collapsedItemText) + let buttonFrame = CGRect(origin: CGPoint(x: baseItemFrame.minX + floor((baseItemFrame.width - buttonSize.width) / 2.0), y: baseItemFrame.minY + floor((baseItemFrame.height - buttonSize.height) / 2.0)), size: buttonSize) + groupExpandActionButtonTransition.setFrame(view: groupExpandActionButton, frame: buttonFrame) + } + + if !itemGroup.isEmbedded, let groupItemRange = groupItems.groupItems { for index in groupItemRange.lowerBound ..< groupItemRange.upperBound { let item = itemGroup.items[index] @@ -1905,6 +2723,7 @@ public final class EmojiPagerContentComponent: Component { let itemNativeFitSize = itemDimensions.fitted(CGSize(width: itemLayout.nativeItemSize, height: itemLayout.nativeItemSize)) let itemVisibleFitSize = itemDimensions.fitted(CGSize(width: itemLayout.visibleItemSize, height: itemLayout.visibleItemSize)) + var animateItemIn = false var updateItemLayerPlaceholder = false var itemTransition = transition let itemLayer: ItemLayer @@ -1913,6 +2732,7 @@ public final class EmojiPagerContentComponent: Component { } else { updateItemLayerPlaceholder = true itemTransition = .immediate + animateItemIn = !transition.animation.isImmediate itemLayer = ItemLayer( item: item, @@ -1981,11 +2801,26 @@ public final class EmojiPagerContentComponent: Component { itemFrame.origin.y += floor((itemFrame.height - itemVisibleFitSize.height) / 2.0) itemFrame.size = itemVisibleFitSize - let itemPosition = CGPoint(x: itemFrame.midX, y: itemFrame.midY) let itemBounds = CGRect(origin: CGPoint(), size: itemFrame.size) - itemTransition.setPosition(layer: itemLayer, position: itemPosition) itemTransition.setBounds(layer: itemLayer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) + if animateItemIn, !transition.animation.isImmediate { + if let previousItemPosition = previousItemPositions?[itemId] { + itemTransition = transition + itemLayer.position = previousItemPosition + } else { + if let contentAnimation = contentAnimation, case .groupExpanded(id: itemGroup.groupId) = contentAnimation.type { + itemLayer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4) + itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + } else { + itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + let itemPosition = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + itemTransition.setPosition(layer: itemLayer, position: itemPosition) + var badge: ItemLayer.Badge? if itemGroup.displayPremiumBadges, let file = item.file, file.isPremiumSticker { badge = .premium @@ -2013,7 +2848,22 @@ public final class EmojiPagerContentComponent: Component { for (id, itemLayer) in self.visibleItemLayers { if !validIds.contains(id) { removedIds.append(id) - itemLayer.removeFromSuperlayer() + + if !transition.animation.isImmediate { + if let position = updatedItemPositions?[id] { + transition.setPosition(layer: itemLayer, position: position, completion: { [weak itemLayer] _ in + itemLayer?.removeFromSuperlayer() + }) + } else { + itemLayer.opacity = 0.0 + itemLayer.animateScale(from: 1.0, to: 0.01, duration: 0.2) + itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak itemLayer] _ in + itemLayer?.removeFromSuperlayer() + }) + } + } else { + itemLayer.removeFromSuperlayer() + } } } for id in removedIds { @@ -2029,7 +2879,16 @@ public final class EmojiPagerContentComponent: Component { for (id, groupHeaderLayer) in self.visibleGroupHeaders { if !validGroupHeaderIds.contains(id) { removedGroupHeaderIds.append(id) - groupHeaderLayer.removeFromSuperlayer() + + if !transition.animation.isImmediate { + groupHeaderLayer.alpha = 0.0 + groupHeaderLayer.layer.animateScale(from: 1.0, to: 0.5, duration: 0.2) + groupHeaderLayer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak groupHeaderLayer] _ in + groupHeaderLayer?.removeFromSuperview() + }) + } else { + groupHeaderLayer.removeFromSuperview() + } } } for id in removedGroupHeaderIds { @@ -2058,6 +2917,17 @@ public final class EmojiPagerContentComponent: Component { self.visibleGroupPremiumButtons.removeValue(forKey: id) } + var removedGroupExpandActionButtonIds: [AnyHashable] = [] + for (id, button) in self.visibleGroupExpandActionButtons { + if !validGroupExpandActionButtons.contains(id) { + removedGroupExpandActionButtonIds.append(id) + button.removeFromSuperview() + } + } + for id in removedGroupExpandActionButtonIds { + self.visibleGroupExpandActionButtons.removeValue(forKey: id) + } + if removedPlaceholerViews { self.updateShimmerIfNeeded() } @@ -2075,6 +2945,12 @@ public final class EmojiPagerContentComponent: Component { } } + private func expandGroup(groupId: AnyHashable) { + self.expandedGroupIds.insert(groupId) + + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(ContentAnimation(type: .groupExpanded(id: groupId)))) + } + func update(component: EmojiPagerContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let previousComponent = self.component @@ -2097,8 +2973,35 @@ public final class EmojiPagerContentComponent: Component { let shimmerForegroundColor = keyboardChildEnvironment.theme.list.itemBlocksBackgroundColor.withMultipliedAlpha(0.15) self.standaloneShimmerEffect.update(background: shimmerBackgroundColor, foreground: shimmerForegroundColor) + var previousItemPositions: [ItemLayer.Key: CGPoint]? + + var calculateUpdatedItemPositions = false + var updatedItemPositions: [ItemLayer.Key: CGPoint]? + var anchorItem: (key: ItemLayer.Key, frame: CGRect)? - if let previousComponent = previousComponent, previousComponent.itemGroups != component.itemGroups { + if let previousComponent = previousComponent, let previousItemLayout = self.itemLayout, previousComponent.itemGroups != component.itemGroups { + if !transition.animation.isImmediate { + var previousItemPositionsValue: [ItemLayer.Key: CGPoint] = [:] + for groupIndex in 0 ..< previousComponent.itemGroups.count { + let itemGroup = previousComponent.itemGroups[groupIndex] + for itemIndex in 0 ..< itemGroup.items.count { + let item = itemGroup.items[itemIndex] + let itemKey: ItemLayer.Key + if let file = item.file { + itemKey = ItemLayer.Key(groupId: itemGroup.groupId, fileId: file.fileId, staticEmoji: nil) + } else if let staticEmoji = item.staticEmoji { + itemKey = ItemLayer.Key(groupId: itemGroup.groupId, fileId: nil, staticEmoji: staticEmoji) + } else { + continue + } + let itemFrame = previousItemLayout.frame(groupIndex: groupIndex, itemIndex: itemIndex) + previousItemPositionsValue[itemKey] = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + } + } + previousItemPositions = previousItemPositionsValue + calculateUpdatedItemPositions = true + } + let effectiveVisibleBounds = CGRect(origin: self.scrollView.bounds.origin, size: self.effectiveVisibleSize) let topVisibleDetectionBounds = effectiveVisibleBounds.offsetBy(dx: 0.0, dy: pagerEnvironment.containerInsets.top) for (key, itemLayer) in self.visibleItemLayers { @@ -2128,13 +3031,21 @@ public final class EmojiPagerContentComponent: Component { hasTitle: itemGroup.title != nil, isPremiumLocked: itemGroup.isPremiumLocked, isFeatured: itemGroup.isFeatured, - itemCount: itemGroup.items.count + itemCount: itemGroup.items.count, + isEmbedded: itemGroup.isEmbedded, + isExpandable: itemGroup.isExpandable )) } var itemTransition = transition - let itemLayout = ItemLayout(width: availableSize.width, containerInsets: UIEdgeInsets(top: pagerEnvironment.containerInsets.top + 9.0, left: pagerEnvironment.containerInsets.left + 12.0, bottom: 9.0 + pagerEnvironment.containerInsets.bottom, right: pagerEnvironment.containerInsets.right + 12.0), itemGroups: itemGroups, itemLayoutType: component.itemLayoutType) + let itemLayout = ItemLayout( + layoutType: component.itemLayoutType, + width: availableSize.width, + containerInsets: UIEdgeInsets(top: pagerEnvironment.containerInsets.top + 9.0, left: pagerEnvironment.containerInsets.left, bottom: 9.0 + pagerEnvironment.containerInsets.bottom, right: pagerEnvironment.containerInsets.right), + itemGroups: itemGroups, + expandedGroupIds: self.expandedGroupIds + ) if let previousItemLayout = self.itemLayout { if previousItemLayout.width != itemLayout.width { itemTransition = .immediate @@ -2162,7 +3073,7 @@ public final class EmojiPagerContentComponent: Component { let effectiveVisibleSize = strongSelf.scrollView.bounds.size if strongSelf.effectiveVisibleSize != effectiveVisibleSize { strongSelf.effectiveVisibleSize = effectiveVisibleSize - strongSelf.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false) + strongSelf.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false, previousItemPositions: nil, updatedItemPositions: nil) } }) } @@ -2210,7 +3121,28 @@ public final class EmojiPagerContentComponent: Component { self.ignoreScrolling = false - self.updateVisibleItems(transition: itemTransition, attemptSynchronousLoads: !(scrollView.isDragging || scrollView.isDecelerating)) + if calculateUpdatedItemPositions { + var updatedItemPositionsValue: [ItemLayer.Key: CGPoint] = [:] + for groupIndex in 0 ..< component.itemGroups.count { + let itemGroup = component.itemGroups[groupIndex] + for itemIndex in 0 ..< itemGroup.items.count { + let item = itemGroup.items[itemIndex] + let itemKey: ItemLayer.Key + if let file = item.file { + itemKey = ItemLayer.Key(groupId: itemGroup.groupId, fileId: file.fileId, staticEmoji: nil) + } else if let staticEmoji = item.staticEmoji { + itemKey = ItemLayer.Key(groupId: itemGroup.groupId, fileId: nil, staticEmoji: staticEmoji) + } else { + continue + } + let itemFrame = itemLayout.frame(groupIndex: groupIndex, itemIndex: itemIndex) + updatedItemPositionsValue[itemKey] = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + } + } + updatedItemPositions = updatedItemPositionsValue + } + + self.updateVisibleItems(transition: itemTransition, attemptSynchronousLoads: !(scrollView.isDragging || scrollView.isDecelerating), previousItemPositions: previousItemPositions, updatedItemPositions: updatedItemPositions) return availableSize } diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift index 8113feb2ff..4fc8b78481 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift @@ -78,6 +78,7 @@ public final class EntityKeyboardComponent: Component { public let emojiContent: EmojiPagerContentComponent public let stickerContent: EmojiPagerContentComponent? public let gifContent: GifPagerContentComponent? + public let hasRecentGifs: Bool public let availableGifSearchEmojies: [GifSearchEmoji] public let defaultToEmojiTab: Bool public let externalTopPanelContainer: PagerExternalTopPanelContainer? @@ -96,6 +97,7 @@ public final class EntityKeyboardComponent: Component { emojiContent: EmojiPagerContentComponent, stickerContent: EmojiPagerContentComponent?, gifContent: GifPagerContentComponent?, + hasRecentGifs: Bool, availableGifSearchEmojies: [GifSearchEmoji], defaultToEmojiTab: Bool, externalTopPanelContainer: PagerExternalTopPanelContainer?, @@ -113,6 +115,7 @@ public final class EntityKeyboardComponent: Component { self.emojiContent = emojiContent self.stickerContent = stickerContent self.gifContent = gifContent + self.hasRecentGifs = hasRecentGifs self.availableGifSearchEmojies = availableGifSearchEmojies self.defaultToEmojiTab = defaultToEmojiTab self.externalTopPanelContainer = externalTopPanelContainer @@ -142,6 +145,9 @@ public final class EntityKeyboardComponent: Component { if lhs.gifContent != rhs.gifContent { return false } + if lhs.hasRecentGifs != rhs.hasRecentGifs { + return false + } if lhs.availableGifSearchEmojies != rhs.availableGifSearchEmojies { return false } @@ -219,18 +225,20 @@ public final class EntityKeyboardComponent: Component { contents.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(gifContent))) var topGifItems: [EntityKeyboardTopPanelComponent.Item] = [] //TODO:localize - topGifItems.append(EntityKeyboardTopPanelComponent.Item( - id: "recent", - isReorderable: false, - content: AnyComponent(EntityKeyboardIconTopPanelComponent( - imageName: "Chat/Input/Media/RecentTabIcon", - theme: component.theme, - title: "Recent", - pressed: { [weak self] in - self?.component?.switchToGifSubject(.recent) - } + if component.hasRecentGifs { + topGifItems.append(EntityKeyboardTopPanelComponent.Item( + id: "recent", + isReorderable: false, + content: AnyComponent(EntityKeyboardIconTopPanelComponent( + imageName: "Chat/Input/Media/RecentTabIcon", + theme: component.theme, + title: "Recent", + pressed: { [weak self] in + self?.component?.switchToGifSubject(.recent) + } + )) )) - )) + } topGifItems.append(EntityKeyboardTopPanelComponent.Item( id: "trending", isReorderable: false, diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift index c0bea11664..1921cbb023 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift @@ -437,6 +437,7 @@ final class EntityKeyboardStaticStickersPanelComponent: Component { self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false + self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.scrollViewContainer.addSubview(self.scrollView) @@ -1114,6 +1115,7 @@ final class EntityKeyboardTopPanelComponent: Component { self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = true + self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.addSubview(self.scrollView) diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift index 3ff0d8f0f8..c6676fbaf3 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift @@ -439,6 +439,7 @@ public final class GifPagerContentComponent: Component { } self.scrollView.showsVerticalScrollIndicator = true self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.addSubview(self.scrollView) diff --git a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift index ffe00bae70..b76800a424 100644 --- a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift +++ b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift @@ -107,27 +107,6 @@ private final class ItemAnimationContext { data.withUnsafeBytes { bytes -> Void in memcpy(context.bytes, bytes.baseAddress!, height * bytesPerRow) - - /*var sourceBuffer = vImage_Buffer() - sourceBuffer.width = UInt(width) - sourceBuffer.height = UInt(height) - sourceBuffer.data = UnsafeMutableRawPointer(mutating: bytes.baseAddress!.advanced(by: firstFrame.range.lowerBound)) - sourceBuffer.rowBytes = bytesPerRow - - var destinationBuffer = vImage_Buffer() - destinationBuffer.width = UInt(32) - destinationBuffer.height = UInt(32) - destinationBuffer.data = context.bytes - destinationBuffer.rowBytes = bytesPerRow - - vImageBoxConvolve_ARGB8888(&sourceBuffer, - &destinationBuffer, - nil, - UInt(width - 32 - 16), UInt(height - 32 - 16), - UInt32(31), - UInt32(31), - nil, - vImage_Flags(kvImageEdgeExtend))*/ } guard let image = context.generateImage() else { @@ -184,42 +163,6 @@ private final class ItemAnimationContext { destinationBuffer.data = context.bytes destinationBuffer.rowBytes = context.bytesPerRow - /*var sourceBuffer = vImage_Buffer() - sourceBuffer.width = UInt(width) - sourceBuffer.height = UInt(height) - sourceBuffer.data = UnsafeMutableRawPointer(mutating: bytes.baseAddress!) - sourceBuffer.rowBytes = bytesPerRow - - let tempBufferBytes = malloc(blurredHeight * context.bytesPerRow) - defer { - free(tempBufferBytes) - } - let temp2BufferBytes = malloc(blurredHeight * context.bytesPerRow) - defer { - free(temp2BufferBytes) - } - memset(temp2BufferBytes, Int32(bitPattern: color?.argb ?? 0xffffffff), blurredHeight * context.bytesPerRow) - - var tempBuffer = vImage_Buffer() - tempBuffer.width = UInt(blurredWidth) - tempBuffer.height = UInt(blurredHeight) - tempBuffer.data = tempBufferBytes - tempBuffer.rowBytes = context.bytesPerRow - - var temp2Buffer = vImage_Buffer() - temp2Buffer.width = UInt(blurredWidth) - temp2Buffer.height = UInt(blurredHeight) - temp2Buffer.data = temp2BufferBytes - temp2Buffer.rowBytes = context.bytesPerRow - - - - vImageScale_ARGB8888(&sourceBuffer, &tempBuffer, nil, vImage_Flags(kvImageDoNotTile)) - //vImageUnpremultiplyData_ARGB8888(&tempBuffer, &tempBuffer, vImage_Flags(kvImageDoNotTile)) - - vImagePremultipliedAlphaBlend_ARGB8888(&tempBuffer, &temp2Buffer, &destinationBuffer, vImage_Flags(kvImageDoNotTile)) - //vImageCopyBuffer(&tempBuffer, &destinationBuffer, 4, vImage_Flags(kvImageDoNotTile))*/ - vImageBoxConvolve_ARGB8888(&destinationBuffer, &destinationBuffer, nil, @@ -302,14 +245,6 @@ private final class ItemAnimationContext { } strongSelf.item = result.item strongSelf.updateIsPlaying() - - if result.item == nil { - for target in strongSelf.targets.copyItems() { - if let target = target.value { - target.updateDisplayPlaceholder(displayPlaceholder: true) - } - } - } } }) } diff --git a/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift index 6135f1aab9..ad7920f3ef 100644 --- a/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift @@ -23,6 +23,7 @@ import ContextUI import GalleryUI import AttachmentTextInputPanelNode import TelegramPresentationData +import TelegramNotices private let staticEmojiMapping: [(EmojiPagerContentComponent.StaticEmojiSegment, [String])] = { guard let path = getAppBundle().path(forResource: "emoji1016", ofType: "txt") else { @@ -45,17 +46,37 @@ private let staticEmojiMapping: [(EmojiPagerContentComponent.StaticEmojiSegment, return result }() +final class EntityKeyboardGifContent: Equatable { + let hasRecentGifs: Bool + let component: GifPagerContentComponent + + init(hasRecentGifs: Bool, component: GifPagerContentComponent) { + self.hasRecentGifs = hasRecentGifs + self.component = component + } + + static func ==(lhs: EntityKeyboardGifContent, rhs: EntityKeyboardGifContent) -> Bool { + if lhs.hasRecentGifs != rhs.hasRecentGifs { + return false + } + if lhs.component != rhs.component { + return false + } + return true + } +} + final class ChatEntityKeyboardInputNode: ChatInputNode { struct InputData: Equatable { var emoji: EmojiPagerContentComponent var stickers: EmojiPagerContentComponent? - var gifs: GifPagerContentComponent? + var gifs: EntityKeyboardGifContent? var availableGifSearchEmojies: [EntityKeyboardComponent.GifSearchEmoji] init( emoji: EmojiPagerContentComponent, stickers: EmojiPagerContentComponent?, - gifs: GifPagerContentComponent?, + gifs: EntityKeyboardGifContent?, availableGifSearchEmojies: [EntityKeyboardComponent.GifSearchEmoji] ) { self.emoji = emoji @@ -88,8 +109,10 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { var supergroupId: AnyHashable var id: AnyHashable var title: String + var subtitle: String? var isPremiumLocked: Bool var isFeatured: Bool + var isExpandable: Bool var items: [EmojiPagerContentComponent.Item] } var itemGroups: [ItemGroup] = [] @@ -134,7 +157,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { itemGroupIndexById[groupId] = itemGroups.count //TODO:localize - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Recently Used", isPremiumLocked: false, isFeatured: false, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Recently Used", subtitle: nil, isPremiumLocked: false, isFeatured: false, isExpandable: false, items: [resultItem])) } } } @@ -153,7 +176,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { itemGroupIndexById[groupId] = itemGroups.count //TODO:localize - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Emoji", isPremiumLocked: false, isFeatured: false, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Emoji", subtitle: nil, isPremiumLocked: false, isFeatured: false, isExpandable: false, items: [resultItem])) } } } @@ -191,7 +214,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { break inner } } - itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: title, isPremiumLocked: isPremiumLocked, isFeatured: false, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: title, subtitle: nil, isPremiumLocked: isPremiumLocked, isFeatured: false, isExpandable: false, items: [resultItem])) } } @@ -217,7 +240,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: featuredEmojiPack.info.title, isPremiumLocked: isPremiumLocked, isFeatured: true, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: featuredEmojiPack.info.title, subtitle: nil, isPremiumLocked: isPremiumLocked, isFeatured: true, isExpandable: true, items: [resultItem])) } } } @@ -234,7 +257,20 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { hasClear = true } - return EmojiPagerContentComponent.ItemGroup(supergroupId: group.supergroupId, groupId: group.id, title: group.title, isFeatured: group.isFeatured, isPremiumLocked: group.isPremiumLocked, hasClear: hasClear, displayPremiumBadges: false, items: group.items) + return EmojiPagerContentComponent.ItemGroup( + supergroupId: group.supergroupId, + groupId: group.id, + title: group.title, + subtitle: group.subtitle, + actionButtonTitle: nil, + isFeatured: group.isFeatured, + isPremiumLocked: group.isPremiumLocked, + isEmbedded: false, + hasClear: hasClear, + isExpandable: group.isExpandable, + displayPremiumBadges: false, + items: group.items + ) }, itemLayoutType: .compact ) @@ -256,7 +292,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { |> distinctUntilChanged let emojiInputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak interfaceInteraction, weak controllerInteraction] item, _, _, _ in + performItemAction: { [weak interfaceInteraction, weak controllerInteraction] _, item, _, _, _ in let _ = (hasPremium |> take(1) |> deliverOnMainQueue).start(next: { hasPremium in guard let controllerInteraction = controllerInteraction, let interfaceInteraction = interfaceInteraction else { return @@ -397,12 +433,44 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { chatPeerId: chatPeerId ) let stickerInputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak controllerInteraction, weak interfaceInteraction] item, view, rect, layer in + performItemAction: { [weak controllerInteraction, weak interfaceInteraction] groupId, item, view, rect, layer in let _ = (hasPremium |> take(1) |> deliverOnMainQueue).start(next: { hasPremium in guard let controllerInteraction = controllerInteraction, let interfaceInteraction = interfaceInteraction else { return } - if let file = item.file { + guard let file = item.file else { + return + } + + if groupId == AnyHashable("featuredTop") { + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak controllerInteraction] views in + guard let controllerInteraction = controllerInteraction else { + return + } + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { + controllerInteraction.navigationController()?.pushViewController(FeaturedStickersScreen( + context: context, + highlightedPackId: featuredStickerPack.info.id, + sendSticker: { [weak controllerInteraction] fileReference, sourceNode, sourceRect in + guard let controllerInteraction = controllerInteraction else { + return false + } + return controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil) + } + )) + + break + } + } + }) + } else { if file.isPremiumSticker && !hasPremium { let controller = PremiumIntroScreen(context: context, source: .stickers) controllerInteraction.navigationController()?.pushViewController(controller) @@ -427,7 +495,52 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { controller.navigationPresentation = .modal controllerInteraction.navigationController()?.pushViewController(controller) }, - addGroupAction: { _, _ in + addGroupAction: { groupId, isPremiumLocked in + guard let controllerInteraction = controllerInteraction, let collectionId = groupId.base as? ItemCollectionId else { + return + } + + if isPremiumLocked { + let controller = PremiumIntroScreen(context: context, source: .stickers) + controllerInteraction.navigationController()?.pushViewController(controller) + + return + } + + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + if featuredStickerPack.info.id == collectionId { + //let _ = context.engine.stickers.addStickerPackInteractively(info: featuredEmojiPack.info, items: featuredEmojiPack.topItems).start() + + let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: featuredStickerPack.info.id.id, accessHash: featuredStickerPack.info.accessHash), forceActualized: false) + |> mapToSignal { result -> Signal in + switch result { + case let .result(info, items, installed): + if installed { + return .complete() + } else { + return context.engine.stickers.addStickerPackInteractively(info: info, items: items) + } + case .fetching: + break + case .none: + break + } + return .complete() + } + |> deliverOnMainQueue).start(completed: { + }) + + break + } + } + }) }, clearGroup: { [weak controllerInteraction] groupId in guard let controllerInteraction = controllerInteraction else { @@ -447,6 +560,20 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { }) ])]) controllerInteraction.presentController(actionSheet, nil) + } else if groupId == AnyHashable("featuredTop") { + let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) + let _ = (context.account.postbox.combinedView(keys: [viewKey]) + |> take(1) + |> deliverOnMainQueue).start(next: { views in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return + } + var stickerPackIds: [Int64] = [] + for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { + stickerPackIds.append(featuredStickerPack.info.id.id) + } + let _ = ApplicationSpecificNotice.setDismissedTrendingStickerPacks(accountManager: context.sharedContext.accountManager, values: stickerPackIds).start() + }) } }, pushController: { [weak controllerInteraction] controller in @@ -494,16 +621,22 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { let stickerNamespaces: [ItemCollectionId.Namespace] = [Namespaces.ItemCollection.CloudStickerPacks] let stickerOrderedItemListCollectionIds: [Int32] = [Namespaces.OrderedItemList.CloudSavedStickers, Namespaces.OrderedItemList.CloudRecentStickers, Namespaces.OrderedItemList.PremiumStickers, Namespaces.OrderedItemList.CloudPremiumStickers] + let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings + let stickerItems: Signal = combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: stickerOrderedItemListCollectionIds, namespaces: stickerNamespaces, aroundIndex: nil, count: 10000000), hasPremium, - context.account.viewTracker.featuredStickerPacks() + context.account.viewTracker.featuredStickerPacks(), + context.engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: Namespaces.CachedItemCollection.featuredStickersConfiguration, id: ValueBoxKey(length: 0))), + ApplicationSpecificNotice.dismissedTrendingStickerPacks(accountManager: context.sharedContext.accountManager) ) - |> map { view, hasPremium, featuredStickerPacks -> EmojiPagerContentComponent in + |> map { view, hasPremium, featuredStickerPacks, featuredStickersConfiguration, dismissedTrendingStickerPacks -> EmojiPagerContentComponent in struct ItemGroup { var supergroupId: AnyHashable var id: AnyHashable var title: String + var subtitle: String? + var actionButtonTitle: String? var isPremiumLocked: Bool var isFeatured: Bool var displayPremiumBadges: Bool @@ -528,6 +661,50 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } } + var installedCollectionIds = Set() + for (id, _, _) in view.collectionInfos { + installedCollectionIds.insert(id) + } + + let dismissedTrendingStickerPacksSet = Set(dismissedTrendingStickerPacks ?? []) + let featuredStickerPacksSet = Set(featuredStickerPacks.map(\.info.id.id)) + + if dismissedTrendingStickerPacksSet != featuredStickerPacksSet { + let featuredStickersConfiguration = featuredStickersConfiguration?.get(FeaturedStickersConfiguration.self) + for featuredStickerPack in featuredStickerPacks { + if installedCollectionIds.contains(featuredStickerPack.info.id) { + continue + } + + guard let item = featuredStickerPack.topItems.first else { + continue + } + + let resultItem = EmojiPagerContentComponent.Item( + file: item.file, + staticEmoji: nil, + subgroupId: nil + ) + + let supergroupId = "featuredTop" + let groupId: AnyHashable = supergroupId + let isPremiumLocked: Bool = item.file.isPremiumSticker && !hasPremium + if isPremiumLocked && isPremiumDisabled { + continue + } + if let groupIndex = itemGroupIndexById[groupId] { + itemGroups[groupIndex].items.append(resultItem) + } else { + itemGroupIndexById[groupId] = itemGroups.count + + let trendingIsPremium = featuredStickersConfiguration?.isPremium ?? false + let title = trendingIsPremium ? strings.Stickers_TrendingPremiumStickers : strings.StickerPacksSettings_FeaturedPacks + + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: title, subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) + } + } + } + if let savedStickers = savedStickers { for item in savedStickers.items { guard let item = item.contents.get(SavedStickerItem.self) else { @@ -549,13 +726,12 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { itemGroupIndexById[groupId] = itemGroups.count //TODO:localize - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Saved", isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Saved", subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) } } } if let recentStickers = recentStickers { - var count = 0 for item in recentStickers.items { guard let item = item.contents.get(RecentMediaItem.self) else { continue @@ -576,12 +752,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { itemGroupIndexById[groupId] = itemGroups.count //TODO:localize - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Recently Used", isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) - } - - count += 1 - if count >= 5 { - break + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Recently Used", subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) } } } @@ -626,16 +797,11 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { itemGroupIndexById[groupId] = itemGroups.count //TODO:localize - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Premium", isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: "Premium", subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, items: [resultItem])) } } } - var installedCollectionIds = Set() - for (id, _, _) in view.collectionInfos { - installedCollectionIds.insert(id) - } - for entry in view.entries { guard let item = entry.item as? StickerPackItem else { continue @@ -658,7 +824,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { break inner } } - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: title, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: true, items: [resultItem])) + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: title, subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: true, items: [resultItem])) } } @@ -684,7 +850,10 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { itemGroups[groupIndex].items.append(resultItem) } else { itemGroupIndexById[groupId] = itemGroups.count - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: featuredStickerPack.info.title, isPremiumLocked: isPremiumLocked, isFeatured: true, displayPremiumBadges: false, items: [resultItem])) + + let subtitle: String = strings.StickerPack_StickerCount(Int32(featuredStickerPack.info.count)) + + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: featuredStickerPack.info.title, subtitle: subtitle, actionButtonTitle: strings.Stickers_Install, isPremiumLocked: isPremiumLocked, isFeatured: true, displayPremiumBadges: false, items: [resultItem])) } } } @@ -697,11 +866,28 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { inputInteraction: stickerInputInteraction, itemGroups: itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in var hasClear = false + var isEmbedded = false if group.id == AnyHashable("recent") { hasClear = true + } else if group.id == AnyHashable("featuredTop") { + hasClear = true + isEmbedded = true } - return EmojiPagerContentComponent.ItemGroup(supergroupId: group.supergroupId, groupId: group.id, title: group.title, isFeatured: group.isFeatured, isPremiumLocked: group.isPremiumLocked, hasClear: hasClear, displayPremiumBadges: group.displayPremiumBadges, items: group.items) + return EmojiPagerContentComponent.ItemGroup( + supergroupId: group.supergroupId, + groupId: group.id, + title: group.title, + subtitle: group.subtitle, + actionButtonTitle: group.actionButtonTitle, + isFeatured: group.isFeatured, + isPremiumLocked: group.isPremiumLocked, + isEmbedded: isEmbedded, + hasClear: hasClear, + isExpandable: false, + displayPremiumBadges: group.displayPremiumBadges, + items: group.items + ) }, itemLayoutType: .detailed ) @@ -752,16 +938,18 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { ) // We are going to subscribe to the actual data when the view is loaded - let gifItems: Signal = .single(GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: .recent, - items: [], - isLoading: false, - loadMoreToken: nil + let gifItems: Signal = .single(EntityKeyboardGifContent( + hasRecentGifs: true, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: .recent, + items: [], + isLoading: false, + loadMoreToken: nil + ) )) - let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings return combineLatest(queue: .mainQueue(), emojiItems, stickerItems, @@ -822,6 +1010,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { private let defaultToEmojiTab: Bool private var currentInputData: InputData private var inputDataDisposable: Disposable? + private var hasRecentGifsDisposable: Disposable? private let controllerInteraction: ChatControllerInteraction? @@ -839,10 +1028,11 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { var switchToTextInput: (() -> Void)? private var currentState: (width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, interfaceState: ChatPresentationInterfaceState, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool)? + private var scheduledInnerTransition: Transition? - private var gifMode: GifPagerContentComponent.Subject = .recent { + private var gifMode: GifPagerContentComponent.Subject? { didSet { - if self.gifMode != oldValue { + if let gifMode = self.gifMode, gifMode != oldValue { self.reloadGifContext() } } @@ -858,17 +1048,17 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } private final class GifContext { - private var componentValue: GifPagerContentComponent? { + private var componentValue: EntityKeyboardGifContent? { didSet { if let componentValue = self.componentValue { self.componentResult.set(.single(componentValue)) } } } - private let componentPromise = Promise() + private let componentPromise = Promise() - private let componentResult = Promise() - var component: Signal { + private let componentResult = Promise() + var component: Signal { return self.componentResult.get() } private var componentDisposable: Disposable? @@ -884,29 +1074,37 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { self.subject = subject self.gifInputInteraction = gifInputInteraction - let gifItems: Signal + let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) + |> map { savedGifs -> Bool in + return !savedGifs.isEmpty + } + + let gifItems: Signal switch subject { case .recent: gifItems = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) - |> map { savedGifs -> GifPagerContentComponent in + |> map { savedGifs -> EntityKeyboardGifContent in var items: [GifPagerContentComponent.Item] = [] for gifItem in savedGifs { items.append(GifPagerContentComponent.Item( file: gifItem.contents.get(RecentMediaItem.self)!.media )) } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items, - isLoading: false, - loadMoreToken: nil + return EntityKeyboardGifContent( + hasRecentGifs: true, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: false, + loadMoreToken: nil + ) ) } case .trending: - gifItems = trendingGifs - |> map { trendingGifs -> GifPagerContentComponent in + gifItems = combineLatest(hasRecentGifs, trendingGifs) + |> map { hasRecentGifs, trendingGifs -> EntityKeyboardGifContent in var items: [GifPagerContentComponent.Item] = [] var isLoading = false @@ -920,18 +1118,21 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { isLoading = true } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items, - isLoading: isLoading, - loadMoreToken: nil + return EntityKeyboardGifContent( + hasRecentGifs: hasRecentGifs, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: nil + ) ) } case let .emojiSearch(query): - gifItems = paneGifSearchForQuery(context: context, query: query, offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil) - |> map { result -> GifPagerContentComponent in + gifItems = combineLatest(hasRecentGifs, paneGifSearchForQuery(context: context, query: query, offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil)) + |> map { hasRecentGifs, result -> EntityKeyboardGifContent in var items: [GifPagerContentComponent.Item] = [] var loadMoreToken: String? @@ -947,13 +1148,16 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { isLoading = true } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items, - isLoading: isLoading, - loadMoreToken: loadMoreToken + return EntityKeyboardGifContent( + hasRecentGifs: hasRecentGifs, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: loadMoreToken + ) ) } } @@ -988,12 +1192,17 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { switch self.subject { case let .emojiSearch(query): - let gifItems: Signal - gifItems = paneGifSearchForQuery(context: context, query: query, offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil) - |> map { result -> GifPagerContentComponent in + let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) + |> map { savedGifs -> Bool in + return !savedGifs.isEmpty + } + + let gifItems: Signal + gifItems = combineLatest(hasRecentGifs, paneGifSearchForQuery(context: context, query: query, offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil)) + |> map { hasRecentGifs, result -> EntityKeyboardGifContent in var items: [GifPagerContentComponent.Item] = [] var existingIds = Set() - for item in componentValue.items { + for item in componentValue.component.items { items.append(item) existingIds.insert(item.file.fileId) } @@ -1015,13 +1224,16 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { isLoading = true } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items, - isLoading: isLoading, - loadMoreToken: loadMoreToken + return EntityKeyboardGifContent( + hasRecentGifs: hasRecentGifs, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: loadMoreToken + ) ) } @@ -1038,7 +1250,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } } } - private let gifComponent = Promise() + private let gifComponent = Promise() private var gifInputInteraction: GifPagerContentComponent.InputInteraction? init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, controllerInteraction: ChatControllerInteraction?) { @@ -1059,6 +1271,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { self.externalTopPanelContainerImpl = PagerExternalTopPanelContainer() + self.inputDataDisposable = (combineLatest(queue: .mainQueue(), updatedInputData, self.gifComponent.get() @@ -1070,8 +1283,12 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { var inputData = inputData inputData.gifs = gifs + var transition: Transition = .immediate + if strongSelf.currentInputData.emoji != inputData.emoji { + transition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(EmojiPagerContentComponent.ContentAnimation(type: .generic)) + } strongSelf.currentInputData = inputData - strongSelf.performLayout() + strongSelf.performLayout(transition: transition) }) self.inputNodeInteraction = ChatMediaInputNodeInteraction( @@ -1137,16 +1354,35 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } } - self.reloadGifContext() + let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) + |> map { savedGifs -> Bool in + return !savedGifs.isEmpty + } + + self.hasRecentGifsDisposable = (hasRecentGifs + |> deliverOnMainQueue).start(next: { [weak self] hasRecentGifs in + guard let strongSelf = self else { + return + } + + if let gifMode = strongSelf.gifMode { + if !hasRecentGifs, case .recent = gifMode { + strongSelf.gifMode = .trending + } + } else { + strongSelf.gifMode = hasRecentGifs ? .recent : .trending + } + }) } deinit { self.inputDataDisposable?.dispose() + self.hasRecentGifsDisposable?.dispose() } private func reloadGifContext() { - if let gifInputInteraction = self.gifInputInteraction { - self.gifContext = GifContext(context: self.context, subject: self.gifMode, gifInputInteraction: gifInputInteraction, trendingGifs: self.trendingGifsPromise.get()) + if let gifInputInteraction = self.gifInputInteraction, let gifMode = self.gifMode { + self.gifContext = GifContext(context: self.context, subject: gifMode, gifInputInteraction: gifInputInteraction, trendingGifs: self.trendingGifsPromise.get()) } } @@ -1154,16 +1390,25 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { self.isMarkInputCollapsed = true } - private func performLayout() { + private func performLayout(transition: Transition) { guard let (width, leftInset, rightInset, bottomInset, standardInputHeight, inputHeight, maximumHeight, inputPanelHeight, interfaceState, deviceMetrics, isVisible, isExpanded) = self.currentState else { return } + self.scheduledInnerTransition = transition let _ = self.updateLayout(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, standardInputHeight: standardInputHeight, inputHeight: inputHeight, maximumHeight: maximumHeight, inputPanelHeight: inputPanelHeight, transition: .immediate, interfaceState: interfaceState, deviceMetrics: deviceMetrics, isVisible: isVisible, isExpanded: isExpanded) } override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool) -> (CGFloat, CGFloat) { self.currentState = (width, leftInset, rightInset, bottomInset, standardInputHeight, inputHeight, maximumHeight, inputPanelHeight, interfaceState, deviceMetrics, isVisible, isExpanded) + let innerTransition: Transition + if let scheduledInnerTransition = self.scheduledInnerTransition { + self.scheduledInnerTransition = nil + innerTransition = scheduledInnerTransition + } else { + innerTransition = Transition(transition) + } + let wasMarkedInputCollapsed = self.isMarkInputCollapsed self.isMarkInputCollapsed = false @@ -1179,14 +1424,14 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { let inputNodeInteraction = self.inputNodeInteraction! let trendingGifsPromise = self.trendingGifsPromise - var mappedTransition = Transition(transition) + var mappedTransition = innerTransition if wasMarkedInputCollapsed || !isExpanded { mappedTransition = mappedTransition.withUserData(EntityKeyboardComponent.MarkInputCollapsed()) } var stickerContent: EmojiPagerContentComponent? = self.currentInputData.stickers - var gifContent: GifPagerContentComponent? = self.currentInputData.gifs + var gifContent: EntityKeyboardGifContent? = self.currentInputData.gifs var stickersEnabled = true if let peer = interfaceState.renderedPeer?.peer as? TelegramChannel { @@ -1204,12 +1449,6 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { gifContent = nil } - if let gifContentValue = gifContent { - if gifContentValue.items.isEmpty { - gifContent = nil - } - } - let entityKeyboardSize = self.entityKeyboardView.update( transition: mappedTransition, component: AnyComponent(EntityKeyboardComponent( @@ -1217,7 +1456,8 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { bottomInset: bottomInset, emojiContent: self.currentInputData.emoji, stickerContent: stickerContent, - gifContent: gifContent, + gifContent: gifContent?.component, + hasRecentGifs: gifContent?.hasRecentGifs ?? false, availableGifSearchEmojies: self.currentInputData.availableGifSearchEmojies, defaultToEmojiTab: self.defaultToEmojiTab, externalTopPanelContainer: self.externalTopPanelContainerImpl, @@ -1483,7 +1723,7 @@ final class EntityInputView: UIView, AttachmentTextInputPanelInputView, UIInputV self.clipsToBounds = true let inputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak self] item, _, _, _ in + performItemAction: { [weak self] _, item, _, _, _ in guard let strongSelf = self else { return }