726 lines
29 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BalancedTextComponent
import MultilineTextComponent
import ListSectionComponent
import ListActionItemComponent
import NavigationStackComponent
import ItemListUI
import UndoUI
import AccountContext
import LottieComponent
import TextFieldComponent
import ListMultilineTextFieldItemComponent
import ButtonComponent
private enum ReportResult {
case reported
}
private final class SheetPageContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
enum Content: Equatable {
struct Item: Equatable {
let title: String
let option: Data
}
case options(items: [Item])
case comment(isOptional: Bool, option: Data)
}
let context: AccountContext
let isFirst: Bool
let title: String?
let subtitle: String
let content: Content
let action: (Content.Item, String?) -> Void
let pop: () -> Void
init(
context: AccountContext,
isFirst: Bool,
title: String?,
subtitle: String,
content: Content,
action: @escaping (Content.Item, String?) -> Void,
pop: @escaping () -> Void
) {
self.context = context
self.isFirst = isFirst
self.title = title
self.subtitle = subtitle
self.content = content
self.action = action
self.pop = pop
}
static func ==(lhs: SheetPageContent, rhs: SheetPageContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.subtitle != rhs.subtitle {
return false
}
if lhs.content != rhs.content {
return false
}
return true
}
final class State: ComponentState {
var backArrowImage: (UIImage, PresentationTheme)?
let playOnce = ActionSlot<Void>()
private var didPlayAnimation = false
let textInputState = ListMultilineTextFieldItemComponent.ExternalState()
func playAnimationIfNeeded() {
guard !self.didPlayAnimation else {
return
}
self.didPlayAnimation = true
self.playOnce.invoke(Void())
}
}
func makeState() -> State {
return State()
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let back = Child(Button.self)
let title = Child(Text.self)
let animation = Child(LottieComponent.self)
let section = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let state = context.state
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let theme = environment.theme
let strings = environment.strings
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let background = background.update(
component: RoundedRectangle(color: theme.list.modalBlocksBackgroundColor, cornerRadius: 8.0),
availableSize: CGSize(width: context.availableSize.width, height: 1000.0),
transition: .immediate
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0))
)
let backArrowImage: UIImage
if let (cached, cachedTheme) = state.backArrowImage, cachedTheme === theme {
backArrowImage = cached
} else {
backArrowImage = NavigationBarTheme.generateBackArrowImage(color: theme.list.itemAccentColor)!
state.backArrowImage = (backArrowImage, theme)
}
let backContents: AnyComponent<Empty>
if component.isFirst {
backContents = AnyComponent(Text(text: strings.Common_Cancel, font: Font.regular(17.0), color: theme.list.itemAccentColor))
} else {
backContents = AnyComponent(
HStack([
AnyComponentWithIdentity(id: "arrow", component: AnyComponent(Image(image: backArrowImage, contentMode: .center))),
AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: strings.Common_Back, font: Font.regular(17.0), color: theme.list.itemAccentColor)))
], spacing: 6.0)
)
}
let back = back.update(
component: Button(
content: backContents,
action: {
component.pop()
}
),
availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height),
transition: .immediate
)
context.add(back
.position(CGPoint(x: sideInset + back.size.width / 2.0 - (!component.isFirst ? 8.0 : 0.0), y: contentSize.height + back.size.height / 2.0))
)
let constrainedTitleWidth = context.availableSize.width - (back.size.width + 16.0) * 2.0
let titleString: String
if let title = component.title {
titleString = title
} else {
titleString = ""
}
let title = title.update(
component: Text(text: titleString, font: Font.semibold(17.0), color: theme.list.itemPrimaryTextColor),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 24.0
var items: [AnyComponentWithIdentity<Empty>] = []
var footer: AnyComponent<Empty>?
switch component.content {
case let .options(options):
for item in options {
items.append(AnyComponentWithIdentity(id: item.title, component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: item.title,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
accessory: .arrow,
action: { _ in
component.action(item, nil)
}
))))
}
case let .comment(isOptional, _):
contentSize.height -= 11.0
let animationHeight: CGFloat = 120.0
let animation = animation.update(
component: LottieComponent(
content: LottieComponent.AppBundleContent(name: "Cop"),
startingPosition: .begin,
playOnce: state.playOnce
),
environment: {},
availableSize: CGSize(width: animationHeight, height: animationHeight),
transition: .immediate
)
context.add(animation
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + animation.size.height / 2.0))
)
contentSize.height += animation.size.height
contentSize.height += 18.0
items.append(
AnyComponentWithIdentity(id: items.count, component: AnyComponent(ListMultilineTextFieldItemComponent(
externalState: state.textInputState,
context: component.context,
theme: theme,
strings: strings,
initialText: "",
resetText: nil,
placeholder: isOptional ? "Add Comment (Optional)" : "Add Comment",
autocapitalizationType: .none,
autocorrectionType: .no,
returnKeyType: .done,
characterLimit: 140,
displayCharacterLimit: true,
emptyLineHandling: .notAllowed,
updated: { [weak state] _ in
state?.updated()
},
returnKeyAction: {
// guard let self else {
// return
// }
// if let titleView = self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View {
// titleView.endEditing(true)
// }
},
textUpdateTransition: .spring(duration: 0.4),
tag: nil
)))
)
footer = AnyComponent(MultilineTextComponent(
text: .plain(
NSAttributedString(string: "Please help us by telling what is wrong with the message you have selected.", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: theme.list.freeTextColor)
),
maximumNumberOfLines: 0
))
}
let section = section.update(
component: ListSectionComponent(
theme: theme,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: component.subtitle.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: footer,
items: items,
isModal: true
),
environment: {},
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
transition: context.transition
)
context.add(section
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + section.size.height / 2.0))
)
contentSize.height += section.size.height
contentSize.height += 54.0
if case let .comment(isOptional, option) = component.content {
contentSize.height -= 16.0
let action = component.action
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8)
),
content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(Text(text: "Send Report", font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))),
isEnabled: isOptional || state.textInputState.hasText,
allowActionWhenDisabled: false,
displaysProgress: false,
action: {
action(SheetPageContent.Content.Item(title: "", option: option), state.textInputState.text.string)
}
),
environment: {},
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0),
transition: context.transition
)
context.add(button
.clipsToBounds(true)
.cornerRadius(10.0)
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += 16.0
if environment.inputHeight.isZero && environment.safeInsets.bottom > 0.0 {
contentSize.height += environment.safeInsets.bottom
}
}
contentSize.height += environment.inputHeight
state.playAnimationIfNeeded()
return contentSize
}
}
}
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let subject: ReportContentSubject
let title: String
let options: [ReportContentResult.Option]
let pts: Int
let openMore: () -> Void
let complete: (ReportResult) -> Void
let dismiss: () -> Void
let update: (ComponentTransition) -> Void
init(
context: AccountContext,
subject: ReportContentSubject,
title: String,
options: [ReportContentResult.Option],
pts: Int,
openMore: @escaping () -> Void,
complete: @escaping (ReportResult) -> Void,
dismiss: @escaping () -> Void,
update: @escaping (ComponentTransition) -> Void
) {
self.context = context
self.subject = subject
self.title = title
self.options = options
self.pts = pts
self.openMore = openMore
self.complete = complete
self.dismiss = dismiss
self.update = update
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.subject != rhs.subject {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.options != rhs.options {
return false
}
if lhs.pts != rhs.pts {
return false
}
return true
}
final class State: ComponentState {
var pushedOptions: [(title: String, subtitle: String, content: SheetPageContent.Content)] = []
let disposable = MetaDisposable()
deinit {
self.disposable.dispose()
}
}
func makeState() -> State {
return State()
}
static var body: Body {
let navigation = Child(NavigationStackComponent<EnvironmentType>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let state = context.state
let update = component.update
let accountContext = component.context
let subject = component.subject
let complete = component.complete
let action: (SheetPageContent.Content.Item, String?) -> Void = { [weak state] item, message in
guard let state else {
return
}
state.disposable.set(
(accountContext.engine.messages.reportContent(subject: subject, option: item.option, message: message)
|> deliverOnMainQueue).start(next: { [weak state] result in
switch result {
case let .options(title, options):
state?.pushedOptions.append((item.title, title, .options(items: options.map { SheetPageContent.Content.Item(title: $0.text, option: $0.option) })))
state?.updated(transition: .spring(duration: 0.45))
case let .addComment(isOptional, option):
state?.pushedOptions.append((item.title, "", .comment(isOptional: isOptional, option: option)))
state?.updated(transition: .spring(duration: 0.45))
case .reported:
complete(.reported)
}
}, error: { error in
// if case .premiumRequired = error {
// complete(.premiumRequired)
// }
})
)
}
let mainTitle: String
switch component.subject {
case .peer:
mainTitle = "Report Peer"
case .messages:
mainTitle = "Report Message"
case .stories:
mainTitle = "Report Story"
}
var items: [AnyComponentWithIdentity<EnvironmentType>] = []
items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent(
SheetPageContent(
context: component.context,
isFirst: true,
title: mainTitle,
subtitle: component.title,
content: .options(items: component.options.map {
SheetPageContent.Content.Item(title: $0.text, option: $0.option)
}),
action: { item, message in
action(item, message)
},
pop: {
component.dismiss()
}
)
)))
for pushedOption in state.pushedOptions {
items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent(
SheetPageContent(
context: component.context,
isFirst: false,
title: pushedOption.title,
subtitle: pushedOption.subtitle,
content: pushedOption.content,
action: { item, message in
action(item, message)
},
pop: { [weak state] in
state?.pushedOptions.removeLast()
update(.spring(duration: 0.45))
}
)
)))
}
var contentSize = CGSize(width: context.availableSize.width, height: 0.0)
let navigation = navigation.update(
component: NavigationStackComponent(
items: items,
clipContent: false,
requestPop: { [weak state] in
state?.pushedOptions.removeLast()
update(.spring(duration: 0.45))
}
),
environment: { environment },
availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height),
transition: context.transition
)
context.add(navigation
.position(CGPoint(x: context.availableSize.width / 2.0, y: navigation.size.height / 2.0))
.clipsToBounds(true)
.cornerRadius(8.0)
)
contentSize.height += navigation.size.height
return contentSize
}
}
}
private final class SheetContainerComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let subject: ReportContentSubject
let title: String
let options: [ReportContentResult.Option]
let openMore: () -> Void
let complete: (ReportResult) -> Void
init(
context: AccountContext,
subject: ReportContentSubject,
title: String,
options: [ReportContentResult.Option],
openMore: @escaping () -> Void,
complete: @escaping (ReportResult) -> Void
) {
self.context = context
self.subject = subject
self.title = title
self.options = options
self.openMore = openMore
self.complete = complete
}
static func ==(lhs: SheetContainerComponent, rhs: SheetContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.subject != rhs.subject {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.options != rhs.options {
return false
}
return true
}
final class State: ComponentState {
var pts: Int = 0
}
func makeState() -> State {
return State()
}
static var body: Body {
let sheet = Child(SheetComponent<EnvironmentType>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
let sheetExternalState = SheetComponent<EnvironmentType>.ExternalState()
return { context in
let environment = context.environment[EnvironmentType.self]
let state = context.state
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
subject: context.component.subject,
title: context.component.title,
options: context.component.options,
pts: state.pts,
openMore: context.component.openMore,
complete: context.component.complete,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
},
update: { [weak state] transition in
state?.pts += 1
state?.updated(transition: transition)
}
)),
backgroundColor: .color(environment.theme.list.modalBlocksBackgroundColor),
followContentSizeChanges: true,
externalState: sheetExternalState,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
if let controller = controller(), !controller.automaticallyControlPresentationContextLayout {
let layout = ContainerViewLayout(
size: context.availableSize,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(environment.safeInsets.bottom, sheetExternalState.contentHeight), right: 0.0),
safeInsets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: 0.0, right: environment.safeInsets.right),
additionalInsets: .zero,
statusBarHeight: environment.statusBarHeight,
inputHeight: nil,
inputHeightIsInteractivellyChanging: false,
inVoiceOver: false
)
controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition)
}
return context.availableSize
}
}
}
public final class ContentReportScreen: ViewControllerComponentContainer {
private let context: AccountContext
public init(
context: AccountContext,
subject: ReportContentSubject,
title: String,
options: [ReportContentResult.Option],
forceDark: Bool = false,
completed: @escaping () -> Void
) {
self.context = context
var completeImpl: ((ReportResult) -> Void)?
super.init(
context: context,
component: SheetContainerComponent(
context: context,
subject: subject,
title: title,
options: options,
openMore: {},
complete: { hidden in
completeImpl?(hidden)
}
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: forceDark ? .dark : .default
)
self.navigationPresentation = .flatModal
completeImpl = { [weak self] result in
guard let self else {
return
}
let navigationController = self.navigationController
self.dismissAnimated()
switch result {
case .reported:
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
Queue.mainQueue().after(0.4, {
completed()
(navigationController?.viewControllers.last as? ViewController)?.present(UndoOverlayController(presentationData: presentationData, content: .emoji(name: "PoliceCar", text: presentationData.strings.Report_Succeed), elevatedLayout: false, action: { _ in return true }), in: .current)
})
}
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
self.view.disablesInteractiveModalDismiss = true
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}