Various improvements

This commit is contained in:
Ilya Laktyushin
2023-07-03 11:52:02 +02:00
parent f87c2ec00f
commit 5b51f35b36
25 changed files with 1122 additions and 287 deletions

View File

@@ -30,6 +30,8 @@ swift_library(
"//submodules/TelegramUI/Components/Stories/PeerListItemComponent",
"//submodules/TelegramUI/Components/MoreHeaderButton",
"//submodules/TelegramUI/Components/EmojiSuggestionsComponent",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/StickerPeekUI",
],
visibility = [
"//visibility:public",

View File

@@ -8,30 +8,18 @@ import AccountContext
import TelegramPresentationData
import PeerListItemComponent
extension ChatPresentationInputQueryResult {
var count: Int {
switch self {
case let .stickers(stickers):
return stickers.count
case let .hashtags(hashtags):
return hashtags.count
case let .mentions(peers):
return peers.count
case let .commands(commands):
return commands.count
default:
return 0
}
}
}
final class ContextResultPanelComponent: Component {
final class ExternalState {
fileprivate(set) var minimizedHeight: CGFloat = 0.0
fileprivate(set) var effectiveHeight: CGFloat = 0.0
init() {
enum Results: Equatable {
case mentions([EnginePeer])
case hashtags([String])
var count: Int {
switch self {
case let .hashtags(hashtags):
return hashtags.count
case let .mentions(peers):
return peers.count
}
}
}
@@ -40,22 +28,19 @@ final class ContextResultPanelComponent: Component {
case hashtag(String)
}
let externalState: ExternalState
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let results: ChatPresentationInputQueryResult
let results: Results
let action: (ResultAction) -> Void
init(
externalState: ExternalState,
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
results: ChatPresentationInputQueryResult,
results: Results,
action: @escaping (ResultAction) -> Void
) {
self.externalState = externalState
self.context = context
self.theme = theme
self.strings = strings
@@ -64,9 +49,6 @@ final class ContextResultPanelComponent: Component {
}
static func ==(lhs: ContextResultPanelComponent, rhs: ContextResultPanelComponent) -> Bool {
if lhs.externalState !== rhs.externalState {
return false
}
if lhs.context !== rhs.context {
return false
}
@@ -87,27 +69,27 @@ final class ContextResultPanelComponent: Component {
var bottomInset: CGFloat
var topInset: CGFloat
var sideInset: CGFloat
var itemHeight: CGFloat
var itemSize: CGSize
var itemCount: Int
var contentSize: CGSize
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemHeight: CGFloat, itemCount: Int) {
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemSize: CGSize, itemCount: Int) {
self.containerSize = containerSize
self.bottomInset = bottomInset
self.topInset = topInset
self.sideInset = sideInset
self.itemHeight = itemHeight
self.itemSize = itemSize
self.itemCount = itemCount
self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemHeight + bottomInset)
self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemSize.height + bottomInset)
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight)))
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.height)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight)))
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.height)))
let minVisibleIndex = minVisibleRow
let maxVisibleIndex = maxVisibleRow
@@ -120,7 +102,7 @@ final class ContextResultPanelComponent: Component {
}
func itemFrame(for index: Int) -> CGRect {
return CGRect(origin: CGPoint(x: 0.0, y: self.topInset + CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerSize.width, height: self.itemHeight))
return CGRect(origin: CGPoint(x: 0.0, y: self.topInset + CGFloat(index) * self.itemSize.height), size: CGSize(width: self.containerSize.width, height: self.itemSize.height))
}
}
@@ -159,7 +141,7 @@ final class ContextResultPanelComponent: Component {
self.scrollView = ScrollView()
self.scrollView.canCancelContentTouches = true
self.scrollView.delaysContentTouches = false
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.scrollView.indicatorStyle = .white
@@ -329,7 +311,7 @@ final class ContextResultPanelComponent: Component {
bottomInset: 0.0,
topInset: 0.0,
sideInset: sideInset,
itemHeight: measureItemSize.height,
itemSize: measureItemSize,
itemCount: component.results.count
)
self.itemLayout = itemLayout
@@ -357,11 +339,6 @@ final class ContextResultPanelComponent: Component {
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
// component.externalState.minimizedHeight = minimizedHeight
// let effectiveHeight: CGFloat = minimizedHeight * dismissFraction + (1.0 - dismissFraction) * (60.0 + component.safeInsets.bottom + 1.0)
// component.externalState.effectiveHeight = min(minimizedHeight, max(0.0, effectiveHeight))
return availableSize
}

View File

@@ -4,6 +4,7 @@ import TelegramCore
import TextFieldComponent
import ChatContextQuery
import AccountContext
import TelegramUIPreferences
func textInputStateContextQueryRangeAndType(inputState: TextFieldComponent.InputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] {
return textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange)
@@ -40,7 +41,7 @@ func inputContextQueries(_ inputState: TextFieldComponent.InputState) -> [ChatPr
func contextQueryResultState(context: AccountContext, inputState: TextFieldComponent.InputState, currentQueryStates: inout [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)]) -> [ChatPresentationInputQueryKind: ChatContextQueryUpdate] {
let inputQueries = inputContextQueries(inputState).filter({ query in
switch query {
case .contextRequest, .command, .emoji:
case .contextRequest, .command:
return false
default:
return true
@@ -75,6 +76,57 @@ func contextQueryResultState(context: AccountContext, inputState: TextFieldCompo
private func updatedContextQueryResultStateForQuery(context: AccountContext, inputQuery: ChatPresentationInputQuery, previousQuery: ChatPresentationInputQuery?) -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> {
switch inputQuery {
case let .emoji(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .emoji:
break
default:
signal = .single({ _ in return .stickers([]) })
}
} else {
signal = .single({ _ in return .stickers([]) })
}
let stickerConfiguration = context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration])
|> map { preferencesView -> StickersSearchConfiguration in
let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue
return StickersSearchConfiguration.with(appConfiguration: appConfiguration)
}
let stickerSettings = context.sharedContext.accountManager.transaction { transaction -> StickerSettings in
let stickerSettings: StickerSettings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.stickerSettings)?.get(StickerSettings.self) ?? .defaultSettings
return stickerSettings
}
let stickers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = combineLatest(stickerConfiguration, stickerSettings)
|> castError(ChatContextQueryError.self)
|> mapToSignal { stickerConfiguration, stickerSettings -> Signal<[FoundStickerItem], ChatContextQueryError> in
let scope: SearchStickersScope
switch stickerSettings.emojiStickerSuggestionMode {
case .none:
scope = []
case .all:
if stickerConfiguration.disableLocalSuggestions {
scope = [.remote]
} else {
scope = [.installed, .remote]
}
case .installed:
scope = [.installed]
}
return context.engine.stickers.searchStickers(query: [query.basicEmoji.0], scope: scope)
|> map { items -> [FoundStickerItem] in
return items.items
}
|> castError(ChatContextQueryError.self)
}
|> map { stickers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in
return .stickers(stickers)
}
}
return signal |> then(stickers)
case let .hashtag(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {

View File

@@ -52,7 +52,9 @@ public final class MessageInputPanelComponent: Component {
public let nextInputMode: (Bool) -> InputMode?
public let areVoiceMessagesAvailable: Bool
public let presentController: (ViewController) -> Void
public let presentInGlobalOverlay: (ViewController) -> Void
public let sendMessageAction: () -> Void
public let sendStickerAction: (TelegramMediaFile) -> Void
public let setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)?
public let lockMediaRecording: (() -> Void)?
public let stopAndPreviewMediaRecording: (() -> Void)?
@@ -73,6 +75,7 @@ public final class MessageInputPanelComponent: Component {
public let displayGradient: Bool
public let bottomInset: CGFloat
public let hideKeyboard: Bool
public let forceIsEditing: Bool
public let disabledPlaceholder: String?
public init(
@@ -86,7 +89,9 @@ public final class MessageInputPanelComponent: Component {
nextInputMode: @escaping (Bool) -> InputMode?,
areVoiceMessagesAvailable: Bool,
presentController: @escaping (ViewController) -> Void,
presentInGlobalOverlay: @escaping (ViewController) -> Void,
sendMessageAction: @escaping () -> Void,
sendStickerAction: @escaping (TelegramMediaFile) -> Void,
setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)?,
lockMediaRecording: (() -> Void)?,
stopAndPreviewMediaRecording: (() -> Void)?,
@@ -107,6 +112,7 @@ public final class MessageInputPanelComponent: Component {
displayGradient: Bool,
bottomInset: CGFloat,
hideKeyboard: Bool,
forceIsEditing: Bool,
disabledPlaceholder: String?
) {
self.externalState = externalState
@@ -119,7 +125,9 @@ public final class MessageInputPanelComponent: Component {
self.alwaysDarkWhenHasText = alwaysDarkWhenHasText
self.areVoiceMessagesAvailable = areVoiceMessagesAvailable
self.presentController = presentController
self.presentInGlobalOverlay = presentInGlobalOverlay
self.sendMessageAction = sendMessageAction
self.sendStickerAction = sendStickerAction
self.setMediaRecordingActive = setMediaRecordingActive
self.lockMediaRecording = lockMediaRecording
self.stopAndPreviewMediaRecording = stopAndPreviewMediaRecording
@@ -140,6 +148,7 @@ public final class MessageInputPanelComponent: Component {
self.displayGradient = displayGradient
self.bottomInset = bottomInset
self.hideKeyboard = hideKeyboard
self.forceIsEditing = forceIsEditing
self.disabledPlaceholder = disabledPlaceholder
}
@@ -204,6 +213,9 @@ public final class MessageInputPanelComponent: Component {
if lhs.hideKeyboard != rhs.hideKeyboard {
return false
}
if lhs.forceIsEditing != rhs.forceIsEditing {
return false
}
if lhs.disabledPlaceholder != rhs.disabledPlaceholder {
return false
}
@@ -248,7 +260,8 @@ public final class MessageInputPanelComponent: Component {
private var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:]
private var contextQueryResults: [ChatPresentationInputQueryKind: ChatPresentationInputQueryResult] = [:]
private var contextQueryResultPanel: ComponentView<Empty>?
private var contextQueryResultPanelExternalState: ContextResultPanelComponent.ExternalState?
private var stickersResultPanel: ComponentView<Empty>?
private var viewForOverlayContent: ViewForOverlayContent?
private var currentEmojiSuggestionView: ComponentHostView<Empty>?
@@ -389,6 +402,10 @@ public final class MessageInputPanelComponent: Component {
self.state?.updated()
}
if result == nil, let stickersResultPanel = self.stickersResultPanel?.view, let panelResult = stickersResultPanel.hitTest(self.convert(point, to: stickersResultPanel), with: event), panelResult !== stickersResultPanel {
return panelResult
}
if result == nil, let contextQueryResultPanel = self.contextQueryResultPanel?.view, let panelResult = contextQueryResultPanel.hitTest(self.convert(point, to: contextQueryResultPanel), with: event), panelResult !== contextQueryResultPanel {
return panelResult
}
@@ -471,6 +488,8 @@ public final class MessageInputPanelComponent: Component {
environment: {},
containerSize: availableTextFieldSize
)
let isEditing = self.textFieldExternalState.isEditing || component.forceIsEditing
let placeholderSize = self.placeholder.update(
transition: .immediate,
@@ -493,7 +512,7 @@ public final class MessageInputPanelComponent: Component {
environment: {},
containerSize: availableTextFieldSize
)
if !self.textFieldExternalState.isEditing && component.setMediaRecordingActive == nil {
if !isEditing && component.setMediaRecordingActive == nil {
insets.right = insets.left
}
@@ -518,7 +537,7 @@ public final class MessageInputPanelComponent: Component {
transition.setAlpha(view: self.bottomGradientView, alpha: component.displayGradient ? 1.0 : 0.0)
let placeholderOriginX: CGFloat
if self.textFieldExternalState.isEditing || component.style == .story {
if isEditing || component.style == .story {
placeholderOriginX = 16.0
} else {
placeholderOriginX = floorToScreenPixels((availableSize.width - placeholderSize.width) / 2.0)
@@ -729,14 +748,14 @@ public final class MessageInputPanelComponent: Component {
let inputActionButtonMode: MessageInputActionButtonComponent.Mode
if case .editor = component.style {
inputActionButtonMode = self.textFieldExternalState.isEditing ? .apply : .none
inputActionButtonMode = isEditing ? .apply : .none
} else {
if hasMediaEditing {
inputActionButtonMode = .send
} else {
if self.textFieldExternalState.hasText {
inputActionButtonMode = .send
} else if !self.textFieldExternalState.isEditing && component.forwardAction != nil {
} else if !isEditing && component.forwardAction != nil {
inputActionButtonMode = .forward
} else {
if component.areVoiceMessagesAvailable {
@@ -831,7 +850,7 @@ public final class MessageInputPanelComponent: Component {
self.addSubview(inputActionButtonView)
}
let inputActionButtonOriginX: CGFloat
if component.setMediaRecordingActive != nil || self.textFieldExternalState.isEditing {
if component.setMediaRecordingActive != nil || isEditing {
inputActionButtonOriginX = size.width - insets.right + floorToScreenPixels((insets.right - inputActionButtonSize.width) * 0.5)
} else {
inputActionButtonOriginX = size.width
@@ -845,7 +864,7 @@ public final class MessageInputPanelComponent: Component {
var fieldIconNextX = fieldBackgroundFrame.maxX - 4.0
var inputModeVisible = false
if component.style == .story || self.textFieldExternalState.isEditing {
if component.style == .story || isEditing {
inputModeVisible = true
}
@@ -996,15 +1015,15 @@ public final class MessageInputPanelComponent: Component {
transition.setPosition(view: timeoutButtonView, position: timeoutIconFrame.center)
transition.setBounds(view: timeoutButtonView, bounds: CGRect(origin: CGPoint(), size: timeoutIconFrame.size))
transition.setAlpha(view: timeoutButtonView, alpha: self.textFieldExternalState.isEditing ? 0.0 : 1.0)
transition.setScale(view: timeoutButtonView, scale: self.textFieldExternalState.isEditing ? 0.1 : 1.0)
transition.setAlpha(view: timeoutButtonView, alpha: isEditing ? 0.0 : 1.0)
transition.setScale(view: timeoutButtonView, scale: isEditing ? 0.1 : 1.0)
}
}
var fieldBackgroundIsDark = false
if self.textFieldExternalState.hasText && component.alwaysDarkWhenHasText {
fieldBackgroundIsDark = true
} else if self.textFieldExternalState.isEditing || component.style == .editor {
} else if isEditing || component.style == .editor {
fieldBackgroundIsDark = true
}
self.fieldBackgroundView.updateColor(color: fieldBackgroundIsDark ? UIColor(white: 0.0, alpha: 0.5) : UIColor(white: 1.0, alpha: 0.09), transition: transition.containedViewLayoutTransition)
@@ -1013,7 +1032,7 @@ public final class MessageInputPanelComponent: Component {
vibrancyPlaceholderView.isHidden = placeholder.isHidden
}
component.externalState.isEditing = self.textFieldExternalState.isEditing
component.externalState.isEditing = isEditing
component.externalState.hasText = self.textFieldExternalState.hasText
component.externalState.insertText = { [weak self] text in
if let self, let view = self.textField.view as? TextFieldComponent.View {
@@ -1177,63 +1196,128 @@ public final class MessageInputPanelComponent: Component {
let panelLeftInset: CGFloat = max(insets.left, 7.0)
let panelRightInset: CGFloat = max(insets.right, 41.0)
if let result = self.contextQueryResults[.mention], result.count > 0 && self.textFieldExternalState.isEditing {
var contextResults: ContextResultPanelComponent.Results?
if let result = self.contextQueryResults[.mention], case let .mentions(mentions) = result, !mentions.isEmpty {
contextResults = .mentions(mentions)
}
if let result = self.contextQueryResults[.emoji], case let .stickers(stickers) = result, !stickers.isEmpty {
let availablePanelHeight: CGFloat = 413.0
var animateIn = false
let panel: ComponentView<Empty>
let externalState: ContextResultPanelComponent.ExternalState
var transition = transition
if let current = self.contextQueryResultPanel, let currentState = self.contextQueryResultPanelExternalState {
if let current = self.stickersResultPanel {
panel = current
} else {
panel = ComponentView<Empty>()
self.stickersResultPanel = panel
animateIn = true
transition = .immediate
}
let panelSize = panel.update(
transition: transition,
component: AnyComponent(StickersResultPanelComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
files: stickers.map { $0.file },
action: { [weak self] sticker in
if let self, let textView = self.textField.view as? TextFieldComponent.View {
textView.updateText(NSAttributedString(), selectionRange: 0 ..< 0)
self.component?.sendStickerAction(sticker)
}
},
present: { [weak self] c in
if let self, let component = self.component {
component.presentController(c)
}
},
presentInGlobalOverlay: { [weak self] c in
if let self, let component = self.component {
component.presentInGlobalOverlay(c)
}
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: availablePanelHeight)
)
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: -panelSize.height + 60.0), size: panelSize)
if let panelView = panel.view as? StickersResultPanelComponent.View {
if panelView.superview == nil {
self.insertSubview(panelView, at: 0)
}
transition.setFrame(view: panelView, frame: panelFrame)
if animateIn {
panelView.animateIn(transition: .spring(duration: 0.4))
}
}
} else if let stickersResultPanel = self.stickersResultPanel?.view as? StickersResultPanelComponent.View {
self.stickersResultPanel = nil
stickersResultPanel.animateOut(transition: .spring(duration: 0.4), completion: { [weak stickersResultPanel] in
stickersResultPanel?.removeFromSuperview()
})
}
if let contextResults, isEditing {
let availablePanelHeight: CGFloat = 413.0
var animateIn = false
let panel: ComponentView<Empty>
var transition = transition
if let current = self.contextQueryResultPanel {
panel = current
externalState = currentState
} else {
panel = ComponentView<Empty>()
externalState = ContextResultPanelComponent.ExternalState()
self.contextQueryResultPanel = panel
self.contextQueryResultPanelExternalState = externalState
animateIn = true
transition = .immediate
}
let panelSize = panel.update(
transition: transition,
component: AnyComponent(ContextResultPanelComponent(
externalState: externalState,
context: component.context,
theme: component.theme,
strings: component.strings,
results: result,
results: contextResults,
action: { [weak self] action in
if let self, case let .mention(peer) = action, let textView = self.textField.view as? TextFieldComponent.View {
if let self, let textView = self.textField.view as? TextFieldComponent.View {
let inputState = textView.getInputState()
var mentionQueryRange: NSRange?
inner: for (range, type, _) in textInputStateContextQueryRangeAndType(inputState: inputState) {
if type == [.mention] {
mentionQueryRange = range
break inner
switch action {
case let .mention(peer):
var mentionQueryRange: NSRange?
inner: for (range, type, _) in textInputStateContextQueryRangeAndType(inputState: inputState) {
if type == [.mention] {
mentionQueryRange = range
break inner
}
}
}
if let range = mentionQueryRange {
let inputText = NSMutableAttributedString(attributedString: inputState.inputText)
if let addressName = peer.addressName, !addressName.isEmpty {
let replacementText = addressName + " "
inputText.replaceCharacters(in: range, with: replacementText)
let selectionPosition = range.lowerBound + (replacementText as NSString).length
textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition)
} else if !peer.compactDisplayTitle.isEmpty {
let replacementText = NSMutableAttributedString()
replacementText.append(NSAttributedString(string: peer.compactDisplayTitle, attributes: [ChatTextInputAttributes.textMention: ChatTextInputTextMentionAttribute(peerId: peer.id)]))
replacementText.append(NSAttributedString(string: " "))
let updatedRange = NSRange(location: range.location - 1, length: range.length + 1)
inputText.replaceCharacters(in: updatedRange, with: replacementText)
let selectionPosition = updatedRange.lowerBound + replacementText.length
textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition)
if let range = mentionQueryRange {
let inputText = NSMutableAttributedString(attributedString: inputState.inputText)
if let addressName = peer.addressName, !addressName.isEmpty {
let replacementText = addressName + " "
inputText.replaceCharacters(in: range, with: replacementText)
let selectionPosition = range.lowerBound + (replacementText as NSString).length
textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition)
} else if !peer.compactDisplayTitle.isEmpty {
let replacementText = NSMutableAttributedString()
replacementText.append(NSAttributedString(string: peer.compactDisplayTitle, attributes: [ChatTextInputAttributes.textMention: ChatTextInputTextMentionAttribute(peerId: peer.id)]))
replacementText.append(NSAttributedString(string: " "))
let updatedRange = NSRange(location: range.location - 1, length: range.length + 1)
inputText.replaceCharacters(in: updatedRange, with: replacementText)
let selectionPosition = updatedRange.lowerBound + replacementText.length
textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition)
}
}
case let .hashtag(hashtag):
let _ = hashtag
}
}
}
@@ -1309,7 +1393,6 @@ public final class MessageInputPanelComponent: Component {
//self.installEmojiSuggestionPreviewGesture(hostView: currentEmojiSuggestionView)
}
let globalPosition: CGPoint
if let textView = self.textField.view {

View File

@@ -0,0 +1,528 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramPresentationData
import PeerListItemComponent
import EmojiTextAttachmentView
import TextFormat
import ContextUI
import StickerPeekUI
import UndoUI
final class StickersResultPanelComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let files: [TelegramMediaFile]
let action: (TelegramMediaFile) -> Void
let present: (ViewController) -> Void
let presentInGlobalOverlay: (ViewController) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
files: [TelegramMediaFile],
action: @escaping (TelegramMediaFile) -> Void,
present: @escaping (ViewController) -> Void,
presentInGlobalOverlay: @escaping (ViewController) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.files = files
self.action = action
self.present = present
self.presentInGlobalOverlay = presentInGlobalOverlay
}
static func ==(lhs: StickersResultPanelComponent, rhs: StickersResultPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.files != rhs.files {
return false
}
return true
}
private struct ItemLayout: Equatable {
var containerSize: CGSize
var bottomInset: CGFloat
var topInset: CGFloat
var sideInset: CGFloat
var itemSize: CGSize
var itemSpacing: CGFloat
var itemsPerRow: Int
var itemCount: Int
var contentSize: CGSize
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemSize: CGSize, itemSpacing: CGFloat, itemsPerRow: Int, itemCount: Int) {
self.containerSize = containerSize
self.bottomInset = bottomInset
self.topInset = topInset
self.sideInset = sideInset
self.itemSize = itemSize
self.itemSpacing = itemSpacing
self.itemsPerRow = itemsPerRow
self.itemCount = itemCount
self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemSize.height + bottomInset)
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.height + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.height + self.itemSpacing)))
let minVisibleIndex = minVisibleRow * self.itemsPerRow
let maxVisibleIndex = maxVisibleRow * self.itemsPerRow + self.itemsPerRow
if maxVisibleIndex >= minVisibleIndex {
return minVisibleIndex ..< (maxVisibleIndex + 1)
} else {
return nil
}
}
func itemFrame(for index: Int) -> CGRect {
let rowIndex = Int(floor(CGFloat(index) / CGFloat(self.itemsPerRow)))
let columnIndex = index % self.itemsPerRow
return CGRect(origin: CGPoint(x: self.sideInset + CGFloat(columnIndex) * (self.itemSize.width + self.itemSpacing), y: self.topInset + CGFloat(rowIndex) * (self.itemSize.height + self.itemSpacing)), size: self.itemSize)
}
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private let backgroundView: BlurredBackgroundView
private let containerView: UIView
private let scrollView: UIScrollView
private var itemLayout: ItemLayout?
private var visibleLayers: [EngineMedia.Id: InlineStickerItemLayer] = [:]
private var fadingMaskLayer: FadingMaskLayer?
private var ignoreScrolling = false
private var component: StickersResultPanelComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
self.backgroundView.isUserInteractionEnabled = false
self.containerView = UIView()
self.scrollView = ScrollView()
self.scrollView.canCancelContentTouches = true
self.scrollView.delaysContentTouches = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.scrollView.indicatorStyle = .white
super.init(frame: frame)
self.clipsToBounds = true
self.scrollView.delegate = self
self.addSubview(self.backgroundView)
self.addSubview(self.containerView)
self.containerView.addSubview(self.scrollView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
let peekRecognizer = PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in
if let self, let component = self.component {
let presentationData = component.strings
let convertedPoint = self.scrollView.convert(point, from: self)
guard self.scrollView.bounds.contains(convertedPoint) else {
return nil
}
var selectedLayer: InlineStickerItemLayer?
for (_, layer) in self.visibleLayers {
if layer.frame.contains(convertedPoint) {
selectedLayer = layer
break
}
}
if let selectedLayer, let file = selectedLayer.file {
return component.context.engine.stickers.isStickerSaved(id: file.fileId)
|> deliverOnMainQueue
|> map { [weak self] isStarred -> (UIView, CGRect, PeekControllerContent)? in
if let self, let component = self.component {
let menuItems: [ContextMenuItem] = []
let _ = menuItems
let _ = presentationData
// if strongSelf.peerId != strongSelf.context.account.peerId && strongSelf.peerId?.namespace != Namespaces.Peer.SecretChat {
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_SendSilently, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/SilentIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// }
//
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_ScheduleMessage, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/ScheduleIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// menuItems.append(
// .action(ContextMenuActionItem(text: isStarred ? presentationData.strings.Stickers_RemoveFromFavorites : presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: isStarred ? UIImage(bundleImageName: "Chat/Context Menu/Unfave") : UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// let _ = (component.context.engine.stickers.toggleStickerSaved(file: file, saved: !isStarred)
// |> deliverOnMainQueue).start(next: { [weak self] result in
// guard let self, let component = self.component else {
// return
// }
// switch result {
// case .generic:
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: nil, text: !isStarred ? presentationData.strings.Conversation_StickerAddedToFavorites : presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false })
// component.presentInGlobalOverlay(controller)
// case let .limitExceeded(limit, premiumLimit):
// let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
// let text: String
// if limit == premiumLimit || premiumConfiguration.isPremiumDisabled {
// text = presentationData.strings.Premium_MaxFavedStickersFinalText
// } else {
// text = presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string
// }
//
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in
// if let self, let component = self.component {
// if case .info = action {
// let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .savedStickers)
// // strongSelf.getControllerInteraction?()?.navigationController()?.pushViewController(controller)
// return true
// }
// }
// return false
// })
// component.presentInGlobalOverlay(controller)
// }
// })
// }
// }))
// )
//
// menuItems.append(
// .action(ContextMenuActionItem(text: presentationData.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// loop: for attribute in file.attributes {
// switch attribute {
// case let .Sticker(_, packReference, _):
// if let packReference = packReference {
// let controller = component.context.sharedContext.makeStickerPackScreen(context: component.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: nil, sendSticker: { [weak self] file, sourceNode, sourceRect in
// if let self, let component = self.component {
// component.action(file)
// return true
// } else {
// return false
// }
// })
// component.present(controller)
// }
// break loop
// default:
// break
// }
// }
// }
// }))
// )
return (self, selectedLayer.frame, StickerPreviewPeekContent(context: component.context, theme: component.theme, strings: component.strings, item: .pack(file), menu: menuItems, openPremiumIntro: { [weak self] in
guard let self, let component = self.component else {
return
}
let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .stickers)
component.present(controller)
// let controller = PremiumIntroScreen(context: component.context, source: .stickers)
// controllerInteraction.navigationController()?.pushViewController(controller)
}))
} else {
return nil
}
}
}
}
return nil
}, present: { [weak self] content, sourceView, sourceRect in
if let self, let component = self.component {
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let controller = PeekController(presentationData: presentationData, content: content, sourceView: {
return (sourceView, sourceRect)
})
// controller.visibilityUpdated = { [weak self] visible in
// self?.previewingStickersPromise.set(visible)
// }
component.presentInGlobalOverlay(controller)
// strongSelf.peekController = controller
// strongSelf.getControllerInteraction?()?.presentGlobalOverlayController(controller, nil)
return controller
}
return nil
}, updateContent: { [weak self] content in
if let self {
var item: TelegramMediaFile?
if let content = content as? StickerPreviewPeekContent, case let .pack(contentItem) = content.item {
item = contentItem
}
let _ = item
let _ = self
//strongSelf.updatePreviewingItem(file: item, animated: true)
}
})
self.addGestureRecognizer(peekRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.scrollView)
if self.scrollView.bounds.contains(location) {
var closestFile: (file: TelegramMediaFile, distance: CGFloat)?
for (_, itemLayer) in self.visibleLayers {
guard let file = itemLayer.file else {
continue
}
if itemLayer.frame.contains(location) {
closestFile = (file, 0.0)
}
}
if let (file, _) = closestFile {
self.component?.action(file)
}
}
}
}
func animateIn(transition: Transition) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
Transition.immediate.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset))
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: 0.0))
}
func animateOut(transition: Transition, completion: @escaping () -> Void) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset), completion: { _ in
completion()
})
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: Transition) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0)
var synchronousLoad = false
if let hint = transition.userData(PeerListItemComponent.TransitionHint.self) {
synchronousLoad = hint.synchronousLoad
}
var visibleIds = Set<EngineMedia.Id>()
if let range = itemLayout.visibleItems(for: visibleBounds) {
for index in range.lowerBound ..< range.upperBound {
guard index < component.files.count else {
continue
}
let itemFrame = itemLayout.itemFrame(for: index)
let item = component.files[index]
visibleIds.insert(item.fileId)
let itemLayer: InlineStickerItemLayer
if let current = self.visibleLayers[item.fileId] {
itemLayer = current
itemLayer.dynamicColor = .white
} else {
itemLayer = InlineStickerItemLayer(
context: component.context,
userLocation: .other,
attemptSynchronousLoad: synchronousLoad,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: item.fileId.id, file: item),
file: item,
cache: component.context.animationCache,
renderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff).mixedWith(UIColor(rgb: 0x1c1c1d), alpha: 0.9),
pointSize: itemFrame.size,
dynamicColor: .white
)
self.visibleLayers[item.fileId] = itemLayer
self.scrollView.layer.addSublayer(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.isVisibleForAnimations = true
}
}
var removedIds: [EngineMedia.Id] = []
for (id, itemLayer) in self.visibleLayers {
if !visibleIds.contains(id) {
itemLayer.removeFromSuperlayer()
removedIds.append(id)
}
}
for id in removedIds {
self.visibleLayers.removeValue(forKey: id)
}
let backgroundSize = CGSize(width: self.scrollView.frame.width, height: self.scrollView.frame.height + 20.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: max(0.0, self.scrollView.contentOffset.y * -1.0)), size: backgroundSize))
self.backgroundView.update(size: backgroundSize, cornerRadius: 11.0, transition: transition.containedViewLayoutTransition)
}
func update(component: StickersResultPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
//let itemUpdated = self.component?.results != component.results
self.component = component
self.state = state
let minimizedHeight = min(availableSize.height, 500.0)
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition)
let itemsPerRow = min(8, max(5, Int(availableSize.width / 80)))
let sideInset: CGFloat = 2.0
let itemSpacing: CGFloat = 2.0
let itemSize = floor((availableSize.width - sideInset * 2.0 - itemSpacing * (CGFloat(itemsPerRow) - 1.0)) / CGFloat(itemsPerRow))
let itemLayout = ItemLayout(
containerSize: CGSize(width: availableSize.width, height: minimizedHeight),
bottomInset: 9.0,
topInset: 9.0,
sideInset: sideInset,
itemSize: CGSize(width: itemSize, height: itemSize),
itemSpacing: itemSpacing,
itemsPerRow: itemsPerRow,
itemCount: component.files.count
)
self.itemLayout = itemLayout
let scrollContentSize = itemLayout.contentSize
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: minimizedHeight)))
let visibleTopContentHeight = min(scrollContentSize.height, itemSize * 3.0 + 19.0)
let topInset = availableSize.height - visibleTopContentHeight
let scrollContentInsets = UIEdgeInsets(top: topInset, left: 0.0, bottom: 19.0, right: 0.0)
let scrollIndicatorInsets = UIEdgeInsets(top: topInset + 17.0, left: 0.0, bottom: 19.0, right: 0.0)
if self.scrollView.contentInset != scrollContentInsets {
self.scrollView.contentInset = scrollContentInsets
}
if self.scrollView.scrollIndicatorInsets != scrollIndicatorInsets {
self.scrollView.scrollIndicatorInsets = scrollIndicatorInsets
}
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
let maskLayer: FadingMaskLayer
if let current = self.fadingMaskLayer {
maskLayer = current
} else {
maskLayer = FadingMaskLayer()
self.fadingMaskLayer = maskLayer
}
if self.containerView.layer.mask == nil {
self.containerView.layer.mask = maskLayer
}
maskLayer.frame = CGRect(origin: .zero, size: self.scrollView.frame.size)
self.containerView.frame = CGRect(origin: .zero, size: availableSize)
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class FadingMaskLayer: SimpleLayer {
let gradientLayer = SimpleLayer()
let fillLayer = SimpleLayer()
override func layoutSublayers() {
let gradientHeight: CGFloat = 110.0
if self.gradientLayer.contents == nil {
self.addSublayer(self.gradientLayer)
self.addSublayer(self.fillLayer)
let gradientImage = generateGradientImage(size: CGSize(width: 1.0, height: gradientHeight), colors: [UIColor.white, UIColor.white, UIColor.white.withAlphaComponent(0.0), UIColor.white.withAlphaComponent(0.0)], locations: [0.0, 0.4, 0.9, 1.0], direction: .vertical)
self.gradientLayer.contents = gradientImage?.cgImage
self.gradientLayer.contentsGravity = .resize
self.fillLayer.backgroundColor = UIColor.white.cgColor
}
self.fillLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.width, height: self.bounds.height - gradientHeight))
self.gradientLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: self.bounds.height - gradientHeight), size: CGSize(width: self.bounds.width, height: gradientHeight))
}
}