mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1165 lines
47 KiB
Swift
1165 lines
47 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import ComponentFlow
|
|
import ComponentDisplayAdapters
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import Markdown
|
|
import TextFormat
|
|
import TelegramPresentationData
|
|
import ViewControllerComponent
|
|
import SheetComponent
|
|
import BundleIconComponent
|
|
import ButtonComponent
|
|
import AccountContext
|
|
import PresentationDataUtils
|
|
import TelegramStringFormatting
|
|
import UndoUI
|
|
import PresentationDataUtils
|
|
import GlassBackgroundComponent
|
|
import GlassBarButtonComponent
|
|
import PlainButtonComponent
|
|
import ResizableSheetComponent
|
|
import EdgeEffect
|
|
import ShareController
|
|
import MessageInputPanelComponent
|
|
import MultilineTextComponent
|
|
import MergedAvatarsNode
|
|
|
|
private final class SheetContent: Component {
|
|
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
|
|
|
let context: AccountContext
|
|
let subject: ShareControllerSubject
|
|
let externalShare: Bool
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let contentHeight: CGFloat
|
|
let groupPeers: [EnginePeer]
|
|
let interactionUpdated: (SharePeersSheetContentView.InteractionState) -> Void
|
|
let trackedScrollViewUpdated: (UIScrollView?) -> Void
|
|
let dismiss: () -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
subject: ShareControllerSubject,
|
|
externalShare: Bool,
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
contentHeight: CGFloat,
|
|
groupPeers: [EnginePeer],
|
|
interactionUpdated: @escaping (SharePeersSheetContentView.InteractionState) -> Void,
|
|
trackedScrollViewUpdated: @escaping (UIScrollView?) -> Void,
|
|
dismiss: @escaping () -> Void
|
|
) {
|
|
self.context = context
|
|
self.subject = subject
|
|
self.externalShare = externalShare
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.contentHeight = contentHeight
|
|
self.groupPeers = groupPeers
|
|
self.interactionUpdated = interactionUpdated
|
|
self.trackedScrollViewUpdated = trackedScrollViewUpdated
|
|
self.dismiss = dismiss
|
|
}
|
|
|
|
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
|
|
if lhs.groupPeers != rhs.groupPeers {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private var isUpdating = false
|
|
|
|
private var peersContentView: SharePeersSheetContentView?
|
|
private let search = ComponentView<Empty>()
|
|
|
|
private var component: SheetContent?
|
|
private(set) weak var state: EmptyComponentState?
|
|
private var environment: EnvironmentType?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
self.disablesInteractiveKeyboardGestureRecognizer = true
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
|
|
}
|
|
|
|
func update(component: SheetContent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
self.isUpdating = true
|
|
defer {
|
|
self.isUpdating = false
|
|
}
|
|
|
|
let environment = environment[EnvironmentType.self].value
|
|
let themeUpdated = self.environment?.theme !== environment.theme
|
|
self.environment = environment
|
|
self.state = state
|
|
|
|
let peersContentView: SharePeersSheetContentView
|
|
if let current = self.peersContentView {
|
|
peersContentView = current
|
|
} else {
|
|
peersContentView = SharePeersSheetContentView(
|
|
context: component.context,
|
|
subject: component.subject,
|
|
externalShare: component.externalShare
|
|
)
|
|
self.peersContentView = peersContentView
|
|
self.addSubview(peersContentView)
|
|
}
|
|
self.component = component
|
|
peersContentView.interactionUpdated = component.interactionUpdated
|
|
peersContentView.trackedScrollViewUpdated = component.trackedScrollViewUpdated
|
|
|
|
let contentSize = CGSize(width: availableSize.width, height: component.contentHeight)
|
|
transition.setFrame(view: peersContentView, frame: CGRect(origin: .zero, size: contentSize))
|
|
peersContentView.update(
|
|
availableSize: contentSize,
|
|
theme: component.theme,
|
|
strings: component.strings,
|
|
transition: themeUpdated ? .immediate : transition.containedViewLayoutTransition
|
|
)
|
|
|
|
let searchSize = self.search.update(
|
|
transition: transition,
|
|
component: AnyComponent(
|
|
SearchInputPanelComponent(
|
|
context: component.context,
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
metrics: environment.metrics,
|
|
safeInsets: .zero,
|
|
groupPeers: component.groupPeers,
|
|
updated: { _ in },
|
|
cancel: {}
|
|
)
|
|
),
|
|
environment: {
|
|
},
|
|
containerSize: CGSize(width: availableSize.width, height: 44.0)
|
|
)
|
|
let searchFrame = CGRect(origin: CGPoint(x: 0.0, y: 76.0), size: searchSize)
|
|
if let searchView = self.search.view {
|
|
if searchView.superview == nil {
|
|
self.addSubview(searchView)
|
|
}
|
|
transition.setFrame(view: searchView, frame: searchFrame)
|
|
}
|
|
|
|
return contentSize
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View()
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
private final class ShareScreenSheetComponent: Component {
|
|
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
|
|
|
private let context: AccountContext
|
|
private let subject: ShareControllerSubject
|
|
private let externalShare: Bool
|
|
|
|
init(
|
|
context: AccountContext,
|
|
subject: ShareControllerSubject,
|
|
externalShare: Bool
|
|
) {
|
|
self.context = context
|
|
self.subject = subject
|
|
self.externalShare = externalShare
|
|
}
|
|
|
|
static func ==(lhs: ShareScreenSheetComponent, rhs: ShareScreenSheetComponent) -> Bool {
|
|
return true
|
|
}
|
|
|
|
final class State: ComponentState {
|
|
var foundPeers: [(peer: EngineRenderedPeer, requiresPremiumForMessaging: Bool)] = []
|
|
var selectedPeerIds = Set<EnginePeer.Id>()
|
|
var selectedPeers: [EngineRenderedPeer] = []
|
|
var selectedTopics: [EnginePeer.Id: (Int64, MessageHistoryThreadData?)] = [:]
|
|
|
|
func updateInteractionState(_ interactionState: SharePeersSheetContentView.InteractionState) {
|
|
self.foundPeers = interactionState.foundPeers
|
|
self.selectedPeerIds = interactionState.selectedPeerIds
|
|
self.selectedPeers = interactionState.selectedPeers
|
|
self.selectedTopics = interactionState.selectedTopics
|
|
self.updated(transition: .spring(duration: 0.4).withUserData(MultilineTextComponent.CrossfadeTransition()))
|
|
}
|
|
}
|
|
|
|
func makeState() -> State {
|
|
return State()
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, ResizableSheetComponentEnvironment)>()
|
|
private let animateOut = ActionSlot<Action<()>>()
|
|
private let sheetExternalState = ResizableSheetComponent<EnvironmentType>.ExternalState()
|
|
|
|
private var isUpdating = false
|
|
|
|
private var component: ShareScreenSheetComponent?
|
|
private(set) weak var state: State?
|
|
private var environment: EnvironmentType?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
|
|
}
|
|
|
|
private func subtitleText(component: ShareScreenSheetComponent, state: State, strings: PresentationStrings) -> String {
|
|
guard !state.selectedPeers.isEmpty else {
|
|
return strings.ShareMenu_SelectChats
|
|
}
|
|
let nameDisplayOrder = component.context.sharedContext.currentPresentationData.with { $0.nameDisplayOrder }
|
|
let selectedTitles = state.selectedPeers.compactMap { peer -> String? in
|
|
if peer.peerId == component.context.account.peerId {
|
|
return strings.DialogList_SavedMessages
|
|
} else {
|
|
return peer.chatMainPeer?.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
|
}
|
|
}
|
|
if selectedTitles.isEmpty {
|
|
return strings.ShareMenu_SelectChats
|
|
} else {
|
|
return selectedTitles.joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
func update(component: ShareScreenSheetComponent, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
self.isUpdating = true
|
|
defer {
|
|
self.isUpdating = false
|
|
}
|
|
|
|
let environment = environment[EnvironmentType.self].value
|
|
self.environment = environment
|
|
self.state = state
|
|
|
|
let controller = environment.controller
|
|
|
|
let dismiss: (Bool) -> Void = { animated in
|
|
if animated {
|
|
self.animateOut.invoke(Action { _ in
|
|
if let controller = controller() {
|
|
controller.dismiss(completion: nil)
|
|
}
|
|
})
|
|
} else {
|
|
if let controller = controller() {
|
|
controller.dismiss(completion: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
let theme = environment.theme.withModalBlocksBackground()
|
|
|
|
var titleItems: [AnyComponentWithIdentity<Empty>] = []
|
|
titleItems.append(AnyComponentWithIdentity(id: "title", component: AnyComponent(
|
|
Text(text: environment.strings.ShareMenu_ShareTo, font: Font.semibold(17.0), color: theme.list.itemPrimaryTextColor)
|
|
)))
|
|
|
|
titleItems.append(AnyComponentWithIdentity(id: "subtitle", component: AnyComponent(
|
|
MultilineTextComponent(text: .plain(
|
|
NSAttributedString(string: self.subtitleText(component: component, state: state, strings: environment.strings), font: Font.regular(12.0), textColor: theme.list.itemSecondaryTextColor)
|
|
))
|
|
)))
|
|
|
|
var groupPeers: [EnginePeer] = []
|
|
for peer in state.selectedPeers {
|
|
if let peer = peer.peer, case .user = peer {
|
|
groupPeers.append(peer)
|
|
}
|
|
}
|
|
|
|
let sheetSize = self.sheet.update(
|
|
transition: transition,
|
|
component: AnyComponent(
|
|
ResizableSheetComponent<EnvironmentType>(
|
|
content: AnyComponent<EnvironmentType>(SheetContent(
|
|
context: component.context,
|
|
subject: component.subject,
|
|
externalShare: component.externalShare,
|
|
theme: theme,
|
|
strings: environment.strings,
|
|
contentHeight: max(0.0, availableSize.height - environment.statusBarHeight - 10.0),
|
|
groupPeers: groupPeers,
|
|
interactionUpdated: { [weak state] interactionState in
|
|
state?.updateInteractionState(interactionState)
|
|
},
|
|
trackedScrollViewUpdated: { [weak self] scrollView in
|
|
self?.sheetExternalState.setTrackedScrollView(scrollView)
|
|
},
|
|
dismiss: {
|
|
dismiss(true)
|
|
}
|
|
)),
|
|
titleItem: AnyComponent(
|
|
VStack(titleItems, spacing: 0.0)
|
|
),
|
|
leftItem: AnyComponent(
|
|
GlassBarButtonComponent(
|
|
size: CGSize(width: 44.0, height: 44.0),
|
|
backgroundColor: nil,
|
|
isDark: theme.overallDarkAppearance,
|
|
state: .glass,
|
|
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Navigation/Close",
|
|
tintColor: theme.chat.inputPanel.panelControlColor
|
|
)
|
|
)),
|
|
action: { _ in
|
|
dismiss(true)
|
|
}
|
|
)
|
|
),
|
|
rightItem: AnyComponent(
|
|
GlassBarButtonComponent(
|
|
size: CGSize(width: 44.0, height: 44.0),
|
|
backgroundColor: nil,
|
|
isDark: theme.overallDarkAppearance,
|
|
state: .glass,
|
|
component: AnyComponentWithIdentity(id: "share", component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Navigation/Share",
|
|
tintColor: theme.chat.inputPanel.panelControlColor
|
|
)
|
|
)),
|
|
action: { _ in
|
|
dismiss(true)
|
|
}
|
|
)
|
|
),
|
|
bottomItem: AnyComponent(
|
|
BottomPanelComponent(
|
|
context: component.context,
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
actionTitle: "Copy Link",
|
|
actionBadge: state.selectedPeerIds.isEmpty ? nil : state.selectedPeerIds.count,
|
|
action: {
|
|
|
|
}
|
|
)
|
|
),
|
|
backgroundColor: .color(theme.list.modalPlainBackgroundColor),
|
|
defaultHeight: 540.0,
|
|
externalState: self.sheetExternalState,
|
|
animateOut: self.animateOut
|
|
)
|
|
),
|
|
environment: {
|
|
environment
|
|
ResizableSheetComponentEnvironment(
|
|
theme: theme,
|
|
statusBarHeight: environment.statusBarHeight,
|
|
safeInsets: environment.safeInsets,
|
|
inputHeight: environment.inputHeight,
|
|
metrics: environment.metrics,
|
|
deviceMetrics: environment.deviceMetrics,
|
|
isDisplaying: environment.isVisible,
|
|
isCentered: environment.metrics.widthClass == .regular,
|
|
screenSize: availableSize,
|
|
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
|
|
dismiss: { animated in
|
|
dismiss(animated)
|
|
}
|
|
)
|
|
},
|
|
containerSize: availableSize
|
|
)
|
|
if let sheetView = self.sheet.view {
|
|
if sheetView.superview == nil {
|
|
self.addSubview(sheetView)
|
|
}
|
|
transition.setFrame(view: sheetView, frame: CGRect(origin: .zero, size: sheetSize))
|
|
}
|
|
|
|
return availableSize
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View()
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
public final class ShareScreen: ViewControllerComponentContainer {
|
|
private let context: AccountContext
|
|
private let subject: ShareControllerSubject
|
|
|
|
public init(
|
|
context: AccountContext,
|
|
subject: ShareControllerSubject,
|
|
externalShare: Bool
|
|
) {
|
|
self.context = context
|
|
self.subject = subject
|
|
|
|
super.init(
|
|
context: context,
|
|
component: ShareScreenSheetComponent(
|
|
context: context,
|
|
subject: subject,
|
|
externalShare: externalShare
|
|
),
|
|
navigationBarAppearance: .none,
|
|
statusBarStyle: .ignore,
|
|
theme: .default
|
|
)
|
|
|
|
self.navigationPresentation = .flatModal
|
|
}
|
|
|
|
required public init(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
private final class BottomPanelComponent: Component {
|
|
let context: AccountContext
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let actionTitle: String?
|
|
let actionBadge: Int?
|
|
let action: () -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
actionTitle: String?,
|
|
actionBadge: Int?,
|
|
action: @escaping () -> Void
|
|
) {
|
|
self.context = context
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.actionTitle = actionTitle
|
|
self.actionBadge = actionBadge
|
|
self.action = action
|
|
}
|
|
|
|
static func ==(lhs: BottomPanelComponent, rhs: BottomPanelComponent) -> Bool {
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.strings !== rhs.strings {
|
|
return false
|
|
}
|
|
if lhs.actionTitle != rhs.actionTitle {
|
|
return false
|
|
}
|
|
if lhs.actionBadge != rhs.actionBadge {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let inputPanel = ComponentView<Empty>()
|
|
private let inputPanelExternalState = MessageInputPanelComponent.ExternalState()
|
|
|
|
private let actionButton = ComponentView<Empty>()
|
|
|
|
private var component: BottomPanelComponent?
|
|
private weak var state: EmptyComponentState?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func update(component: BottomPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
self.component = component
|
|
self.state = state
|
|
|
|
var contentHeight: CGFloat = 0.0
|
|
|
|
let inputPanelInset: CGFloat = 19.0
|
|
|
|
let mainActionIsVisible = (component.actionBadge ?? 0) > 0
|
|
|
|
self.inputPanel.parentState = state
|
|
let inputPanelSize = self.inputPanel.update(
|
|
transition: transition,
|
|
component: AnyComponent(MessageInputPanelComponent(
|
|
externalState: self.inputPanelExternalState,
|
|
context: component.context,
|
|
theme: component.theme,
|
|
strings: component.strings,
|
|
style: .gift,
|
|
placeholder: .plain("Add a caption..."),
|
|
sendPaidMessageStars: nil,
|
|
maxLength: 1024,
|
|
queryTypes: [],
|
|
alwaysDarkWhenHasText: false,
|
|
useGrayBackground: false,
|
|
resetInputContents: nil,
|
|
nextInputMode: { _ in return .emoji },
|
|
areVoiceMessagesAvailable: false,
|
|
presentController: { c in
|
|
},
|
|
presentInGlobalOverlay: { c in
|
|
},
|
|
sendMessageAction: { [weak self] _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
//self.deactivateInput()
|
|
},
|
|
sendMessageOptionsAction: nil,
|
|
sendStickerAction: { _ in },
|
|
setMediaRecordingActive: nil,
|
|
lockMediaRecording: {
|
|
},
|
|
stopAndPreviewMediaRecording: {
|
|
},
|
|
discardMediaRecordingPreview: nil,
|
|
attachmentAction: nil,
|
|
myReaction: nil,
|
|
likeAction: nil,
|
|
likeOptionsAction: nil,
|
|
inputModeAction: { [weak self] in
|
|
if let self {
|
|
let _ = self
|
|
// switch self.currentInputMode {
|
|
// case .text:
|
|
// self.currentInputMode = .emoji
|
|
// case .emoji:
|
|
// self.currentInputMode = .text
|
|
// default:
|
|
// self.currentInputMode = .emoji
|
|
// }
|
|
// if self.currentInputMode == .text {
|
|
// self.activateInput()
|
|
// } else {
|
|
// self.state?.updated(transition: .immediate)
|
|
// }
|
|
}
|
|
},
|
|
timeoutAction: nil,
|
|
forwardAction: nil,
|
|
paidMessageAction: nil,
|
|
moreAction: nil,
|
|
presentCaptionPositionTooltip: nil,
|
|
presentVoiceMessagesUnavailableTooltip: nil,
|
|
presentTextLengthLimitTooltip: {
|
|
},
|
|
presentTextFormattingTooltip: {
|
|
},
|
|
paste: { _ in
|
|
},
|
|
audioRecorder: nil,
|
|
videoRecordingStatus: nil,
|
|
isRecordingLocked: false,
|
|
hasRecordedVideo: false,
|
|
recordedAudioPreview: nil,
|
|
hasRecordedVideoPreview: false,
|
|
wasRecordingDismissed: false,
|
|
timeoutValue: nil,
|
|
timeoutSelected: false,
|
|
displayGradient: false,
|
|
bottomInset: 0.0,
|
|
isFormattingLocked: false,
|
|
hideKeyboard: false, //self.currentInputMode == .emoji,
|
|
customInputView: nil,
|
|
forceIsEditing: false, //self.currentInputMode == .emoji,
|
|
disabledPlaceholder: nil,
|
|
header: nil,
|
|
isChannel: false,
|
|
storyItem: nil,
|
|
chatLocation: nil
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width + inputPanelInset * 2.0, height: 160.0)
|
|
)
|
|
let inputPanelFrame = CGRect(origin: CGPoint(x: -inputPanelInset, y: 0.0), size: inputPanelSize)
|
|
if let inputPanelView = self.inputPanel.view {
|
|
if inputPanelView.superview == nil {
|
|
self.addSubview(inputPanelView)
|
|
}
|
|
transition.setPosition(view: inputPanelView, position: inputPanelFrame.center)
|
|
transition.setBounds(view: inputPanelView, bounds: CGRect(origin: .zero, size: inputPanelFrame.size))
|
|
transition.setBlur(layer: inputPanelView.layer, radius: mainActionIsVisible ? 0.0 : 10.0)
|
|
//transition.setScale(view: inputPanelView, scale: mainActionIsVisible ? 1.0 : 0.7)
|
|
}
|
|
if mainActionIsVisible {
|
|
contentHeight += inputPanelSize.height
|
|
contentHeight += 8.0
|
|
}
|
|
|
|
let buttonTitle: AnyComponentWithIdentity<Empty>
|
|
if let actionBadge = component.actionBadge, actionBadge > 0 {
|
|
buttonTitle = AnyComponentWithIdentity(id: "send", component: AnyComponent(ButtonTextContentComponent(
|
|
text: "Send",
|
|
badge: actionBadge,
|
|
textColor: component.theme.list.itemCheckColors.foregroundColor,
|
|
badgeBackground: component.theme.list.itemCheckColors.foregroundColor,
|
|
badgeForeground: component.theme.list.itemCheckColors.fillColor
|
|
)))
|
|
} else {
|
|
buttonTitle = AnyComponentWithIdentity(id: component.actionTitle ?? "", component: AnyComponent(Text(
|
|
text: component.actionTitle ?? "",
|
|
font: Font.semibold(17.0),
|
|
color: component.theme.list.itemCheckColors.foregroundColor
|
|
)))
|
|
}
|
|
|
|
let actionButtonSize = self.actionButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(ButtonComponent(
|
|
background: ButtonComponent.Background(
|
|
style: .glass,
|
|
color: component.theme.list.itemCheckColors.fillColor,
|
|
foreground: component.theme.list.itemCheckColors.foregroundColor,
|
|
pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
|
|
),
|
|
content: buttonTitle,
|
|
isEnabled: true,
|
|
displaysProgress: false,
|
|
action: { [weak self] in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
component.action()
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width, height: availableSize.height)
|
|
)
|
|
let actionButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: actionButtonSize)
|
|
if let actionButtonView = self.actionButton.view {
|
|
if actionButtonView.superview == nil {
|
|
self.addSubview(actionButtonView)
|
|
}
|
|
transition.setFrame(view: actionButtonView, frame: actionButtonFrame)
|
|
}
|
|
|
|
contentHeight += actionButtonSize.height
|
|
|
|
return CGSize(width: availableSize.width, height: contentHeight)
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
private final class SearchInputPanelComponent: Component {
|
|
public final class ResetText: Equatable {
|
|
public let value: String
|
|
|
|
public init(value: String) {
|
|
self.value = value
|
|
}
|
|
|
|
public static func ==(lhs: ResetText, rhs: ResetText) -> Bool {
|
|
return lhs === rhs
|
|
}
|
|
}
|
|
|
|
let context: AccountContext
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let metrics: LayoutMetrics
|
|
let safeInsets: UIEdgeInsets
|
|
let placeholder: String?
|
|
let groupPeers: [EnginePeer]
|
|
let resetText: ResetText?
|
|
let updated: ((String) -> Void)
|
|
let cancel: () -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
metrics: LayoutMetrics,
|
|
safeInsets: UIEdgeInsets,
|
|
placeholder: String? = nil,
|
|
groupPeers: [EnginePeer] = [],
|
|
resetText: ResetText? = nil,
|
|
updated: @escaping ((String) -> Void),
|
|
cancel: @escaping () -> Void
|
|
) {
|
|
self.context = context
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.metrics = metrics
|
|
self.safeInsets = safeInsets
|
|
self.placeholder = placeholder
|
|
self.groupPeers = groupPeers
|
|
self.resetText = resetText
|
|
self.updated = updated
|
|
self.cancel = cancel
|
|
}
|
|
|
|
public static func ==(lhs: SearchInputPanelComponent, rhs: SearchInputPanelComponent) -> Bool {
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.strings !== rhs.strings {
|
|
return false
|
|
}
|
|
if lhs.metrics != rhs.metrics {
|
|
return false
|
|
}
|
|
if lhs.safeInsets != rhs.safeInsets {
|
|
return false
|
|
}
|
|
if lhs.placeholder != rhs.placeholder {
|
|
return false
|
|
}
|
|
if lhs.groupPeers != rhs.groupPeers {
|
|
return false
|
|
}
|
|
if lhs.resetText != rhs.resetText {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private final class TextField: UITextField {
|
|
var sideInset: CGFloat = 0.0
|
|
|
|
override func textRect(forBounds bounds: CGRect) -> CGRect {
|
|
return CGRect(origin: CGPoint(x: self.sideInset, y: 0.0), size: CGSize(width: bounds.width - self.sideInset * 2.0, height: bounds.height))
|
|
}
|
|
|
|
override func editingRect(forBounds bounds: CGRect) -> CGRect {
|
|
return CGRect(origin: CGPoint(x: self.sideInset, y: 0.0), size: CGSize(width: bounds.width - self.sideInset * 2.0, height: bounds.height))
|
|
}
|
|
}
|
|
|
|
public final class View: UIView, UITextFieldDelegate {
|
|
private let containerView: GlassBackgroundContainerView
|
|
private let fieldBackgroundView: GlassBackgroundView
|
|
|
|
private let icon = ComponentView<Empty>()
|
|
private var placeholder = ComponentView<Empty>()
|
|
|
|
private let textField: TextField
|
|
private let clearButton = ComponentView<Empty>()
|
|
|
|
private let rightButton = ComponentView<Empty>()
|
|
|
|
private var component: SearchInputPanelComponent?
|
|
private weak var state: EmptyComponentState?
|
|
private var isUpdating: Bool = false
|
|
|
|
public var currentText: String {
|
|
return self.textField.text ?? ""
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
self.containerView = GlassBackgroundContainerView()
|
|
self.fieldBackgroundView = GlassBackgroundView()
|
|
self.textField = TextField()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.containerView)
|
|
self.containerView.contentView.addSubview(self.fieldBackgroundView)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
@objc private func textDidChange() {
|
|
if !self.isUpdating {
|
|
self.state?.updated(transition: .immediate)
|
|
}
|
|
self.component?.updated(self.currentText)
|
|
}
|
|
|
|
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
if !self.currentText.isEmpty {
|
|
self.textField.resignFirstResponder()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
public func setText(text: String, updateState: Bool) {
|
|
self.textField.text = text
|
|
if updateState {
|
|
self.state?.updated(transition: .immediate, isLocal: true)
|
|
self.component?.updated(self.currentText)
|
|
} else {
|
|
self.state?.updated(transition: .immediate, isLocal: true)
|
|
}
|
|
}
|
|
|
|
public func activateInput() {
|
|
self.textField.becomeFirstResponder()
|
|
}
|
|
|
|
public func deactivateInput() -> Bool {
|
|
self.textField.resignFirstResponder()
|
|
|
|
return self.currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
func update(component: SearchInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
self.isUpdating = true
|
|
defer {
|
|
self.isUpdating = false
|
|
}
|
|
|
|
let previousComponent = self.component
|
|
self.component = component
|
|
self.state = state
|
|
|
|
if self.textField.superview == nil {
|
|
self.addSubview(self.textField)
|
|
|
|
self.textField.accessibilityTraits = .searchField
|
|
self.textField.autocorrectionType = .no
|
|
self.textField.autocapitalizationType = .sentences
|
|
self.textField.enablesReturnKeyAutomatically = true
|
|
self.textField.returnKeyType = .search
|
|
self.textField.delegate = self
|
|
self.textField.addTarget(self, action: #selector(self.textDidChange), for: .editingChanged)
|
|
}
|
|
|
|
let themeUpdated = component.theme !== previousComponent?.theme
|
|
|
|
if themeUpdated {
|
|
self.textField.font = Font.regular(17.0)
|
|
self.textField.textColor = component.theme.list.itemPrimaryTextColor
|
|
self.textField.keyboardAppearance = component.theme.overallDarkAppearance ? .dark : .light
|
|
}
|
|
|
|
let backgroundColor = component.theme.list.plainBackgroundColor.withMultipliedAlpha(0.75)
|
|
|
|
let edgeInsets = UIEdgeInsets(top: 0.0, left: 16.0, bottom: 0.0, right: 16.0)
|
|
let fieldHeight: CGFloat = 44.0
|
|
let buttonSpacing: CGFloat = 10.0
|
|
|
|
var rightButtonWidth: CGFloat = 44.0
|
|
let rightButtonContent: AnyComponentWithIdentity<Empty>
|
|
if self.textField.isFirstResponder {
|
|
rightButtonContent = AnyComponentWithIdentity(id: "close", component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Navigation/Close",
|
|
tintColor: component.theme.chat.inputPanel.panelControlColor
|
|
)
|
|
))
|
|
} else if component.groupPeers.count > 1 {
|
|
rightButtonContent = AnyComponentWithIdentity(id: "createGroupAvatars", component: AnyComponent(
|
|
HStack([
|
|
AnyComponentWithIdentity(id: "icon", component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Navigation/CreateGroup",
|
|
tintColor: component.theme.chat.inputPanel.panelControlColor,
|
|
maxSize: CGSize(width: 22.0, height: 22.0)
|
|
)
|
|
)),
|
|
AnyComponentWithIdentity(id: "avatars", component: AnyComponent(
|
|
MergedAvatarsComponent(
|
|
context: component.context,
|
|
peers: component.groupPeers,
|
|
imageSize: 24.0,
|
|
imageSpacing: 15.0,
|
|
borderWidth: 2.0 - UIScreenPixel,
|
|
avatarFontSize: 10.0
|
|
)
|
|
))
|
|
], spacing: 3.0)
|
|
))
|
|
rightButtonWidth = 24.0 + CGFloat(min(3, component.groupPeers.count) - 1) * 14.0 + 44.0
|
|
} else {
|
|
rightButtonContent = AnyComponentWithIdentity(id: "createGroup", component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Navigation/CreateGroup",
|
|
tintColor: component.theme.chat.inputPanel.panelControlColor
|
|
)
|
|
))
|
|
}
|
|
|
|
|
|
let rightButtonSize = CGSize(width: rightButtonWidth, height: 44.0)
|
|
let _ = self.rightButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(GlassBarButtonComponent(
|
|
size: rightButtonSize,
|
|
backgroundColor: backgroundColor,
|
|
isDark: component.theme.overallDarkAppearance,
|
|
state: .glass,
|
|
component: rightButtonContent,
|
|
action: { [weak self] _ in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
let _ = self.deactivateInput()
|
|
component.cancel()
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: rightButtonSize
|
|
)
|
|
|
|
let cancelButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - edgeInsets.right - rightButtonSize.width, y: edgeInsets.top), size: rightButtonSize)
|
|
let fieldFrame = CGRect(origin: CGPoint(x: edgeInsets.left, y: edgeInsets.top), size: CGSize(width: max(0.0, cancelButtonFrame.minX - buttonSpacing - edgeInsets.left), height: fieldHeight))
|
|
|
|
self.fieldBackgroundView.update(size: fieldFrame.size, cornerRadius: fieldFrame.height * 0.5, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)
|
|
transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame)
|
|
|
|
let fieldSideInset: CGFloat = 41.0
|
|
self.textField.sideInset = fieldSideInset
|
|
let fieldContentWidth = max(0.0, fieldFrame.width - fieldSideInset * 2.0 - 30.0)
|
|
|
|
let iconSize = self.icon.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(BundleIconComponent(name: "Components/Search Bar/Loupe", tintColor: component.theme.list.itemPrimaryTextColor)),
|
|
environment: {},
|
|
containerSize: CGSize(width: fieldContentWidth, height: 100.0)
|
|
)
|
|
|
|
let iconFrame = CGRect(origin: CGPoint(x: fieldFrame.minX + 11.0, y: fieldFrame.minY + floor((fieldFrame.height - iconSize.height) * 0.5)), size: iconSize)
|
|
if let iconView = self.icon.view {
|
|
if iconView.superview == nil {
|
|
iconView.layer.anchorPoint = CGPoint()
|
|
iconView.isUserInteractionEnabled = false
|
|
self.insertSubview(iconView, belowSubview: self.textField)
|
|
}
|
|
transition.setPosition(view: iconView, position: iconFrame.origin)
|
|
iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
|
|
}
|
|
|
|
let placeholderSize = self.placeholder.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: component.placeholder ?? component.strings.Common_Search, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.6)))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: fieldContentWidth, height: 100.0)
|
|
)
|
|
|
|
let placeholderFrame = CGRect(origin: CGPoint(x: fieldFrame.minX + fieldSideInset, y: fieldFrame.minY + floor((fieldFrame.height - placeholderSize.height) * 0.5)), size: placeholderSize)
|
|
if let placeholderView = self.placeholder.view {
|
|
if placeholderView.superview == nil {
|
|
placeholderView.layer.anchorPoint = CGPoint()
|
|
placeholderView.isUserInteractionEnabled = false
|
|
self.insertSubview(placeholderView, belowSubview: self.textField)
|
|
}
|
|
transition.setPosition(view: placeholderView, position: placeholderFrame.origin)
|
|
placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size)
|
|
|
|
placeholderView.isHidden = !self.currentText.isEmpty
|
|
}
|
|
|
|
transition.setFrame(view: self.textField, frame: fieldFrame)
|
|
|
|
let clearButtonSize = self.clearButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(PlainButtonComponent(
|
|
content: AnyComponent(BundleIconComponent(
|
|
name: "Components/Search Bar/Clear",
|
|
tintColor: component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.4)
|
|
)),
|
|
effectAlignment: .center,
|
|
minSize: CGSize(width: 44.0, height: 44.0),
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.setText(text: "", updateState: true)
|
|
},
|
|
animateAlpha: false,
|
|
animateScale: true
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 44.0, height: 44.0)
|
|
)
|
|
if let clearButtonView = self.clearButton.view {
|
|
if clearButtonView.superview == nil {
|
|
self.addSubview(clearButtonView)
|
|
}
|
|
transition.setFrame(view: clearButtonView, frame: CGRect(origin: CGPoint(x: fieldFrame.maxX - clearButtonSize.width, y: fieldFrame.minY + floor((fieldFrame.height - clearButtonSize.height) * 0.5)), size: clearButtonSize))
|
|
clearButtonView.isHidden = self.currentText.isEmpty
|
|
}
|
|
|
|
if let cancelButtonView = self.rightButton.view {
|
|
if cancelButtonView.superview == nil {
|
|
self.containerView.contentView.addSubview(cancelButtonView)
|
|
}
|
|
transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame)
|
|
}
|
|
|
|
let size = CGSize(width: availableSize.width, height: edgeInsets.top + fieldHeight + edgeInsets.bottom)
|
|
|
|
transition.setFrame(view: self.containerView, frame: CGRect(origin: .zero, size: size))
|
|
self.containerView.update(size: size, isDark: component.theme.overallDarkAppearance, transition: transition)
|
|
|
|
return size
|
|
}
|
|
}
|
|
|
|
public func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
private final class MergedAvatarsComponent: Component {
|
|
let context: AccountContext
|
|
let peers: [EnginePeer]
|
|
let imageSize: CGFloat
|
|
let imageSpacing: CGFloat
|
|
let borderWidth: CGFloat
|
|
let avatarFontSize: CGFloat
|
|
let synchronousLoad: Bool
|
|
let pressed: (() -> Void)?
|
|
|
|
init(
|
|
context: AccountContext,
|
|
peers: [EnginePeer],
|
|
imageSize: CGFloat = MergedAvatarsNode.defaultMergedImageSize,
|
|
imageSpacing: CGFloat = MergedAvatarsNode.defaultMergedImageSpacing,
|
|
borderWidth: CGFloat = MergedAvatarsNode.defaultBorderWidth,
|
|
avatarFontSize: CGFloat = MergedAvatarsNode.defaultAvatarFontSize,
|
|
synchronousLoad: Bool = false,
|
|
pressed: (() -> Void)? = nil
|
|
) {
|
|
self.context = context
|
|
self.peers = peers
|
|
self.imageSize = imageSize
|
|
self.imageSpacing = imageSpacing
|
|
self.borderWidth = borderWidth
|
|
self.avatarFontSize = avatarFontSize
|
|
self.synchronousLoad = synchronousLoad
|
|
self.pressed = pressed
|
|
}
|
|
|
|
static func ==(lhs: MergedAvatarsComponent, rhs: MergedAvatarsComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.peers != rhs.peers {
|
|
return false
|
|
}
|
|
if lhs.imageSize != rhs.imageSize {
|
|
return false
|
|
}
|
|
if lhs.imageSpacing != rhs.imageSpacing {
|
|
return false
|
|
}
|
|
if lhs.borderWidth != rhs.borderWidth {
|
|
return false
|
|
}
|
|
if lhs.avatarFontSize != rhs.avatarFontSize {
|
|
return false
|
|
}
|
|
if lhs.synchronousLoad != rhs.synchronousLoad {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let mergedAvatarsNode: MergedAvatarsNode
|
|
|
|
private var component: MergedAvatarsComponent?
|
|
private weak var state: EmptyComponentState?
|
|
|
|
override init(frame: CGRect) {
|
|
self.mergedAvatarsNode = MergedAvatarsNode()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubnode(self.mergedAvatarsNode)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func update(component: MergedAvatarsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
self.component = component
|
|
self.state = state
|
|
|
|
let visiblePeers = Array(component.peers.prefix(3)).map { $0._asPeer() }
|
|
let size = CGSize(
|
|
width: visiblePeers.isEmpty ? 0.0 : component.imageSize + component.imageSpacing * CGFloat(max(0, visiblePeers.count - 1)),
|
|
height: visiblePeers.isEmpty ? 0.0 : component.imageSize
|
|
)
|
|
|
|
self.mergedAvatarsNode.pressed = component.pressed
|
|
self.mergedAvatarsNode.isUserInteractionEnabled = component.pressed != nil
|
|
self.mergedAvatarsNode.isHidden = visiblePeers.isEmpty
|
|
|
|
if !visiblePeers.isEmpty {
|
|
self.mergedAvatarsNode.update(
|
|
context: component.context,
|
|
peers: visiblePeers,
|
|
synchronousLoad: component.synchronousLoad,
|
|
imageSize: component.imageSize,
|
|
imageSpacing: component.imageSpacing,
|
|
borderWidth: component.borderWidth,
|
|
avatarFontSize: component.avatarFontSize
|
|
)
|
|
self.mergedAvatarsNode.updateLayout(size: size)
|
|
}
|
|
|
|
transition.setFrame(view: self.mergedAvatarsNode.view, frame: CGRect(origin: .zero, size: size))
|
|
return size
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|