Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

2372 lines
103 KiB
Swift

import Foundation
import UIKit
import AVFoundation
import Display
import Postbox
import AccountContext
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import BundleIconComponent
import ListSectionComponent
import ListActionItemComponent
import ListTextFieldItemComponent
import AlertComponent
import ItemListUI
import PeerInfoUI
import AvatarNode
import MapResourceToAvatarSizes
import PlainButtonComponent
import RadialStatusNode
import PeerListItemComponent
import PeerSelectionScreen
import AvatarEditorScreen
import CameraScreen
import MediaEditor
import MediaEditorScreen
import Photos
private enum CommunityAddChatsMode: Equatable {
case allMembers
case onlyAdmins
init(defaultBannedRights: TelegramChatBannedRights?) {
if defaultBannedRights?.flags.contains(.banManageLinkedPeers) == true {
self = .onlyAdmins
} else {
self = .allMembers
}
}
}
private enum CommunityEditSaveError {
case generic
}
private struct CommunityPendingPeerAvatar {
let previewRepresentation: TelegramMediaImageRepresentation
let isLoadingPreview: Bool
let uploadedPhoto: Signal<UploadedPeerPhotoData, NoError>
let uploadedVideo: Signal<UploadedPeerPhotoData?, NoError>?
let videoStartTimestamp: Double?
let markup: UploadPeerPhotoMarkup?
}
private enum CommunityCreateAvatarSetup {
private static func makePhotoRepresentation(context: AccountContext, image: UIImage) -> (LocalFileMediaResource, TelegramMediaImageRepresentation)? {
guard let data = image.jpegData(compressionQuality: 0.6) else {
return nil
}
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
context.engine.resources.storeResourceData(id: EngineMediaResource.Id(resource.id), data: data)
let representation = TelegramMediaImageRepresentation(
dimensions: PixelDimensions(width: 640, height: 640),
resource: resource,
progressiveSizes: [],
immediateThumbnailData: nil,
hasVideo: false,
isPersonal: false
)
return (resource, representation)
}
static func photo(context: AccountContext, image: UIImage) -> CommunityPendingPeerAvatar? {
guard let (resource, representation) = self.makePhotoRepresentation(context: context, image: image) else {
return nil
}
return CommunityPendingPeerAvatar(
previewRepresentation: representation,
isLoadingPreview: false,
uploadedPhoto: context.engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(resource)),
uploadedVideo: nil,
videoStartTimestamp: nil,
markup: nil
)
}
static func video(
context: AccountContext,
image: UIImage,
video: MediaEditorScreenImpl.MediaResult.VideoResult?,
values: MediaEditorValues?,
markup: UploadPeerPhotoMarkup?,
didCompleteLoadingPreview: @escaping (CommunityPendingPeerAvatar) -> Void
) -> CommunityPendingPeerAvatar? {
var shouldUploadVideo = true
if markup != nil {
if let data = context.currentAppConfiguration.with({ $0 }).data, let uploadVideoValue = data["upload_markup_video"] as? Bool, uploadVideoValue {
shouldUploadVideo = true
} else {
shouldUploadVideo = false
}
}
guard let (photoResource, representation) = self.makePhotoRepresentation(context: context, image: image) else {
return nil
}
let uploadedPhoto = context.engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(photoResource))
var videoStartTimestamp: Double?
if let values, let coverImageTimestamp = values.coverImageTimestamp, coverImageTimestamp > 0.0 {
videoStartTimestamp = coverImageTimestamp - (values.videoTrimRange?.lowerBound ?? 0.0)
}
let hasVideoUpload = shouldUploadVideo && video != nil && values != nil
guard hasVideoUpload, let video, let values else {
return CommunityPendingPeerAvatar(
previewRepresentation: representation,
isLoadingPreview: false,
uploadedPhoto: uploadedPhoto,
uploadedVideo: nil,
videoStartTimestamp: videoStartTimestamp,
markup: markup
)
}
let account = context.account
var exportSubject: Signal<(MediaEditorVideoExport.Subject, Double), NoError>?
switch video {
case let .imageFile(path):
if let image = UIImage(contentsOfFile: path) {
exportSubject = .single((.image(image: image), 3.0))
}
case let .videoFile(path):
let asset = AVURLAsset(url: URL(fileURLWithPath: path))
exportSubject = .single((.video(asset: asset, isStory: false), asset.duration.seconds))
case let .asset(localIdentifier):
exportSubject = Signal { subscriber in
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
if fetchResult.count != 0 {
let asset = fetchResult.object(at: 0)
if asset.mediaType == .video {
PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in
if let avAsset {
subscriber.putNext((.video(asset: avAsset, isStory: true), avAsset.duration.seconds))
subscriber.putCompletion()
}
}
} else {
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in
if let image {
subscriber.putNext((.image(image: image), 3.0))
subscriber.putCompletion()
}
}
}
}
return EmptyDisposable
}
}
guard let exportSubject else {
return CommunityPendingPeerAvatar(
previewRepresentation: representation,
isLoadingPreview: false,
uploadedPhoto: uploadedPhoto,
uploadedVideo: nil,
videoStartTimestamp: videoStartTimestamp,
markup: markup
)
}
let videoResource = exportSubject
|> castError(UploadPeerPhotoError.self)
|> mapToSignal { exportSubject, duration -> Signal<TelegramMediaResource?, UploadPeerPhotoError> in
return Signal { subscriber in
let configuration = recommendedVideoExportConfiguration(values: values, duration: duration, forceFullHd: true, frameRate: 60.0, isAvatar: true)
let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4")
let videoExport = MediaEditorVideoExport(postbox: context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: tempFile.path, textScale: 2.0)
let disposable = (videoExport.status
|> deliverOnMainQueue).startStrict(next: { status in
switch status {
case .completed:
if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path), options: .mappedIfSafe) {
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true)
subscriber.putNext(resource)
subscriber.putCompletion()
}
EngineTempBox.shared.dispose(tempFile)
case .progress:
break
default:
break
}
})
return ActionDisposable {
disposable.dispose()
}
}
}
var completedAvatar: CommunityPendingPeerAvatar?
let uploadedVideo = (videoResource
|> `catch` { _ -> Signal<TelegramMediaResource?, NoError> in
return .single(nil)
}
|> mapToSignal { resource -> Signal<UploadedPeerPhotoData?, NoError> in
if let resource {
return context.engine.peers.uploadedPeerVideo(resource: EngineMediaResource(resource))
|> map(Optional.init)
} else {
return .single(nil)
}
}
|> afterNext { next in
if let next, next.isCompleted, let completedAvatar {
didCompleteLoadingPreview(completedAvatar)
}
})
let pendingAvatar = CommunityPendingPeerAvatar(
previewRepresentation: representation,
isLoadingPreview: true,
uploadedPhoto: uploadedPhoto,
uploadedVideo: uploadedVideo,
videoStartTimestamp: videoStartTimestamp,
markup: markup
)
completedAvatar = CommunityPendingPeerAvatar(
previewRepresentation: representation,
isLoadingPreview: false,
uploadedPhoto: uploadedPhoto,
uploadedVideo: uploadedVideo,
videoStartTimestamp: videoStartTimestamp,
markup: markup
)
return pendingAvatar
}
}
private let navigationCheckImage: UIImage = {
return generateImage(CGSize(width: 20.0, height: 20.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2.6)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: CGPoint(x: 4.0, y: 10.5))
context.addLine(to: CGPoint(x: 8.0, y: 14.5))
context.addLine(to: CGPoint(x: 16.5, y: 5.5))
context.strokePath()
})!.withRenderingMode(.alwaysTemplate)
}()
private final class CommunityAvatarComponent: Component {
typealias EnvironmentType = Empty
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let peer: EnginePeer
let isEnabled: Bool
let uploadingImage: UIImage?
let uploadProgress: CGFloat?
let action: () -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
peer: EnginePeer,
isEnabled: Bool,
uploadingImage: UIImage?,
uploadProgress: CGFloat?,
action: @escaping () -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.peer = peer
self.isEnabled = isEnabled
self.uploadingImage = uploadingImage
self.uploadProgress = uploadProgress
self.action = action
}
static func ==(lhs: CommunityAvatarComponent, rhs: CommunityAvatarComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.uploadingImage !== rhs.uploadingImage {
return false
}
if lhs.uploadProgress != rhs.uploadProgress {
return false
}
return true
}
final class View: UIView {
private let avatarShadow = UIImageView()
private let avatarNode: AvatarNode
private let uploadingImageView = UIImageView()
private let uploadingOverlayView = UIView()
private let statusNode: RadialStatusNode
private let button = HighlightTrackingButton()
private let actionButton = ComponentView<Empty>()
private var component: CommunityAvatarComponent?
override init(frame: CGRect) {
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)))
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.6))
super.init(frame: frame)
self.avatarShadow.image = UIImage(bundleImageName: "Components/CommunityShadow")
self.uploadingImageView.contentMode = .scaleAspectFill
self.uploadingImageView.clipsToBounds = true
self.uploadingImageView.isUserInteractionEnabled = false
self.uploadingImageView.isHidden = true
self.uploadingOverlayView.backgroundColor = UIColor(white: 0.0, alpha: 0.4)
self.uploadingOverlayView.isUserInteractionEnabled = false
self.uploadingOverlayView.isHidden = true
self.statusNode.isUserInteractionEnabled = false
self.addSubview(self.avatarShadow)
self.addSubnode(self.avatarNode)
self.addSubview(self.uploadingImageView)
self.addSubview(self.uploadingOverlayView)
self.addSubnode(self.statusNode)
self.addSubview(self.button)
self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard self.component?.isEnabled == true else {
return
}
self.component?.action()
}
func transitionView() -> UIView {
if self.component?.uploadingImage != nil {
return self.uploadingImageView
} else {
return self.avatarNode.view
}
}
func update(component: CommunityAvatarComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
self.component = component
let avatarSize = CGSize(width: 100.0, height: 100.0)
let avatarFrame = CGRect(
origin: CGPoint(
x: floorToScreenPixels((availableSize.width - avatarSize.width) / 2.0),
y: 0.0
),
size: avatarSize
)
let hasAvatar = !component.peer.profileImageRepresentations.isEmpty || component.uploadingImage != nil
let overrideImage: AvatarNodeImageOverride?
if !hasAvatar {
overrideImage = .editAvatarIcon(forceNone: true)
} else {
overrideImage = nil
}
self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize.width * 16.0 / 37.0))
self.avatarNode.setPeer(
context: component.context,
theme: component.theme,
peer: component.peer,
overrideImage: overrideImage,
clipStyle: .roundedRect,
synchronousLoad: false,
displayDimensions: avatarSize
)
self.avatarNode.frame = avatarFrame
self.uploadingImageView.layer.cornerRadius = floor(avatarSize.width * 0.25)
self.uploadingOverlayView.layer.cornerRadius = floor(avatarSize.width * 0.25)
self.button.frame = avatarFrame.insetBy(dx: -8.0, dy: -8.0)
self.button.isUserInteractionEnabled = component.isEnabled
if let uploadingImage = component.uploadingImage {
self.uploadingImageView.isHidden = false
self.uploadingOverlayView.isHidden = false
self.uploadingImageView.image = uploadingImage
self.uploadingImageView.frame = avatarFrame
self.uploadingOverlayView.frame = avatarFrame
let statusSize = CGSize(width: 50.0, height: 50.0)
self.statusNode.frame = CGRect(
origin: CGPoint(
x: floorToScreenPixels(avatarFrame.midX - statusSize.width * 0.5),
y: floorToScreenPixels(avatarFrame.midY - statusSize.height * 0.5)
),
size: statusSize
)
self.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: component.uploadProgress, cancelEnabled: false, animateRotation: true))
} else {
self.uploadingImageView.isHidden = true
self.uploadingImageView.image = nil
self.uploadingOverlayView.isHidden = true
self.statusNode.transitionToState(.none)
}
let actionButtonText = hasAvatar ? component.strings.Settings_ChangePhoto : component.strings.Settings_SetPhoto
let actionButtonSize = self.actionButton.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(Text(
text: actionButtonText,
font: Font.regular(17.0),
color: component.theme.list.itemAccentColor
)),
contentInsets: UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0),
action: { [weak self] in
guard self?.component?.isEnabled == true else {
return
}
self?.component?.action()
},
isEnabled: component.isEnabled,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 44.0)
)
if let actionButtonView = self.actionButton.view {
if actionButtonView.superview == nil {
self.addSubview(actionButtonView)
}
let actionButtonFrame = CGRect(
origin: CGPoint(
x: floorToScreenPixels((availableSize.width - actionButtonSize.width) / 2.0),
y: avatarFrame.maxY + 30.0
),
size: actionButtonSize
)
actionButtonView.frame = actionButtonFrame
}
if hasAvatar, let shadowImage = self.avatarShadow.image {
self.avatarShadow.isHidden = false
self.avatarShadow.tintColor = component.theme.list.freeTextColor
let aspectRatio = shadowImage.size.width / shadowImage.size.height
let shadowSize = CGSize(width: avatarSize.width * aspectRatio, height: avatarSize.height)
let shadowFrame = shadowSize.centered(around: avatarFrame.center).offsetBy(dx: -13.0, dy: 0.0)
self.avatarShadow.frame = shadowFrame
} else {
self.avatarShadow.isHidden = true
}
return CGSize(width: availableSize.width, height: avatarSize.height + 30.0 + actionButtonSize.height + 12.0)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
private final class ItemAccessoryComponent: Component {
enum CountStyle: Equatable {
case plain
case badge
}
let count: Int32?
let countStyle: CountStyle
let theme: PresentationTheme
init(count: Int32?, countStyle: CountStyle, theme: PresentationTheme) {
self.count = count
self.countStyle = countStyle
self.theme = theme
}
static func ==(lhs: ItemAccessoryComponent, rhs: ItemAccessoryComponent) -> Bool {
if lhs.count != rhs.count {
return false
}
if lhs.countStyle != rhs.countStyle {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private let text = ComponentView<Empty>()
private let badgeBackgroundView = UIView()
private let chevronView = UIImageView()
private var component: ItemAccessoryComponent?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.badgeBackgroundView)
self.addSubview(self.chevronView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ItemAccessoryComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
self.component = component
if themeUpdated || self.chevronView.image == nil {
self.chevronView.image = PresentationResourcesItemList.disclosureArrowImage(component.theme)
}
let countValue = component.count.flatMap { $0 > 0 ? Int($0) : nil }
var textSize = CGSize()
if let countValue {
let textColor: UIColor
let font: UIFont
let backgroundColor: UIColor?
let textConstrainedSize: CGSize
switch component.countStyle {
case .plain:
textColor = component.theme.list.itemSecondaryTextColor
font = Font.regular(17.0)
backgroundColor = nil
textConstrainedSize = CGSize(width: 80.0, height: 30.0)
case .badge:
textColor = component.theme.list.itemCheckColors.foregroundColor
font = Font.medium(12.0)
backgroundColor = component.theme.list.itemCheckColors.fillColor
textConstrainedSize = CGSize(width: 80.0, height: 20.0)
}
let measuredSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "\(countValue)", font: font, textColor: textColor)),
horizontalAlignment: .center,
verticalAlignment: .middle
)),
environment: {},
containerSize: textConstrainedSize
)
textSize = measuredSize
if let textView = self.text.view {
if textView.superview == nil {
self.insertSubview(textView, aboveSubview: self.badgeBackgroundView)
}
textView.isHidden = false
textView.backgroundColor = nil
textView.layer.cornerRadius = 0.0
textView.clipsToBounds = false
}
if let backgroundColor {
self.badgeBackgroundView.isHidden = false
self.badgeBackgroundView.backgroundColor = backgroundColor
self.badgeBackgroundView.layer.cornerRadius = 10.0
self.badgeBackgroundView.clipsToBounds = true
} else {
self.badgeBackgroundView.isHidden = true
}
} else {
self.text.view?.isHidden = true
self.badgeBackgroundView.isHidden = true
}
let chevronSize = self.chevronView.image?.size ?? CGSize(width: 8.0, height: 13.0)
let spacing: CGFloat = countValue == nil ? 0.0 : 4.0
let size = CGSize(width: textSize.width + spacing + chevronSize.width - 9.0, height: max(20.0, chevronSize.height))
var currentX: CGFloat = 0.0
if countValue != nil {
let badgeBackgroundSize = CGSize(width: max(20.0, textSize.width), height: 20.0)
transition.setFrame(view: self.badgeBackgroundView, frame: CGRect(origin: CGPoint(x: currentX + textSize.width * 0.5 - badgeBackgroundSize.width * 0.5, y: floor((size.height - badgeBackgroundSize.height) * 0.5)), size: badgeBackgroundSize))
if let textView = self.text.view {
transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: currentX, y: floor((size.height - textSize.height) * 0.5)), size: textSize))
}
currentX += textSize.width + spacing
}
transition.setFrame(view: self.chevronView, frame: CGRect(origin: CGPoint(x: currentX, y: floor((size.height - chevronSize.height) * 0.5)), size: chevronSize))
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, transition: transition)
}
}
private final class CommunityEditScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let mode: CommunityEditScreenMode
let completed: () -> Void
init(context: AccountContext, mode: CommunityEditScreenMode, completed: @escaping () -> Void) {
self.context = context
self.mode = mode
self.completed = completed
}
static func ==(lhs: CommunityEditScreenComponent, rhs: CommunityEditScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.mode != rhs.mode {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private struct DraftLinkedPeer: Equatable {
let peerId: EnginePeer.Id
var visible: Bool
}
final class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollView
private let avatarHeader = ComponentView<Empty>()
private let titleSection = ComponentView<Empty>()
private let permissionsSection = ComponentView<Empty>()
private let managementSection = ComponentView<Empty>()
private let chatsSection = ComponentView<Empty>()
private let deleteSection = ComponentView<Empty>()
private var component: CommunityEditScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private var isUpdating = false
private var ignoreScrolling = false
private let titleFieldTag = NSObject()
private var resetTitleText: String?
private var didFocusTitleField = false
private var community: TelegramCommunity?
private var cachedData: CachedCommunityData?
private var linkedPeers: [EnginePeer.Id: EnginePeer] = [:]
private var linkedPeerCachedData: [EnginePeer.Id: CachedPeerData] = [:]
private var currentLinkedPeerIds: [EnginePeer.Id] = []
private var draftLinkedPeers: [DraftLinkedPeer] = []
private var createdCommunityId: EnginePeer.Id?
private var createdCommunityTitle: String?
private var currentTitle = ""
private var initialTitle = ""
private var selectedMode: CommunityAddChatsMode = .allMembers
private var initialMode: CommunityAddChatsMode = .allMembers
private var didInitializeState = false
private var isSaving = false
private var isDeleting = false
private var isAddActionInProgress = false
private var isUpdatingAvatar = false
private var uploadingAvatarImage: UIImage?
private var avatarUploadProgress: CGFloat?
private var pendingAvatar: CommunityPendingPeerAvatar?
private var avatarPickerHolder: Any?
private var dataDisposable: Disposable?
private let linkedPeersDisposable = MetaDisposable()
private let linkedPeerDataDisposable = MetaDisposable()
private let addChatDisposable = MetaDisposable()
private let saveDisposable = MetaDisposable()
private let deleteDisposable = MetaDisposable()
private let avatarDisposable = MetaDisposable()
private let avatarUploadStatusDisposable = MetaDisposable()
private let cachedAdminsIcon = renderSettingsIcon(name: "Item List/Icons/Admin", backgroundColors: [UIColor(rgb: 0x34C759)])
private let cachedRequestsIcon = renderSettingsIcon(name: "Item List/Icons/Requests", backgroundColors: [UIColor(rgb: 0x0079ff)])
private let cachedBannedIcon = renderSettingsIcon(name: "Item List/Icons/Block", backgroundColors: [UIColor(rgb: 0xFF453A)])
override init(frame: CGRect) {
self.scrollView = ScrollView()
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.scrollsToTop = false
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.alwaysBounceVertical = true
super.init(frame: frame)
self.scrollView.delegate = self
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.dataDisposable?.dispose()
self.linkedPeersDisposable.dispose()
self.linkedPeerDataDisposable.dispose()
self.addChatDisposable.dispose()
self.saveDisposable.dispose()
self.deleteDisposable.dispose()
self.avatarDisposable.dispose()
self.avatarUploadStatusDisposable.dispose()
}
func scrollToTop() {
self.scrollView.setContentOffset(CGPoint(), animated: true)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.endEditing(true)
}
private func updateScrolling(transition: ComponentTransition) {
guard let environment = self.environment, let controller = environment.controller(), let navigationBar = controller.navigationBar, let edgeEffectView = navigationBar.edgeEffectView else {
return
}
let alphaDistance: CGFloat = 16.0
let edgeEffectAlpha = max(0.0, min(1.0, self.scrollView.contentOffset.y / alphaDistance))
transition.setAlpha(view: edgeEffectView, alpha: edgeEffectAlpha)
}
private var editCommunityId: EnginePeer.Id? {
guard let component = self.component else {
return nil
}
if case let .edit(communityId) = component.mode {
return communityId
} else {
return nil
}
}
private var createSourcePeerId: EnginePeer.Id? {
guard let component = self.component else {
return nil
}
if case let .create(peerId) = component.mode {
return peerId
} else {
return nil
}
}
private var isCreateMode: Bool {
return self.createSourcePeerId != nil
}
private var trimmedTitle: String {
return self.currentTitle.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasChanges: Bool {
if self.trimmedTitle != self.initialTitle {
return true
}
if self.selectedMode != self.initialMode {
return true
}
return false
}
private func ensureDataSignal(component: CommunityEditScreenComponent) {
if case let .create(peerId) = component.mode {
if !self.didInitializeState {
self.didInitializeState = true
self.currentTitle = ""
self.initialTitle = ""
self.selectedMode = .allMembers
self.initialMode = .allMembers
self.draftLinkedPeers = [DraftLinkedPeer(peerId: peerId, visible: true)]
self.updateLinkedPeerSignals(component: component, ids: [peerId])
}
return
}
if self.dataDisposable != nil {
return
}
guard case let .edit(communityId) = component.mode else {
return
}
component.context.account.viewTracker.forceUpdateCachedPeerData(peerId: communityId)
self.dataDisposable = (combineLatest(
component.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: communityId)),
component.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.CachedData(id: communityId))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] peer, cachedData in
guard let self else {
return
}
let community: TelegramCommunity?
if let peer {
switch peer {
case let .community(value):
community = value
default:
community = nil
}
} else {
community = nil
}
self.community = community
self.cachedData = cachedData as? CachedCommunityData
let linkedPeerIds = self.cachedData?.linkedPeers.map(\.peerId) ?? []
self.updateLinkedPeerSignals(component: component, ids: linkedPeerIds)
if let community {
let mode = CommunityAddChatsMode(defaultBannedRights: community.defaultBannedRights)
if !self.didInitializeState || (!self.hasChanges && !self.isSaving) {
self.didInitializeState = true
self.initialTitle = community.title
self.currentTitle = community.title
self.initialMode = mode
self.selectedMode = mode
self.resetTitleText = community.title
}
}
self.state?.updated(transition: .spring(duration: 0.35))
})
}
private func updateLinkedPeerSignals(component: CommunityEditScreenComponent, ids: [EnginePeer.Id]) {
if self.currentLinkedPeerIds == ids {
return
}
self.currentLinkedPeerIds = ids
if ids.isEmpty {
self.linkedPeers = [:]
self.linkedPeerCachedData = [:]
self.linkedPeersDisposable.set(nil)
self.linkedPeerDataDisposable.set(nil)
self.state?.updated(transition: .immediate)
return
}
self.linkedPeersDisposable.set((component.context.engine.data.subscribe(
EngineDataMap(ids.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] peersById in
guard let self else {
return
}
var peers: [EnginePeer.Id: EnginePeer] = [:]
for id in ids {
if let maybePeer = peersById[id], let peer = maybePeer {
peers[id] = peer
}
}
self.linkedPeers = peers
self.state?.updated(transition: .spring(duration: 0.35))
}))
self.linkedPeerDataDisposable.set((component.context.engine.data.subscribe(
EngineDataMap(ids.map(TelegramEngine.EngineData.Item.Peer.CachedData.init(id:)))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] cachedDataById in
guard let self else {
return
}
var cachedPeerData: [EnginePeer.Id: CachedPeerData] = [:]
for id in ids {
if let maybeCachedData = cachedDataById[id], let cachedData = maybeCachedData {
cachedPeerData[id] = cachedData
}
}
self.linkedPeerCachedData = cachedPeerData
self.state?.updated(transition: .spring(duration: 0.35))
}))
}
private func dismissController() {
self.environment?.controller()?.dismiss()
}
private func presentError() {
guard let component = self.component, let environment = self.environment else {
return
}
environment.controller()?.present(AlertScreen(
context: component.context,
title: nil,
text: environment.strings.Login_UnknownError,
actions: [
AlertScreen.Action(title: environment.strings.Common_OK, type: .default)
]
), in: .window(.root))
}
private func openAvatarSetup() {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
return
}
if self.isSaving || self.isDeleting || self.isUpdatingAvatar {
return
}
if self.isCreateMode {
self.openCreateAvatarSetup(component: component)
return
}
guard let community = self.community else {
return
}
let peer = EnginePeer(community)
component.context.sharedContext.displaySetPhoto(
parentController: controller,
context: component.context,
peer: peer,
canDelete: !peer.profileImageRepresentations.isEmpty,
performDelete: { [weak self] in
self?.confirmRemoveAvatar()
},
completion: { _ in },
completedWithUploadingImage: { [weak self] image, uploadStatus in
guard let self else {
return nil
}
self.isUpdatingAvatar = true
self.uploadingAvatarImage = image
self.avatarUploadProgress = 0.027
self.state?.updated(transition: .easeInOut(duration: 0.2))
self.avatarUploadStatusDisposable.set((uploadStatus
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
guard let self else {
return
}
switch status {
case let .progress(value):
self.avatarUploadProgress = max(0.027, CGFloat(value))
if !self.isUpdatingAvatar {
self.isUpdatingAvatar = true
}
self.state?.updated(transition: .easeInOut(duration: 0.2))
case .done:
self.isUpdatingAvatar = false
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
}
}, completed: { [weak self] in
guard let self, self.isUpdatingAvatar else {
return
}
self.isUpdatingAvatar = false
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
}))
return (self.avatarHeader.view as? CommunityAvatarComponent.View)?.transitionView()
}
)
}
private func applyPendingAvatar(_ avatar: CommunityPendingPeerAvatar) {
self.pendingAvatar = avatar
self.state?.updated(transition: .easeInOut(duration: 0.2))
}
private func updatePendingAvatarIfCurrent(_ avatar: CommunityPendingPeerAvatar) {
if self.pendingAvatar?.previewRepresentation.resource.id == avatar.previewRepresentation.resource.id {
self.applyPendingAvatar(avatar)
}
}
private func clearPendingAvatar() {
self.pendingAvatar = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
}
private func openCreateAvatarSetup(component: CommunityEditScreenComponent) {
self.endEditing(true)
let keyboardInputData = Promise<AvatarKeyboardInputData>()
keyboardInputData.set(AvatarEditorScreen.inputData(context: component.context, isGroup: true))
var dismissPickerImpl: (() -> Void)?
let (mainController, pickerHolder) = component.context.sharedContext.makeAvatarMediaPickerScreen(
context: component.context,
peerType: .community,
getSourceRect: { return nil },
canDelete: self.pendingAvatar != nil,
performDelete: { [weak self] in
self?.clearPendingAvatar()
},
completion: { [weak self] result, transitionView, transitionRect, transitionImage, fromCamera, _, cancelled in
guard let self, let component = self.component else {
return
}
self.avatarPickerHolder = nil
let applyPhoto: (UIImage) -> Void = { [weak self] image in
guard let self else {
return
}
if let avatar = CommunityCreateAvatarSetup.photo(context: component.context, image: image) {
self.applyPendingAvatar(avatar)
}
}
let applyVideo: (UIImage, MediaEditorScreenImpl.MediaResult.VideoResult?, MediaEditorValues?, UploadPeerPhotoMarkup?) -> Void = { [weak self] image, video, values, markup in
guard let self else {
return
}
if let avatar = CommunityCreateAvatarSetup.video(context: component.context, image: image, video: video, values: values, markup: markup, didCompleteLoadingPreview: { [weak self] avatar in
self?.updatePendingAvatarIfCurrent(avatar)
}) {
self.applyPendingAvatar(avatar)
}
}
let subject: Signal<MediaEditorScreenImpl.Subject?, NoError>
if let asset = result as? PHAsset {
subject = .single(.asset(asset))
} else if let image = result as? UIImage {
subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false))
} else if let result = result as? Signal<CameraScreenImpl.Result, NoError> {
subject = result
|> map { value -> MediaEditorScreenImpl.Subject? in
switch value {
case .pendingImage:
return nil
case let .image(image):
return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false)
case let .video(video):
return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: [], additionalVideoPosition: .topLeft, fromCamera: false)
default:
return nil
}
}
} else {
let controller = AvatarEditorScreen(context: component.context, inputData: keyboardInputData.get(), peerType: .community, markup: nil)
controller.imageCompletion = { image, commit in
applyPhoto(image)
commit()
}
controller.videoCompletion = { image, _, _, markup, commit in
applyVideo(image, nil, nil, markup)
commit()
}
self.pushController(controller)
return
}
let editorController = MediaEditorScreenImpl(
context: component.context,
mode: .avatarEditor(clipStyle: .roundedRect),
subject: subject,
transitionIn: fromCamera ? .camera : transitionView.flatMap({ .gallery(
MediaEditorScreenImpl.TransitionIn.GalleryTransitionIn(
sourceView: $0,
sourceRect: transitionRect,
sourceImage: transitionImage
)
) }),
transitionOut: { finished, _ in
if !finished, let transitionView {
return MediaEditorScreenImpl.TransitionOut(
destinationView: transitionView,
destinationRect: transitionView.bounds,
destinationCornerRadius: 0.0
)
}
return nil
},
completion: { results, commit in
guard let result = results.first else {
return
}
switch result.media {
case let .image(image, _):
applyPhoto(image)
commit({})
case let .video(video, coverImage, values, _, _):
if let coverImage {
applyVideo(coverImage, video, values, nil)
}
commit({})
default:
break
}
dismissPickerImpl?()
} as ([MediaEditorScreenImpl.Result], @escaping (@escaping () -> Void) -> Void) -> Void
)
editorController.cancelled = { _ in
cancelled()
}
self.pushController(editorController)
},
dismissed: { [weak self] in
self?.avatarPickerHolder = nil
}
)
self.avatarPickerHolder = pickerHolder
if let mainController {
dismissPickerImpl = { [weak mainController] in
if let mainController, let navigationController = mainController.navigationController {
var viewControllers = navigationController.viewControllers
viewControllers = viewControllers.filter { controller in
return !(controller is CameraScreen) && controller !== mainController
}
navigationController.setViewControllers(viewControllers, animated: false)
}
}
if mainController is ActionSheetController {
self.environment?.controller()?.present(mainController, in: .window(.root))
} else {
mainController.navigationPresentation = .flatModal
mainController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.pushController(mainController)
}
}
}
private func confirmRemoveAvatar() {
guard let component = self.component, let environment = self.environment else {
return
}
if self.isSaving || self.isDeleting || self.isUpdatingAvatar {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = ActionSheetController(presentationData: presentationData)
actionSheet.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
self?.removeAvatar()
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
environment.controller()?.present(actionSheet, in: .window(.root))
}
private func removeAvatar() {
guard let component = self.component, let communityId = self.editCommunityId else {
return
}
if self.isSaving || self.isDeleting || self.isUpdatingAvatar {
return
}
self.isUpdatingAvatar = true
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
let signal = component.context.engine.peers.updatePeerPhoto(peerId: communityId, photo: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(engine: component.context.engine, resource: resource, representations: representations)
})
|> mapError { _ -> CommunityEditSaveError in
return .generic
}
self.avatarDisposable.set((signal
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
switch result {
case .complete:
self.isUpdatingAvatar = false
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
case .progress:
break
}
}, error: { [weak self] _ in
guard let self else {
return
}
self.isUpdatingAvatar = false
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
self.presentError()
}, completed: { [weak self] in
guard let self, self.isUpdatingAvatar else {
return
}
self.isUpdatingAvatar = false
self.uploadingAvatarImage = nil
self.avatarUploadProgress = nil
self.state?.updated(transition: .easeInOut(duration: 0.2))
}))
}
private func confirmDeleteCommunity() {
guard let component = self.component, let environment = self.environment else {
return
}
if self.isSaving || self.isDeleting {
return
}
environment.controller()?.present(AlertScreen(
context: component.context,
title: environment.strings.Community_Edit_DeleteCommunity,
text: environment.strings.Community_Edit_DeleteConfirmationText,
actions: [
AlertScreen.Action(title: environment.strings.Common_Cancel, type: .generic),
AlertScreen.Action(title: environment.strings.Common_Delete, type: .defaultDestructive, action: { [weak self] in
self?.deleteCommunity()
})
]
), in: .window(.root))
}
private func deleteCommunity() {
guard let component = self.component, let communityId = self.editCommunityId else {
return
}
if self.isSaving || self.isDeleting {
return
}
self.isDeleting = true
self.state?.updated(transition: .easeInOut(duration: 0.2))
let signal = component.context.engine.peers.deleteChannel(peerId: communityId)
|> mapError { _ -> CommunityEditSaveError in
return .generic
}
|> ignoreValues
|> then(
component.context.engine.peers.joinedCommunities()
|> castError(CommunityEditSaveError.self)
|> ignoreValues
)
self.deleteDisposable.set((signal
|> deliverOnMainQueue).startStrict(error: { [weak self] _ in
guard let self else {
return
}
self.isDeleting = false
self.state?.updated(transition: .easeInOut(duration: 0.2))
self.presentError()
}, completed: { [weak self] in
guard let self else {
return
}
self.isDeleting = false
if let navigationController = self.environment?.controller()?.navigationController {
var viewControllers = navigationController.viewControllers
viewControllers = viewControllers.filter { c in
if c is CommunityViewScreen {
return false
} else {
return true
}
}
navigationController.setViewControllers(viewControllers, animated: false)
}
self.dismissController()
}))
}
private func pushController(_ controller: ViewController) {
guard let environment = self.environment else {
return
}
if let navigationController = environment.controller()?.navigationController as? NavigationController {
navigationController.pushViewController(controller)
} else {
environment.controller()?.present(controller, in: .window(.root))
}
}
private func openAdministrators() {
guard let component = self.component, let communityId = self.editCommunityId else {
return
}
let controller = channelAdminsController(
context: component.context,
updatedPresentationData: nil,
peerId: communityId
)
self.pushController(controller)
}
private func openRemovedUsers() {
guard let component = self.component, let communityId = self.editCommunityId else {
return
}
let controller = channelBlacklistController(
context: component.context,
updatedPresentationData: nil,
peerId: communityId
)
self.pushController(controller)
}
private func openPendingRequests() {
guard let component = self.component, let communityId = self.editCommunityId else {
return
}
let controller = component.context.sharedContext.makeCommunityRequestsScreen(context: component.context, communityId: communityId, existingContext: nil)
self.pushController(controller)
}
private func openPeer(_ peer: EnginePeer) {
guard let component = self.component, let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController else {
return
}
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: component.context,
chatLocation: .peer(peer),
keepStack: .always,
forceOpenChat: true
))
}
private func openDraftPeerVisibility(peer: EnginePeer) {
guard let component = self.component, self.isCreateMode else {
return
}
if self.isSaving || self.isDeleting {
return
}
let initialVisibility = self.draftLinkedPeers.first(where: { $0.peerId == peer.id })?.visible ?? true
let controller = component.context.sharedContext.makeCommunityAddScreen(
context: component.context,
peerId: peer.id,
initialVisibility: initialVisibility,
completed: { [weak self] isVisible in
guard let self else {
return
}
if let index = self.draftLinkedPeers.firstIndex(where: { $0.peerId == peer.id }) {
self.draftLinkedPeers[index].visible = isVisible
}
self.state?.updated(transition: .spring(duration: 0.35))
}
)
self.environment?.controller()?.present(controller, in: .window(.root))
}
private func openAddChat() {
guard let component = self.component, let environment = self.environment else {
return
}
if self.isSaving || self.isDeleting || self.isAddActionInProgress {
return
}
let communitiesConfiguration = CommunitiesConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
let linkedPeersCount: Int32
let excludedPeerIds: Set<EnginePeer.Id>
if self.isCreateMode {
linkedPeersCount = Int32(self.draftLinkedPeers.count)
excludedPeerIds = Set(self.draftLinkedPeers.map(\.peerId))
} else {
linkedPeersCount = Int32(self.cachedData?.linkedPeers.count ?? 0)
excludedPeerIds = Set((self.cachedData?.linkedPeers ?? []).map(\.peerId))
}
if linkedPeersCount >= communitiesConfiguration.peersLimit {
environment.controller()?.present(AlertScreen(
context: component.context,
title: nil,
text: environment.strings.Community_Edit_ChatsLimitReached,
actions: [
AlertScreen.Action(title: environment.strings.Common_OK, type: .default)
]
), in: .window(.root))
return
}
self.isAddActionInProgress = true
self.state?.updated(transition: .spring(duration: 0.35))
self.addChatDisposable.set((component.context.engine.peers.adminedPublicChannels(scope: .forCommunity)
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak self] channels in
guard let self else {
return
}
self.isAddActionInProgress = false
self.addChatDisposable.set(nil)
self.state?.updated(transition: .spring(duration: 0.35))
guard let component = self.component, let environment = self.environment else {
return
}
let channels = channels.filter { channel in
!excludedPeerIds.contains(channel.peer.id)
}
let selectionController = PeerSelectionScreen(
context: component.context,
initialData: PeerSelectionScreen.communityInitialData(channels: channels),
updatedPresentationData: nil,
completion: { [weak self] channel in
guard let self, let component = self.component, let channel else {
return
}
let controller: ViewController
if self.isCreateMode {
controller = component.context.sharedContext.makeCommunityAddScreen(
context: component.context,
peerId: channel.peer.id,
initialVisibility: true,
completed: { [weak self] isVisible in
guard let self, let component = self.component else {
return
}
if let index = self.draftLinkedPeers.firstIndex(where: { $0.peerId == channel.peer.id }) {
self.draftLinkedPeers[index].visible = isVisible
} else {
self.draftLinkedPeers.append(DraftLinkedPeer(peerId: channel.peer.id, visible: isVisible))
}
self.updateLinkedPeerSignals(component: component, ids: self.draftLinkedPeers.map(\.peerId))
self.state?.updated(transition: .spring(duration: 0.35))
}
)
} else {
guard let communityId = self.editCommunityId else {
return
}
controller = component.context.sharedContext.makeCommunityAddScreen(
context: component.context,
communityId: communityId,
peerId: channel.peer.id,
completed: { [weak self] immediate in
guard let self, let component = self.component, let communityId = self.editCommunityId else {
return
}
component.context.account.viewTracker.forceUpdateCachedPeerData(peerId: communityId)
self.state?.updated(transition: .spring(duration: 0.35))
}
)
}
self.environment?.controller()?.present(controller, in: .window(.root))
}
)
selectionController.navigationPresentation = .modal
environment.controller()?.push(selectionController)
}))
}
private func rightsWithUpdatedMode(_ mode: CommunityAddChatsMode, current: TelegramChatBannedRights?) -> TelegramChatBannedRights {
var flags = current?.flags ?? TelegramChatBannedRightsFlags()
switch mode {
case .allMembers:
flags.remove(.banManageLinkedPeers)
case .onlyAdmins:
flags.insert(.banManageLinkedPeers)
}
return TelegramChatBannedRights(flags: flags, untilDate: current?.untilDate ?? Int32.max)
}
private func animateTitleError() {
if let view = self.titleSection.findTaggedView(tag: self.titleFieldTag) as? ListTextFieldItemComponent.View {
view.animateError()
view.activateInput()
}
}
private func saveCreateCommunity(component: CommunityEditScreenComponent, sourcePeerId: EnginePeer.Id) {
if self.isSaving || self.isDeleting {
return
}
let title = self.trimmedTitle
if title.isEmpty {
self.animateTitleError()
return
}
guard let sourcePeer = self.draftLinkedPeers.first(where: { $0.peerId == sourcePeerId }) else {
return
}
self.endEditing(true)
self.isSaving = true
self.state?.updated(transition: .easeInOut(duration: 0.2))
let createSignal: Signal<EnginePeer.Id, CommunityEditSaveError>
let shouldApplySourcePeerLink = self.createdCommunityId != nil
if let createdCommunityId = self.createdCommunityId {
createSignal = .single(createdCommunityId)
} else {
createSignal = component.context.engine.peers.createCommunity(
title: title,
about: nil,
peerId: sourcePeerId,
visible: sourcePeer.visible
)
|> mapError { _ -> CommunityEditSaveError in
return .generic
}
|> afterNext { [weak self] communityId in
self?.createdCommunityId = communityId
self?.createdCommunityTitle = title
}
}
let pendingAvatar = self.pendingAvatar
let shouldUpdateMode = self.selectedMode != .allMembers || self.createdCommunityId != nil
let peersToLink = shouldApplySourcePeerLink ? self.draftLinkedPeers : self.draftLinkedPeers.filter { $0.peerId != sourcePeerId }
let signal = createSignal
|> mapToSignal { communityId -> Signal<Never, CommunityEditSaveError> in
var signal: Signal<Never, CommunityEditSaveError> = .complete()
|> delay(0.5, queue: Queue.mainQueue())
if let pendingAvatar {
signal = signal
|> then(component.context.engine.peers.updatePeerPhoto(
peerId: communityId,
photo: pendingAvatar.uploadedPhoto,
video: pendingAvatar.uploadedVideo,
videoStartTimestamp: pendingAvatar.videoStartTimestamp,
markup: pendingAvatar.markup,
mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(engine: component.context.engine, resource: resource, representations: representations)
}
)
|> mapError { _ -> CommunityEditSaveError in
return .generic
}
|> ignoreValues)
}
if shouldUpdateMode {
let rights = self.rightsWithUpdatedMode(self.selectedMode, current: nil)
signal = signal
|> then(component.context.engine.peers.updateDefaultChannelMemberBannedRights(peerId: communityId, rights: rights)
|> castError(CommunityEditSaveError.self))
}
for peer in peersToLink {
signal = signal
|> then(component.context.engine.peers.toggleCommunityPeerLink(
communityId: communityId,
peerId: peer.peerId,
action: .link(visible: peer.visible)
)
|> `catch` { error -> Signal<Never, CommunityEditSaveError> in
if case .requestCreated = error {
return .complete()
} else {
return .fail(.generic)
}
})
}
return signal
}
|> then(
component.context.engine.peers.joinedCommunities()
|> castError(CommunityEditSaveError.self)
|> ignoreValues
)
self.saveDisposable.set((signal
|> deliverOnMainQueue).startStrict(error: { [weak self] _ in
guard let self else {
return
}
self.isSaving = false
self.state?.updated(transition: .easeInOut(duration: 0.2))
self.presentError()
}, completed: { [weak self] in
guard let self else {
return
}
self.isSaving = false
component.completed()
}))
}
func save() {
guard let component = self.component else {
return
}
if case let .create(peerId) = component.mode {
self.saveCreateCommunity(component: component, sourcePeerId: peerId)
return
}
guard let component = self.component, let community = self.community else {
return
}
if self.isSaving || self.isDeleting {
return
}
let title = self.trimmedTitle
if title.isEmpty {
self.animateTitleError()
return
}
if !self.hasChanges {
self.dismissController()
return
}
self.isSaving = true
let titleChanged = title != self.initialTitle
let modeChanged = self.selectedMode != self.initialMode
var signal: Signal<Never, CommunityEditSaveError> = .complete()
if titleChanged {
signal = signal
|> then(component.context.engine.peers.updatePeerTitle(peerId: community.id, title: title)
|> mapError { _ -> CommunityEditSaveError in
return .generic
}
|> ignoreValues)
}
if modeChanged {
let rights = self.rightsWithUpdatedMode(self.selectedMode, current: community.defaultBannedRights)
signal = signal
|> then(component.context.engine.peers.updateDefaultChannelMemberBannedRights(peerId: community.id, rights: rights)
|> castError(CommunityEditSaveError.self))
}
self.saveDisposable.set((signal
|> deliverOnMainQueue).startStrict(error: { [weak self] _ in
guard let self else {
return
}
self.isSaving = false
self.presentError()
}, completed: { [weak self] in
guard let self else {
return
}
self.isSaving = false
self.dismissController()
}))
}
private func selectMode(_ mode: CommunityAddChatsMode) {
if self.isSaving || self.isDeleting {
return
}
if self.selectedMode != mode {
self.selectedMode = mode
self.state?.updated(transition: .spring(duration: 0.35))
}
}
private func permissionItem(
id: String,
title: String,
subtitle: String,
mode: CommunityAddChatsMode,
theme: PresentationTheme,
presentationData: PresentationData
) -> AnyComponentWithIdentity<Empty> {
return AnyComponentWithIdentity(id: id, component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: "title", component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: title,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
AnyComponentWithIdentity(id: "subtitle", component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: subtitle,
font: Font.regular(13.0),
textColor: theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 0
)))
], alignment: .left, spacing: 4.0)),
contentInsets: UIEdgeInsets(top: 9.0, left: 0.0, bottom: 9.0, right: 0.0),
leftIcon: .check(ListActionItemComponent.LeftIcon.Check(
style: .tick,
isSelected: self.selectedMode == mode,
toggle: { [weak self] in
self?.selectMode(mode)
}
)),
accessory: nil,
action: { [weak self] _ in
self?.selectMode(mode)
},
highlighting: self.isSaving ? .disabled : .default
)))
}
private func managementItem(
id: String,
title: String,
icon: UIImage?,
count: Int32?,
countStyle: ItemAccessoryComponent.CountStyle,
theme: PresentationTheme,
presentationData: PresentationData,
action: (() -> Void)?
) -> AnyComponentWithIdentity<Empty> {
let itemAction: ((UIView) -> Void)? = action.map { action in
return { _ in
action()
}
}
return AnyComponentWithIdentity(id: id, component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: title,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)),
contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0),
leftIcon: .custom(AnyComponentWithIdentity(id: icon, component: AnyComponent(Image(image: icon, size: CGSize(width: 30.0, height: 30.0)))), false),
accessory: .custom(ListActionItemComponent.CustomAccessory(
component: AnyComponentWithIdentity(id: "\(id)-accessory-\(count ?? 0)", component: AnyComponent(ItemAccessoryComponent(
count: count,
countStyle: countStyle,
theme: theme
))),
insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 16.0),
isInteractive: false
)),
action: itemAction,
highlighting: itemAction == nil ? .disabled : .default
)))
}
private func chatsHeaderTitle(presentationData: PresentationData) -> String {
let count: Int
if self.isCreateMode {
count = self.draftLinkedPeers.count
} else {
count = self.cachedData?.linkedPeers.count ?? 0
}
return presentationData.strings.Community_Edit_ChatsHeader(Int32(clamping: count))
}
private func memberCountString(peerId: EnginePeer.Id, presentationData: PresentationData) -> String? {
guard let cachedData = self.linkedPeerCachedData[peerId] else {
return nil
}
let count: Int32?
if let cachedData = cachedData as? CachedChannelData {
count = cachedData.participantsSummary.memberCount
} else if let cachedData = cachedData as? CachedGroupData {
count = Int32(cachedData.participants?.participants.count ?? 0)
} else {
count = nil
}
guard let count else {
return nil
}
return presentationData.strings.Conversation_StatusMembers(count)
}
private func addChatItem(
theme: PresentationTheme,
presentationData: PresentationData
) -> AnyComponentWithIdentity<Empty> {
return AnyComponentWithIdentity(id: "addChat", component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_Edit_AddChat,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemAccentColor
)),
maximumNumberOfLines: 1
)),
contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0),
leftIcon: .custom(AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(name: "Item List/AddCommunityIcon", tintColor: theme.list.itemAccentColor))), false),
accessory: self.isAddActionInProgress ? .activity : nil,
action: { [weak self] _ in
self?.openAddChat()
},
highlighting: (self.isSaving || self.isDeleting || self.isAddActionInProgress) ? .disabled : .default
)))
}
private func chatItem(
context: AccountContext,
peer: EnginePeer,
theme: PresentationTheme,
presentationData: PresentationData,
isHidden: Bool = false
) -> AnyComponentWithIdentity<Empty> {
let subtitle: PeerListItemComponent.Subtitle?
if case .user = peer {
subtitle = PeerListItemComponent.Subtitle(text: presentationData.strings.Bot_GenericBotStatus, color: .neutral)
} else {
subtitle = self.memberCountString(peerId: peer.id, presentationData: presentationData).flatMap {
PeerListItemComponent.Subtitle(text: $0, color: .neutral)
}
}
return AnyComponentWithIdentity(id: peer.id, component: AnyComponent(PeerListItemComponent(
context: context,
theme: theme,
strings: presentationData.strings,
style: .generic,
sideInset: 0.0,
title: peer.compactDisplayTitle,
titleAccessory: isHidden ? .hidden : .none,
peer: peer,
subtitle: subtitle,
subtitleAccessory: .none,
presence: nil,
rightAccessory: .none,
selectionState: .none,
isEnabled: !(self.isSaving || self.isDeleting),
hasNext: false,
extractedTheme: PeerListItemComponent.ExtractedTheme(
inset: 2.0,
background: theme.list.itemBlocksBackgroundColor
),
insets: UIEdgeInsets(top: -1.0, left: 0.0, bottom: -1.0, right: 0.0),
action: { [weak self] peer, _, _ in
self?.openPeer(peer)
}
)))
}
private func draftChatItem(
context: AccountContext,
peer: EnginePeer,
visible: Bool,
theme: PresentationTheme,
presentationData: PresentationData
) -> AnyComponentWithIdentity<Empty> {
let subtitle = self.memberCountString(peerId: peer.id, presentationData: presentationData).flatMap {
PeerListItemComponent.Subtitle(text: $0, color: .neutral)
}
return AnyComponentWithIdentity(id: peer.id, component: AnyComponent(PeerListItemComponent(
context: context,
theme: theme,
strings: presentationData.strings,
style: .generic,
sideInset: 0.0,
title: peer.compactDisplayTitle,
titleAccessory: visible ? .none : .hidden,
peer: peer,
subtitle: subtitle,
subtitleAccessory: .none,
presence: nil,
rightAccessory: .none,
selectionState: .none,
isEnabled: !(self.isSaving || self.isDeleting),
hasNext: false,
extractedTheme: PeerListItemComponent.ExtractedTheme(
inset: 2.0,
background: theme.list.itemBlocksBackgroundColor
),
insets: UIEdgeInsets(top: -1.0, left: 0.0, bottom: -1.0, right: 0.0),
action: { [weak self] peer, _, _ in
self?.openDraftPeerVisibility(peer: peer)
}
)))
}
private func deleteItem(
theme: PresentationTheme,
presentationData: PresentationData
) -> AnyComponentWithIdentity<Empty> {
return AnyComponentWithIdentity(id: "delete", component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_Edit_DeleteCommunity,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemDestructiveColor
)),
horizontalAlignment: .center,
maximumNumberOfLines: 1
)),
titleAlignment: .center,
contentInsets: UIEdgeInsets(top: 12.0, left: 0.0, bottom: 12.0, right: 0.0),
leftIcon: nil,
accessory: self.isDeleting ? .activity : nil,
action: { [weak self] _ in
self?.confirmDeleteCommunity()
},
highlighting: (self.isSaving || self.isDeleting) ? .disabled : .default
)))
}
func update(component: CommunityEditScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let environment = environment[EnvironmentType.self].value
self.environment = environment
self.component = component
self.state = state
self.ensureDataSignal(component: component)
let theme = environment.theme
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let sectionSpacing: CGFloat = 24.0
self.backgroundColor = theme.list.blocksBackgroundColor
self.scrollView.backgroundColor = theme.list.blocksBackgroundColor
var contentHeight = environment.navigationHeight - 36.0
let resetTitleText = self.resetTitleText
self.resetTitleText = nil
let avatarPeer: EnginePeer?
if let community = self.community {
avatarPeer = EnginePeer(community)
} else if self.isCreateMode {
avatarPeer = EnginePeer(TelegramCommunity(
id: PeerId(namespace: .max, id: PeerId.Id._internalFromInt64Value(0)),
accessHash: nil,
title: self.trimmedTitle,
photo: self.pendingAvatar.flatMap { [$0.previewRepresentation] } ?? [],
creationDate: 0,
participationStatus: .member,
flags: [.isCreator],
collapsedInDialogs: nil,
adminRights: nil,
defaultBannedRights: nil
))
} else {
avatarPeer = nil
}
if let avatarPeer {
var transition = transition
if self.avatarHeader.view == nil {
transition = .immediate
}
let avatarHeaderSize = self.avatarHeader.update(
transition: transition,
component: AnyComponent(CommunityAvatarComponent(
context: component.context,
theme: theme,
strings: presentationData.strings,
peer: avatarPeer,
isEnabled: !(self.isSaving || self.isDeleting || self.isUpdatingAvatar),
uploadingImage: self.uploadingAvatarImage,
uploadProgress: self.avatarUploadProgress,
action: { [weak self] in
self?.openAvatarSetup()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 120.0)
)
if let avatarHeaderView = self.avatarHeader.view {
if avatarHeaderView.superview == nil {
self.scrollView.addSubview(avatarHeaderView)
}
transition.setFrame(view: avatarHeaderView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: avatarHeaderSize))
}
}
contentHeight += 179.0
let titleSectionSize = self.titleSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: [
AnyComponentWithIdentity(id: "title", component: AnyComponent(ListTextFieldItemComponent(
style: .glass,
theme: theme,
initialText: self.currentTitle,
resetText: resetTitleText.flatMap { ListTextFieldItemComponent.ResetText(value: $0) },
placeholder: self.isCreateMode ? presentationData.strings.Community_Edit_CommunityNamePlaceholder : "",
characterLimit: 128,
autocapitalizationType: .words,
autocorrectionType: .yes,
updated: { [weak self] value in
guard let self else {
return
}
self.currentTitle = value
},
tag: self.titleFieldTag
)))
]
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let titleSectionView = self.titleSection.view {
if titleSectionView.superview == nil {
self.scrollView.addSubview(titleSectionView)
}
transition.setFrame(view: titleSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: titleSectionSize))
}
if self.isCreateMode && !self.didFocusTitleField {
self.didFocusTitleField = true
Queue.mainQueue().after(0.1, { [weak self] in
guard let self, let view = self.titleSection.findTaggedView(tag: self.titleFieldTag) as? ListTextFieldItemComponent.View else {
return
}
view.activateInput()
})
}
contentHeight += titleSectionSize.height + sectionSpacing
let permissionsSectionSize = self.permissionsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_Edit_WhoCanAddChats,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 1
)),
footer: nil,
items: [
self.permissionItem(
id: "allMembers",
title: presentationData.strings.Community_Edit_PermissionAllMembers,
subtitle: presentationData.strings.Community_Edit_PermissionAllMembersInfo,
mode: .allMembers,
theme: theme,
presentationData: presentationData
),
self.permissionItem(
id: "onlyAdmins",
title: presentationData.strings.Community_Edit_PermissionOnlyAdmins,
subtitle: presentationData.strings.Community_Edit_PermissionOnlyAdminsInfo,
mode: .onlyAdmins,
theme: theme,
presentationData: presentationData
)
]
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let permissionsSectionView = self.permissionsSection.view {
if permissionsSectionView.superview == nil {
self.scrollView.addSubview(permissionsSectionView)
}
transition.setFrame(view: permissionsSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: permissionsSectionSize))
}
contentHeight += permissionsSectionSize.height
contentHeight += sectionSpacing
let adminsCount = self.cachedData?.adminsCount
let removedUsersCount = self.cachedData?.kickedCount
let pendingRequests = self.cachedData?.pendingRequests
var managementItems: [AnyComponentWithIdentity<Empty>] = [
self.managementItem(
id: "administrators",
title: presentationData.strings.Community_Edit_Administrators,
icon: self.cachedAdminsIcon,
count: adminsCount,
countStyle: .plain,
theme: theme,
presentationData: presentationData,
action: { [weak self] in
self?.openAdministrators()
}
)
]
if let pendingRequests, pendingRequests > 0 {
managementItems.append(self.managementItem(
id: "pendingRequests",
title: presentationData.strings.Community_Edit_PendingRequests,
icon: self.cachedRequestsIcon,
count: pendingRequests,
countStyle: .badge,
theme: theme,
presentationData: presentationData,
action: { [weak self] in
self?.openPendingRequests()
}
))
}
managementItems.append(self.managementItem(
id: "removedUsers",
title: presentationData.strings.Community_Edit_RemovedUsers,
icon: self.cachedBannedIcon,
count: removedUsersCount,
countStyle: .plain,
theme: theme,
presentationData: presentationData,
action: { [weak self] in
self?.openRemovedUsers()
}
))
if !self.isCreateMode && !managementItems.isEmpty {
let managementSectionSize = self.managementSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: managementItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let managementSectionView = self.managementSection.view {
if managementSectionView.superview == nil {
self.scrollView.addSubview(managementSectionView)
}
transition.setFrame(view: managementSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: managementSectionSize))
}
contentHeight += managementSectionSize.height
contentHeight += sectionSpacing
}
var chatItems: [AnyComponentWithIdentity<Empty>] = []
if self.isCreateMode {
chatItems.append(self.addChatItem(
theme: theme,
presentationData: presentationData
))
for draftPeer in self.draftLinkedPeers {
guard let peer = self.linkedPeers[draftPeer.peerId] else {
continue
}
chatItems.append(self.draftChatItem(
context: component.context,
peer: peer,
visible: draftPeer.visible,
theme: theme,
presentationData: presentationData
))
}
} else {
chatItems.append(self.addChatItem(
theme: theme,
presentationData: presentationData
))
let linkedPeers = self.cachedData?.linkedPeers ?? []
for linkedPeer in linkedPeers {
guard let peer = self.linkedPeers[linkedPeer.peerId] else {
continue
}
chatItems.append(self.chatItem(
context: component.context,
peer: peer,
theme: theme,
presentationData: presentationData,
isHidden: linkedPeer.visible == false
))
}
}
let chatsSectionSize = self.chatsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: self.chatsHeaderTitle(presentationData: presentationData),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 1
)),
footer: nil,
items: chatItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let chatsSectionView = self.chatsSection.view {
if chatsSectionView.superview == nil {
self.scrollView.addSubview(chatsSectionView)
}
transition.setFrame(view: chatsSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: chatsSectionSize))
}
contentHeight += chatsSectionSize.height
if let community = self.community, community.flags.contains(.isCreator) {
contentHeight += sectionSpacing
var transition = transition
if self.deleteSection.view == nil {
transition = .immediate
}
let deleteSectionSize = self.deleteSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: [
self.deleteItem(
theme: theme,
presentationData: presentationData
)
]
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let deleteSectionView = self.deleteSection.view {
if deleteSectionView.superview == nil {
self.scrollView.addSubview(deleteSectionView)
}
transition.setFrame(view: deleteSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: deleteSectionSize))
}
contentHeight += deleteSectionSize.height
}
contentHeight += 32.0 + environment.safeInsets.bottom
let contentSize = CGSize(width: availableSize.width, height: max(contentHeight, availableSize.height + 1.0))
self.ignoreScrolling = true
if self.scrollView.frame.size != availableSize {
self.scrollView.frame = CGRect(origin: .zero, size: availableSize)
}
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0)
if self.scrollView.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class CommunityEditScreen: ViewControllerComponentContainer {
public convenience init(context: AccountContext, communityId: EnginePeer.Id) {
self.init(context: context, mode: .edit(communityId: communityId), completed: {})
}
public init(context: AccountContext, mode: CommunityEditScreenMode, completed: @escaping () -> Void) {
super.init(
context: context,
component: CommunityEditScreenComponent(context: context, mode: mode, completed: completed),
navigationBarAppearance: .default,
theme: .default,
updatedPresentationData: nil
)
self.title = ""
switch mode {
case .create:
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.closePressed))
case .edit:
self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: UIView())
}
self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "___done", style: .plain, target: self, action: #selector(self.savePressed))
self.scrollToTop = { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? CommunityEditScreenComponent.View else {
return
}
componentView.scrollToTop()
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func closePressed() {
self.dismiss()
}
@objc private func savePressed() {
guard let componentView = self.node.hostView.componentView as? CommunityEditScreenComponent.View else {
return
}
componentView.save()
}
}