mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-12-22 22:25:57 +00:00
Story caption improvements
This commit is contained in:
@@ -24,6 +24,9 @@ swift_library(
|
||||
"//submodules/Components/HierarchyTrackingLayer",
|
||||
"//submodules/TelegramUI/Components/AudioWaveformComponent",
|
||||
"//submodules/MediaPlayer:UniversalMediaPlayer",
|
||||
"//submodules/ChatContextQuery",
|
||||
"//submodules/TextFormat",
|
||||
"//submodules/TelegramUI/Components/Stories/PeerListItemComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import ComponentDisplayAdapters
|
||||
import TelegramCore
|
||||
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 ResultAction {
|
||||
case mention(EnginePeer)
|
||||
case hashtag(String)
|
||||
}
|
||||
|
||||
let externalState: ExternalState
|
||||
let context: AccountContext
|
||||
let theme: PresentationTheme
|
||||
let strings: PresentationStrings
|
||||
let results: ChatPresentationInputQueryResult
|
||||
let action: (ResultAction) -> Void
|
||||
|
||||
init(
|
||||
externalState: ExternalState,
|
||||
context: AccountContext,
|
||||
theme: PresentationTheme,
|
||||
strings: PresentationStrings,
|
||||
results: ChatPresentationInputQueryResult,
|
||||
action: @escaping (ResultAction) -> Void
|
||||
) {
|
||||
self.externalState = externalState
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.results = results
|
||||
self.action = action
|
||||
}
|
||||
|
||||
static func ==(lhs: ContextResultPanelComponent, rhs: ContextResultPanelComponent) -> Bool {
|
||||
if lhs.externalState !== rhs.externalState {
|
||||
return false
|
||||
}
|
||||
if lhs.context !== rhs.context {
|
||||
return false
|
||||
}
|
||||
if lhs.theme !== rhs.theme {
|
||||
return false
|
||||
}
|
||||
if lhs.strings !== rhs.strings {
|
||||
return false
|
||||
}
|
||||
if lhs.results != rhs.results {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private struct ItemLayout: Equatable {
|
||||
var containerSize: CGSize
|
||||
var bottomInset: CGFloat
|
||||
var topInset: CGFloat
|
||||
var sideInset: CGFloat
|
||||
var itemHeight: CGFloat
|
||||
var itemCount: Int
|
||||
|
||||
var contentSize: CGSize
|
||||
|
||||
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemHeight: CGFloat, itemCount: Int) {
|
||||
self.containerSize = containerSize
|
||||
self.bottomInset = bottomInset
|
||||
self.topInset = topInset
|
||||
self.sideInset = sideInset
|
||||
self.itemHeight = itemHeight
|
||||
self.itemCount = itemCount
|
||||
|
||||
self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemHeight + 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)))
|
||||
minVisibleRow = max(0, minVisibleRow)
|
||||
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight)))
|
||||
|
||||
let minVisibleIndex = minVisibleRow
|
||||
let maxVisibleIndex = maxVisibleRow
|
||||
|
||||
if maxVisibleIndex >= minVisibleIndex {
|
||||
return minVisibleIndex ..< (maxVisibleIndex + 1)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScrollView: UIScrollView {
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let result = super.hitTest(point, with: event)
|
||||
if result === self {
|
||||
return nil
|
||||
}
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
override func touchesShouldCancel(in view: UIView) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
|
||||
private let backgroundView: BlurredBackgroundView
|
||||
private let scrollView: UIScrollView
|
||||
|
||||
private var itemLayout: ItemLayout?
|
||||
|
||||
private let measureItem = ComponentView<Empty>()
|
||||
|
||||
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
|
||||
|
||||
private var ignoreScrolling = false
|
||||
|
||||
private var component: ContextResultPanelComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
|
||||
|
||||
self.scrollView = ScrollView()
|
||||
self.scrollView.canCancelContentTouches = true
|
||||
self.scrollView.delaysContentTouches = false
|
||||
self.scrollView.showsVerticalScrollIndicator = true
|
||||
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.scrollView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
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 validIds: [AnyHashable] = []
|
||||
if let range = itemLayout.visibleItems(for: visibleBounds), case let .mentions(peers) = component.results {
|
||||
for index in range.lowerBound ..< range.upperBound {
|
||||
guard index < peers.count else {
|
||||
continue
|
||||
}
|
||||
|
||||
let itemFrame = itemLayout.itemFrame(for: index)
|
||||
|
||||
var itemTransition = transition
|
||||
let peer = peers[index]
|
||||
validIds.append(peer.id)
|
||||
|
||||
let visibleItem: ComponentView<Empty>
|
||||
if let current = self.visibleItems[peer.id] {
|
||||
visibleItem = current
|
||||
} else {
|
||||
if !transition.animation.isImmediate {
|
||||
itemTransition = .immediate
|
||||
}
|
||||
visibleItem = ComponentView()
|
||||
self.visibleItems[peer.id] = visibleItem
|
||||
}
|
||||
|
||||
let _ = visibleItem.update(
|
||||
transition: itemTransition,
|
||||
component: AnyComponent(PeerListItemComponent(
|
||||
context: component.context,
|
||||
theme: component.theme,
|
||||
strings: component.strings,
|
||||
style: .compact,
|
||||
sideInset: itemLayout.sideInset,
|
||||
title: peer.displayTitle(strings: component.strings, displayOrder: .firstLast),
|
||||
peer: peer,
|
||||
subtitle: peer.addressName.flatMap { "@\($0)" },
|
||||
subtitleAccessory: .none,
|
||||
selectionState: .none,
|
||||
hasNext: index != peers.count - 1,
|
||||
action: { [weak self] peer in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.action(.mention(peer))
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: itemFrame.size
|
||||
)
|
||||
if let itemView = visibleItem.view {
|
||||
var animateIn = false
|
||||
if itemView.superview == nil {
|
||||
animateIn = true
|
||||
self.scrollView.addSubview(itemView)
|
||||
}
|
||||
itemTransition.setFrame(view: itemView, frame: itemFrame)
|
||||
|
||||
if animateIn, synchronousLoad {
|
||||
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var removeIds: [AnyHashable] = []
|
||||
for (id, visibleItem) in self.visibleItems {
|
||||
if !validIds.contains(id) {
|
||||
removeIds.append(id)
|
||||
if let itemView = visibleItem.view {
|
||||
itemView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in removeIds {
|
||||
self.visibleItems.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: ContextResultPanelComponent, 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)
|
||||
|
||||
let sideInset: CGFloat = 3.0
|
||||
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition)
|
||||
|
||||
let measureItemSize = self.measureItem.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(PeerListItemComponent(
|
||||
context: component.context,
|
||||
theme: component.theme,
|
||||
strings: component.strings,
|
||||
style: .compact,
|
||||
sideInset: sideInset,
|
||||
title: "AAAAAAAAAAAA",
|
||||
peer: nil,
|
||||
subtitle: "BBBBBBB",
|
||||
subtitleAccessory: .none,
|
||||
selectionState: .none,
|
||||
hasNext: true,
|
||||
action: { _ in
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: availableSize.width, height: 1000.0)
|
||||
)
|
||||
|
||||
let itemLayout = ItemLayout(
|
||||
containerSize: CGSize(width: availableSize.width, height: minimizedHeight),
|
||||
bottomInset: 0.0,
|
||||
topInset: 0.0,
|
||||
sideInset: sideInset,
|
||||
itemHeight: measureItemSize.height,
|
||||
itemCount: component.results.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, measureItemSize.height * 3.5 + 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import SwiftSignalKit
|
||||
import TextFieldComponent
|
||||
import ChatContextQuery
|
||||
import AccountContext
|
||||
|
||||
func textInputStateContextQueryRangeAndType(inputState: TextFieldComponent.InputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] {
|
||||
return textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange)
|
||||
}
|
||||
|
||||
func inputContextQueries(_ inputState: TextFieldComponent.InputState) -> [ChatPresentationInputQuery] {
|
||||
let inputString: NSString = inputState.inputText.string as NSString
|
||||
var result: [ChatPresentationInputQuery] = []
|
||||
for (possibleQueryRange, possibleTypes, additionalStringRange) in textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange) {
|
||||
let query = inputString.substring(with: possibleQueryRange)
|
||||
if possibleTypes == [.emoji] {
|
||||
result.append(.emoji(query.basicEmoji.0))
|
||||
} else if possibleTypes == [.hashtag] {
|
||||
result.append(.hashtag(query))
|
||||
} else if possibleTypes == [.mention] {
|
||||
let types: ChatInputQueryMentionTypes = [.members]
|
||||
// if possibleQueryRange.lowerBound == 1 {
|
||||
// types.insert(.contextBots)
|
||||
// }
|
||||
result.append(.mention(query: query, types: types))
|
||||
} else if possibleTypes == [.command] {
|
||||
result.append(.command(query))
|
||||
} else if possibleTypes == [.contextRequest], let additionalStringRange = additionalStringRange {
|
||||
let additionalString = inputString.substring(with: additionalStringRange)
|
||||
result.append(.contextRequest(addressName: query, query: additionalString))
|
||||
}
|
||||
// else if possibleTypes == [.emojiSearch], !query.isEmpty, let inputLanguage = chatPresentationInterfaceState.interfaceState.inputLanguage {
|
||||
// result.append(.emojiSearch(query: query, languageCode: inputLanguage, range: possibleQueryRange))
|
||||
// }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
var updates: [ChatPresentationInputQueryKind: ChatContextQueryUpdate] = [:]
|
||||
|
||||
for query in inputQueries {
|
||||
let previousQuery = currentQueryStates[query.kind]?.0
|
||||
if previousQuery != query {
|
||||
let signal = updatedContextQueryResultStateForQuery(context: context, inputQuery: query, previousQuery: previousQuery)
|
||||
updates[query.kind] = .update(query, signal)
|
||||
}
|
||||
}
|
||||
|
||||
for currentQueryKind in currentQueryStates.keys {
|
||||
var found = false
|
||||
inner: for query in inputQueries {
|
||||
if query.kind == currentQueryKind {
|
||||
found = true
|
||||
break inner
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
updates[currentQueryKind] = .remove
|
||||
}
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
private func updatedContextQueryResultStateForQuery(context: AccountContext, inputQuery: ChatPresentationInputQuery, previousQuery: ChatPresentationInputQuery?) -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> {
|
||||
switch inputQuery {
|
||||
case let .hashtag(query):
|
||||
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
|
||||
if let previousQuery = previousQuery {
|
||||
switch previousQuery {
|
||||
case .hashtag:
|
||||
break
|
||||
default:
|
||||
signal = .single({ _ in return .hashtags([]) })
|
||||
}
|
||||
} else {
|
||||
signal = .single({ _ in return .hashtags([]) })
|
||||
}
|
||||
|
||||
let hashtags: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.messages.recentlyUsedHashtags()
|
||||
|> map { hashtags -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
|
||||
let normalizedQuery = query.lowercased()
|
||||
var result: [String] = []
|
||||
for hashtag in hashtags {
|
||||
if hashtag.lowercased().hasPrefix(normalizedQuery) {
|
||||
result.append(hashtag)
|
||||
}
|
||||
}
|
||||
return { _ in return .hashtags(result) }
|
||||
}
|
||||
|> castError(ChatContextQueryError.self)
|
||||
|
||||
return signal |> then(hashtags)
|
||||
case let .mention(query, _):
|
||||
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
|
||||
if let previousQuery = previousQuery {
|
||||
switch previousQuery {
|
||||
case .mention:
|
||||
break
|
||||
default:
|
||||
signal = .single({ _ in return .mentions([]) })
|
||||
}
|
||||
} else {
|
||||
signal = .single({ _ in return .mentions([]) })
|
||||
}
|
||||
|
||||
let normalizedQuery = query.lowercased()
|
||||
let peers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.contacts.searchLocalPeers(query: normalizedQuery)
|
||||
|> map { peersAndPresences -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
|
||||
let peers = peersAndPresences.filter { peer in
|
||||
if let peer = peer.peer, case .user = peer {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}.compactMap { $0.peer }
|
||||
return { _ in return .mentions(peers) }
|
||||
}
|
||||
|> castError(ChatContextQueryError.self)
|
||||
|
||||
return signal |> then(peers)
|
||||
default:
|
||||
return .complete()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import SwiftSignalKit
|
||||
import AppBundle
|
||||
import TextFieldComponent
|
||||
import BundleIconComponent
|
||||
@@ -9,16 +10,28 @@ import AccountContext
|
||||
import TelegramPresentationData
|
||||
import ChatPresentationInterfaceState
|
||||
import LottieComponent
|
||||
import ChatContextQuery
|
||||
import TextFormat
|
||||
|
||||
public final class MessageInputPanelComponent: Component {
|
||||
public enum Style {
|
||||
case story
|
||||
case editor
|
||||
}
|
||||
|
||||
public enum InputMode: Hashable {
|
||||
case keyboard
|
||||
case stickers
|
||||
case emoji
|
||||
}
|
||||
|
||||
public final class ExternalState {
|
||||
public fileprivate(set) var isEditing: Bool = false
|
||||
public fileprivate(set) var hasText: Bool = false
|
||||
|
||||
public fileprivate(set) var insertText: (NSAttributedString) -> Void = { _ in }
|
||||
public fileprivate(set) var deleteBackward: () -> Void = { }
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
||||
@@ -30,6 +43,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
public let style: Style
|
||||
public let placeholder: String
|
||||
public let alwaysDarkWhenHasText: Bool
|
||||
public let nextInputMode: InputMode?
|
||||
public let areVoiceMessagesAvailable: Bool
|
||||
public let presentController: (ViewController) -> Void
|
||||
public let sendMessageAction: () -> Void
|
||||
@@ -38,6 +52,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
public let stopAndPreviewMediaRecording: (() -> Void)?
|
||||
public let discardMediaRecordingPreview: (() -> Void)?
|
||||
public let attachmentAction: (() -> Void)?
|
||||
public let inputModeAction: (() -> Void)?
|
||||
public let timeoutAction: ((UIView) -> Void)?
|
||||
public let forwardAction: (() -> Void)?
|
||||
public let presentVoiceMessagesUnavailableTooltip: ((UIView) -> Void)?
|
||||
@@ -50,6 +65,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
public let timeoutSelected: Bool
|
||||
public let displayGradient: Bool
|
||||
public let bottomInset: CGFloat
|
||||
public let hideKeyboard: Bool
|
||||
|
||||
public init(
|
||||
externalState: ExternalState,
|
||||
@@ -59,6 +75,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
style: Style,
|
||||
placeholder: String,
|
||||
alwaysDarkWhenHasText: Bool,
|
||||
nextInputMode: InputMode?,
|
||||
areVoiceMessagesAvailable: Bool,
|
||||
presentController: @escaping (ViewController) -> Void,
|
||||
sendMessageAction: @escaping () -> Void,
|
||||
@@ -67,6 +84,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
stopAndPreviewMediaRecording: (() -> Void)?,
|
||||
discardMediaRecordingPreview: (() -> Void)?,
|
||||
attachmentAction: (() -> Void)?,
|
||||
inputModeAction: (() -> Void)?,
|
||||
timeoutAction: ((UIView) -> Void)?,
|
||||
forwardAction: (() -> Void)?,
|
||||
presentVoiceMessagesUnavailableTooltip: ((UIView) -> Void)?,
|
||||
@@ -78,13 +96,15 @@ public final class MessageInputPanelComponent: Component {
|
||||
timeoutValue: String?,
|
||||
timeoutSelected: Bool,
|
||||
displayGradient: Bool,
|
||||
bottomInset: CGFloat
|
||||
bottomInset: CGFloat,
|
||||
hideKeyboard: Bool
|
||||
) {
|
||||
self.externalState = externalState
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.style = style
|
||||
self.nextInputMode = nextInputMode
|
||||
self.placeholder = placeholder
|
||||
self.alwaysDarkWhenHasText = alwaysDarkWhenHasText
|
||||
self.areVoiceMessagesAvailable = areVoiceMessagesAvailable
|
||||
@@ -95,6 +115,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
self.stopAndPreviewMediaRecording = stopAndPreviewMediaRecording
|
||||
self.discardMediaRecordingPreview = discardMediaRecordingPreview
|
||||
self.attachmentAction = attachmentAction
|
||||
self.inputModeAction = inputModeAction
|
||||
self.timeoutAction = timeoutAction
|
||||
self.forwardAction = forwardAction
|
||||
self.presentVoiceMessagesUnavailableTooltip = presentVoiceMessagesUnavailableTooltip
|
||||
@@ -107,6 +128,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
self.timeoutSelected = timeoutSelected
|
||||
self.displayGradient = displayGradient
|
||||
self.bottomInset = bottomInset
|
||||
self.hideKeyboard = hideKeyboard
|
||||
}
|
||||
|
||||
public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool {
|
||||
@@ -125,6 +147,9 @@ public final class MessageInputPanelComponent: Component {
|
||||
if lhs.style != rhs.style {
|
||||
return false
|
||||
}
|
||||
if lhs.nextInputMode != rhs.nextInputMode {
|
||||
return false
|
||||
}
|
||||
if lhs.placeholder != rhs.placeholder {
|
||||
return false
|
||||
}
|
||||
@@ -164,6 +189,9 @@ public final class MessageInputPanelComponent: Component {
|
||||
if (lhs.forwardAction == nil) != (rhs.forwardAction == nil) {
|
||||
return false
|
||||
}
|
||||
if lhs.hideKeyboard != rhs.hideKeyboard {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -199,6 +227,12 @@ public final class MessageInputPanelComponent: Component {
|
||||
private var currentMediaInputIsVoice: Bool = true
|
||||
private var mediaCancelFraction: CGFloat = 0.0
|
||||
|
||||
private var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:]
|
||||
private var contextQueryResults: [ChatPresentationInputQueryKind: ChatPresentationInputQueryResult] = [:]
|
||||
|
||||
private var contextQueryResultPanel: ComponentView<Empty>?
|
||||
private var contextQueryResultPanelExternalState: ContextResultPanelComponent.ExternalState?
|
||||
|
||||
private var component: MessageInputPanelComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
|
||||
@@ -256,9 +290,56 @@ public final class MessageInputPanelComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
public func updateContextQueries() {
|
||||
guard let component = self.component, let textFieldView = self.textField.view as? TextFieldComponent.View else {
|
||||
return
|
||||
}
|
||||
let context = component.context
|
||||
let inputState = textFieldView.getInputState()
|
||||
|
||||
let contextQueryUpdates = contextQueryResultState(context: context, inputState: inputState, currentQueryStates: &self.contextQueryStates)
|
||||
|
||||
for (kind, update) in contextQueryUpdates {
|
||||
switch update {
|
||||
case .remove:
|
||||
if let (_, disposable) = self.contextQueryStates[kind] {
|
||||
disposable.dispose()
|
||||
self.contextQueryStates.removeValue(forKey: kind)
|
||||
self.contextQueryResults[kind] = nil
|
||||
}
|
||||
case let .update(query, signal):
|
||||
let currentQueryAndDisposable = self.contextQueryStates[kind]
|
||||
currentQueryAndDisposable?.1.dispose()
|
||||
|
||||
var inScope = true
|
||||
var inScopeResult: ((ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?)?
|
||||
self.contextQueryStates[kind] = (query, (signal
|
||||
|> deliverOnMainQueue).start(next: { [weak self] result in
|
||||
if let self {
|
||||
if Thread.isMainThread && inScope {
|
||||
inScope = false
|
||||
inScopeResult = result
|
||||
} else {
|
||||
self.contextQueryResults[kind] = result(self.contextQueryResults[kind])
|
||||
self.state?.updated(transition: .immediate)
|
||||
}
|
||||
}
|
||||
}))
|
||||
inScope = false
|
||||
if let inScopeResult = inScopeResult {
|
||||
self.contextQueryResults[kind] = inScopeResult(self.contextQueryResults[kind])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let result = super.hitTest(point, with: event)
|
||||
|
||||
if result == nil, let contextQueryResultPanel = self.contextQueryResultPanel?.view, let panelResult = contextQueryResultPanel.hitTest(self.convert(point, to: contextQueryResultPanel), with: event), panelResult !== contextQueryResultPanel {
|
||||
return panelResult
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -276,6 +357,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
|
||||
let baseFieldHeight: CGFloat = 40.0
|
||||
|
||||
let previousComponent = self.component
|
||||
self.component = component
|
||||
self.state = state
|
||||
|
||||
@@ -317,9 +399,16 @@ public final class MessageInputPanelComponent: Component {
|
||||
let textFieldSize = self.textField.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(TextFieldComponent(
|
||||
context: component.context,
|
||||
strings: component.strings,
|
||||
externalState: self.textFieldExternalState,
|
||||
placeholder: ""
|
||||
fontSize: 17.0,
|
||||
textColor: UIColor(rgb: 0xffffff),
|
||||
insets: UIEdgeInsets(top: 9.0, left: 8.0, bottom: 10.0, right: 48.0),
|
||||
hideKeyboard: component.hideKeyboard,
|
||||
present: { c in
|
||||
component.presentController(c)
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: availableTextFieldSize
|
||||
@@ -644,36 +733,100 @@ public final class MessageInputPanelComponent: Component {
|
||||
}
|
||||
|
||||
var fieldIconNextX = fieldBackgroundFrame.maxX - 4.0
|
||||
if case .story = component.style {
|
||||
let stickerButtonSize = self.stickerButton.update(
|
||||
transition: transition,
|
||||
component: AnyComponent(Button(
|
||||
content: AnyComponent(BundleIconComponent(
|
||||
name: "Chat/Input/Text/AccessoryIconStickers",
|
||||
tintColor: .white
|
||||
)),
|
||||
action: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.component?.attachmentAction?()
|
||||
|
||||
var inputModeVisible = false
|
||||
if component.style == .story || self.textFieldExternalState.isEditing {
|
||||
inputModeVisible = true
|
||||
}
|
||||
|
||||
let animationName: String
|
||||
var animationPlay = false
|
||||
|
||||
if let inputMode = component.nextInputMode {
|
||||
switch inputMode {
|
||||
case .keyboard:
|
||||
if let previousInputMode = previousComponent?.nextInputMode {
|
||||
if case .stickers = previousInputMode {
|
||||
animationName = "input_anim_stickerToKey"
|
||||
animationPlay = true
|
||||
} else if case .emoji = previousInputMode {
|
||||
animationName = "input_anim_smileToKey"
|
||||
animationPlay = true
|
||||
} else {
|
||||
animationName = "input_anim_stickerToKey"
|
||||
}
|
||||
).minSize(CGSize(width: 32.0, height: 32.0))),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 32.0, height: 32.0)
|
||||
)
|
||||
if let stickerButtonView = self.stickerButton.view {
|
||||
if stickerButtonView.superview == nil {
|
||||
self.addSubview(stickerButtonView)
|
||||
} else {
|
||||
animationName = "input_anim_stickerToKey"
|
||||
}
|
||||
let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldBackgroundFrame.minY + floor((fieldBackgroundFrame.height - stickerButtonSize.height) * 0.5)), size: stickerButtonSize)
|
||||
transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center)
|
||||
transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size))
|
||||
|
||||
transition.setAlpha(view: stickerButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0)
|
||||
transition.setScale(view: stickerButtonView, scale: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.1 : 1.0)
|
||||
|
||||
case .stickers:
|
||||
if let previousInputMode = previousComponent?.nextInputMode {
|
||||
if case .keyboard = previousInputMode {
|
||||
animationName = "input_anim_keyToSticker"
|
||||
animationPlay = true
|
||||
} else if case .emoji = previousInputMode {
|
||||
animationName = "input_anim_smileToSticker"
|
||||
animationPlay = true
|
||||
} else {
|
||||
animationName = "input_anim_keyToSticker"
|
||||
}
|
||||
} else {
|
||||
animationName = "input_anim_keyToSticker"
|
||||
}
|
||||
case .emoji:
|
||||
if let previousInputMode = previousComponent?.nextInputMode {
|
||||
if case .keyboard = previousInputMode {
|
||||
animationName = "input_anim_keyToSmile"
|
||||
animationPlay = true
|
||||
} else if case .stickers = previousInputMode {
|
||||
animationName = "input_anim_stickerToSmile"
|
||||
animationPlay = true
|
||||
} else {
|
||||
animationName = "input_anim_keyToSmile"
|
||||
}
|
||||
} else {
|
||||
animationName = "input_anim_keyToSmile"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
animationName = ""
|
||||
}
|
||||
|
||||
let stickerButtonSize = self.stickerButton.update(
|
||||
transition: transition,
|
||||
component: AnyComponent(Button(
|
||||
content: AnyComponent(LottieComponent(
|
||||
content: LottieComponent.AppBundleContent(name: animationName),
|
||||
color: .white
|
||||
)),
|
||||
action: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.component?.inputModeAction?()
|
||||
}
|
||||
).minSize(CGSize(width: 32.0, height: 32.0))),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 32.0, height: 32.0)
|
||||
)
|
||||
if let stickerButtonView = self.stickerButton.view as? Button.View {
|
||||
if stickerButtonView.superview == nil {
|
||||
self.addSubview(stickerButtonView)
|
||||
}
|
||||
let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldFrame.maxY - 4.0 - stickerButtonSize.height), size: stickerButtonSize)
|
||||
transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center)
|
||||
transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size))
|
||||
|
||||
transition.setAlpha(view: stickerButtonView, alpha: (hasMediaRecording || hasMediaEditing || !inputModeVisible) ? 0.0 : 1.0)
|
||||
transition.setScale(view: stickerButtonView, scale: (hasMediaRecording || hasMediaEditing || !inputModeVisible) ? 0.1 : 1.0)
|
||||
|
||||
if inputModeVisible {
|
||||
fieldIconNextX -= stickerButtonSize.width + 2.0
|
||||
|
||||
if let animationView = stickerButtonView.content as? LottieComponent.View {
|
||||
if animationPlay {
|
||||
animationView.playOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,14 +876,13 @@ public final class MessageInputPanelComponent: Component {
|
||||
if timeoutButtonView.superview == nil {
|
||||
self.addSubview(timeoutButtonView)
|
||||
}
|
||||
let timeoutIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - timeoutButtonSize.width, y: fieldFrame.maxY - 4.0 - timeoutButtonSize.height), size: timeoutButtonSize)
|
||||
let originX = fieldBackgroundFrame.maxX - 4.0
|
||||
let timeoutIconFrame = CGRect(origin: CGPoint(x: originX - timeoutButtonSize.width, y: fieldFrame.maxY - 4.0 - timeoutButtonSize.height), size: timeoutButtonSize)
|
||||
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)
|
||||
|
||||
fieldIconNextX -= timeoutButtonSize.width + 2.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +900,16 @@ public final class MessageInputPanelComponent: Component {
|
||||
|
||||
component.externalState.isEditing = self.textFieldExternalState.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 {
|
||||
view.insertText(text)
|
||||
}
|
||||
}
|
||||
component.externalState.deleteBackward = { [weak self] in
|
||||
if let self, let view = self.textField.view as? TextFieldComponent.View {
|
||||
view.deleteBackward()
|
||||
}
|
||||
}
|
||||
|
||||
if hasMediaRecording {
|
||||
if let dismissingMediaRecordingPanel = self.dismissingMediaRecordingPanel {
|
||||
@@ -894,6 +1056,94 @@ public final class MessageInputPanelComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
self.updateContextQueries()
|
||||
|
||||
if let result = self.contextQueryResults[.mention], result.count > 0 && self.textFieldExternalState.isEditing {
|
||||
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 {
|
||||
panel = current
|
||||
externalState = currentState
|
||||
} else {
|
||||
panel = ComponentView<Empty>()
|
||||
externalState = ContextResultPanelComponent.ExternalState()
|
||||
self.contextQueryResultPanel = panel
|
||||
self.contextQueryResultPanelExternalState = externalState
|
||||
animateIn = true
|
||||
transition = .immediate
|
||||
}
|
||||
let panelLeftInset: CGFloat = max(insets.left, 7.0)
|
||||
let panelRightInset: CGFloat = max(insets.right, 41.0)
|
||||
let panelSize = panel.update(
|
||||
transition: transition,
|
||||
component: AnyComponent(ContextResultPanelComponent(
|
||||
externalState: externalState,
|
||||
context: component.context,
|
||||
theme: component.theme,
|
||||
strings: component.strings,
|
||||
results: result,
|
||||
action: { [weak self] action in
|
||||
if let self, case let .mention(peer) = action, 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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: availableSize.width - panelLeftInset - panelRightInset, height: availablePanelHeight)
|
||||
)
|
||||
|
||||
let panelFrame = CGRect(origin: CGPoint(x: insets.left, y: -panelSize.height + 33.0), size: panelSize)
|
||||
if let panelView = panel.view as? ContextResultPanelComponent.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 contextQueryResultPanel = self.contextQueryResultPanel?.view as? ContextResultPanelComponent.View {
|
||||
self.contextQueryResultPanel = nil
|
||||
contextQueryResultPanel.animateOut(transition: .spring(duration: 0.4), completion: { [weak contextQueryResultPanel] in
|
||||
contextQueryResultPanel?.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user