diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index b779e3322b..c05f275d35 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -159,14 +159,16 @@ public struct ChatAvailableMessageActionOptions: OptionSet { public struct ChatAvailableMessageActions { public var options: ChatAvailableMessageActionOptions public var banAuthor: Peer? + public var banAuthors: [Peer] public var disableDelete: Bool public var isCopyProtected: Bool public var setTag: Bool public var editTags: Set - public init(options: ChatAvailableMessageActionOptions, banAuthor: Peer?, disableDelete: Bool, isCopyProtected: Bool, setTag: Bool, editTags: Set) { + public init(options: ChatAvailableMessageActionOptions, banAuthor: Peer?, banAuthors: [Peer], disableDelete: Bool, isCopyProtected: Bool, setTag: Bool, editTags: Set) { self.options = options self.banAuthor = banAuthor + self.banAuthors = banAuthors self.disableDelete = disableDelete self.isCopyProtected = isCopyProtected self.setTag = setTag diff --git a/submodules/AttachmentUI/Sources/AttachmentContainer.swift b/submodules/AttachmentUI/Sources/AttachmentContainer.swift index 9f2c472512..ace93ca1d4 100644 --- a/submodules/AttachmentUI/Sources/AttachmentContainer.swift +++ b/submodules/AttachmentUI/Sources/AttachmentContainer.swift @@ -68,6 +68,7 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { var isPanningUpdated: (Bool) -> Void = { _ in } var isExpandedUpdated: (Bool) -> Void = { _ in } + var isPanGestureEnabled: (() -> Bool)? var onExpandAnimationCompleted: () -> Void = {} override init() { @@ -132,6 +133,12 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { if case .regular = layout.metrics.widthClass { return false } + + if let isPanGestureEnabled = self.isPanGestureEnabled { + if !isPanGestureEnabled() { + return false + } + } } return true } diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index f1ec3c9d1f..24c45642b9 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -93,6 +93,7 @@ public protocol AttachmentContainable: ViewController { var cancelPanGesture: () -> Void { get set } var isContainerPanning: () -> Bool { get set } var isContainerExpanded: () -> Bool { get set } + var isPanGestureEnabled: (() -> Bool)? { get } var mediaPickerContext: AttachmentMediaPickerContext? { get } func isContainerPanningUpdated(_ panning: Bool) @@ -124,6 +125,10 @@ public extension AttachmentContainable { func shouldDismissImmediately() -> Bool { return true } + + var isPanGestureEnabled: (() -> Bool)? { + return nil + } } public enum AttachmentMediaPickerSendMode { @@ -351,6 +356,17 @@ public class AttachmentController: ViewController { } } + self.container.isPanGestureEnabled = { [weak self] in + guard let self, let currentController = self.currentControllers.last else { + return true + } + if let isPanGestureEnabled = currentController.isPanGestureEnabled { + return isPanGestureEnabled() + } else { + return true + } + } + self.container.shouldCancelPanGesture = { [weak self] in if let strongSelf = self, let currentController = strongSelf.currentControllers.last { if !currentController.shouldDismissImmediately() { @@ -548,6 +564,7 @@ public class AttachmentController: ViewController { strongSelf.panel.updateBackgroundAlpha(alpha, transition: transition) } } + controller.cancelPanGesture = { [weak self] in if let strongSelf = self { strongSelf.container.cancelPanGesture() diff --git a/submodules/ComposePollUI/BUILD b/submodules/ComposePollUI/BUILD index f5acb65af9..209d04ead7 100644 --- a/submodules/ComposePollUI/BUILD +++ b/submodules/ComposePollUI/BUILD @@ -37,6 +37,10 @@ swift_library( "//submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent", "//submodules/TelegramUI/Components/ListActionItemComponent", "//submodules/TelegramUI/Components/TextFieldComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/ChatPresentationInterfaceState", + "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ComposePollUI/Sources/ComposePollScreen.swift b/submodules/ComposePollUI/Sources/ComposePollScreen.swift index 749a47b84a..a3f6abfcc0 100644 --- a/submodules/ComposePollUI/Sources/ComposePollScreen.swift +++ b/submodules/ComposePollUI/Sources/ComposePollScreen.swift @@ -21,6 +21,11 @@ import PeerAllowedReactionsScreen import AttachmentUI import ListMultilineTextFieldItemComponent import ListActionItemComponent +import ChatEntityKeyboardInputNode +import ChatPresentationInterfaceState +import EmojiSuggestionsComponent +import TextFormat +import TextFieldComponent final class ComposePollScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -48,7 +53,7 @@ final class ComposePollScreenComponent: Component { private final class PollOption { let id: Int - let textInputState = ListComposePollOptionComponent.ExternalState() + let textInputState = TextFieldComponent.ExternalState() let textFieldTag = NSObject() var resetText: String? @@ -78,10 +83,7 @@ final class ComposePollScreenComponent: Component { private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? - private var emojiContent: EmojiPagerContentComponent? - private var emojiContentDisposable: Disposable? - - private let pollTextInputState = ListMultilineTextFieldItemComponent.ExternalState() + private let pollTextInputState = TextFieldComponent.ExternalState() private let pollTextFieldTag = NSObject() private var resetPollText: String? @@ -96,7 +98,18 @@ final class ComposePollScreenComponent: Component { private var isQuiz: Bool = false private var selectedQuizOptionId: Int? - private var displayInput: Bool = false + private var currentInputMode: ListComposePollOptionComponent.InputMode = .keyboard + + private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? + private var inputMediaNodeDataDisposable: Disposable? + private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() + private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? + private var inputMediaNode: ChatEntityKeyboardInputNode? + private var inputMediaNodeBackground = SimpleLayer() + + private let inputMediaNodeDataPromise = Promise() + + private var currentEmojiSuggestionView: ComponentHostView? override init(frame: CGRect) { self.scrollView = UIScrollView() @@ -121,7 +134,7 @@ final class ComposePollScreenComponent: Component { } deinit { - self.emojiContentDisposable?.dispose() + self.inputMediaNodeDataDisposable?.dispose() } func scrollToTop() { @@ -150,8 +163,19 @@ final class ComposePollScreenComponent: Component { if self.selectedQuizOptionId == pollOption.id { selectedQuizOption = optionData } + var entities: [MessageTextEntity] = [] + for entity in generateChatInputTextEntities(pollOption.textInputState.text) { + switch entity.type { + case .CustomEmoji: + entities.append(entity) + default: + break + } + } + mappedOptions.append(TelegramMediaPollOption( text: pollOption.textInputState.text.string, + entities: entities, opaqueIdentifier: optionData )) } @@ -174,10 +198,20 @@ final class ComposePollScreenComponent: Component { mappedSolution = self.quizAnswerTextInputState.text.string } + var textEntities: [MessageTextEntity] = [] + for entity in generateChatInputTextEntities(self.pollTextInputState.text) { + switch entity.type { + case .CustomEmoji: + textEntities.append(entity) + default: + break + } + } + return ComposedPoll( publicity: self.isAnonymous ? .anonymous : .public, kind: mappedKind, - text: self.pollTextInputState.text.string, + text: ComposedPoll.Text(string: self.pollTextInputState.text.string, entities: textEntities), options: mappedOptions, correctAnswers: mappedCorrectAnswers, results: TelegramMediaPollResults( @@ -215,6 +249,187 @@ final class ComposePollScreenComponent: Component { } } + func isPanGestureEnabled() -> Bool { + if self.inputMediaNode != nil { + return false + } + + for (_, state) in self.collectTextInputStates() { + if state.isEditing { + return false + } + } + + return true + } + + private func updateInputMediaNode( + component: ComposePollScreenComponent, + availableSize: CGSize, + bottomInset: CGFloat, + inputHeight: CGFloat, + effectiveInputHeight: CGFloat, + metrics: LayoutMetrics, + deviceMetrics: DeviceMetrics, + transition: Transition + ) -> CGFloat { + let bottomInset: CGFloat = bottomInset + 8.0 + let bottomContainerInset: CGFloat = 0.0 + let needsInputActivation: Bool = !"".isEmpty + + var height: CGFloat = 0.0 + if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { + let inputMediaNode: ChatEntityKeyboardInputNode + var inputMediaNodeTransition = transition + var animateIn = false + if let current = self.inputMediaNode { + inputMediaNode = current + } else { + animateIn = true + inputMediaNodeTransition = inputMediaNodeTransition.withAnimation(.none) + inputMediaNode = ChatEntityKeyboardInputNode( + context: component.context, + currentInputData: inputData, + updatedInputData: self.inputMediaNodeDataPromise.get(), + defaultToEmojiTab: true, + opaqueTopPanelBackground: false, + interaction: self.inputMediaInteraction, + chatPeerId: nil, + stateContext: self.inputMediaNodeStateContext + ) + inputMediaNode.clipsToBounds = true + + inputMediaNode.externalTopPanelContainerImpl = nil + inputMediaNode.useExternalSearchContainer = true + if inputMediaNode.view.superview == nil { + self.inputMediaNodeBackground.removeAllAnimations() + self.layer.addSublayer(self.inputMediaNodeBackground) + self.addSubview(inputMediaNode.view) + } + self.inputMediaNode = inputMediaNode + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let presentationInterfaceState = ChatPresentationInterfaceState( + chatWallpaper: .builtin(WallpaperSettings()), + theme: presentationData.theme, + strings: presentationData.strings, + dateTimeFormat: presentationData.dateTimeFormat, + nameDisplayOrder: presentationData.nameDisplayOrder, + limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, + fontSize: presentationData.chatFontSize, + bubbleCorners: presentationData.chatBubbleCorners, + accountPeerId: component.context.account.peerId, + mode: .standard(.default), + chatLocation: .peer(id: component.context.account.peerId), + subject: nil, + peerNearbyData: nil, + greetingData: nil, + pendingUnpinnedAllMessages: false, + activeGroupCallInfo: nil, + hasActiveGroupCall: false, + importState: nil, + threadData: nil, + isGeneralThreadClosed: nil, + replyMessage: nil, + accountPeerColor: nil, + businessIntro: nil + ) + + self.inputMediaNodeBackground.backgroundColor = presentationData.theme.rootController.navigationBar.opaqueBackgroundColor.cgColor + + let heightAndOverflow = inputMediaNode.updateLayout(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, bottomInset: bottomInset, standardInputHeight: deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: inputHeight < 100.0 ? inputHeight - bottomContainerInset : inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: metrics, deviceMetrics: deviceMetrics, isVisible: true, isExpanded: false) + let inputNodeHeight = heightAndOverflow.0 + let inputNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputNodeHeight), size: CGSize(width: availableSize.width, height: inputNodeHeight)) + + let inputNodeBackgroundFrame = CGRect(origin: CGPoint(x: inputNodeFrame.minX, y: inputNodeFrame.minY - 6.0), size: CGSize(width: inputNodeFrame.width, height: inputNodeFrame.height + 6.0)) + + if needsInputActivation { + let inputNodeFrame = inputNodeFrame.offsetBy(dx: 0.0, dy: inputNodeHeight) + Transition.immediate.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + Transition.immediate.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } + + if animateIn { + var targetFrame = inputMediaNode.frame + targetFrame.origin.y = availableSize.height + inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: targetFrame) + + let inputNodeBackgroundTargetFrame = CGRect(origin: CGPoint(x: targetFrame.minX, y: targetFrame.minY - 6.0), size: CGSize(width: targetFrame.width, height: targetFrame.height + 6.0)) + + inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundTargetFrame) + + transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + transition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } else { + inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } + + height = heightAndOverflow.0 + } else if let inputMediaNode = self.inputMediaNode { + self.inputMediaNode = nil + + var targetFrame = inputMediaNode.frame + targetFrame.origin.y = availableSize.height + transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in + if let inputMediaNode { + Queue.mainQueue().after(0.3) { + inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in + inputMediaNode?.view.removeFromSuperview() + }) + } + } + }) + transition.setFrame(layer: self.inputMediaNodeBackground, frame: targetFrame, completion: { [weak self] _ in + Queue.mainQueue().after(0.3) { + guard let self else { + return + } + if self.currentInputMode == .keyboard { + self.inputMediaNodeBackground.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak self] finished in + guard let self else { + return + } + + if finished { + self.inputMediaNodeBackground.removeFromSuperlayer() + } + self.inputMediaNodeBackground.removeAllAnimations() + }) + } + } + }) + } + + /*if needsInputActivation { + needsInputActivation = false + Queue.mainQueue().justDispatch { + inputPanelView.activateInput() + } + }*/ + + /*if let controller = self.environment?.controller() as? ComposePollScreen { + controller.updateTabBarAlpha(self.inputMediaNode == nil ? 1.0 : 0.0, transition.containedViewLayoutTransition) + }*/ + + return height + } + + private func collectTextInputStates() -> [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] { + var textInputStates: [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] = [] + if let textInputView = self.pollTextSection.findTaggedView(tag: self.pollTextFieldTag) as? ListComposePollOptionComponent.View { + textInputStates.append((textInputView, self.pollTextInputState)) + } + for pollOption in self.pollOptions { + if let textInputView = findTaggedComponentViewImpl(view: self.pollOptionsSectionContainer, tag: pollOption.textFieldTag) as? ListComposePollOptionComponent.View { + textInputStates.append((textInputView, pollOption.textInputState)) + } + } + + return textInputStates + } + func update(component: ComposePollScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.isUpdating = true defer { @@ -234,6 +449,104 @@ final class ComposePollScreenComponent: Component { id: self.nextPollOptionId )) self.nextPollOptionId += 1 + + self.inputMediaNodeDataPromise.set( + ChatEntityKeyboardInputNode.inputData( + context: component.context, + chatPeerId: nil, + areCustomEmojiEnabled: true, + hasTrending: false, + hasSearch: true, + hasStickers: false, + hasGifs: false, + hideBackground: true, + sendGif: nil + ) + ) + self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + self.inputMediaNodeData = value + }) + + self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( + sendSticker: { _, _, _, _, _, _, _, _, _ in + return false + }, + sendEmoji: { _, _, _ in + let _ = self + }, + sendGif: { _, _, _, _, _ in + return false + }, + sendBotContextResultAsGif: { _, _ , _, _, _, _ in + return false + }, + updateChoosingSticker: { _ in + }, + switchToTextInput: { [weak self] in + guard let self else { + return + } + self.currentInputMode = .keyboard + self.state?.updated(transition: .immediate) + }, + dismissTextInput: { + }, + insertText: { [weak self] text in + guard let self else { + return + } + + for (textInputView, externalState) in self.collectTextInputStates() { + if externalState.isEditing { + textInputView.insertText(text: text) + break + } + } + }, + backwardsDeleteText: { [weak self] in + guard let self else { + return + } + for (textInputView, externalState) in self.collectTextInputStates() { + if externalState.isEditing { + textInputView.backwardsDeleteText() + break + } + } + }, + openStickerEditor: { + }, + presentController: { [weak self] c, a in + guard let self else { + return + } + self.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + presentGlobalOverlayController: { [weak self] c, a in + guard let self else { + return + } + self.environment?.controller()?.presentInGlobalOverlay(c, with: a) + }, + getNavigationController: { [weak self] in + guard let self else { + return nil + } + return self.environment?.controller()?.navigationController as? NavigationController + }, + requestLayout: { [weak self] transition in + guard let self else { + return + } + if !self.isUpdating { + self.state?.updated(transition: Transition(transition)) + } + } + ) } self.component = component @@ -244,95 +557,6 @@ final class ComposePollScreenComponent: Component { let sideInset: CGFloat = 16.0 + environment.safeInsets.left let sectionSpacing: CGFloat = 24.0 - if self.emojiContentDisposable == nil { - let emojiContent = EmojiPagerContentComponent.emojiInputData( - context: component.context, - animationCache: component.context.animationCache, - animationRenderer: component.context.animationRenderer, - isStandalone: false, - subject: .emoji, - hasTrending: false, - topReactionItems: [], - areUnicodeEmojiEnabled: false, - areCustomEmojiEnabled: true, - chatPeerId: nil, - selectedItems: Set(), - backgroundIconColor: nil, - hasSearch: false, - forceHasPremium: true - ) - self.emojiContentDisposable = (emojiContent - |> deliverOnMainQueue).start(next: { [weak self] emojiContent in - guard let self else { - return - } - self.emojiContent = emojiContent - - emojiContent.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak self] _, item, _, _, _, _ in - guard let self else { - return - } - guard let itemFile = item.itemFile else { - return - } - - AudioServicesPlaySystemSound(0x450) - - let _ = itemFile - - if !self.isUpdating { - self.state?.updated(transition: .spring(duration: 0.25)) - } - }, - deleteBackwards: { - }, - openStickerSettings: { - }, - openFeatured: { - }, - openSearch: { - }, - addGroupAction: { _, _, _ in - }, - clearGroup: { _ in - }, - editAction: { _ in - }, - pushController: { c in - }, - presentController: { c in - }, - presentGlobalOverlayController: { c in - }, - navigationController: { - return nil - }, - requestUpdate: { _ in - }, - updateSearchQuery: { _ in - }, - updateScrollingToItemGroup: { - }, - onScroll: {}, - chatPeerId: nil, - peekBehavior: nil, - customLayout: nil, - externalBackground: nil, - externalExpansionView: nil, - customContentView: nil, - useOpaqueTheme: true, - hideBackground: false, - stateContext: nil, - addImage: nil - ) - - if !self.isUpdating { - self.state?.updated(transition: .immediate) - } - }) - } - if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor } @@ -344,23 +568,42 @@ final class ComposePollScreenComponent: Component { contentHeight += topInset var pollTextSectionItems: [AnyComponentWithIdentity] = [] - pollTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( + pollTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( externalState: self.pollTextInputState, context: component.context, theme: environment.theme, strings: environment.strings, - initialText: "", - resetText: self.resetPollText.flatMap { resetPollText in - return ListMultilineTextFieldItemComponent.ResetText(value: resetPollText) + resetText: self.resetPollText.flatMap { resetText in + return ListComposePollOptionComponent.ResetText(value: resetText) }, - placeholder: "Enter Question", - autocapitalizationType: .none, - autocorrectionType: .no, characterLimit: 256, - emptyLineHandling: .oneConsecutive, - updated: { _ in + returnKeyAction: { [weak self] in + guard let self else { + return + } + if !self.pollOptions.isEmpty { + if let pollOptionView = self.pollOptionsSectionContainer.itemViews[self.pollOptions[0].id] { + if let pollOptionComponentView = pollOptionView.contents.view as? ListComposePollOptionComponent.View { + pollOptionComponentView.activateInput() + } + } + } + }, + backspaceKeyAction: nil, + selection: nil, + inputMode: self.currentInputMode, + toggleInputMode: { [weak self] in + guard let self else { + return + } + switch self.currentInputMode { + case .keyboard: + self.currentInputMode = .emoji + case .emoji: + self.currentInputMode = .keyboard + } + self.state?.updated(transition: .spring(duration: 0.4)) }, - textUpdateTransition: .spring(duration: 0.4), tag: self.pollTextFieldTag )))) self.resetPollText = nil @@ -385,12 +628,16 @@ final class ComposePollScreenComponent: Component { containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let pollTextSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: pollTextSectionSize) - if let pollTextSectionView = self.pollTextSection.view { + if let pollTextSectionView = self.pollTextSection.view as? ListSectionComponent.View { if pollTextSectionView.superview == nil { self.scrollView.addSubview(pollTextSectionView) self.pollTextSection.parentState = state } transition.setFrame(view: pollTextSectionView, frame: pollTextSectionFrame) + + if let itemView = pollTextSectionView.itemView(id: 0) as? ListComposePollOptionComponent.View { + itemView.updateCustomPlaceholder(value: "Ask a Question", size: itemView.bounds.size, transition: .immediate) + } } contentHeight += pollTextSectionSize.height contentHeight += sectionSpacing @@ -454,7 +701,21 @@ final class ComposePollScreenComponent: Component { } } }, - selection: optionSelection + selection: optionSelection, + inputMode: self.currentInputMode, + toggleInputMode: { [weak self] in + guard let self else { + return + } + switch self.currentInputMode { + case .keyboard: + self.currentInputMode = .emoji + case .emoji: + self.currentInputMode = .keyboard + } + self.state?.updated(transition: .spring(duration: 0.4)) + }, + tag: pollOption.textFieldTag )))) let item = pollOptionsSectionItems[i] @@ -475,7 +736,7 @@ final class ComposePollScreenComponent: Component { transition: itemTransition, component: item.component, environment: {}, - containerSize: CGSize(width: availableSize.width, height: availableSize.height) + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) ) pollOptionsSectionReadyItems.append(ListSectionContentView.ReadyItem( @@ -530,6 +791,7 @@ final class ComposePollScreenComponent: Component { background: .all ), width: availableSize.width - sideInset * 2.0, + leftInset: 0.0, readyItems: pollOptionsSectionReadyItems, transition: transition ) @@ -777,65 +1039,156 @@ final class ComposePollScreenComponent: Component { } var inputHeight: CGFloat = 0.0 - if self.displayInput, let emojiContent = self.emojiContent { - let reactionSelectionControl: ComponentView - var animateIn = false - if let current = self.reactionSelectionControl { - reactionSelectionControl = current - } else { - animateIn = true - reactionSelectionControl = ComponentView() - self.reactionSelectionControl = reactionSelectionControl + inputHeight += self.updateInputMediaNode( + component: component, + availableSize: availableSize, + bottomInset: environment.safeInsets.bottom, + inputHeight: 0.0, + effectiveInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), + metrics: environment.metrics, + deviceMetrics: environment.deviceMetrics, + transition: transition + ) + + let textInputStates = self.collectTextInputStates() + + let isEditing = textInputStates.contains(where: { $0.state.isEditing }) + + if let (_, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, emojiSuggestion.disposable == nil { + emojiSuggestion.disposable = (EmojiSuggestionsComponent.suggestionData(context: component.context, isSavedMessages: false, query: emojiSuggestion.position.value) + |> deliverOnMainQueue).start(next: { [weak self, weak suggestionTextInputState, weak emojiSuggestion] result in + guard let self, let suggestionTextInputState, let emojiSuggestion, suggestionTextInputState.currentEmojiSuggestion === emojiSuggestion else { + return + } + + emojiSuggestion.value = result + self.state?.updated() + }) + } + + for (_, suggestionTextInputState) in textInputStates { + var hasTrackingView = suggestionTextInputState.hasTrackingView + if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile], value.isEmpty { + hasTrackingView = false } - let reactionSelectionControlSize = reactionSelectionControl.update( - transition: animateIn ? .immediate : transition, - component: AnyComponent(EmojiSelectionComponent( - theme: environment.theme, - strings: environment.strings, - sideInset: environment.safeInsets.left, - bottomInset: environment.safeInsets.bottom, - deviceMetrics: environment.deviceMetrics, - emojiContent: emojiContent, - stickerContent: nil, - backgroundIconColor: nil, - backgroundColor: environment.theme.list.itemBlocksBackgroundColor, - separatorColor: environment.theme.list.itemBlocksSeparatorColor, - backspace: { [weak self] in - guard let self else { + if !suggestionTextInputState.isEditing { + hasTrackingView = false + } + + if !hasTrackingView { + if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion { + suggestionTextInputState.currentEmojiSuggestion = nil + currentEmojiSuggestion.disposable?.dispose() + } + + if let currentEmojiSuggestionView = self.currentEmojiSuggestionView { + self.currentEmojiSuggestionView = nil + + currentEmojiSuggestionView.alpha = 0.0 + currentEmojiSuggestionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak currentEmojiSuggestionView] _ in + currentEmojiSuggestionView?.removeFromSuperview() + }) + } + } + } + + if let (suggestionTextInputView, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = emojiSuggestion.value as? [TelegramMediaFile] { + let currentEmojiSuggestionView: ComponentHostView + if let current = self.currentEmojiSuggestionView { + currentEmojiSuggestionView = current + } else { + currentEmojiSuggestionView = ComponentHostView() + self.currentEmojiSuggestionView = currentEmojiSuggestionView + self.addSubview(currentEmojiSuggestionView) + + currentEmojiSuggestionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + + let globalPosition: CGPoint + if let textView = suggestionTextInputView.textFieldView { + globalPosition = textView.convert(emojiSuggestion.localPosition, to: self) + } else { + globalPosition = .zero + } + + let sideInset: CGFloat = 7.0 + + let viewSize = currentEmojiSuggestionView.update( + transition: .immediate, + component: AnyComponent(EmojiSuggestionsComponent( + context: component.context, + userLocation: .other, + theme: EmojiSuggestionsComponent.Theme(theme: environment.theme), + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + files: value, + action: { [weak self, weak suggestionTextInputView, weak suggestionTextInputState] file in + guard let self, let suggestionTextInputView, let suggestionTextInputState, let textView = suggestionTextInputView.textFieldView, let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion else { return } - if !self.isUpdating { - self.state?.updated(transition: .spring(duration: 0.25)) + let _ = self + + AudioServicesPlaySystemSound(0x450) + + let inputState = textView.getInputState() + let inputText = NSMutableAttributedString(attributedString: inputState.inputText) + + var text: String? + var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? + loop: for attribute in file.attributes { + switch attribute { + case let .CustomEmoji(_, _, displayText, _): + text = displayText + emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) + break loop + default: + break + } + } + + if let emojiAttribute = emojiAttribute, let text = text { + let replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) + + let range = currentEmojiSuggestion.position.range + let previousText = inputText.attributedSubstring(from: range) + inputText.replaceCharacters(in: range, with: replacementText) + + var replacedUpperBound = range.lowerBound + while true { + if inputText.attributedSubstring(from: NSRange(location: 0, length: replacedUpperBound)).string.hasSuffix(previousText.string) { + let replaceRange = NSRange(location: replacedUpperBound - previousText.length, length: previousText.length) + if replaceRange.location < 0 { + break + } + let adjacentString = inputText.attributedSubstring(from: replaceRange) + if adjacentString.string != previousText.string || adjacentString.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) != nil { + break + } + inputText.replaceCharacters(in: replaceRange, with: NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: emojiAttribute.interactivelySelectedFromPackId, fileId: emojiAttribute.fileId, file: emojiAttribute.file)])) + replacedUpperBound = replaceRange.lowerBound + } else { + break + } + } + + let selectionPosition = range.lowerBound + (replacementText.string as NSString).length + textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) } } )), environment: {}, - containerSize: CGSize(width: availableSize.width, height: availableSize.height) + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) - let reactionSelectionControlFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - reactionSelectionControlSize.height), size: reactionSelectionControlSize) - if let reactionSelectionControlView = reactionSelectionControl.view { - if reactionSelectionControlView.superview == nil { - self.addSubview(reactionSelectionControlView) - } - if animateIn { - reactionSelectionControlView.frame = reactionSelectionControlFrame - transition.animatePosition(view: reactionSelectionControlView, from: CGPoint(x: 0.0, y: reactionSelectionControlFrame.height), to: CGPoint(), additive: true) - } else { - transition.setFrame(view: reactionSelectionControlView, frame: reactionSelectionControlFrame) - } - } - inputHeight = reactionSelectionControlSize.height - } else if let reactionSelectionControl = self.reactionSelectionControl { - self.reactionSelectionControl = nil - if let reactionSelectionControlView = reactionSelectionControl.view { - transition.setPosition(view: reactionSelectionControlView, position: CGPoint(x: reactionSelectionControlView.center.x, y: availableSize.height + reactionSelectionControlView.bounds.height * 0.5), completion: { [weak reactionSelectionControlView] _ in - reactionSelectionControlView?.removeFromSuperview() - }) + + let viewFrame = CGRect(origin: CGPoint(x: min(availableSize.width - sideInset - viewSize.width, max(sideInset, floor(globalPosition.x - viewSize.width / 2.0))), y: globalPosition.y - 4.0 - viewSize.height), size: viewSize) + currentEmojiSuggestionView.frame = viewFrame + if let componentView = currentEmojiSuggestionView.componentView as? EmojiSuggestionsComponent.View { + componentView.adjustBackground(relativePositionX: floor(globalPosition.x + 10.0)) } } - if self.displayInput { + if isEditing { contentHeight += bottomInset + 8.0 contentHeight += inputHeight } else { @@ -857,7 +1210,7 @@ final class ComposePollScreenComponent: Component { self.updateScrolling(transition: transition) - if self.pollTextInputState.isEditing || self.pollOptions.contains(where: { $0.textInputState.isEditing }) { + if isEditing { if let controller = environment.controller() as? ComposePollScreen { DispatchQueue.main.async { [weak controller] in controller?.requestAttachmentMenuExpansion() @@ -908,6 +1261,15 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont } public var mediaPickerContext: AttachmentMediaPickerContext? + public var isPanGestureEnabled: (() -> Bool)? { + return { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.View else { + return true + } + return componentView.isPanGestureEnabled() + } + } + public init( context: AccountContext, peer: EnginePeer, diff --git a/submodules/ComposePollUI/Sources/CreatePollController.swift b/submodules/ComposePollUI/Sources/CreatePollController.swift index a8cb5b4ab3..d64ad833f8 100644 --- a/submodules/ComposePollUI/Sources/CreatePollController.swift +++ b/submodules/ComposePollUI/Sources/CreatePollController.swift @@ -488,10 +488,20 @@ private func createPollControllerEntries(presentationData: PresentationData, pee } public final class ComposedPoll { + public struct Text { + public let string: String + public let entities: [MessageTextEntity] + + public init(string: String, entities: [MessageTextEntity]) { + self.string = string + self.entities = entities + } + } + public let publicity: TelegramMediaPollPublicity public let kind: TelegramMediaPollKind - public let text: String + public let text: Text public let options: [TelegramMediaPollOption] public let correctAnswers: [Data]? public let results: TelegramMediaPollResults @@ -500,7 +510,7 @@ public final class ComposedPoll { public init( publicity: TelegramMediaPollPublicity, kind: TelegramMediaPollKind, - text: String, + text: Text, options: [TelegramMediaPollOption], correctAnswers: [Data]?, results: TelegramMediaPollResults, @@ -928,7 +938,7 @@ public func createPollController(context: AccountContext, updatedPresentationDat let optionText = state.options[i].item.text.trimmingCharacters(in: .whitespacesAndNewlines) if !optionText.isEmpty { let optionData = "\(i)".data(using: .utf8)! - options.append(TelegramMediaPollOption(text: optionText, opaqueIdentifier: optionData)) + options.append(TelegramMediaPollOption(text: optionText, entities: [], opaqueIdentifier: optionData)) if state.isQuiz && state.options[i].item.isSelected { correctAnswers = [optionData] } @@ -959,7 +969,7 @@ public func createPollController(context: AccountContext, updatedPresentationDat completion(ComposedPoll( publicity: publicity, kind: kind, - text: processPollText(state.text), + text: ComposedPoll.Text(string: processPollText(state.text), entities: []), options: options, correctAnswers: correctAnswers, results: TelegramMediaPollResults(voters: nil, totalVoters: nil, recentVoters: [], solution: resolvedSolution), diff --git a/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift b/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift index 84a43f333c..09482df1a1 100644 --- a/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift +++ b/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift @@ -10,17 +10,11 @@ import TextFieldComponent import AccountContext import MultilineTextComponent import PresentationDataUtils +import LottieComponent +import PlainButtonComponent +import SwiftSignalKit public final class ListComposePollOptionComponent: Component { - public final class ExternalState { - public fileprivate(set) var hasText: Bool = false - public fileprivate(set) var text: NSAttributedString = NSAttributedString() - public fileprivate(set) var isEditing: Bool = false - - public init() { - } - } - public final class ResetText: Equatable { public let value: String @@ -50,7 +44,31 @@ public final class ListComposePollOptionComponent: Component { } } - public let externalState: ExternalState? + public enum InputMode { + case keyboard + case emoji + } + + public final class EmojiSuggestion { + public struct Position: Equatable { + public var range: NSRange + public var value: String + } + + public var localPosition: CGPoint + public var position: Position + public var disposable: Disposable? + public var value: Any? + + init(localPosition: CGPoint, position: Position) { + self.localPosition = localPosition + self.position = position + self.disposable = nil + self.value = nil + } + } + + public let externalState: TextFieldComponent.ExternalState? public let context: AccountContext public let theme: PresentationTheme public let strings: PresentationStrings @@ -59,9 +77,12 @@ public final class ListComposePollOptionComponent: Component { public let returnKeyAction: (() -> Void)? public let backspaceKeyAction: (() -> Void)? public let selection: Selection? + public let inputMode: InputMode? + public let toggleInputMode: (() -> Void)? + public let tag: AnyObject? public init( - externalState: ExternalState?, + externalState: TextFieldComponent.ExternalState?, context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, @@ -69,7 +90,10 @@ public final class ListComposePollOptionComponent: Component { characterLimit: Int, returnKeyAction: (() -> Void)?, backspaceKeyAction: (() -> Void)?, - selection: Selection? + selection: Selection?, + inputMode: InputMode?, + toggleInputMode: (() -> Void)?, + tag: AnyObject? = nil ) { self.externalState = externalState self.context = context @@ -80,6 +104,9 @@ public final class ListComposePollOptionComponent: Component { self.returnKeyAction = returnKeyAction self.backspaceKeyAction = backspaceKeyAction self.selection = selection + self.inputMode = inputMode + self.toggleInputMode = toggleInputMode + self.tag = tag } public static func ==(lhs: ListComposePollOptionComponent, rhs: ListComposePollOptionComponent) -> Bool { @@ -104,6 +131,9 @@ public final class ListComposePollOptionComponent: Component { if lhs.selection != rhs.selection { return false } + if lhs.inputMode != rhs.inputMode { + return false + } return true } @@ -181,9 +211,10 @@ public final class ListComposePollOptionComponent: Component { } } - public final class View: UIView, ListSectionComponent.ChildView { + public final class View: UIView, ListSectionComponent.ChildView, ComponentTaggedView { private let textField = ComponentView() - private let textFieldExternalState = TextFieldComponent.ExternalState() + + private var modeSelector: ComponentView? private var checkView: CheckView? @@ -201,6 +232,10 @@ public final class ListComposePollOptionComponent: Component { } } + public var textFieldView: TextFieldComponent.View? { + return self.textField.view as? TextFieldComponent.View + } + public var customUpdateIsHighlighted: ((Bool) -> Void)? public private(set) var separatorInset: CGFloat = 0.0 @@ -212,40 +247,68 @@ public final class ListComposePollOptionComponent: Component { preconditionFailure() } + public func matches(tag: Any) -> Bool { + if let component = self.component, let componentTag = component.tag { + let tag = tag as AnyObject + if componentTag === tag { + return true + } + } + return false + } + public func activateInput() { if let textFieldView = self.textField.view as? TextFieldComponent.View { textFieldView.activateInput() } } + public func insertText(text: NSAttributedString) { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.insertText(text) + } + } + + public func backwardsDeleteText() { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.deleteBackward() + } + } + func update(component: ListComposePollOptionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } + let previousComponent = self.component self.component = component self.state = state let verticalInset: CGFloat = 12.0 var leftInset: CGFloat = 16.0 - let rightInset: CGFloat = 16.0 + var rightInset: CGFloat = 16.0 + let modeSelectorSize = CGSize(width: 32.0, height: 32.0) if component.selection != nil { leftInset += 34.0 } + if component.inputMode != nil { + rightInset += 34.0 + } + let textFieldSize = self.textField.update( transition: transition, component: AnyComponent(TextFieldComponent( context: component.context, theme: component.theme, strings: component.strings, - externalState: self.textFieldExternalState, + externalState: component.externalState ?? TextFieldComponent.ExternalState(), fontSize: 17.0, textColor: component.theme.list.itemPrimaryTextColor, insets: UIEdgeInsets(top: verticalInset, left: 8.0, bottom: verticalInset, right: 8.0), - hideKeyboard: false, + hideKeyboard: component.inputMode == .emoji, customInputView: nil, resetText: component.resetText.flatMap { resetText in return NSAttributedString(string: resetText.value, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor) @@ -275,10 +338,10 @@ public final class ListComposePollOptionComponent: Component { } )), environment: {}, - containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: availableSize.height) + containerSize: CGSize(width: availableSize.width - leftInset - rightInset + 8.0 * 2.0, height: availableSize.height) ) - let size = CGSize(width: textFieldSize.width, height: textFieldSize.height - 1.0) + let size = CGSize(width: availableSize.width, height: textFieldSize.height - 1.0) let textFieldFrame = CGRect(origin: CGPoint(x: leftInset - 16.0, y: 0.0), size: textFieldSize) if let textFieldView = self.textField.view { @@ -327,11 +390,73 @@ public final class ListComposePollOptionComponent: Component { }) } - self.separatorInset = leftInset + if let inputMode = component.inputMode { + var modeSelectorTransition = transition + let modeSelector: ComponentView + if let current = self.modeSelector { + modeSelector = current + } else { + modeSelectorTransition = modeSelectorTransition.withAnimation(.none) + modeSelector = ComponentView() + self.modeSelector = modeSelector + } + let animationName: String + var playAnimation = false + if let previousComponent, let previousInputMode = previousComponent.inputMode { + if previousInputMode != inputMode { + playAnimation = true + } + } + switch inputMode { + case .keyboard: + animationName = "input_anim_keyToSmile" + case .emoji: + animationName = "input_anim_smileToKey" + } + + let _ = modeSelector.update( + transition: modeSelectorTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: animationName + ), + color: component.theme.chat.inputPanel.inputControlColor.blitOver(component.theme.list.itemBlocksBackgroundColor, alpha: 1.0), + size: modeSelectorSize + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.toggleInputMode?() + }, + animateScale: false + )), + environment: {}, + containerSize: modeSelectorSize + ) + let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - 4.0 - modeSelectorSize.width, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize) + if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View { + if modeSelectorView.superview == nil { + self.addSubview(modeSelectorView) + } + + if playAnimation, let animationView = modeSelectorView.contentView as? LottieComponent.View { + animationView.playOnce() + } + + modeSelectorTransition.setFrame(view: modeSelectorView, frame: modeSelectorFrame) + if let externalState = component.externalState { + modeSelectorView.isHidden = !externalState.isEditing + } + } + } else if let modeSelector = self.modeSelector { + self.modeSelector = nil + modeSelector.view?.removeFromSuperview() + } - component.externalState?.hasText = self.textFieldExternalState.hasText - component.externalState?.text = self.textFieldExternalState.text - component.externalState?.isEditing = self.textFieldExternalState.isEditing + self.separatorInset = leftInset return size } @@ -378,7 +503,9 @@ public final class ListComposePollOptionComponent: Component { transition.setPosition(view: placeholderView, position: placeholderFrame.origin) placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) - placeholderView.isHidden = self.textFieldExternalState.hasText + if let externalState = component.externalState { + placeholderView.isHidden = externalState.hasText + } } } else if let customPlaceholder = self.customPlaceholder { self.customPlaceholder = nil diff --git a/submodules/LegacyComponents/Sources/TGIconSwitchView.m b/submodules/LegacyComponents/Sources/TGIconSwitchView.m index 677863d286..a96b6c5787 100644 --- a/submodules/LegacyComponents/Sources/TGIconSwitchView.m +++ b/submodules/LegacyComponents/Sources/TGIconSwitchView.m @@ -41,49 +41,47 @@ static const void *positionChangedKey = &positionChangedKey; - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self != nil) { - if (iosMajorVersion() >= 8) { - _offIconView = [[UIImageView alloc] initWithImage:TGComponentsImageNamed(@"PermissionSwitchOff.png")]; - _onIconView = [[UIImageView alloc] initWithImage:TGComponentsImageNamed(@"PermissionSwitchOn.png")]; - self.layer.cornerRadius = 17.0f; - self.backgroundColor = [UIColor redColor]; - self.tintColor = [UIColor redColor]; - UIView *handleView = self.subviews[0].subviews.lastObject; - if (iosMajorVersion() >= 13) { - handleView = self.subviews[0].subviews[1].subviews.lastObject; - } else { - handleView = self.subviews[0].subviews.lastObject; - } - - static Class subclass; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - subclass = freedomMakeClass([handleView.layer class], [TGBaseIconSwitch class]); - object_setClass(handleView.layer, subclass); - }); - - CGPoint offset = CGPointZero; - if (iosMajorVersion() >= 12) { - offset = CGPointMake(-7.0, -3.0); - } - - _offIconView.frame = CGRectOffset(_offIconView.bounds, TGScreenPixelFloor(21.5f) + offset.x, TGScreenPixelFloor(14.5f) + offset.y); - _onIconView.frame = CGRectOffset(_onIconView.bounds, 20.0f + offset.x, 15.0f + offset.y); - [handleView addSubview:_onIconView]; - [handleView addSubview:_offIconView]; - - _onIconView.alpha = 0.0f; - - [self addTarget:self action:@selector(currentValueChanged) forControlEvents:UIControlEventValueChanged]; - - __weak TGIconSwitchView *weakSelf = self; - void (^block)(CGPoint) = ^(CGPoint point) { - __strong TGIconSwitchView *strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf updateState:point.x > 30.0 animated:true force:false]; - } - }; - objc_setAssociatedObject(handleView.layer, positionChangedKey, [block copy], OBJC_ASSOCIATION_RETAIN); + _offIconView = [[UIImageView alloc] initWithImage:TGComponentsImageNamed(@"PermissionSwitchOff.png")]; + _onIconView = [[UIImageView alloc] initWithImage:TGComponentsImageNamed(@"PermissionSwitchOn.png")]; + self.layer.cornerRadius = 17.0f; + self.backgroundColor = [UIColor redColor]; + self.tintColor = [UIColor redColor]; + UIView *handleView = self.subviews[0].subviews.lastObject; + if (iosMajorVersion() >= 13) { + handleView = self.subviews[0].subviews[1].subviews.lastObject; + } else { + handleView = self.subviews[0].subviews.lastObject; } + + static Class subclass; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + subclass = freedomMakeClass([handleView.layer class], [TGBaseIconSwitch class]); + object_setClass(handleView.layer, subclass); + }); + + CGPoint offset = CGPointZero; + if (iosMajorVersion() >= 12) { + offset = CGPointMake(-7.0, -3.0); + } + + _offIconView.frame = CGRectOffset(_offIconView.bounds, TGScreenPixelFloor(21.5f) + offset.x, TGScreenPixelFloor(14.5f) + offset.y); + _onIconView.frame = CGRectOffset(_onIconView.bounds, 20.0f + offset.x, 15.0f + offset.y); + [handleView addSubview:_onIconView]; + [handleView addSubview:_offIconView]; + + _onIconView.alpha = 0.0f; + + [self addTarget:self action:@selector(currentValueChanged) forControlEvents:UIControlEventValueChanged]; + + __weak TGIconSwitchView *weakSelf = self; + void (^block)(CGPoint) = ^(CGPoint point) { + __strong TGIconSwitchView *strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf updateState:point.x > 30.0 animated:true force:false]; + } + }; + objc_setAssociatedObject(handleView.layer, positionChangedKey, [block copy], OBJC_ASSOCIATION_RETAIN); } return self; } diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index f0a220d341..0eaf6bac7e 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -2012,11 +2012,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { lineSpacing: 0.18 ))) ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( backgroundColor: gradientColors[i], foregroundColor: .white, iconName: perk.iconName - ))), + )))), action: { [weak state] _ in var demoSubject: PremiumDemoScreen.Subject switch perk { @@ -2179,11 +2179,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { lineSpacing: 0.18 ))) ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( backgroundColor: gradientColors[min(i, gradientColors.count - 1)], foregroundColor: .white, iconName: perk.iconName - ))), + )))), action: { [weak state] _ in let isPremium = state?.isPremium == true if isPremium { @@ -2363,11 +2363,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { lineSpacing: 0.18 ))) ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( backgroundColor: UIColor(rgb: 0x676bff), foregroundColor: .white, iconName: "Premium/BusinessPerk/Status" - ))), + )))), icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( context: context.component.context, color: accentColor, @@ -2404,11 +2404,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { lineSpacing: 0.18 ))) ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( backgroundColor: UIColor(rgb: 0x4492ff), foregroundColor: .white, iconName: "Premium/BusinessPerk/Tag" - ))), + )))), action: { _ in push(accountContext.sharedContext.makeFilterSettingsController(context: accountContext, modal: false, scrollToTags: true, dismissed: nil)) } @@ -2435,11 +2435,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { lineSpacing: 0.18 ))) ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(PerkIconComponent( backgroundColor: UIColor(rgb: 0x41a6a5), foregroundColor: .white, iconName: "Premium/Perk/Stories" - ))), + )))), action: { _ in push(accountContext.sharedContext.makeMyStoriesController(context: accountContext, isArchive: false)) } diff --git a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift index b3e4c2f189..42798b6167 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift @@ -433,7 +433,7 @@ func textMediaAndExpirationTimerFromApiMedia(_ media: Api.MessageMedia?, _ peerI } else { kind = .poll(multipleAnswers: (flags & (1 << 2)) != 0) } - return (TelegramMediaPoll(pollId: MediaId(namespace: Namespaces.Media.CloudPoll, id: id), publicity: publicity, kind: kind, text: question, options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: TelegramMediaPollResults(apiResults: results), isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod), nil, nil, nil, nil) + return (TelegramMediaPoll(pollId: MediaId(namespace: Namespaces.Media.CloudPoll, id: id), publicity: publicity, kind: kind, text: question, textEntities: [], options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: TelegramMediaPollResults(apiResults: results), isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod), nil, nil, nil, nil) } case let .messageMediaDice(value, emoticon): return (TelegramMediaDice(emoji: emoticon, value: value), nil, nil, nil, nil) diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaPoll.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaPoll.swift index 3d58b37739..b22e227098 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaPoll.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaPoll.swift @@ -7,7 +7,7 @@ extension TelegramMediaPollOption { init(apiOption: Api.PollAnswer) { switch apiOption { case let .pollAnswer(text, option): - self.init(text: text, opaqueIdentifier: option.makeData()) + self.init(text: text, entities: [], opaqueIdentifier: option.makeData()) } } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 5b6ffebfd7..18f849d883 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -3897,7 +3897,7 @@ func replayFinalState( } else { kind = .poll(multipleAnswers: (flags & (1 << 2)) != 0) } - updatedPoll = TelegramMediaPoll(pollId: MediaId(namespace: Namespaces.Media.CloudPoll, id: id), publicity: publicity, kind: kind, text: question, options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: poll.results, isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod) + updatedPoll = TelegramMediaPoll(pollId: MediaId(namespace: Namespaces.Media.CloudPoll, id: id), publicity: publicity, kind: kind, text: question, textEntities: [], options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: poll.results, isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod) } } updatedPoll = updatedPoll.withUpdatedResults(TelegramMediaPollResults(apiResults: results), min: resultsMin) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift index b70b1876d6..4f8cf01d5e 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift @@ -3,20 +3,24 @@ import Postbox public struct TelegramMediaPollOption: Equatable, PostboxCoding { public let text: String + public let entities: [MessageTextEntity] public let opaqueIdentifier: Data - public init(text: String, opaqueIdentifier: Data) { + public init(text: String, entities: [MessageTextEntity], opaqueIdentifier: Data) { self.text = text + self.entities = entities self.opaqueIdentifier = opaqueIdentifier } public init(decoder: PostboxDecoder) { self.text = decoder.decodeStringForKey("t", orElse: "") + self.entities = decoder.decodeObjectArrayWithDecoderForKey("et") self.opaqueIdentifier = decoder.decodeDataForKey("i") ?? Data() } public func encode(_ encoder: PostboxEncoder) { encoder.encodeString(self.text, forKey: "t") + encoder.encodeObjectArray(self.entities, forKey: "et") encoder.encodeData(self.opaqueIdentifier, forKey: "i") } } @@ -150,17 +154,19 @@ public final class TelegramMediaPoll: Media, Equatable { public let kind: TelegramMediaPollKind public let text: String + public let textEntities: [MessageTextEntity] public let options: [TelegramMediaPollOption] public let correctAnswers: [Data]? public let results: TelegramMediaPollResults public let isClosed: Bool public let deadlineTimeout: Int32? - public init(pollId: MediaId, publicity: TelegramMediaPollPublicity, kind: TelegramMediaPollKind, text: String, options: [TelegramMediaPollOption], correctAnswers: [Data]?, results: TelegramMediaPollResults, isClosed: Bool, deadlineTimeout: Int32?) { + public init(pollId: MediaId, publicity: TelegramMediaPollPublicity, kind: TelegramMediaPollKind, text: String, textEntities: [MessageTextEntity], options: [TelegramMediaPollOption], correctAnswers: [Data]?, results: TelegramMediaPollResults, isClosed: Bool, deadlineTimeout: Int32?) { self.pollId = pollId self.publicity = publicity self.kind = kind self.text = text + self.textEntities = textEntities self.options = options self.correctAnswers = correctAnswers self.results = results @@ -177,6 +183,7 @@ public final class TelegramMediaPoll: Media, Equatable { self.publicity = TelegramMediaPollPublicity(rawValue: decoder.decodeInt32ForKey("pb", orElse: 0)) ?? TelegramMediaPollPublicity.anonymous self.kind = decoder.decodeObjectForKey("kn", decoder: { TelegramMediaPollKind(decoder: $0) }) as? TelegramMediaPollKind ?? TelegramMediaPollKind.poll(multipleAnswers: false) self.text = decoder.decodeStringForKey("t", orElse: "") + self.textEntities = decoder.decodeObjectArrayWithDecoderForKey("te") self.options = decoder.decodeObjectArrayWithDecoderForKey("os") self.correctAnswers = decoder.decodeOptionalDataArrayForKey("ca") self.results = decoder.decodeObjectForKey("rs", decoder: { TelegramMediaPollResults(decoder: $0) }) as? TelegramMediaPollResults ?? TelegramMediaPollResults(voters: nil, totalVoters: nil, recentVoters: [], solution: nil) @@ -191,6 +198,7 @@ public final class TelegramMediaPoll: Media, Equatable { encoder.encodeObject(self.kind, forKey: "kn") encoder.encodeBytes(buffer, forKey: "i") encoder.encodeString(self.text, forKey: "t") + encoder.encodeObjectArray(self.textEntities, forKey: "te") encoder.encodeObjectArray(self.options, forKey: "os") if let correctAnswers = self.correctAnswers { encoder.encodeDataArray(correctAnswers, forKey: "ca") @@ -230,6 +238,9 @@ public final class TelegramMediaPoll: Media, Equatable { if lhs.text != rhs.text { return false } + if lhs.textEntities != rhs.textEntities { + return false + } if lhs.options != rhs.options { return false } @@ -273,6 +284,6 @@ public final class TelegramMediaPoll: Media, Equatable { } else { updatedResults = results } - return TelegramMediaPoll(pollId: self.pollId, publicity: self.publicity, kind: self.kind, text: self.text, options: self.options, correctAnswers: self.correctAnswers, results: updatedResults, isClosed: self.isClosed, deadlineTimeout: self.deadlineTimeout) + return TelegramMediaPoll(pollId: self.pollId, publicity: self.publicity, kind: self.kind, text: self.text, textEntities: self.textEntities, options: self.options, correctAnswers: self.correctAnswers, results: updatedResults, isClosed: self.isClosed, deadlineTimeout: self.deadlineTimeout) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift index d7174c3e95..f9880bef27 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift @@ -44,7 +44,7 @@ func _internal_requestMessageSelectPollOption(account: Account, messageId: Messa } else { kind = .poll(multipleAnswers: (flags & (1 << 2)) != 0) } - resultPoll = TelegramMediaPoll(pollId: pollId, publicity: publicity, kind: kind, text: question, options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: TelegramMediaPollResults(apiResults: results), isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod) + resultPoll = TelegramMediaPoll(pollId: pollId, publicity: publicity, kind: kind, text: question, textEntities: [], options: answers.map(TelegramMediaPollOption.init(apiOption:)), correctAnswers: nil, results: TelegramMediaPollResults(apiResults: results), isClosed: (flags & (1 << 0)) != 0, deadlineTimeout: closePeriod) } } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 986ef44504..9a7c974d27 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -446,6 +446,7 @@ swift_library( "//submodules/TelegramUI/Components/Ads/AdsInfoScreen", "//submodules/TelegramUI/Components/Ads/AdsReportScreen", "//submodules/TelegramUI/Components/Settings/BotSettingsScreen", + "//submodules/TelegramUI/Components/AdminUserActionsSheet", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD b/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD new file mode 100644 index 0000000000..d6ef632670 --- /dev/null +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD @@ -0,0 +1,37 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AdminUserActionsSheet", + module_name = "AdminUserActionsSheet", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/PresentationDataUtils", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/AvatarNode", + "//submodules/CheckNode", + "//submodules/UndoUI", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent copy.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent copy.swift new file mode 100644 index 0000000000..dcd78b898f --- /dev/null +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent copy.swift @@ -0,0 +1,342 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import MultilineTextComponent +import AvatarNode +import TelegramPresentationData +import CheckNode +import TelegramStringFormatting +import ListSectionComponent + +/*final class AdminUserActionsSwitchComponent: Component { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool) + } + + enum SubtitleIcon { + case lock + } + + enum Subtitle: Equatable { + case presence(EnginePeer.Presence?) + case text(text: String, icon: SubtitleIcon) + } + + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let sideInset: CGFloat + let title: String + let subtitle: Subtitle + let peer: EnginePeer? + let selectionState: SelectionState + let hasNext: Bool + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + sideInset: CGFloat, + title: String, + subtitle: Subtitle, + peer: EnginePeer?, + selectionState: SelectionState, + hasNext: Bool, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.sideInset = sideInset + self.title = title + self.subtitle = subtitle + self.peer = peer + self.selectionState = selectionState + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: AdminUserActionsSwitchComponent, rhs: AdminUserActionsSwitchComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.subtitle != rhs.subtitle { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let label = ComponentView() + private let separatorLayer: SimpleLayer + private let avatarNode: AvatarNode + + private var labelIconView: UIImageView? + private var checkLayer: CheckLayer? + + private var component: AdminUserActionsSwitchComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + + self.containerButton = HighlightTrackingButton() + + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.isLayerBacked = true + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + self.addSubview(self.containerButton) + self.containerButton.layer.addSublayer(self.avatarNode.layer) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component, let peer = component.peer else { + return + } + component.action(peer) + } + + func update(component: AdminUserActionsSwitchComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + var hasSelectionUpdated = false + if let previousComponent = self.component { + switch previousComponent.selectionState { + case .none: + if case .none = component.selectionState { + } else { + hasSelectionUpdated = true + } + case .editing: + if case .editing = component.selectionState { + } else { + hasSelectionUpdated = true + } + } + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 60.0 + let verticalInset: CGFloat = 1.0 + let leftInset: CGFloat = 62.0 + component.sideInset + var rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset + let avatarLeftInset: CGFloat = component.sideInset + 10.0 + + if case let .editing(isSelected) = component.selectionState { + rightInset += 48.0 + + let checkSize: CGFloat = 22.0 + + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + if themeUpdated { + checkLayer.theme = CheckNodeTheme(theme: component.theme, style: .plain) + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: component.theme, style: .plain)) + self.checkLayer = checkLayer + self.containerButton.layer.addSublayer(checkLayer) + checkLayer.frame = CGRect(origin: CGPoint(x: -checkSize, y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize)) + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: CGRect(origin: CGPoint(x: availableSize.width - rightInset + floor((48.0 - checkSize) * 0.5), y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize))) + } else { + if let checkLayer = self.checkLayer { + self.checkLayer = nil + transition.setPosition(layer: checkLayer, position: CGPoint(x: -checkLayer.bounds.width * 0.5, y: checkLayer.position.y), completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } + } + + let avatarSize: CGFloat = 40.0 + + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floor((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) + if self.avatarNode.bounds.isEmpty { + self.avatarNode.frame = avatarFrame + } else { + transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame) + } + if let peer = component.peer { + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + if peer.id == component.context.account.peerId { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: .savedMessagesIcon, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } else { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } + } + + var labelIcon: UIImage? + let labelData: (String, Bool) + switch component.subtitle { + case let .presence(presence): + if let presence { + labelData = stringAndActivityForUserPresence(strings: component.strings, dateTimeFormat: PresentationDateTimeFormat(), presence: presence, relativeTo: Int32(Date().timeIntervalSince1970)) + } else { + labelData = (component.strings.LastSeen_Offline, false) + } + case let .text(text, icon): + switch icon { + case .lock: + labelIcon = PresentationResourcesItemList.peerStatusLockedImage(component.theme) + } + labelData = (text, false) + } + + var maxTextSize = availableSize.width - leftInset - rightInset + if labelIcon != nil { + maxTextSize -= 48.0 + } + + let labelSize = self.label.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: labelData.0, font: Font.regular(15.0), textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: maxTextSize, height: 100.0) + ) + + let previousTitleFrame = self.title.view?.frame + var previousTitleContents: UIView? + if hasSelectionUpdated && !"".isEmpty { + previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: maxTextSize, height: 100.0) + ) + + let titleSpacing: CGFloat = 1.0 + let centralContentHeight: CGFloat = titleSize.height + labelSize.height + titleSpacing + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { + previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) + self.addSubview(previousTitleContents) + + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) + transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in + previousTitleContents?.removeFromSuperview() + }) + transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) + } + } + + if let labelIcon { + let labelIconView: UIImageView + if let current = self.labelIconView { + labelIconView = current + } else { + labelIconView = UIImageView() + self.labelIconView = labelIconView + self.containerButton.addSubview(labelIconView) + } + labelIconView.image = labelIcon + + let labelIconFrame = CGRect(origin: CGPoint(x: availableSize.width - rightInset - 48.0 + floor((48.0 - labelIcon.size.width) * 0.5), y: floor((height - verticalInset * 2.0 - labelIcon.size.height) / 2.0)), size: CGSize(width: labelIcon.size.width, height: labelIcon.size.height)) + transition.setFrame(view: labelIconView, frame: labelIconFrame) + } else { + if let labelIconView = self.labelIconView { + self.labelIconView = nil + labelIconView.removeFromSuperview() + } + } + + if let labelView = self.label.view { + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.containerButton.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing), size: labelSize)) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + } + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + self.separatorLayer.isHidden = !component.hasNext + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} +*/ diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift new file mode 100644 index 0000000000..dba2a48811 --- /dev/null +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift @@ -0,0 +1,274 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import MultilineTextComponent +import AvatarNode +import TelegramPresentationData +import CheckNode +import TelegramStringFormatting +import ListSectionComponent + +private let avatarFont = avatarPlaceholderFont(size: 15.0) + +private func cancelContextGestures(view: UIView) { + if let gestureRecognizers = view.gestureRecognizers { + for gesture in gestureRecognizers { + if let gesture = gesture as? ContextGesture { + gesture.cancel() + } + } + } + for subview in view.subviews { + cancelContextGestures(view: subview) + } +} + +final class AdminUserActionsPeerComponent: Component { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool) + } + + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let sideInset: CGFloat + let title: String + let peer: EnginePeer? + let selectionState: SelectionState + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + sideInset: CGFloat, + title: String, + peer: EnginePeer?, + selectionState: SelectionState, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.sideInset = sideInset + self.title = title + self.peer = peer + self.selectionState = selectionState + self.action = action + } + + static func ==(lhs: AdminUserActionsPeerComponent, rhs: AdminUserActionsPeerComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + return true + } + + final class View: UIView, ListSectionComponent.ChildView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let label = ComponentView() + private let avatarNode: AvatarNode + + private var labelIconView: UIImageView? + private var checkLayer: CheckLayer? + + private var component: AdminUserActionsPeerComponent? + private weak var state: EmptyComponentState? + + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public var separatorInset: CGFloat = 0.0 + + override init(frame: CGRect) { + self.containerButton = HighlightTrackingButton() + + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.isLayerBacked = true + + super.init(frame: frame) + + self.addSubview(self.containerButton) + self.containerButton.layer.addSublayer(self.avatarNode.layer) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component, let peer = component.peer else { + return + } + component.action(peer) + } + + func update(component: AdminUserActionsPeerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + var hasSelectionUpdated = false + if let previousComponent = self.component { + switch previousComponent.selectionState { + case .none: + if case .none = component.selectionState { + } else { + hasSelectionUpdated = true + } + case .editing: + if case .editing = component.selectionState { + } else { + hasSelectionUpdated = true + } + } + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 44.0 + let verticalInset: CGFloat = 1.0 + let leftInset: CGFloat = 30.0 + component.sideInset + var rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset + var avatarLeftInset: CGFloat = component.sideInset + 10.0 + + if case let .editing(isSelected) = component.selectionState { + rightInset += 46.0 + avatarLeftInset += 24.0 + + let checkSize: CGFloat = 22.0 + + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + if themeUpdated { + checkLayer.theme = CheckNodeTheme(theme: component.theme, style: .plain) + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: component.theme, style: .plain)) + self.checkLayer = checkLayer + self.containerButton.layer.addSublayer(checkLayer) + checkLayer.frame = CGRect(origin: CGPoint(x: -checkSize, y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize)) + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: CGRect(origin: CGPoint(x: floor((22.0 - checkSize) * 0.5), y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize))) + } else { + if let checkLayer = self.checkLayer { + self.checkLayer = nil + transition.setPosition(layer: checkLayer, position: CGPoint(x: -checkLayer.bounds.width * 0.5, y: checkLayer.position.y), completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } + } + + let avatarSize: CGFloat = 30.0 + + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floor((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) + if self.avatarNode.bounds.isEmpty { + self.avatarNode.frame = avatarFrame + } else { + transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame) + } + if let peer = component.peer { + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + if peer.id == component.context.account.peerId { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: .savedMessagesIcon, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } else { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } + } + + let avatarTitleSpacing: CGFloat = 5.0 + let maxTextSize = availableSize.width - avatarLeftInset - avatarSize - avatarTitleSpacing - rightInset + + let previousTitleFrame = self.title.view?.frame + var previousTitleContents: UIView? + if hasSelectionUpdated && !"".isEmpty { + previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: maxTextSize, height: 100.0) + ) + + let centralContentHeight: CGFloat = titleSize.height + + let titleFrame = CGRect(origin: CGPoint(x: avatarLeftInset + avatarSize + avatarTitleSpacing, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { + previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) + self.addSubview(previousTitleContents) + + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) + transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in + previousTitleContents?.removeFromSuperview() + }) + transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) + } + } + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + self.separatorInset = leftInset + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift new file mode 100644 index 0000000000..bb37bc8c81 --- /dev/null +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -0,0 +1,1343 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import ButtonComponent +import PresentationDataUtils +import Markdown +import UndoUI +import AvatarNode +import TelegramStringFormatting +import ListSectionComponent +import ListActionItemComponent +import PlainButtonComponent + +private let banSendMediaFlags: TelegramChatBannedRightsFlags = [ + .banSendPhotos, + .banSendVideos, + .banSendGifs, + .banSendMusic, + .banSendFiles, + .banSendVoice, + .banSendInstantVideos, + .banEmbedLinks, + .banSendPolls +] + +private final class AdminUserActionsSheetComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let chatPeer: EnginePeer + let peers: [EnginePeer] + let messageCount: Int + let completion: (AdminUserActionsSheet.Result) -> Void + + init( + context: AccountContext, + chatPeer: EnginePeer, + peers: [EnginePeer], + messageCount: Int, + completion: @escaping (AdminUserActionsSheet.Result) -> Void + ) { + self.context = context + self.chatPeer = chatPeer + self.peers = peers + self.messageCount = messageCount + self.completion = completion + } + + static func ==(lhs: AdminUserActionsSheetComponent, rhs: AdminUserActionsSheetComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.chatPeer != rhs.chatPeer { + return false + } + if lhs.peers != rhs.peers { + return false + } + if lhs.messageCount != rhs.messageCount { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var containerSize: CGSize + var containerInset: CGFloat + var bottomInset: CGFloat + var topInset: CGFloat + + init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat) { + self.containerSize = containerSize + self.containerInset = containerInset + self.bottomInset = bottomInset + self.topInset = topInset + } + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + } + + final class View: UIView, UIScrollViewDelegate { + private let dimView: UIView + private let backgroundLayer: SimpleLayer + private let navigationBarContainer: SparseContainerView + private let navigationBackgroundView: BlurredBackgroundView + private let navigationBarSeparator: SimpleLayer + private let scrollView: ScrollView + private let scrollContentClippingView: SparseContainerView + private let scrollContentView: UIView + + private let leftButton = ComponentView() + + private let title = ComponentView() + private let actionButton = ComponentView() + + private let optionsSection = ComponentView() + private let optionsFooter = ComponentView() + private let configSection = ComponentView() + + private let bottomOverscrollLimit: CGFloat + + private var ignoreScrolling: Bool = false + + private var component: AdminUserActionsSheetComponent? + private weak var state: EmptyComponentState? + private var environment: ViewControllerComponentContainer.Environment? + private var isUpdating: Bool = false + + private var itemLayout: ItemLayout? + + private var topOffsetDistance: CGFloat? + + private var isOptionReportExpanded: Bool = false + private var optionReportSelectedPeers = Set() + private var isOptionDeleteAllExpanded: Bool = false + private var optionDeleteAllSelectedPeers = Set() + private var isOptionBanExpanded: Bool = false + private var optionBanSelectedPeers = Set() + + private var isConfigurationExpanded: Bool = false + private var configSendMessages: Bool = false + private var configSendMedia: Bool = false + private var configAddUsers: Bool = false + private var configPinMessages: Bool = false + private var configChangeInfo: Bool = false + + private var previousWasConfigurationExpanded: Bool = false + + override init(frame: CGRect) { + self.bottomOverscrollLimit = 200.0 + + self.dimView = UIView() + + self.backgroundLayer = SimpleLayer() + self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + self.backgroundLayer.cornerRadius = 10.0 + + self.navigationBarContainer = SparseContainerView() + + self.navigationBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.navigationBarSeparator = SimpleLayer() + + self.scrollView = ScrollView() + + self.scrollContentClippingView = SparseContainerView() + self.scrollContentClippingView.clipsToBounds = true + + self.scrollContentView = UIView() + + super.init(frame: frame) + + self.addSubview(self.dimView) + self.layer.addSublayer(self.backgroundLayer) + + self.scrollView.delaysContentTouches = true + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.addSubview(self.scrollContentClippingView) + self.scrollContentClippingView.addSubview(self.scrollView) + + self.scrollView.addSubview(self.scrollContentView) + + self.addSubview(self.navigationBarContainer) + + self.navigationBarContainer.addSubview(self.navigationBackgroundView) + self.navigationBarContainer.layer.addSublayer(self.navigationBarSeparator) + + self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + /*guard let itemLayout = self.itemLayout, let topOffsetDistance = self.topOffsetDistance else { + return + } + + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + topOffset = max(0.0, topOffset) + + if topOffset < topOffsetDistance { + targetContentOffset.pointee.y = scrollView.contentOffset.y + scrollView.setContentOffset(CGPoint(x: 0.0, y: itemLayout.topInset), animated: true) + }*/ + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.bounds.contains(point) { + return nil + } + if !self.backgroundLayer.frame.contains(point) { + return self.dimView + } + + if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { + return result + } + + let result = super.hitTest(point, with: event) + return result + } + + @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + guard let environment = self.environment, let controller = environment.controller() else { + return + } + controller.dismiss() + } + } + + private func calculateResult() -> AdminUserActionsSheet.Result { + var reportSpamPeers: [EnginePeer.Id] = [] + var deleteAllFromPeers: [EnginePeer.Id] = [] + var banPeers: [EnginePeer.Id] = [] + var updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights] = [:] + + for id in self.optionReportSelectedPeers.sorted() { + reportSpamPeers.append(id) + } + for id in self.optionDeleteAllSelectedPeers.sorted() { + deleteAllFromPeers.append(id) + } + + if !self.isConfigurationExpanded { + for id in self.optionBanSelectedPeers.sorted() { + banPeers.append(id) + } + } else { + var banFlags: TelegramChatBannedRightsFlags = [] + + if !self.configSendMessages { + banFlags.insert(.banSendText) + } + if !self.configSendMedia { + banFlags.formUnion(banSendMediaFlags) + } + if !self.configAddUsers { + banFlags.insert(.banAddMembers) + } + if !self.configPinMessages { + banFlags.insert(.banPinMessages) + } + if !self.configChangeInfo { + banFlags.insert(.banChangeInfo) + } + + let bannedRights = TelegramChatBannedRights(flags: banFlags, untilDate: Int32.max) + for id in self.optionBanSelectedPeers.sorted() { + updateBannedRights[id] = bannedRights + } + } + + return AdminUserActionsSheet.Result( + reportSpamPeers: reportSpamPeers, + deleteAllFromPeers: deleteAllFromPeers, + banPeers: banPeers, + updateBannedRights: updateBannedRights + ) + } + + private func updateScrolling(transition: Transition) { + guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { + return + } + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + + let navigationAlpha: CGFloat = 1.0 - max(0.0, min(1.0, (topOffset + 20.0) / 20.0)) + transition.setAlpha(view: self.navigationBackgroundView, alpha: navigationAlpha) + transition.setAlpha(layer: self.navigationBarSeparator, alpha: navigationAlpha) + + topOffset = max(0.0, topOffset) + transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) + + transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) + + let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) + self.topOffsetDistance = topOffsetDistance + var topOffsetFraction = topOffset / topOffsetDistance + topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) + + let transitionFactor: CGFloat = 1.0 - topOffsetFraction + if self.isUpdating { + DispatchQueue.main.async { [weak controller] in + guard let controller else { + return + } + controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) + } + } else { + controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) + } + } + + func animateIn() { + self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } + } + + func animateOut(completion: @escaping () -> Void) { + let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY + + self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in + completion() + }) + self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + } + + if let environment = self.environment, let controller = environment.controller() { + controller.updateModalStyleOverlayTransitionFactor(0.0, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + } + + func update(component: AdminUserActionsSheetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let themeUpdated = self.environment?.theme !== environment.theme + + let resetScrolling = self.scrollView.bounds.width != availableSize.width + + let sideInset: CGFloat = 16.0 + + if self.component == nil { + } + + self.component = component + self.state = state + self.environment = environment + + if themeUpdated { + self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.backgroundLayer.backgroundColor = environment.theme.list.blocksBackgroundColor.cgColor + + self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) + self.navigationBarSeparator.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + } + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) + + var contentHeight: CGFloat = 0.0 + contentHeight += 54.0 + contentHeight += 16.0 + + let leftButtonSize = self.leftButton.update( + transition: transition, + component: AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)), + action: { [weak self] in + guard let self, let controller = self.environment?.controller() else { + return + } + controller.dismiss() + } + ).minSize(CGSize(width: 44.0, height: 56.0))), + environment: {}, + containerSize: CGSize(width: 120.0, height: 100.0) + ) + let leftButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: leftButtonSize) + if let leftButtonView = self.leftButton.view { + if leftButtonView.superview == nil { + self.navigationBarContainer.addSubview(leftButtonView) + } + transition.setFrame(view: leftButtonView, frame: leftButtonFrame) + } + + let containerInset: CGFloat = environment.statusBarHeight + 10.0 + + let clippingY: CGFloat + + enum OptionsSection { + case report + case deleteAll + case ban + } + + let optionsItem: (OptionsSection) -> AnyComponentWithIdentity = { section in + let sectionId: AnyHashable + let selectedPeers: Set + let isExpanded: Bool + let title: String + + switch section { + case .report: + sectionId = "report" + selectedPeers = self.optionReportSelectedPeers + isExpanded = self.isOptionReportExpanded + + title = "Report Spam" + case .deleteAll: + sectionId = "delete-all" + selectedPeers = self.optionDeleteAllSelectedPeers + isExpanded = self.isOptionDeleteAllExpanded + + if component.peers.count == 1 { + title = "Delete All from \(component.peers[0].compactDisplayTitle)" + } else { + title = "Delete All from Users" + } + case .ban: + sectionId = "ban" + selectedPeers = self.optionBanSelectedPeers + isExpanded = self.isOptionBanExpanded + + let banTitle: String + let restrictTitle: String + if component.peers.count == 1 { + banTitle = "Ban \(component.peers[0].compactDisplayTitle)" + restrictTitle = "Restrict \(component.peers[0].compactDisplayTitle)" + } else { + banTitle = "Ban Users" + restrictTitle = "Restrict Users" + } + title = self.isConfigurationExpanded ? restrictTitle : banTitle + } + + var accessory: ListActionItemComponent.Accessory? + if component.peers.count > 1 { + accessory = .custom(ListActionItemComponent.CustomAccessory( + component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(OptionSectionExpandIndicatorComponent( + theme: environment.theme, + count: selectedPeers.isEmpty ? component.peers.count : selectedPeers.count, + isExpanded: isExpanded + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + + switch section { + case .report: + self.isOptionReportExpanded = !self.isOptionReportExpanded + case .deleteAll: + self.isOptionDeleteAllExpanded = !self.isOptionDeleteAllExpanded + case .ban: + self.isOptionBanExpanded = !self.isOptionBanExpanded + } + + self.state?.updated(transition: .spring(duration: 0.35)) + }, + animateScale: false + ))), + insets: UIEdgeInsets(top: 0.0, left: 6.0, bottom: 0.0, right: 2.0), + isInteractive: true + )) + } + + return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: .check(ListActionItemComponent.LeftIcon.Check( + isSelected: !selectedPeers.isEmpty, + toggle: { [weak self] in + guard let self, let component = self.component else { + return + } + + var selectedPeers: Set + switch section { + case .report: + selectedPeers = self.optionReportSelectedPeers + case .deleteAll: + selectedPeers = self.optionDeleteAllSelectedPeers + case .ban: + selectedPeers = self.optionBanSelectedPeers + } + + if selectedPeers.isEmpty { + for peer in component.peers { + selectedPeers.insert(peer.id) + } + } else { + selectedPeers.removeAll() + } + + switch section { + case .report: + self.optionReportSelectedPeers = selectedPeers + case .deleteAll: + self.optionDeleteAllSelectedPeers = selectedPeers + case .ban: + self.optionBanSelectedPeers = selectedPeers + if self.isConfigurationExpanded && self.optionBanSelectedPeers.isEmpty { + self.isConfigurationExpanded = false + } + } + + self.state?.updated(transition: .spring(duration: 0.35)) + } + )), + icon: .none, + accessory: accessory, + action: { [weak self] _ in + guard let self else { + return + } + + var selectedPeers: Set + switch section { + case .report: + selectedPeers = self.optionReportSelectedPeers + case .deleteAll: + selectedPeers = self.optionDeleteAllSelectedPeers + case .ban: + selectedPeers = self.optionBanSelectedPeers + } + + if selectedPeers.isEmpty { + for peer in component.peers { + selectedPeers.insert(peer.id) + } + } else { + selectedPeers.removeAll() + } + + switch section { + case .report: + self.optionReportSelectedPeers = selectedPeers + case .deleteAll: + self.optionDeleteAllSelectedPeers = selectedPeers + case .ban: + self.optionBanSelectedPeers = selectedPeers + } + + self.state?.updated(transition: .spring(duration: 0.35)) + }, + highlighting: .disabled + ))) + } + + let expandedPeersItem: (OptionsSection) -> AnyComponentWithIdentity = { section in + let sectionId: AnyHashable + let selectedPeers: Set + switch section { + case .report: + sectionId = "report-peers" + selectedPeers = self.optionReportSelectedPeers + case .deleteAll: + sectionId = "delete-all-peers" + selectedPeers = self.optionDeleteAllSelectedPeers + case .ban: + sectionId = "ban-peers" + selectedPeers = self.optionBanSelectedPeers + } + + var peerItems: [AnyComponentWithIdentity] = [] + for peer in component.peers { + peerItems.append(AnyComponentWithIdentity(id: peer.id, component: AnyComponent(AdminUserActionsPeerComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + sideInset: 0.0, + title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), + peer: peer, + selectionState: .editing(isSelected: selectedPeers.contains(peer.id)), + action: { [weak self] peer in + guard let self else { + return + } + + var selectedPeers: Set + switch section { + case .report: + selectedPeers = self.optionReportSelectedPeers + case .deleteAll: + selectedPeers = self.optionDeleteAllSelectedPeers + case .ban: + selectedPeers = self.optionBanSelectedPeers + } + + if selectedPeers.contains(peer.id) { + selectedPeers.remove(peer.id) + } else { + selectedPeers.insert(peer.id) + } + + switch section { + case .report: + self.optionReportSelectedPeers = selectedPeers + case .deleteAll: + self.optionDeleteAllSelectedPeers = selectedPeers + case .ban: + self.optionBanSelectedPeers = selectedPeers + } + + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + } + )))) + } + return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListSubSectionComponent( + theme: environment.theme, + leftInset: 62.0, + items: peerItems + ))) + } + + //TODO:localize + let titleString: String + if component.messageCount == 1 { + titleString = "Delete 1 Message?" + } else { + titleString = "Delete \(component.messageCount) Messages?" + } + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((54.0 - titleSize.height) * 0.5)), size: titleSize) + if let titleView = title.view { + if titleView.superview == nil { + self.navigationBarContainer.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + + let navigationBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 54.0)) + transition.setFrame(view: self.navigationBackgroundView, frame: navigationBackgroundFrame) + self.navigationBackgroundView.update(size: navigationBackgroundFrame.size, cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) + transition.setFrame(layer: self.navigationBarSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: 54.0), size: CGSize(width: availableSize.width, height: UIScreenPixel))) + + var optionsSectionItems: [AnyComponentWithIdentity] = [] + + optionsSectionItems.append(optionsItem(.report)) + if self.isOptionReportExpanded { + optionsSectionItems.append(expandedPeersItem(.report)) + } + + optionsSectionItems.append(optionsItem(.deleteAll)) + if self.isOptionDeleteAllExpanded { + optionsSectionItems.append(expandedPeersItem(.deleteAll)) + } + + optionsSectionItems.append(optionsItem(.ban)) + if self.isOptionBanExpanded { + optionsSectionItems.append(expandedPeersItem(.ban)) + } + + var optionsSectionTransition = transition + if self.previousWasConfigurationExpanded != self.isConfigurationExpanded { + self.previousWasConfigurationExpanded = self.isConfigurationExpanded + optionsSectionTransition = optionsSectionTransition.withAnimation(.none) + } + let optionsSectionSize = self.optionsSection.update( + transition: optionsSectionTransition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "ADDITIONAL ACTIONS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: optionsSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) + ) + + let optionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: optionsSectionSize) + if let optionsSectionView = self.optionsSection.view { + if optionsSectionView.superview == nil { + self.scrollContentView.addSubview(optionsSectionView) + self.optionsSection.parentState = state + } + transition.setFrame(view: optionsSectionView, frame: optionsSectionFrame) + } + contentHeight += optionsSectionSize.height + + let partiallyRestrictTitle: String + let fullyBanTitle: String + if component.peers.count == 1 { + partiallyRestrictTitle = "Partially restrict this user" + fullyBanTitle = "Fully ban this user" + } else { + partiallyRestrictTitle = "Partially restrict users" + fullyBanTitle = "Fully ban users" + } + + let optionsFooterSize = self.optionsFooter.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(OptionsSectionFooterComponent( + theme: environment.theme, + text: self.isConfigurationExpanded ? fullyBanTitle : partiallyRestrictTitle, + fontSize: presentationData.listsFontSize.itemListBaseHeaderFontSize, + isExpanded: self.isConfigurationExpanded + )), + effectAlignment: .left, + contentInsets: UIEdgeInsets(), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + self.isConfigurationExpanded = !self.isConfigurationExpanded + if self.isConfigurationExpanded && self.optionBanSelectedPeers.isEmpty { + for peer in component.peers { + self.optionBanSelectedPeers.insert(peer.id) + } + } + self.state?.updated(transition: .spring(duration: 0.35)) + }, + animateAlpha: true, + animateScale: false, + animateContents: true + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + + var configSectionItems: [AnyComponentWithIdentity] = [] + + enum ConfigItem: Hashable { + case sendMessages + case sendMedia + case addUsers + case pinMessages + case changeInfo + } + + let allConfigItems: [ConfigItem] = [ + .sendMessages, + .sendMedia, + .addUsers, + .pinMessages, + .changeInfo + ] + if case let .channel(channel) = component.chatPeer { + let defaultBannedFlags = channel.defaultBannedRights?.flags ?? [] + + loop: for configItem in allConfigItems { + let itemTitle: String + let itemValue: Bool + switch configItem { + case .sendMessages: + if defaultBannedFlags.contains(.banSendText) { + continue loop + } + + itemTitle = "Send Text Messages" + itemValue = self.configSendMessages + case .sendMedia: + if !defaultBannedFlags.intersection(banSendMediaFlags).isEmpty { + continue loop + } + + itemTitle = "Send Media" + itemValue = self.configSendMedia + case .addUsers: + if defaultBannedFlags.contains(.banAddMembers) { + continue loop + } + + itemTitle = "Add Users" + itemValue = self.configAddUsers + case .pinMessages: + if defaultBannedFlags.contains(.banPinMessages) { + continue loop + } + + itemTitle = "Pin Messages" + itemValue = self.configPinMessages + case .changeInfo: + if defaultBannedFlags.contains(.banChangeInfo) { + continue loop + } + + itemTitle = "Change Chat Info" + itemValue = self.configChangeInfo + } + + configSectionItems.append(AnyComponentWithIdentity(id: configItem, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: itemTitle, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle( + style: .icons, + isOn: itemValue, + isInteractive: true, + action: { [weak self] _ in + guard let self else { + return + } + switch configItem { + case .sendMessages: + self.configSendMessages = !self.configSendMessages + case .sendMedia: + self.configSendMedia = !self.configSendMedia + case .addUsers: + self.configAddUsers = !self.configAddUsers + case .pinMessages: + self.configPinMessages = !self.configPinMessages + case .changeInfo: + self.configChangeInfo = !self.configChangeInfo + } + self.state?.updated(transition: .spring(duration: 0.35)) + } + )), + action: nil + )))) + } + } + + let configSectionSize = self.configSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "WHAT CAN THIS USER DO?", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: configSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) + ) + let configSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 30.0), size: configSectionSize) + if let configSectionView = self.configSection.view { + if configSectionView.superview == nil { + configSectionView.clipsToBounds = true + configSectionView.layer.cornerRadius = 11.0 + self.scrollContentView.addSubview(configSectionView) + self.configSection.parentState = state + } + let effectiveConfigSectionFrame: CGRect + if self.isConfigurationExpanded { + effectiveConfigSectionFrame = configSectionFrame + } else { + effectiveConfigSectionFrame = CGRect(origin: CGPoint(x: configSectionFrame.minX, y: configSectionFrame.minY - 30.0), size: CGSize(width: configSectionFrame.width, height: 0.0)) + } + transition.setFrame(view: configSectionView, frame: effectiveConfigSectionFrame) + transition.setAlpha(view: configSectionView, alpha: self.isConfigurationExpanded ? 1.0 : 0.0) + } + + let optionsFooterFrame: CGRect + if self.isConfigurationExpanded { + contentHeight += 30.0 + contentHeight += configSectionSize.height + contentHeight += 7.0 + optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) + contentHeight += optionsFooterSize.height + } else { + contentHeight += 7.0 + optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) + contentHeight += optionsFooterSize.height + } + if let optionsFooterView = self.optionsFooter.view { + if optionsFooterView.superview == nil { + self.scrollContentView.addSubview(optionsFooterView) + } + transition.setFrame(view: optionsFooterView, frame: optionsFooterFrame) + } + + contentHeight += 30.0 + + let actionButtonSize = self.actionButton.update( + transition: transition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: environment.theme.list.itemCheckColors.fillColor, + foreground: environment.theme.list.itemCheckColors.foregroundColor, + pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(ButtonTextContentComponent( + text: "Proceed", + badge: 0, + textColor: environment.theme.list.itemCheckColors.foregroundColor, + badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, + badgeForeground: environment.theme.list.itemCheckColors.fillColor + )) + ), + isEnabled: true, + displaysProgress: false, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + self.environment?.controller()?.dismiss() + component.completion(self.calculateResult()) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + let bottomPanelHeight = 8.0 + environment.safeInsets.bottom + actionButtonSize.height + let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize) + if let actionButtonView = actionButton.view { + if actionButtonView.superview == nil { + self.addSubview(actionButtonView) + } + transition.setFrame(view: actionButtonView, frame: actionButtonFrame) + } + + contentHeight += bottomPanelHeight + + clippingY = actionButtonFrame.minY - 24.0 + + let topInset: CGFloat = max(0.0, availableSize.height - containerInset - contentHeight) + + let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset) + + self.scrollContentClippingView.layer.cornerRadius = 10.0 + + self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset) + + transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) + + transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) + transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) + + let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: clippingY - containerInset)) + transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) + transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) + + self.ignoreScrolling = true + let previousBounds = self.scrollView.bounds + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) + let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + if resetScrolling { + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) + } else { + if !previousBounds.isEmpty, !transition.animation.isImmediate { + let bounds = self.scrollView.bounds + if bounds.maxY != previousBounds.maxY { + let offsetY = previousBounds.maxY - bounds.maxY + transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) + } + } + } + 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, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class AdminUserActionsSheet: ViewControllerComponentContainer { + public final class Result { + public let reportSpamPeers: [EnginePeer.Id] + public let deleteAllFromPeers: [EnginePeer.Id] + public let banPeers: [EnginePeer.Id] + public let updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights] + + init(reportSpamPeers: [EnginePeer.Id], deleteAllFromPeers: [EnginePeer.Id], banPeers: [EnginePeer.Id], updateBannedRights: [EnginePeer.Id : TelegramChatBannedRights]) { + self.reportSpamPeers = reportSpamPeers + self.deleteAllFromPeers = deleteAllFromPeers + self.banPeers = banPeers + self.updateBannedRights = updateBannedRights + } + } + + private let context: AccountContext + + private var isDismissed: Bool = false + + public init(context: AccountContext, chatPeer: EnginePeer, peers: [EnginePeer], messageCount: Int, completion: @escaping (Result) -> Void) { + self.context = context + + /*#if DEBUG + var peers = peers + + if !"".isEmpty { + var nextPeerId: Int64 = 1 + let makePeer: () -> EnginePeer = { + guard case let .user(user) = peers[0] else { + preconditionFailure() + } + let id = nextPeerId + nextPeerId += 1 + return .user(TelegramUser( + id: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(id)), + accessHash: user.accessHash, + firstName: user.firstName, + lastName: user.lastName, + username: user.username, + phone: user.phone, + photo: user.photo, + botInfo: user.botInfo, + restrictionInfo: user.restrictionInfo, + flags: user.flags, + emojiStatus: user.emojiStatus, + usernames: user.usernames, + storiesHidden: user.storiesHidden, + nameColor: user.nameColor, + backgroundEmojiId: user.backgroundEmojiId, + profileColor: user.profileColor, + profileBackgroundEmojiId: user.profileBackgroundEmojiId + )) + } + + for _ in 0 ..< 3 { + peers.append(makePeer()) + } + } + #endif*/ + + super.init(context: context, component: AdminUserActionsSheetComponent(context: context, chatPeer: chatPeer, peers: peers, messageCount: messageCount, completion: completion), navigationBarAppearance: .none) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.disablesInteractiveModalDismiss = true + + if let componentView = self.node.hostView.componentView as? AdminUserActionsSheetComponent.View { + componentView.animateIn() + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + + if let componentView = self.node.hostView.componentView as? AdminUserActionsSheetComponent.View { + componentView.animateOut(completion: { [weak self] in + completion?() + self?.dismiss(animated: false) + }) + } else { + self.dismiss(animated: false) + } + } + } +} + +private let optionExpandUsersIcon: UIImage? = { + let sourceImage = UIImage(bundleImageName: "Chat/Input/Accessory Panels/PanelTextGroupIcon")! + return generateImage(CGSize(width: sourceImage.size.width, height: sourceImage.size.height), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + sourceImage.draw(at: CGPoint(x: 0.0, y: 0.0)) + UIGraphicsPopContext() + })!.precomposed().withRenderingMode(.alwaysTemplate) +}() + +private final class OptionSectionExpandIndicatorComponent: Component { + let theme: PresentationTheme + let count: Int + let isExpanded: Bool + + init( + theme: PresentationTheme, + count: Int, + isExpanded: Bool + ) { + self.theme = theme + self.count = count + self.isExpanded = isExpanded + } + + static func ==(lhs: OptionSectionExpandIndicatorComponent, rhs: OptionSectionExpandIndicatorComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.count != rhs.count { + return false + } + if lhs.isExpanded != rhs.isExpanded { + return false + } + return true + } + + final class View: UIView { + private let iconView: UIImageView + private let arrowView: UIImageView + private let count = ComponentView() + + override init(frame: CGRect) { + self.iconView = UIImageView() + self.arrowView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.iconView) + self.addSubview(self.arrowView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: OptionSectionExpandIndicatorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let countArrowSpacing: CGFloat = -1.0 + let iconCountSpacing: CGFloat = 2.0 + + if self.iconView.image == nil { + self.iconView.image = optionExpandUsersIcon + } + self.iconView.tintColor = component.theme.list.itemPrimaryTextColor + let iconSize = CGSize(width: 12.0, height: 12.0) + + if self.arrowView.image == nil { + self.arrowView.image = PresentationResourcesItemList.downArrowImage(component.theme)?.withRenderingMode(.alwaysTemplate) + } + self.arrowView.tintColor = component.theme.list.itemPrimaryTextColor + + let arrowSize = CGSize(width: 20.0, height: 20.0) + let countSize = self.count.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "\(component.count)", font: Font.semibold(13.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + let size = CGSize(width: 60.0, height: availableSize.height) + + var arrowFrame = CGRect(origin: CGPoint(x: size.width - arrowSize.width - 10.0, y: floor((size.height - arrowSize.height) * 0.5)), size: arrowSize) + if component.isExpanded { + arrowFrame = arrowFrame.offsetBy(dx: 0.0, dy: -1.0) + } else { + arrowFrame = arrowFrame.offsetBy(dx: 0.0, dy: 1.0) + } + + let countFrame = CGRect(origin: CGPoint(x: arrowFrame.minX - countArrowSpacing - countSize.width, y: floor((size.height - countSize.height) * 0.5)), size: countSize) + + let iconFrame = CGRect(origin: CGPoint(x: countFrame.minX - iconCountSpacing - iconSize.width, y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + + if let countView = self.count.view { + if countView.superview == nil { + self.addSubview(countView) + } + countView.frame = countFrame + } + + transition.setPosition(view: self.arrowView, position: arrowFrame.center) + self.arrowView.bounds = CGRect(origin: CGPoint(), size: arrowFrame.size) + transition.setTransform(view: self.arrowView, transform: CATransform3DMakeRotation(component.isExpanded ? CGFloat.pi : 0.0, 0.0, 0.0, 1.0)) + + self.iconView.frame = iconFrame + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +private final class OptionsSectionFooterComponent: Component { + let theme: PresentationTheme + let text: String + let fontSize: CGFloat + let isExpanded: Bool + + init( + theme: PresentationTheme, + text: String, + fontSize: CGFloat, + isExpanded: Bool + ) { + self.theme = theme + self.text = text + self.fontSize = fontSize + self.isExpanded = isExpanded + } + + static func ==(lhs: OptionsSectionFooterComponent, rhs: OptionsSectionFooterComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.text != rhs.text { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + if lhs.isExpanded != rhs.isExpanded { + return false + } + return true + } + + final class View: UIView { + private let arrowView: UIImageView + private let textView: ImmediateTextView + + override init(frame: CGRect) { + self.arrowView = UIImageView() + + self.textView = ImmediateTextView() + self.textView.maximumNumberOfLines = 0 + + super.init(frame: frame) + + self.addSubview(self.arrowView) + self.addSubview(self.textView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: OptionsSectionFooterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + if self.arrowView.image == nil { + self.arrowView.image = PresentationResourcesItemList.downArrowImage(component.theme)?.withRenderingMode(.alwaysTemplate) + } + self.arrowView.tintColor = component.theme.list.itemAccentColor + + let arrowSize = CGSize(width: 14.0, height: 14.0) + + let attributedText = NSMutableAttributedString(attributedString: NSAttributedString(string: component.text, font: Font.regular(component.fontSize), textColor: component.theme.list.itemAccentColor)) + attributedText.append(NSAttributedString(string: ">", font: Font.regular(component.fontSize), textColor: .clear)) + self.textView.attributedText = attributedText + let textLayout = self.textView.updateLayoutFullInfo(availableSize) + + let size = textLayout.size + let textFrame = CGRect(origin: CGPoint(), size: textLayout.size) + self.textView.frame = textFrame + + var arrowFrame = CGRect() + if let lineRect = textLayout.linesRects().last { + arrowFrame = CGRect(origin: CGPoint(x: textFrame.minX + lineRect.maxX - arrowSize.width + 6.0, y: textFrame.minY + lineRect.maxY - lineRect.height - arrowSize.height + 4.0), size: arrowSize) + } + + self.arrowView.center = arrowFrame.center + self.arrowView.bounds = CGRect(origin: CGPoint(), size: arrowFrame.size) + transition.setTransform(view: self.arrowView, transform: CATransform3DMakeRotation(component.isExpanded ? CGFloat.pi : 0.0, 0.0, 0.0, 1.0)) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index e8fc71dcb5..6f81354381 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1199,6 +1199,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } replyInfoNode.updateTouchesAtPoint(translatedPoint) } + if let forwardInfoNode = strongSelf.forwardInfoNode { + var translatedPoint: CGPoint? + let convertedNodeFrame = forwardInfoNode.view.convert(forwardInfoNode.bounds, to: strongSelf.view) + if let point = point, convertedNodeFrame.insetBy(dx: -4.0, dy: -4.0).contains(point) { + translatedPoint = strongSelf.view.convert(point, to: forwardInfoNode.view) + } + forwardInfoNode.updateTouchesAtPoint(translatedPoint) + } for contentNode in strongSelf.contentNodes { var translatedPoint: CGPoint? let convertedNodeFrame = contentNode.view.convert(contentNode.bounds, to: strongSelf.view) @@ -2312,6 +2320,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let sizeAndApply = forwardInfoLayout(item.context, item.presentationData, item.presentationData.strings, .bubble(incoming: incoming), forwardSource, forwardAuthorSignature, forwardPsaType, nil, CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude)) forwardInfoSizeApply = (sizeAndApply.0, { width in sizeAndApply.1(width) }) + headerSize.height += 2.0 forwardInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, forwardInfoSizeApply.0.width + bubbleWidthInsets) headerSize.height += forwardInfoSizeApply.0.height @@ -2341,6 +2350,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if storyType != .regular { headerSize.height += 6.0 + } else { + headerSize.height += 2.0 } forwardInfoOriginY = headerSize.height @@ -2349,6 +2360,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if storyType != .regular { headerSize.height += 16.0 + } else { + headerSize.height += 2.0 } } @@ -4425,7 +4438,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI item.controllerInteraction.openPeer(EnginePeer(peer), peer is TelegramUser ? .info(nil) : .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) } else if let _ = forwardInfo.authorSignature { var subRect: CGRect? - if let textNode = forwardInfoNode.textNode { + if let textNode = forwardInfoNode.nameNode { subRect = textNode.frame } item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, forwardInfoNode, subRect) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD index 84fcec074b..dd8301c5f5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/TelegramUI/Components/TextNodeWithEntities", "//submodules/TelegramUI/Components/AnimationCache", "//submodules/TelegramUI/Components/MultiAnimationRenderer", + "//submodules/AvatarNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift index f924d4be48..754ab9d7af 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageForwardInfoNode/Sources/ChatMessageForwardInfoNode.swift @@ -7,6 +7,7 @@ import TelegramCore import TelegramPresentationData import LocalizedPeerData import AccountContext +import AvatarNode public enum ChatMessageForwardInfoType: Equatable { case bubble(incoming: Bool) @@ -73,10 +74,16 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { } } - public private(set) var textNode: TextNode? + public private(set) var titleNode: TextNode? + public private(set) var nameNode: TextNode? private var credibilityIconNode: ASImageNode? private var infoNode: InfoButtonNode? private var expiredStoryIconView: UIImageView? + private var avatarNode: AvatarNode? + + private var theme: PresentationTheme? + private var highlightColor: UIColor? + private var linkHighlightingNode: LinkHighlightingNode? public var openPsa: ((String, ASDisplayNode) -> Void)? @@ -107,11 +114,61 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { } } + public func updateTouchesAtPoint(_ point: CGPoint?) { + var isHighlighted = false + if point != nil { + isHighlighted = true + } + + var initialRects: [CGRect] = [] + let addRects: (TextNode, CGPoint, CGFloat) -> Void = { textNode, offset, additionalWidth in + guard let cachedLayout = textNode.cachedLayout else { + return + } + for rect in cachedLayout.linesRects() { + var rect = rect + rect.size.width += rect.origin.x + additionalWidth + rect.origin.x = 0.0 + initialRects.append(rect.offsetBy(dx: offset.x, dy: offset.y)) + } + } + + let offsetY: CGFloat = -12.0 + if let titleNode = self.titleNode { + addRects(titleNode, CGPoint(x: titleNode.frame.minX, y: offsetY + titleNode.frame.minY), 0.0) + + if let nameNode = self.nameNode { + addRects(nameNode, CGPoint(x: titleNode.frame.minX, y: offsetY + nameNode.frame.minY), nameNode.frame.minX - titleNode.frame.minX) + } + } + + if isHighlighted, !initialRects.isEmpty, let highlightColor = self.highlightColor { + let rects = initialRects + + let linkHighlightingNode: LinkHighlightingNode + if let current = self.linkHighlightingNode { + linkHighlightingNode = current + } else { + linkHighlightingNode = LinkHighlightingNode(color: highlightColor) + self.linkHighlightingNode = linkHighlightingNode + self.addSubnode(linkHighlightingNode) + } + linkHighlightingNode.frame = self.bounds + linkHighlightingNode.updateRects(rects) + } else if let linkHighlightingNode = self.linkHighlightingNode { + self.linkHighlightingNode = nil + linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in + linkHighlightingNode?.removeFromSupernode() + }) + } + } + public static func asyncLayout(_ maybeNode: ChatMessageForwardInfoNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ strings: PresentationStrings, _ type: ChatMessageForwardInfoType, _ peer: Peer?, _ authorName: String?, _ psaType: String?, _ storyData: StoryData?, _ constrainedSize: CGSize) -> (CGSize, (CGFloat) -> ChatMessageForwardInfoNode) { - let textNodeLayout = TextNode.asyncLayout(maybeNode?.textNode) + let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode) + let nameNodeLayout = TextNode.asyncLayout(maybeNode?.nameNode) return { context, presentationData, strings, type, peer, authorName, psaType, storyData, constrainedSize in - let fontSize = floor(presentationData.fontSize.baseDisplaySize * 13.0 / 17.0) + let fontSize = floor(presentationData.fontSize.baseDisplaySize * 14.0 / 17.0) let prefixFont = Font.regular(fontSize) let peerFont = Font.medium(fontSize) @@ -134,87 +191,94 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { } let titleColor: UIColor - let completeSourceString: PresentationStrings.FormattedString + let titleString: PresentationStrings.FormattedString + var authorString: String? switch type { - case let .bubble(incoming): - if let psaType = psaType { - titleColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barPositive : presentationData.theme.theme.chat.message.outgoing.polls.barPositive - - var customFormat: String? - let key = "Message.ForwardedPsa.\(psaType)" - if let string = presentationData.strings.primaryComponent.dict[key] { - customFormat = string - } else if let string = presentationData.strings.secondaryComponent?.dict[key] { - customFormat = string - } - - if let customFormat = customFormat { - if let range = customFormat.range(of: "%@") { - let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) - let rightPart = String(customFormat[range.upperBound...]) - - let formattedText = leftPart + peerString + rightPart - completeSourceString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) - } else { - completeSourceString = PresentationStrings.FormattedString(string: customFormat, ranges: []) - } - } else { - completeSourceString = strings.Message_GenericForwardedPsa(peerString) - } - } else { - if incoming { - if let nameColor = peer?.nameColor { - titleColor = context.peerNameColors.get(nameColor, dark: presentationData.theme.theme.overallDarkAppearance).main - } else { - titleColor = presentationData.theme.theme.chat.message.incoming.accentTextColor - } - } else { - titleColor = presentationData.theme.theme.chat.message.outgoing.accentTextColor - } - - if let storyData = storyData { - switch storyData.storyType { - case .regular: - completeSourceString = strings.Message_ForwardedStoryShort(peerString) - case .expired: - completeSourceString = strings.Message_ForwardedExpiredStoryShort(peerString) - case .unavailable: - completeSourceString = strings.Message_ForwardedUnavailableStoryShort(peerString) - } - } else { - completeSourceString = strings.Message_ForwardedMessageShort(peerString) - } - } - case .standalone: - let serviceColor = serviceMessageColorComponents(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper) - titleColor = serviceColor.primaryText + case let .bubble(incoming): + if let psaType = psaType { + titleColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barPositive : presentationData.theme.theme.chat.message.outgoing.polls.barPositive - if let psaType = psaType { - var customFormat: String? - let key = "Message.ForwardedPsa.\(psaType)" - if let string = presentationData.strings.primaryComponent.dict[key] { - customFormat = string - } else if let string = presentationData.strings.secondaryComponent?.dict[key] { - customFormat = string - } - - if let customFormat = customFormat { - if let range = customFormat.range(of: "%@") { - let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) - let rightPart = String(customFormat[range.upperBound...]) - - let formattedText = leftPart + peerString + rightPart - completeSourceString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) - } else { - completeSourceString = PresentationStrings.FormattedString(string: customFormat, ranges: []) - } + var customFormat: String? + let key = "Message.ForwardedPsa.\(psaType)" + if let string = presentationData.strings.primaryComponent.dict[key] { + customFormat = string + } else if let string = presentationData.strings.secondaryComponent?.dict[key] { + customFormat = string + } + + if let customFormat = customFormat { + if let range = customFormat.range(of: "%@") { + let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) + let rightPart = String(customFormat[range.upperBound...]) + + let formattedText = leftPart + peerString + rightPart + titleString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) } else { - completeSourceString = strings.Message_GenericForwardedPsa(peerString) + titleString = PresentationStrings.FormattedString(string: customFormat, ranges: []) } } else { - completeSourceString = strings.Message_ForwardedMessageShort(peerString) + titleString = strings.Message_GenericForwardedPsa(peerString) } + } else { + if incoming { + if let nameColor = peer?.nameColor { + titleColor = context.peerNameColors.get(nameColor, dark: presentationData.theme.theme.overallDarkAppearance).main + } else { + titleColor = presentationData.theme.theme.chat.message.incoming.accentTextColor + } + } else { + titleColor = presentationData.theme.theme.chat.message.outgoing.accentTextColor + } + + //TODO:localize + if let storyData = storyData { + switch storyData.storyType { + case .regular: + titleString = PresentationStrings.FormattedString(string: "Forwarded story from", ranges: []) + authorString = peerString + case .expired: + titleString = PresentationStrings.FormattedString(string: "Expired story from", ranges: []) + authorString = peerString + case .unavailable: + titleString = PresentationStrings.FormattedString(string: "Expired story from", ranges: []) + authorString = peerString + } + } else { + titleString = PresentationStrings.FormattedString(string: "Forwarded from", ranges: []) + authorString = peerString + } + } + case .standalone: + let serviceColor = serviceMessageColorComponents(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper) + titleColor = serviceColor.primaryText + + if let psaType = psaType { + var customFormat: String? + let key = "Message.ForwardedPsa.\(psaType)" + if let string = presentationData.strings.primaryComponent.dict[key] { + customFormat = string + } else if let string = presentationData.strings.secondaryComponent?.dict[key] { + customFormat = string + } + + if let customFormat = customFormat { + if let range = customFormat.range(of: "%@") { + let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) + let rightPart = String(customFormat[range.upperBound...]) + + let formattedText = leftPart + peerString + rightPart + titleString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) + } else { + titleString = PresentationStrings.FormattedString(string: customFormat, ranges: []) + } + } else { + titleString = strings.Message_GenericForwardedPsa(peerString) + } + } else { + titleString = PresentationStrings.FormattedString(string: "Forwarded from", ranges: []) + authorString = peerString + } } var currentCredibilityIconImage: UIImage? @@ -230,17 +294,17 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { if peer.isFake { switch type { - case let .bubble(incoming): - currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) - case .standalone: - currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) + case let .bubble(incoming): + currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) + case .standalone: + currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) } } else if peer.isScam { switch type { - case let .bubble(incoming): - currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) - case .standalone: - currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) + case let .bubble(incoming): + currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) + case .standalone: + currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) } } else { currentCredibilityIconImage = nil @@ -249,10 +313,9 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { highlight = false } - //let completeString: NSString = (completeSourceString.string.replacingOccurrences(of: "\n", with: " \n")) as NSString - let completeString: NSString = completeSourceString.string as NSString - let string = NSMutableAttributedString(string: completeString as String, attributes: [NSAttributedString.Key.foregroundColor: titleColor, NSAttributedString.Key.font: prefixFont]) - if highlight, let range = completeSourceString.ranges.first?.range { + let rawTitleString: NSString = titleString.string as NSString + let string = NSMutableAttributedString(string: rawTitleString as String, attributes: [NSAttributedString.Key.foregroundColor: titleColor, NSAttributedString.Key.font: prefixFont]) + if highlight, let range = titleString.ranges.first?.range { string.addAttributes([NSAttributedString.Key.font: peerFont], range: range) } @@ -278,9 +341,34 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { } } - let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: string, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth, height: constrainedSize.height), alignment: .natural, cutout: cutout, insets: UIEdgeInsets())) + let (titleLayout, titleApply) = titleNodeLayout(TextNodeLayoutArguments(attributedString: string, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth, height: constrainedSize.height), alignment: .natural, cutout: cutout, insets: UIEdgeInsets())) - return (CGSize(width: textLayout.size.width + credibilityIconWidth + infoWidth, height: textLayout.size.height), { width in + var nameLayoutAndApply: (TextNodeLayout, () -> TextNode)? + if let authorString { + nameLayoutAndApply = nameNodeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: authorString, font: peerFont, textColor: titleColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth, height: constrainedSize.height), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + } + + let titleAuthorSpacing: CGFloat = 0.0 + + var authorAvatarInset: CGFloat = 0.0 + if peer != nil { + authorAvatarInset = 19.0 + } + + let resultSize: CGSize + if let nameLayoutAndApply { + resultSize = CGSize( + width: max( + titleLayout.size.width + credibilityIconWidth + infoWidth, + authorAvatarInset + nameLayoutAndApply.0.size.width + ), + height: titleLayout.size.height + titleAuthorSpacing + nameLayoutAndApply.0.size.height + ) + } else { + resultSize = CGSize(width: titleLayout.size.width + credibilityIconWidth + infoWidth, height: titleLayout.size.height) + } + + return (resultSize, { width in let node: ChatMessageForwardInfoNode if let maybeNode = maybeNode { node = maybeNode @@ -288,15 +376,57 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { node = ChatMessageForwardInfoNode() } - let textNode = textApply() - textNode.displaysAsynchronously = !presentationData.isPreview + node.theme = presentationData.theme.theme + node.highlightColor = titleColor.withMultipliedAlpha(0.1) - if node.textNode == nil { - textNode.isUserInteractionEnabled = false - node.textNode = textNode - node.addSubnode(textNode) + let titleNode = titleApply() + titleNode.displaysAsynchronously = !presentationData.isPreview + + if node.titleNode == nil { + titleNode.isUserInteractionEnabled = false + node.titleNode = titleNode + node.addSubnode(titleNode) + } + titleNode.frame = CGRect(origin: CGPoint(x: leftOffset, y: 0.0), size: titleLayout.size) + + if let (nameLayout, nameApply) = nameLayoutAndApply { + let nameNode = nameApply() + if node.nameNode == nil { + nameNode.isUserInteractionEnabled = false + node.nameNode = nameNode + node.addSubnode(nameNode) + } + nameNode.frame = CGRect(origin: CGPoint(x: leftOffset + authorAvatarInset, y: titleLayout.size.height + titleAuthorSpacing), size: nameLayout.size) + + if let peer, authorAvatarInset != 0.0 { + let avatarNode: AvatarNode + if let current = node.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) + node.avatarNode = avatarNode + node.addSubnode(avatarNode) + } + let avatarSize = CGSize(width: 16.0, height: 16.0) + avatarNode.frame = CGRect(origin: CGPoint(x: leftOffset, y: titleLayout.size.height + titleAuthorSpacing), size: avatarSize) + avatarNode.updateSize(size: avatarSize) + avatarNode.setPeer(context: context, theme: presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: avatarSize) + } else { + if let avatarNode = node.avatarNode { + node.avatarNode = nil + avatarNode.removeFromSupernode() + } + } + } else { + if let nameNode = node.nameNode { + node.nameNode = nil + nameNode.removeFromSupernode() + } + if let avatarNode = node.avatarNode { + node.avatarNode = nil + avatarNode.removeFromSupernode() + } } - textNode.frame = CGRect(origin: CGPoint(x: leftOffset, y: 0.0), size: textLayout.size) if let storyData, case .expired = storyData.storyType { let expiredStoryIconView: UIImageView @@ -334,7 +464,7 @@ public class ChatMessageForwardInfoNode: ASDisplayNode { node.credibilityIconNode = credibilityIconNode node.addSubnode(credibilityIconNode) } - credibilityIconNode.frame = CGRect(origin: CGPoint(x: textLayout.size.width + 4.0, y: 16.0), size: credibilityIconImage.size) + credibilityIconNode.frame = CGRect(origin: CGPoint(x: titleLayout.size.width + 4.0, y: 16.0), size: credibilityIconImage.size) credibilityIconNode.image = credibilityIconImage } else { node.credibilityIconNode?.removeFromSupernode() diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD index cbea42ae83..7a038fc101 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/BUILD @@ -26,6 +26,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", "//submodules/TelegramUI/Components/Chat/PollBubbleTimerNode", "//submodules/TelegramUI/Components/Chat/MergedAvatarsNode", + "//submodules/TelegramUI/Components/TextNodeWithEntities", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 940ca9feb7..3af80c780c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -16,6 +16,7 @@ import ChatMessageBubbleContentNode import ChatMessageItemCommon import PollBubbleTimerNode import MergedAvatarsNode +import TextNodeWithEntities private final class ChatMessagePollOptionRadioNodeParameters: NSObject { let timestamp: Double @@ -386,7 +387,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { private(set) var radioNode: ChatMessagePollOptionRadioNode? private let percentageNode: ASDisplayNode private var percentageImage: UIImage? - private var titleNode: TextNode? + private var titleNode: TextNodeWithEntities? private let buttonNode: HighlightTrackingButtonNode let separatorNode: ASDisplayNode private let resultBarNode: ASImageNode @@ -400,6 +401,20 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { weak var previousOptionNode: ChatMessagePollOptionNode? + var visibilityRect: CGRect? { + didSet { + if self.visibilityRect != oldValue { + if let titleNode = self.titleNode { + if let visibilityRect = self.visibilityRect { + titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNode.textNode.frame.minY) + } else { + titleNode.visibilityRect = nil + } + } + } + } + } + override init() { self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.alpha = 0.0 @@ -476,19 +491,35 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } } - static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ accountPeerId: PeerId, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollOptionNode))) { - let makeTitleLayout = TextNode.asyncLayout(maybeNode?.titleNode) + static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) { + let makeTitleLayout = TextNodeWithEntities.asyncLayout(maybeNode?.titleNode) let currentResult = maybeNode?.currentResult let currentSelection = maybeNode?.currentSelection let currentTheme = maybeNode?.theme - return { accountPeerId, presentationData, message, poll, option, optionResult, constrainedWidth in + return { context, presentationData, message, poll, option, optionResult, constrainedWidth in let leftInset: CGFloat = 50.0 let rightInset: CGFloat = 12.0 - let incoming = message.effectivelyIncoming(accountPeerId) + let incoming = message.effectivelyIncoming(context.account.peerId) - let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: option.text, font: presentationData.messageFont, textColor: incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: max(1.0, constrainedWidth - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude), alignment: .left, cutout: nil, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0))) + let optionTextColor: UIColor = incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor + let optionAttributedText = stringWithAppliedEntities( + option.text, + entities: option.entities, + baseColor: optionTextColor, + linkColor: optionTextColor, + baseFont: presentationData.messageFont, + linkFont: presentationData.messageFont, + boldFont: presentationData.messageFont, + italicFont: presentationData.messageFont, + boldItalicFont: presentationData.messageFont, + fixedFont: presentationData.messageFont, + blockQuoteFont: presentationData.messageFont, + message: message + ) + + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: optionAttributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: max(1.0, constrainedWidth - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude), alignment: .left, cutout: nil, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0))) let contentHeight: CGFloat = max(46.0, titleLayout.size.height + 22.0) @@ -578,7 +609,7 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { } return (titleLayout.size.width + leftInset + rightInset, { width in - return (CGSize(width: width, height: contentHeight), { animated, inProgress in + return (CGSize(width: width, height: contentHeight), { animated, inProgress, attemptSynchronous in let node: ChatMessagePollOptionNode if let maybeNode = maybeNode { node = maybeNode @@ -596,17 +627,29 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { node.buttonNode.accessibilityLabel = option.text - let titleNode = titleApply() + let titleNode = titleApply(TextNodeWithEntities.Arguments( + context: context, + cache: context.animationCache, + renderer: context.animationRenderer, + placeholderColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + attemptSynchronous: attemptSynchronous + )) + let titleNodeFrame: CGRect + if titleLayout.hasRTL { + titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 11.0), size: titleLayout.size) + } else { + titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: titleLayout.size) + } if node.titleNode !== titleNode { node.titleNode = titleNode - node.addSubnode(titleNode) - titleNode.isUserInteractionEnabled = false - } - if titleLayout.hasRTL { - titleNode.frame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 11.0), size: titleLayout.size) - } else { - titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: titleLayout.size) + node.addSubnode(titleNode.textNode) + titleNode.textNode.isUserInteractionEnabled = false + + if let visibilityRect = node.visibilityRect { + titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNodeFrame.minY) + } } + titleNode.textNode.frame = titleNodeFrame if shouldHaveRadioNode { let radioNode: ChatMessagePollOptionRadioNode @@ -773,7 +816,7 @@ private final class SolutionButtonNode: HighlightableButtonNode { } public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { - private let textNode: TextNode + private let textNode: TextNodeWithEntities private let typeNode: TextNode private var timerNode: PollBubbleTimerNode? private let solutionButtonNode: SolutionButtonNode @@ -792,12 +835,34 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { return self.solutionButtonNode } + override public var visibility: ListViewItemNodeVisibility { + didSet { + if oldValue != self.visibility { + switch self.visibility { + case .none: + self.textNode.visibilityRect = nil + for optionNode in self.optionNodes { + optionNode.visibilityRect = nil + } + case let .visible(_, subRect): + var subRect = subRect + subRect.origin.x = 0.0 + subRect.size.width = 10000.0 + self.textNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -self.textNode.textNode.frame.minY) + for optionNode in self.optionNodes { + optionNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -optionNode.frame.minY) + } + } + } + } + } + required public init() { - self.textNode = TextNode() - self.textNode.isUserInteractionEnabled = false - self.textNode.contentMode = .topLeft - self.textNode.contentsScale = UIScreenScale - self.textNode.displaysAsynchronously = false + self.textNode = TextNodeWithEntities() + self.textNode.textNode.isUserInteractionEnabled = false + self.textNode.textNode.contentMode = .topLeft + self.textNode.textNode.contentsScale = UIScreenScale + self.textNode.textNode.displaysAsynchronously = false self.typeNode = TextNode() self.typeNode.isUserInteractionEnabled = false @@ -844,7 +909,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { super.init() - self.addSubnode(self.textNode) + self.addSubnode(self.textNode.textNode) self.addSubnode(self.typeNode) self.addSubnode(self.avatarsNode) self.addSubnode(self.votersNode) @@ -914,7 +979,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { - let makeTextLayout = TextNode.asyncLayout(self.textNode) + let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) let makeTypeLayout = TextNode.asyncLayout(self.typeNode) let makeVotersLayout = TextNode.asyncLayout(self.votersNode) let makeSubmitInactiveTextLayout = TextNode.asyncLayout(self.buttonSubmitInactiveTextNode) @@ -931,7 +996,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } } - var previousOptionNodeLayouts: [Data: (_ accountPeerId: PeerId, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollOptionNode)))] = [:] + var previousOptionNodeLayouts: [Data: (_ contet: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)))] = [:] for optionNode in self.optionNodes { if let option = optionNode.option { previousOptionNodeLayouts[option.opaqueIdentifier] = ChatMessagePollOptionNode.asyncLayout(optionNode) @@ -1049,7 +1114,25 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing - let attributedText = NSAttributedString(string: poll?.text ?? "", font: item.presentationData.messageBoldFont, textColor: messageTheme.primaryTextColor) + let attributedText: NSAttributedString + if let poll { + attributedText = stringWithAppliedEntities( + poll.text, + entities: poll.textEntities, + baseColor: messageTheme.primaryTextColor, + linkColor: messageTheme.linkTextColor, + baseFont: item.presentationData.messageBoldFont, + linkFont: item.presentationData.messageBoldFont, + boldFont: item.presentationData.messageBoldFont, + italicFont: item.presentationData.messageBoldFont, + boldItalicFont: item.presentationData.messageBoldFont, + fixedFont: item.presentationData.messageBoldFont, + blockQuoteFont: item.presentationData.messageBoldFont, + message: message + ) + } else { + attributedText = NSAttributedString(string: "", font: item.presentationData.messageBoldFont, textColor: messageTheme.primaryTextColor) + } let textInsets = UIEdgeInsets(top: 2.0, left: 0.0, bottom: 5.0, right: 0.0) @@ -1144,7 +1227,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { isClosed = false } - var pollOptionsFinalizeLayouts: [(CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollOptionNode)] = [] + var pollOptionsFinalizeLayouts: [(CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)] = [] if let poll = poll { var optionVoterCount: [Int: Int32] = [:] var maxOptionVoterCount: Int32 = 0 @@ -1186,7 +1269,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { for i in 0 ..< poll.options.count { let option = poll.options[i] - let makeLayout: (_ accountPeerId: PeerId, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollOptionNode))) + let makeLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ optionResult: ChatMessagePollOptionResult?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) if let previous = previousOptionNodeLayouts[option.opaqueIdentifier] { makeLayout = previous } else { @@ -1202,7 +1285,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } else if isClosed { optionResult = ChatMessagePollOptionResult(normalized: 0, percent: 0, count: 0) } - let result = makeLayout(item.context.account.peerId, item.presentationData, item.message, poll, option, optionResult, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0) + let result = makeLayout(item.context, item.presentationData, item.message, poll, option, optionResult, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0) boundingSize.width = max(boundingSize.width, result.minimumWidth + layoutConstants.bubble.borderInset * 2.0) pollOptionsFinalizeLayouts.append(result.1) } @@ -1233,7 +1316,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let typeOptionsSpacing: CGFloat = 3.0 resultSize.height += titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing - var optionNodesSizesAndApply: [(CGSize, (Bool, Bool) -> ChatMessagePollOptionNode)] = [] + var optionNodesSizesAndApply: [(CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)] = [] for finalizeLayout in pollOptionsFinalizeLayouts { let result = finalizeLayout(boundingWidth - layoutConstants.bubble.borderInset * 2.0) resultSize.width = max(resultSize.width, result.0.width + layoutConstants.bubble.borderInset * 2.0) @@ -1268,28 +1351,34 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.item = item strongSelf.poll = poll - let cachedLayout = strongSelf.textNode.cachedLayout + let cachedLayout = strongSelf.textNode.textNode.cachedLayout if case .System = animation { if let cachedLayout = cachedLayout { if cachedLayout != textLayout { - if let textContents = strongSelf.textNode.contents { + if let textContents = strongSelf.textNode.textNode.contents { let fadeNode = ASDisplayNode() fadeNode.displaysAsynchronously = false fadeNode.contents = textContents - fadeNode.frame = strongSelf.textNode.frame + fadeNode.frame = strongSelf.textNode.textNode.frame fadeNode.isLayerBacked = true strongSelf.addSubnode(fadeNode) fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in fadeNode?.removeFromSupernode() }) - strongSelf.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } } } - let _ = textApply() + let _ = textApply(TextNodeWithEntities.Arguments( + context: item.context, + cache: item.context.animationCache, + renderer: item.context.animationRenderer, + placeholderColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : item.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + attemptSynchronous: synchronousLoad) + ) let _ = typeApply() var verticalOffset = textFrame.maxY + titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing @@ -1302,7 +1391,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { isRequesting = inProgressOpaqueIds.contains(poll.options[i].opaqueIdentifier) } } - let optionNode = apply(animation.isAnimated, isRequesting) + let optionNode = apply(animation.isAnimated, isRequesting, synchronousLoad) if optionNode.supernode !== strongSelf { strongSelf.addSubnode(optionNode) let option = optionNode.option @@ -1337,9 +1426,9 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.optionNodes = updatedOptionNodes if textLayout.hasRTL { - strongSelf.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size) + strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size) } else { - strongSelf.textNode.frame = textFrame + strongSelf.textNode.textNode.frame = textFrame } let typeFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + titleTypeSpacing), size: typeLayout.size) strongSelf.typeNode.frame = typeFrame @@ -1599,26 +1688,26 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { - self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateAdded(_ currentTimestamp: Double, duration: Double) { - self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { - self.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) self.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { - let textNodeFrame = self.textNode.frame - if let (index, attributes) = self.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { + let textNodeFrame = self.textNode.textNode.frame + if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true - if let (attributeText, fullText) = self.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { + if let (attributeText, fullText) = self.textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) } return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed))) diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 2aedcd6da1..b5cc2f4ae1 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -160,7 +160,18 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { return hasPremium } - public static func inputData(context: AccountContext, chatPeerId: PeerId?, areCustomEmojiEnabled: Bool, hasEdit: Bool = false, hasTrending: Bool = true, hasSearch: Bool = true, hideBackground: Bool = false, sendGif: ((FileMediaReference, UIView, CGRect, Bool, Bool) -> Bool)?) -> Signal { + public static func inputData( + context: AccountContext, + chatPeerId: PeerId?, + areCustomEmojiEnabled: Bool, + hasEdit: Bool = false, + hasTrending: Bool = true, + hasSearch: Bool = true, + hasStickers: Bool = true, + hasGifs: Bool = true, + hideBackground: Bool = false, + sendGif: ((FileMediaReference, UIView, CGRect, Bool, Bool) -> Bool)? + ) -> Signal { let animationCache = context.animationCache let animationRenderer = context.animationRenderer @@ -184,7 +195,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings - let stickerItems = EmojiPagerContentComponent.stickerInputData(context: context, animationCache: animationCache, animationRenderer: animationRenderer, stickerNamespaces: stickerNamespaces, stickerOrderedItemListCollectionIds: stickerOrderedItemListCollectionIds, chatPeerId: chatPeerId, hasSearch: hasSearch, hasTrending: hasTrending, forceHasPremium: false, hasEdit: hasEdit, hideBackground: hideBackground) + let stickerItems: Signal + if hasStickers { + stickerItems = EmojiPagerContentComponent.stickerInputData(context: context, animationCache: animationCache, animationRenderer: animationRenderer, stickerNamespaces: stickerNamespaces, stickerOrderedItemListCollectionIds: stickerOrderedItemListCollectionIds, chatPeerId: chatPeerId, hasSearch: hasSearch, hasTrending: hasTrending, forceHasPremium: false, hasEdit: hasEdit, hideBackground: hideBackground) + |> map(Optional.init) + } else { + stickerItems = .single(nil) + } let reactions: Signal<[String], NoError> = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.App()) |> map { appConfiguration -> [String] in @@ -197,10 +214,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } |> distinctUntilChanged - let animatedEmojiStickers = context.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) - |> map { animatedEmoji -> [String: [StickerPackItem]] in - var animatedEmojiStickers: [String: [StickerPackItem]] = [:] - switch animatedEmoji { + let animatedEmojiStickers: Signal<[String: [StickerPackItem]], NoError> + + if hasGifs { + animatedEmojiStickers = context.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) + |> map { animatedEmoji -> [String: [StickerPackItem]] in + var animatedEmojiStickers: [String: [StickerPackItem]] = [:] + switch animatedEmoji { case let .result(_, items, _): for item in items { if let emoji = item.getStringRepresentationsOfIndexKeys().first { @@ -213,8 +233,11 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } default: break + } + return animatedEmojiStickers } - return animatedEmojiStickers + } else { + animatedEmojiStickers = .single([:]) } let gifInputInteraction = GifPagerContentComponent.InputInteraction( @@ -236,22 +259,27 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { ) // We are going to subscribe to the actual data when the view is loaded - let gifItems: Signal = .single(EntityKeyboardGifContent( - hasRecentGifs: true, - component: GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: .recent, - items: [], - isLoading: false, - loadMoreToken: nil, - displaySearchWithPlaceholder: nil, - searchCategories: nil, - searchInitiallyHidden: true, - searchState: .empty(hasResults: false), - hideBackground: hideBackground - ) - )) + let gifItems: Signal + if hasGifs { + gifItems = .single(EntityKeyboardGifContent( + hasRecentGifs: true, + component: GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: .recent, + items: [], + isLoading: false, + loadMoreToken: nil, + displaySearchWithPlaceholder: nil, + searchCategories: nil, + searchInitiallyHidden: true, + searchState: .empty(hasResults: false), + hideBackground: hideBackground + ) + )) + } else { + gifItems = .single(nil) + } return combineLatest(queue: .mainQueue(), emojiItems, diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/BUILD b/submodules/TelegramUI/Components/ListActionItemComponent/BUILD index ee143c4bab..e9b7043eb5 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/BUILD +++ b/submodules/TelegramUI/Components/ListActionItemComponent/BUILD @@ -15,6 +15,7 @@ swift_library( "//submodules/TelegramPresentationData", "//submodules/TelegramUI/Components/ListSectionComponent", "//submodules/SwitchNode", + "//submodules/CheckNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 0b0fe45934..714c81efed 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -5,11 +5,13 @@ import ComponentFlow import TelegramPresentationData import ListSectionComponent import SwitchNode +import CheckNode public final class ListActionItemComponent: Component { public enum ToggleStyle { case regular case icons + case lock } public struct Toggle: Equatable { @@ -42,10 +44,23 @@ public final class ListActionItemComponent: Component { } } + public struct CustomAccessory: Equatable { + public var component: AnyComponentWithIdentity + public var insets: UIEdgeInsets + public var isInteractive: Bool + + public init(component: AnyComponentWithIdentity, insets: UIEdgeInsets = UIEdgeInsets(), isInteractive: Bool = false) { + self.component = component + self.insets = insets + self.isInteractive = isInteractive + } + } + public enum Accessory: Equatable { case arrow case toggle(Toggle) case activity + case custom(CustomAccessory) } public enum IconInsets: Equatable { @@ -65,22 +80,57 @@ public final class ListActionItemComponent: Component { } } + public enum LeftIcon: Equatable { + public final class Check: Equatable { + public let isSelected: Bool + public let toggle: (() -> Void)? + + public init(isSelected: Bool, toggle: (() -> Void)?) { + self.isSelected = isSelected + self.toggle = toggle + } + + public static func ==(lhs: Check, rhs: Check) -> Bool { + if lhs === rhs { + return true + } + if lhs.isSelected != rhs.isSelected { + return false + } + if (lhs.toggle == nil) != (rhs.toggle == nil) { + return false + } + return true + } + } + + case check(Check) + case custom(AnyComponentWithIdentity) + } + + public enum Highlighting { + case `default` + case disabled + } + public let theme: PresentationTheme public let title: AnyComponent public let contentInsets: UIEdgeInsets - public let leftIcon: AnyComponentWithIdentity? + public let leftIcon: LeftIcon? public let icon: Icon? public let accessory: Accessory? public let action: ((UIView) -> Void)? + public let highlighting: Highlighting public init( theme: PresentationTheme, title: AnyComponent, contentInsets: UIEdgeInsets = UIEdgeInsets(top: 12.0, left: 0.0, bottom: 12.0, right: 0.0), - leftIcon: AnyComponentWithIdentity? = nil, + leftIcon: LeftIcon? = nil, icon: Icon? = nil, accessory: Accessory? = .arrow, - action: ((UIView) -> Void)? + action: ((UIView) -> Void)?, + highlighting: Highlighting = .default ) { self.theme = theme self.title = title @@ -89,6 +139,7 @@ public final class ListActionItemComponent: Component { self.icon = icon self.accessory = accessory self.action = action + self.highlighting = highlighting } public static func ==(lhs: ListActionItemComponent, rhs: ListActionItemComponent) -> Bool { @@ -113,18 +164,96 @@ public final class ListActionItemComponent: Component { if (lhs.action == nil) != (rhs.action == nil) { return false } + if lhs.highlighting != rhs.highlighting { + return false + } return true } + private final class CheckView: HighlightTrackingButton { + private var checkLayer: CheckLayer? + private var theme: PresentationTheme? + + var action: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + + self.highligthedChanged = { [weak self] highlighted in + if let self, self.bounds.width > 0.0 { + let animateScale = true + + let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width + let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width + + if highlighted { + self.layer.removeAnimation(forKey: "opacity") + self.layer.removeAnimation(forKey: "transform.scale") + + if animateScale { + let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) + transition.setScale(layer: self.layer, scale: topScale) + } + } else { + if animateScale { + let transition = Transition(animation: .none) + transition.setScale(layer: self.layer, scale: 1.0) + + self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + + self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + } + } + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + self.action?() + } + + func update(size: CGSize, theme: PresentationTheme, isSelected: Bool, transition: Transition) { + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + } else { + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check) + self.checkLayer = checkLayer + self.layer.addSublayer(checkLayer) + } + + if self.theme !== theme { + self.theme = theme + + checkLayer.theme = CheckNodeTheme(theme: theme, style: .plain) + } + + checkLayer.frame = CGRect(origin: CGPoint(), size: size) + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } + } + public final class View: HighlightTrackingButton, ListSectionComponent.ChildView { private let title = ComponentView() private var leftIcon: ComponentView? + private var leftCheckView: CheckView? private var icon: ComponentView? private var arrowView: UIImageView? private var switchNode: SwitchNode? private var iconSwitchNode: IconSwitchNode? private var activityIndicatorView: UIActivityIndicatorView? + private var customAccessoryView: ComponentView? private var component: ListActionItemComponent? @@ -147,6 +276,9 @@ public final class ListActionItemComponent: Component { guard let self, let component = self.component, component.action != nil else { return } + if isHighlighted, component.highlighting == .disabled { + return + } if case .toggle = component.accessory, component.action == nil { return } @@ -180,6 +312,9 @@ public final class ListActionItemComponent: Component { let themeUpdated = component.theme !== previousComponent?.theme + var customAccessorySize: CGSize? + var customAccessoryTransition: Transition = transition + var contentLeftInset: CGFloat = 16.0 let contentRightInset: CGFloat switch component.accessory { @@ -195,13 +330,41 @@ public final class ListActionItemComponent: Component { contentRightInset = 76.0 case .activity: contentRightInset = 76.0 + case let .custom(customAccessory): + if case let .custom(previousCustomAccessory) = previousComponent?.accessory, previousCustomAccessory.component.id != customAccessory.component.id { + self.customAccessoryView?.view?.removeFromSuperview() + self.customAccessoryView = nil + } + + let customAccessoryView: ComponentView + if let current = self.customAccessoryView { + customAccessoryView = current + } else { + customAccessoryTransition = customAccessoryTransition.withAnimation(.none) + customAccessoryView = ComponentView() + self.customAccessoryView = customAccessoryView + } + + let customAccessorySizeValue = customAccessoryView.update( + transition: customAccessoryTransition, + component: customAccessory.component.component, + environment: {}, + containerSize: availableSize + ) + customAccessorySize = customAccessorySizeValue + contentRightInset = customAccessorySizeValue.width + customAccessory.insets.left + customAccessory.insets.right } var contentHeight: CGFloat = 0.0 contentHeight += component.contentInsets.top - if component.leftIcon != nil { - contentLeftInset += 46.0 + if let leftIcon = component.leftIcon { + switch leftIcon { + case .check: + contentLeftInset += 46.0 + case .custom: + contentLeftInset += 46.0 + } } let titleSize = self.title.update( @@ -275,39 +438,103 @@ public final class ListActionItemComponent: Component { } if let leftIconValue = component.leftIcon { - if previousComponent?.leftIcon?.id != leftIconValue.id, let leftIcon = self.leftIcon { - self.leftIcon = nil - if let iconView = leftIcon.view { - transition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in - iconView?.removeFromSuperview() + switch leftIconValue { + case let .check(check): + if let leftIcon = self.leftIcon { + self.leftIcon = nil + if let iconView = leftIcon.view { + transition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in + iconView?.removeFromSuperview() + }) + } + } + + let leftCheckView: CheckView + var animateIn = false + if let current = self.leftCheckView { + leftCheckView = current + } else { + animateIn = true + leftCheckView = CheckView() + self.leftCheckView = leftCheckView + self.addSubview(leftCheckView) + + leftCheckView.action = { [weak self] in + guard let self, let component = self.component else { + return + } + if case let .check(check) = component.leftIcon { + check.toggle?() + } + } + } + + leftCheckView.isUserInteractionEnabled = check.toggle != nil + + let checkSize = CGSize(width: 22.0, height: 22.0) + let checkFrame = CGRect(origin: CGPoint(x: floor((contentLeftInset - checkSize.width) * 0.5), y: floor((contentHeight - checkSize.height) * 0.5)), size: checkSize) + + if animateIn { + leftCheckView.frame = CGRect(origin: CGPoint(x: -checkSize.width, y: self.bounds.height == 0.0 ? checkFrame.minY : floor((self.bounds.height - checkSize.height) * 0.5)), size: checkFrame.size) + transition.setPosition(view: leftCheckView, position: checkFrame.center) + transition.setBounds(view: leftCheckView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) + leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, transition: .immediate) + } else { + transition.setPosition(view: leftCheckView, position: checkFrame.center) + transition.setBounds(view: leftCheckView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) + leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, transition: transition) + } + case let .custom(customLeftIcon): + var resetLeftIcon = false + if case let .custom(previousCustomLeftIcon) = previousComponent?.leftIcon { + if previousCustomLeftIcon.id != customLeftIcon.id { + resetLeftIcon = true + } + } else { + resetLeftIcon = true + } + if resetLeftIcon { + if let leftIcon = self.leftIcon { + self.leftIcon = nil + if let iconView = leftIcon.view { + transition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in + iconView?.removeFromSuperview() + }) + } + } + } + if let leftCheckView = self.leftCheckView { + self.leftCheckView = nil + transition.setAlpha(view: leftCheckView, alpha: 0.0, completion: { [weak leftCheckView] _ in + leftCheckView?.removeFromSuperview() }) } - } - - var leftIconTransition = transition - let leftIcon: ComponentView - if let current = self.leftIcon { - leftIcon = current - } else { - leftIconTransition = leftIconTransition.withAnimation(.none) - leftIcon = ComponentView() - self.leftIcon = leftIcon - } - - let leftIconSize = leftIcon.update( - transition: leftIconTransition, - component: leftIconValue.component, - environment: {}, - containerSize: CGSize(width: availableSize.width, height: availableSize.height) - ) - let leftIconFrame = CGRect(origin: CGPoint(x: floor((contentLeftInset - leftIconSize.width) * 0.5), y: floor((min(60.0, contentHeight) - leftIconSize.height) * 0.5)), size: leftIconSize) - if let leftIconView = leftIcon.view { - if leftIconView.superview == nil { - leftIconView.isUserInteractionEnabled = false - self.addSubview(leftIconView) - transition.animateAlpha(view: leftIconView, from: 0.0, to: 1.0) + + var leftIconTransition = transition + let leftIcon: ComponentView + if let current = self.leftIcon { + leftIcon = current + } else { + leftIconTransition = leftIconTransition.withAnimation(.none) + leftIcon = ComponentView() + self.leftIcon = leftIcon + } + + let leftIconSize = leftIcon.update( + transition: leftIconTransition, + component: customLeftIcon.component, + environment: {}, + containerSize: CGSize(width: availableSize.width, height: availableSize.height) + ) + let leftIconFrame = CGRect(origin: CGPoint(x: floor((contentLeftInset - leftIconSize.width) * 0.5), y: floor((min(60.0, contentHeight) - leftIconSize.height) * 0.5)), size: leftIconSize) + if let leftIconView = leftIcon.view { + if leftIconView.superview == nil { + leftIconView.isUserInteractionEnabled = false + self.addSubview(leftIconView) + transition.animateAlpha(view: leftIconView, from: 0.0, to: 1.0) + } + leftIconTransition.setFrame(view: leftIconView, frame: leftIconFrame) } - leftIconTransition.setFrame(view: leftIconView, frame: leftIconFrame) } } else { if let leftIcon = self.leftIcon { @@ -318,6 +545,12 @@ public final class ListActionItemComponent: Component { }) } } + if let leftCheckView = self.leftCheckView { + self.leftCheckView = nil + transition.setAlpha(view: leftCheckView, alpha: 0.0, completion: { [weak leftCheckView] _ in + leftCheckView?.removeFromSuperview() + }) + } } if case .arrow = component.accessory { @@ -385,7 +618,7 @@ public final class ListActionItemComponent: Component { let switchSize = CGSize(width: 51.0, height: 31.0) let switchFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - switchSize.width, y: floor((min(60.0, contentHeight) - switchSize.height) * 0.5)), size: switchSize) switchTransition.setFrame(view: switchNode.view, frame: switchFrame) - case .icons: + case .icons, .lock: let switchNode: IconSwitchNode var switchTransition = transition var updateSwitchTheme = themeUpdated @@ -466,6 +699,24 @@ public final class ListActionItemComponent: Component { } } + if case let .custom(customAccessory) = component.accessory, let customAccessoryView = self.customAccessoryView, let customAccessorySize { + let activityAccessoryFrame = CGRect(origin: CGPoint(x: availableSize.width - customAccessory.insets.right - customAccessorySize.width, y: floor((contentHeight - customAccessorySize.height) * 0.5)), size: customAccessorySize) + if let customAccessoryComponentView = customAccessoryView.view { + if customAccessoryComponentView.superview == nil { + customAccessoryComponentView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0) + self.addSubview(customAccessoryComponentView) + } + customAccessoryComponentView.isUserInteractionEnabled = customAccessory.isInteractive + customAccessoryTransition.setPosition(view: customAccessoryComponentView, position: CGPoint(x: activityAccessoryFrame.maxX, y: activityAccessoryFrame.minY)) + customAccessoryTransition.setBounds(view: customAccessoryComponentView, bounds: CGRect(origin: CGPoint(), size: activityAccessoryFrame.size)) + } + } else { + if let customAccessoryView = self.customAccessoryView { + self.customAccessoryView = nil + customAccessoryView.view?.removeFromSuperview() + } + } + self.separatorInset = contentLeftInset return CGSize(width: availableSize.width, height: contentHeight) diff --git a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift index d707a340ca..e2727290a0 100644 --- a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift +++ b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift @@ -88,8 +88,6 @@ public final class ListSectionContentView: UIView { super.init(frame: CGRect()) - self.clipsToBounds = true - self.layer.addSublayer(self.contentSeparatorContainerLayer) self.layer.addSublayer(self.contentHighlightContainerLayer) self.addSubview(self.contentItemContainerView) @@ -132,9 +130,16 @@ public final class ListSectionContentView: UIView { } } - public func update(configuration: Configuration, width: CGFloat, readyItems: [ReadyItem], transition: Transition) -> UpdateResult { + public func update(configuration: Configuration, width: CGFloat, leftInset: CGFloat, readyItems: [ReadyItem], transition: Transition) -> UpdateResult { self.configuration = configuration + switch configuration.background { + case .all, .range: + self.clipsToBounds = true + case let .none(clipped): + self.clipsToBounds = clipped + } + let backgroundColor: UIColor if self.highlightedItemId != nil && configuration.extendsItemHighlightToSection { backgroundColor = configuration.theme.list.itemHighlightedBackgroundColor @@ -149,9 +154,11 @@ public final class ListSectionContentView: UIView { let readyItem = readyItems[index] validItemIds.append(readyItem.id) - let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: innerContentHeight), size: readyItem.size) + let itemFrame = CGRect(origin: CGPoint(x: leftInset, y: innerContentHeight), size: readyItem.size) if let itemComponentView = readyItem.itemView.contents.view { + var isAdded = false if itemComponentView.superview == nil { + isAdded = true readyItem.itemView.addSubview(itemComponentView) self.contentItemContainerView.addSubview(readyItem.itemView) self.contentSeparatorContainerLayer.addSublayer(readyItem.itemView.separatorLayer) @@ -174,7 +181,26 @@ public final class ListSectionContentView: UIView { if let itemComponentView = itemComponentView as? ListSectionComponentChildView { separatorInset = itemComponentView.separatorInset } - readyItem.transition.setFrame(view: readyItem.itemView, frame: itemFrame) + + let itemSeparatorFrame = CGRect(origin: CGPoint(x: separatorInset, y: itemFrame.maxY - UIScreenPixel), size: CGSize(width: width - separatorInset, height: UIScreenPixel)) + + if isAdded && itemComponentView is ListSubSectionComponent.View { + readyItem.itemView.frame = itemFrame + readyItem.itemView.clipsToBounds = true + readyItem.itemView.frame = CGRect(origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY), size: CGSize(width: itemFrame.width, height: 0.0)) + let itemView = readyItem.itemView + transition.setFrame(view: readyItem.itemView, frame: itemFrame, completion: { [weak itemView] completed in + if completed { + itemView?.clipsToBounds = false + } + }) + + readyItem.itemView.separatorLayer.frame = CGRect(origin: CGPoint(x: itemSeparatorFrame.minX, y: itemFrame.minY), size: CGSize(width: itemSeparatorFrame.width, height: 0.0)) + transition.setFrame(layer: readyItem.itemView.separatorLayer, frame: itemSeparatorFrame) + } else { + readyItem.transition.setFrame(view: readyItem.itemView, frame: itemFrame) + readyItem.transition.setFrame(layer: readyItem.itemView.separatorLayer, frame: itemSeparatorFrame) + } let itemSeparatorTopOffset: CGFloat = index == 0 ? 0.0 : -UIScreenPixel let itemHighlightFrame = CGRect(origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY + itemSeparatorTopOffset), size: CGSize(width: itemFrame.width, height: itemFrame.height - itemSeparatorTopOffset)) @@ -182,9 +208,6 @@ public final class ListSectionContentView: UIView { readyItem.transition.setFrame(view: itemComponentView, frame: CGRect(origin: CGPoint(), size: itemFrame.size)) - let itemSeparatorFrame = CGRect(origin: CGPoint(x: separatorInset, y: itemFrame.maxY - UIScreenPixel), size: CGSize(width: width - separatorInset, height: UIScreenPixel)) - readyItem.transition.setFrame(layer: readyItem.itemView.separatorLayer, frame: itemSeparatorFrame) - let separatorAlpha: CGFloat if configuration.displaySeparators { if index != readyItems.count - 1 { @@ -206,6 +229,12 @@ public final class ListSectionContentView: UIView { if !validItemIds.contains(id) { removedItemIds.append(id) + if let itemComponentView = itemView.contents.view, itemComponentView is ListSubSectionComponent.View { + itemView.clipsToBounds = true + transition.setFrame(view: itemView, frame: CGRect(origin: itemView.frame.origin, size: CGSize(width: itemView.bounds.width, height: 0.0))) + transition.setFrame(layer: itemView.separatorLayer, frame: CGRect(origin: CGPoint(x: itemView.separatorLayer.frame.minX, y: itemView.frame.minY), size: itemView.separatorLayer.bounds.size)) + } + transition.setAlpha(view: itemView, alpha: 0.0, completion: { [weak itemView] _ in itemView?.removeFromSuperview() }) @@ -328,8 +357,6 @@ public final class ListSectionComponent: Component { private var header: ComponentView? private var footer: ComponentView? - private var highlightedItemId: AnyHashable? - private var component: ListSectionComponent? public override init(frame: CGRect) { @@ -345,6 +372,10 @@ public final class ListSectionComponent: Component { preconditionFailure() } + public func itemView(id: AnyHashable) -> UIView? { + return self.contentView.itemViews[id]?.contents.view + } + func update(component: ListSectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component @@ -422,6 +453,7 @@ public final class ListSectionComponent: Component { background: component.background ), width: availableSize.width, + leftInset: 0.0, readyItems: readyItems, transition: transition ) @@ -483,3 +515,135 @@ public final class ListSectionComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +public final class ListSubSectionComponent: Component { + public typealias ChildView = ListSectionComponentChildView + + public let theme: PresentationTheme + public let leftInset: CGFloat + public let items: [AnyComponentWithIdentity] + public let displaySeparators: Bool + + public init( + theme: PresentationTheme, + leftInset: CGFloat, + items: [AnyComponentWithIdentity], + displaySeparators: Bool = true + ) { + self.theme = theme + self.leftInset = leftInset + self.items = items + self.displaySeparators = displaySeparators + } + + public static func ==(lhs: ListSubSectionComponent, rhs: ListSubSectionComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.leftInset != rhs.leftInset { + return false + } + if lhs.items != rhs.items { + return false + } + if lhs.displaySeparators != rhs.displaySeparators { + return false + } + return true + } + + public final class View: UIView, ListSectionComponent.ChildView { + private let contentView: ListSectionContentView + + private var component: ListSubSectionComponent? + + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public var separatorInset: CGFloat = 0.0 + + public override init(frame: CGRect) { + self.contentView = ListSectionContentView() + + super.init(frame: CGRect()) + + self.addSubview(self.contentView) + } + + required public init?(coder: NSCoder) { + preconditionFailure() + } + + public func itemView(id: AnyHashable) -> UIView? { + return self.contentView.itemViews[id]?.contents.view + } + + func update(component: ListSubSectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + + var contentHeight: CGFloat = 0.0 + + var readyItems: [ListSectionContentView.ReadyItem] = [] + for i in 0 ..< component.items.count { + let item = component.items[i] + let itemId = item.id + + let itemView: ListSectionContentView.ItemView + var itemTransition = transition + if let current = self.contentView.itemViews[itemId] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = ListSectionContentView.ItemView() + self.contentView.itemViews[itemId] = itemView + itemView.contents.parentState = state + } + + let itemSize = itemView.contents.update( + transition: itemTransition, + component: item.component, + environment: {}, + containerSize: CGSize(width: availableSize.width - component.leftInset, height: availableSize.height) + ) + + readyItems.append(ListSectionContentView.ReadyItem( + id: itemId, + itemView: itemView, + size: itemSize, + transition: itemTransition + )) + } + + let contentResult = self.contentView.update( + configuration: ListSectionContentView.Configuration( + theme: component.theme, + displaySeparators: component.displaySeparators, + extendsItemHighlightToSection: false, + background: .none(clipped: false) + ), + width: availableSize.width - component.leftInset, + leftInset: 0.0, + readyItems: readyItems, + transition: transition + ) + let innerContentHeight = contentResult.size.height + + let contentFrame = CGRect(origin: CGPoint(x: component.leftInset, y: contentHeight), size: CGSize(width: availableSize.width - component.leftInset, height: innerContentHeight)) + transition.setFrame(view: self.contentView, frame: contentFrame) + transition.setFrame(view: self.contentView.externalContentBackgroundView, frame: contentResult.backgroundFrame.offsetBy(dx: contentFrame.minX, dy: contentFrame.minY)) + + contentHeight += innerContentHeight + + self.separatorInset = component.leftInset + + return CGSize(width: availableSize.width, height: contentHeight) + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + diff --git a/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift index f91488ec89..74a091362b 100644 --- a/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift +++ b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift @@ -168,6 +168,10 @@ public final class PlainButtonComponent: Component { } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.isHidden || self.alpha == 0.0 { + return nil + } + let result = super.hitTest(point, with: event) if result != nil { return result diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift index c7eda3161a..9035470244 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift @@ -853,10 +853,10 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Chat List/ComposeIcon", tintColor: environment.theme.list.itemAccentColor - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { @@ -929,11 +929,11 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, tintColor: !isSelected ? .clear : environment.theme.list.itemAccentColor, contentMode: .center - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { @@ -1199,11 +1199,11 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, tintColor: !self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { @@ -1229,11 +1229,11 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, tintColor: self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { @@ -1277,10 +1277,10 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Chat List/AddIcon", tintColor: environment.theme.list.itemAccentColor - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinksSetupScreen.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinksSetupScreen.swift index 66c7fd76f8..fc531baa09 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinksSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinksSetupScreen.swift @@ -413,10 +413,10 @@ final class BusinessLinksSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Item List/AddLinkIcon", tintColor: environment.theme.list.itemAccentColor - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift index e663082df3..c258c60fb4 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift @@ -528,10 +528,10 @@ final class BusinessDaySetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Item List/AddTimeIcon", tintColor: environment.theme.list.itemAccentColor - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift index 722de3591a..8b26c01139 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift @@ -422,10 +422,10 @@ final class BusinessRecipientListScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Chat List/AddIcon", tintColor: environment.theme.list.itemAccentColor - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index d022792bd4..7709c95048 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -770,11 +770,11 @@ final class ChatbotSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, tintColor: !self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { @@ -800,11 +800,11 @@ final class ChatbotSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, tintColor: self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center - ))), + )))), accessory: nil, action: { [weak self] _ in guard let self else { diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index e70d075b3b..b242d121f6 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -1047,7 +1047,6 @@ public final class TextFieldComponent: Component { if let current = component.externalState.currentEmojiSuggestion, current.position.value == emojiSuggestionPosition.value { emojiSuggestion = current } else { - emojiSuggestion = EmojiSuggestion(localPosition: trackingPosition, position: emojiSuggestionPosition) component.externalState.currentEmojiSuggestion = emojiSuggestion } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 4c4bb60dab..2665d276e2 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -9041,6 +9041,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let strongSelf = self, !actions.options.isEmpty { if let banAuthor = actions.banAuthor { strongSelf.presentBanMessageOptions(accountPeerId: strongSelf.context.account.peerId, author: banAuthor, messageIds: messageIds, options: actions.options) + } else if !actions.banAuthors.isEmpty { + strongSelf.presentMultiBanMessageOptions(accountPeerId: strongSelf.context.account.peerId, authors: actions.banAuthors, messageIds: messageIds, options: actions.options) } else { if actions.options.intersection([.deleteLocally, .deleteGlobally]).isEmpty { strongSelf.presentClearCacheSuggestion() @@ -13864,7 +13866,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min ... Int64.max)), publicity: poll.publicity, kind: poll.kind, - text: poll.text, + text: poll.text.string, + textEntities: poll.text.entities, options: poll.options, correctAnswers: poll.correctAnswers, results: poll.results, @@ -15650,298 +15653,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatDisplayNode.dismissInput() self.present(actionSheet, in: .window(.root)) } - - func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set, options: ChatAvailableMessageActionOptions) { - guard let peerId = self.chatLocation.peerId else { - return - } - do { - self.navigationActionDisposable.set((self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id) - |> deliverOnMainQueue).startStrict(next: { [weak self] participant in - if let strongSelf = self { - let canBan = participant?.canBeBannedBy(peerId: accountPeerId) ?? true - - let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) - var items: [ActionSheetItem] = [] - - var actions = Set([0]) - - let toggleCheck: (Int, Int) -> Void = { [weak actionSheet] category, itemIndex in - if actions.contains(category) { - actions.remove(category) - } else { - actions.insert(category) - } - actionSheet?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in - if let item = item as? ActionSheetCheckboxItem { - return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action) - } - return item - }) - } - - var itemIndex = 0 - var categories: [Int] = [0] - if canBan { - categories.append(1) - } - categories.append(contentsOf: [2, 3]) - - for categoryId in categories as [Int] { - var title = "" - if categoryId == 0 { - title = strongSelf.presentationData.strings.Conversation_Moderate_Delete - } else if categoryId == 1 { - title = strongSelf.presentationData.strings.Conversation_Moderate_Ban - } else if categoryId == 2 { - title = strongSelf.presentationData.strings.Conversation_Moderate_Report - } else if categoryId == 3 { - title = strongSelf.presentationData.strings.Conversation_Moderate_DeleteAllMessages(EnginePeer(author).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - let index = itemIndex - items.append(ActionSheetCheckboxItem(title: title, label: "", value: actions.contains(categoryId), action: { value in - toggleCheck(categoryId, index) - })) - itemIndex += 1 - } - - items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Done, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - if actions.contains(3) { - let _ = strongSelf.context.engine.messages.deleteAllMessagesWithAuthor(peerId: peerId, authorId: author.id, namespace: Namespaces.Message.Cloud).startStandalone() - let _ = strongSelf.context.engine.messages.clearAuthorHistory(peerId: peerId, memberId: author.id).startStandalone() - } else if actions.contains(0) { - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() - } - if actions.contains(1) { - let _ = strongSelf.context.engine.peers.removePeerMember(peerId: peerId, memberId: author.id).startStandalone() - } - } - })) - - actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ])]) - strongSelf.chatDisplayNode.dismissInput() - strongSelf.present(actionSheet, in: .window(.root)) - } - })) - } - } - - func presentDeleteMessageOptions(messageIds: Set, options: ChatAvailableMessageActionOptions, contextController: ContextControllerProtocol?, completion: @escaping (ContextMenuActionResult) -> Void) { - let _ = (self.context.engine.data.get( - EngineDataMap(messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init(id:))) - ) - |> deliverOnMainQueue).start(next: { [weak self] messages in - guard let self else { - return - } - - let actionSheet = ActionSheetController(presentationData: self.presentationData) - var items: [ActionSheetItem] = [] - var personalPeerName: String? - var isChannel = false - if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser { - personalPeerName = EnginePeer(user).compactDisplayTitle - } else if let peer = self.presentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat, let associatedPeerId = peer.associatedPeerId, let user = self.presentationInterfaceState.renderedPeer?.peers[associatedPeerId] as? TelegramUser { - personalPeerName = EnginePeer(user).compactDisplayTitle - } else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = channel.info { - isChannel = true - } - - if options.contains(.cancelSending) { - items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ContextMenuCancelSending, color: .destructive, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() - } - })) - } - - var contextItems: [ContextMenuItem] = [] - var canDisplayContextMenu = true - - var unsendPersonalMessages = false - if options.contains(.unsendPersonal) { - canDisplayContextMenu = false - items.append(ActionSheetTextItem(title: self.presentationData.strings.Chat_UnsendMyMessagesAlertTitle(personalPeerName ?? "").string)) - items.append(ActionSheetSwitchItem(title: self.presentationData.strings.Chat_UnsendMyMessages, isOn: false, action: { value in - unsendPersonalMessages = value - })) - } else if options.contains(.deleteGlobally) { - let globalTitle: String - if isChannel { - globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone - } else if let personalPeerName = personalPeerName { - globalTitle = self.presentationData.strings.Conversation_DeleteMessagesFor(personalPeerName).string - } else { - globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone - } - contextItems.append(.action(ContextMenuActionItem(text: globalTitle, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in - if let strongSelf = self { - var giveaway: TelegramMediaGiveaway? - for messageId in messageIds { - if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { - if let media = message.media.first(where: { $0 is TelegramMediaGiveaway }) as? TelegramMediaGiveaway { - giveaway = media - break - } - } - } - let commit = { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() - } - if let giveaway { - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - if currentTime < giveaway.untilDate { - Queue.mainQueue().after(0.2) { - let dateString = stringForDate(timestamp: giveaway.untilDate, timeZone: .current, strings: strongSelf.presentationData.strings) - strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Title, text: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Text(dateString).string, actions: [TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.Common_Delete, action: { - commit() - }), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { - })], parseMarkdown: true), in: .window(.root)) - } - f(.default) - } else { - f(.dismissWithoutContent) - commit() - } - } else { - if "".isEmpty { - f(.dismissWithoutContent) - commit() - } else { - c.dismiss(completion: { - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { - commit() - }) - }) - } - } - } - }))) - items.append(ActionSheetButtonItem(title: globalTitle, color: .destructive, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() - } - })) - } - if options.contains(.deleteLocally) { - var localOptionText = self.presentationData.strings.Conversation_DeleteMessagesForMe - if self.chatLocation.peerId == self.context.account.peerId { - if case .peer(self.context.account.peerId) = self.chatLocation, messages.values.allSatisfy({ message in message?._asMessage().effectivelyIncoming(self.context.account.peerId) ?? false }) { - localOptionText = self.presentationData.strings.Chat_ConfirmationRemoveFromSavedMessages - } else { - localOptionText = self.presentationData.strings.Chat_ConfirmationDeleteFromSavedMessages - } - } else if case .scheduledMessages = self.presentationInterfaceState.subject { - localOptionText = messageIds.count > 1 ? self.presentationData.strings.ScheduledMessages_DeleteMany : self.presentationData.strings.ScheduledMessages_Delete - } else { - if options.contains(.unsendPersonal) { - localOptionText = self.presentationData.strings.Chat_DeleteMessagesConfirmation(Int32(messageIds.count)) - } else if case .peer(self.context.account.peerId) = self.chatLocation { - if messageIds.count == 1 { - localOptionText = self.presentationData.strings.Conversation_Moderate_Delete - } else { - localOptionText = self.presentationData.strings.Conversation_DeleteManyMessages - } - } - } - contextItems.append(.action(ContextMenuActionItem(text: localOptionText, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - - let commit: () -> Void = { - guard let strongSelf = self else { - return - } - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: unsendPersonalMessages ? .forEveryone : .forLocalPeer).startStandalone() - } - - if "".isEmpty { - f(.dismissWithoutContent) - commit() - } else { - c.dismiss(completion: { - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { - commit() - }) - }) - } - } - }))) - items.append(ActionSheetButtonItem(title: localOptionText, color: .destructive, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: unsendPersonalMessages ? .forEveryone : .forLocalPeer).startStandalone() - - } - })) - } - - if canDisplayContextMenu, let contextController = contextController { - contextController.setItems(.single(ContextController.Items(content: .list(contextItems))), minHeight: nil, animated: true) - } else { - actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ])]) - - if let contextController = contextController { - contextController.dismiss(completion: { [weak self] in - self?.present(actionSheet, in: .window(.root)) - }) - } else { - self.chatDisplayNode.dismissInput() - self.present(actionSheet, in: .window(.root)) - completion(.default) - } - } - }) - } - - func presentClearCacheSuggestion() { - guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } - self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) - - let actionSheet = ActionSheetController(presentationData: self.presentationData) - var items: [ActionSheetItem] = [] - - items.append(DeleteChatPeerActionSheetItem(context: self.context, peer: EnginePeer(peer), chatPeer: EnginePeer(peer), action: .clearCacheSuggestion, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder)) - - var presented = false - items.append(ActionSheetButtonItem(title: self.presentationData.strings.ClearCache_FreeSpace, color: .accent, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self, !presented { - presented = true - let context = strongSelf.context - strongSelf.push(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in - return storageUsageExceptionsScreen(context: context, category: category) - })) - } - })) - - actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ])]) - self.chatDisplayNode.dismissInput() - self.presentInGlobalOverlay(actionSheet) - } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift new file mode 100644 index 0000000000..22ec38f6b9 --- /dev/null +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -0,0 +1,512 @@ +import Foundation +import TelegramPresentationData +import AccountContext +import Postbox +import TelegramCore +import SwiftSignalKit +import Display +import TelegramPresentationData +import PresentationDataUtils +import UndoUI +import AdminUserActionsSheet +import ContextUI +import TelegramStringFormatting +import StorageUsageScreen +import SettingsUI +import DeleteChatPeerActionSheetItem + +fileprivate struct InitialBannedRights { + var value: TelegramChatBannedRights? +} + +extension ChatControllerImpl { + fileprivate func applyAdminUserActionsResult(messageIds: Set, result: AdminUserActionsSheet.Result, initialUserBannedRights: [EnginePeer.Id: InitialBannedRights]) { + guard let peerId = self.chatLocation.peerId else { + return + } + + //TODO:localize + let title: String = "Messages Deleted" + var text: String = "" + var undoRights: [EnginePeer.Id: InitialBannedRights] = [:] + + if !result.reportSpamPeers.isEmpty { + if !text.isEmpty { + text.append("\n") + } + if result.reportSpamPeers.count == 1 { + text.append("**1** user reported for spam") + } else { + text.append("**\(result.reportSpamPeers.count)** users reported for spam") + } + } + if !result.banPeers.isEmpty { + if !text.isEmpty { + text.append("\n") + } + if result.banPeers.count == 1 { + text.append("**1** user banned") + } else { + text.append("**\(result.banPeers.count)** users banned") + } + for id in result.banPeers { + if let value = initialUserBannedRights[id] { + undoRights[id] = value + } + } + } + if !result.updateBannedRights.isEmpty { + if !text.isEmpty { + text.append("\n") + } + if result.updateBannedRights.count == 1 { + text.append("**1** user restricted") + } else { + text.append("**\(result.updateBannedRights.count)** users restricted") + } + for id in result.banPeers { + if let value = initialUserBannedRights[id] { + undoRights[id] = value + } + } + } + + do { + let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + + for authorId in result.deleteAllFromPeers { + let _ = self.context.engine.messages.deleteAllMessagesWithAuthor(peerId: peerId, authorId: authorId, namespace: Namespaces.Message.Cloud).startStandalone() + let _ = self.context.engine.messages.clearAuthorHistory(peerId: peerId, memberId: authorId).startStandalone() + } + + for authorId in result.reportSpamPeers { + let _ = self.context.engine.peers.reportPeer(peerId: authorId, reason: .spam, message: "").startStandalone() + } + + for authorId in result.banPeers { + let _ = self.context.engine.peers.removePeerMember(peerId: peerId, memberId: authorId).startStandalone() + } + + for (authorId, rights) in result.updateBannedRights { + let _ = self.context.engine.peers.updateChannelMemberBannedRights(peerId: peerId, memberId: authorId, rights: rights).startStandalone() + } + } + + self.present( + UndoOverlayController( + presentationData: self.presentationData, + content: undoRights.isEmpty ? .actionSucceeded(title: text.isEmpty ? nil : title, text: text.isEmpty ? title : text, cancel: nil, destructive: false) : .removedChat(title: title, text: text), + elevatedLayout: false, + action: { [weak self] action in + guard let self else { + return true + } + + switch action { + case .commit: + break + case .undo: + for (authorId, rights) in initialUserBannedRights { + let _ = self.context.engine.peers.updateChannelMemberBannedRights(peerId: peerId, memberId: authorId, rights: rights.value).startStandalone() + } + default: + break + } + return true + } + ), + in: .current + ) + + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + } + + func presentMultiBanMessageOptions(accountPeerId: PeerId, authors: [Peer], messageIds: Set, options: ChatAvailableMessageActionOptions) { + guard let peerId = self.chatLocation.peerId else { + return + } + + self.navigationActionDisposable.set((combineLatest(authors.map { author in + self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id) + }) + |> deliverOnMainQueue).startStrict(next: { [weak self] participants in + guard let self else { + return + } + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] chatPeer in + guard let self, let chatPeer else { + return + } + var peers: [EnginePeer] = [] + var initialUserBannedRights: [EnginePeer.Id: InitialBannedRights] = [:] + for maybeParticipant in participants { + guard let participant = maybeParticipant else { + continue + } + switch participant { + case .creator: + break + case let .member(_, _, _, banInfo, _): + if let banInfo { + initialUserBannedRights[participant.peerId] = InitialBannedRights(value: banInfo.rights) + } else { + initialUserBannedRights[participant.peerId] = InitialBannedRights(value: nil) + } + } + } + for author in authors { + peers.append(EnginePeer(author)) + } + self.push(AdminUserActionsSheet( + context: self.context, + chatPeer: chatPeer, + peers: peers, + messageCount: messageIds.count, + completion: { [weak self] result in + guard let self else { + return + } + self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights) + } + )) + }) + })) + } + + func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set, options: ChatAvailableMessageActionOptions) { + guard let peerId = self.chatLocation.peerId else { + return + } + + do { + self.navigationActionDisposable.set((self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id) + |> deliverOnMainQueue).startStrict(next: { [weak self] participant in + if let strongSelf = self { + if "".isEmpty { + let _ = (strongSelf.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Peer.Peer(id: author.id) + ) + |> deliverOnMainQueue).startStandalone(next: { chatPeer, authorPeer in + guard let self, let chatPeer else { + return + } + guard let authorPeer else { + return + } + var initialUserBannedRights: [EnginePeer.Id: InitialBannedRights] = [:] + if let participant { + switch participant { + case .creator: + break + case let .member(_, _, _, banInfo, _): + if let banInfo { + initialUserBannedRights[participant.peerId] = InitialBannedRights(value: banInfo.rights) + } else { + initialUserBannedRights[participant.peerId] = InitialBannedRights(value: nil) + } + } + } + self.push(AdminUserActionsSheet( + context: self.context, + chatPeer: chatPeer, + peers: [authorPeer], + messageCount: messageIds.count, + completion: { [weak self] result in + guard let self else { + return + } + self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights) + } + )) + }) + return + } + + let canBan = participant?.canBeBannedBy(peerId: accountPeerId) ?? true + + let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) + var items: [ActionSheetItem] = [] + + var actions = Set([0]) + + let toggleCheck: (Int, Int) -> Void = { [weak actionSheet] category, itemIndex in + if actions.contains(category) { + actions.remove(category) + } else { + actions.insert(category) + } + actionSheet?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in + if let item = item as? ActionSheetCheckboxItem { + return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action) + } + return item + }) + } + + var itemIndex = 0 + var categories: [Int] = [0] + if canBan { + categories.append(1) + } + categories.append(contentsOf: [2, 3]) + + for categoryId in categories as [Int] { + var title = "" + if categoryId == 0 { + title = strongSelf.presentationData.strings.Conversation_Moderate_Delete + } else if categoryId == 1 { + title = strongSelf.presentationData.strings.Conversation_Moderate_Ban + } else if categoryId == 2 { + title = strongSelf.presentationData.strings.Conversation_Moderate_Report + } else if categoryId == 3 { + title = strongSelf.presentationData.strings.Conversation_Moderate_DeleteAllMessages(EnginePeer(author).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + let index = itemIndex + items.append(ActionSheetCheckboxItem(title: title, label: "", value: actions.contains(categoryId), action: { value in + toggleCheck(categoryId, index) + })) + itemIndex += 1 + } + + items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Done, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + if actions.contains(3) { + let _ = strongSelf.context.engine.messages.deleteAllMessagesWithAuthor(peerId: peerId, authorId: author.id, namespace: Namespaces.Message.Cloud).startStandalone() + let _ = strongSelf.context.engine.messages.clearAuthorHistory(peerId: peerId, memberId: author.id).startStandalone() + } else if actions.contains(0) { + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } + if actions.contains(1) { + let _ = strongSelf.context.engine.peers.removePeerMember(peerId: peerId, memberId: author.id).startStandalone() + } + } + })) + + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + strongSelf.chatDisplayNode.dismissInput() + strongSelf.present(actionSheet, in: .window(.root)) + } + })) + } + } + + func presentDeleteMessageOptions(messageIds: Set, options: ChatAvailableMessageActionOptions, contextController: ContextControllerProtocol?, completion: @escaping (ContextMenuActionResult) -> Void) { + let _ = (self.context.engine.data.get( + EngineDataMap(messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init(id:))) + ) + |> deliverOnMainQueue).start(next: { [weak self] messages in + guard let self else { + return + } + + let actionSheet = ActionSheetController(presentationData: self.presentationData) + var items: [ActionSheetItem] = [] + var personalPeerName: String? + var isChannel = false + if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser { + personalPeerName = EnginePeer(user).compactDisplayTitle + } else if let peer = self.presentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat, let associatedPeerId = peer.associatedPeerId, let user = self.presentationInterfaceState.renderedPeer?.peers[associatedPeerId] as? TelegramUser { + personalPeerName = EnginePeer(user).compactDisplayTitle + } else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = channel.info { + isChannel = true + } + + if options.contains(.cancelSending) { + items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ContextMenuCancelSending, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } + })) + } + + var contextItems: [ContextMenuItem] = [] + var canDisplayContextMenu = true + + var unsendPersonalMessages = false + if options.contains(.unsendPersonal) { + canDisplayContextMenu = false + items.append(ActionSheetTextItem(title: self.presentationData.strings.Chat_UnsendMyMessagesAlertTitle(personalPeerName ?? "").string)) + items.append(ActionSheetSwitchItem(title: self.presentationData.strings.Chat_UnsendMyMessages, isOn: false, action: { value in + unsendPersonalMessages = value + })) + } else if options.contains(.deleteGlobally) { + let globalTitle: String + if isChannel { + globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone + } else if let personalPeerName = personalPeerName { + globalTitle = self.presentationData.strings.Conversation_DeleteMessagesFor(personalPeerName).string + } else { + globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone + } + contextItems.append(.action(ContextMenuActionItem(text: globalTitle, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in + if let strongSelf = self { + var giveaway: TelegramMediaGiveaway? + for messageId in messageIds { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + if let media = message.media.first(where: { $0 is TelegramMediaGiveaway }) as? TelegramMediaGiveaway { + giveaway = media + break + } + } + } + let commit = { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } + if let giveaway { + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + if currentTime < giveaway.untilDate { + Queue.mainQueue().after(0.2) { + let dateString = stringForDate(timestamp: giveaway.untilDate, timeZone: .current, strings: strongSelf.presentationData.strings) + strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Title, text: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Text(dateString).string, actions: [TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.Common_Delete, action: { + commit() + }), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { + })], parseMarkdown: true), in: .window(.root)) + } + f(.default) + } else { + f(.dismissWithoutContent) + commit() + } + } else { + if "".isEmpty { + f(.dismissWithoutContent) + commit() + } else { + c.dismiss(completion: { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { + commit() + }) + }) + } + } + } + }))) + items.append(ActionSheetButtonItem(title: globalTitle, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } + })) + } + if options.contains(.deleteLocally) { + var localOptionText = self.presentationData.strings.Conversation_DeleteMessagesForMe + if self.chatLocation.peerId == self.context.account.peerId { + if case .peer(self.context.account.peerId) = self.chatLocation, messages.values.allSatisfy({ message in message?._asMessage().effectivelyIncoming(self.context.account.peerId) ?? false }) { + localOptionText = self.presentationData.strings.Chat_ConfirmationRemoveFromSavedMessages + } else { + localOptionText = self.presentationData.strings.Chat_ConfirmationDeleteFromSavedMessages + } + } else if case .scheduledMessages = self.presentationInterfaceState.subject { + localOptionText = messageIds.count > 1 ? self.presentationData.strings.ScheduledMessages_DeleteMany : self.presentationData.strings.ScheduledMessages_Delete + } else { + if options.contains(.unsendPersonal) { + localOptionText = self.presentationData.strings.Chat_DeleteMessagesConfirmation(Int32(messageIds.count)) + } else if case .peer(self.context.account.peerId) = self.chatLocation { + if messageIds.count == 1 { + localOptionText = self.presentationData.strings.Conversation_Moderate_Delete + } else { + localOptionText = self.presentationData.strings.Conversation_DeleteManyMessages + } + } + } + contextItems.append(.action(ContextMenuActionItem(text: localOptionText, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + + let commit: () -> Void = { + guard let strongSelf = self else { + return + } + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: unsendPersonalMessages ? .forEveryone : .forLocalPeer).startStandalone() + } + + if "".isEmpty { + f(.dismissWithoutContent) + commit() + } else { + c.dismiss(completion: { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { + commit() + }) + }) + } + } + }))) + items.append(ActionSheetButtonItem(title: localOptionText, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: unsendPersonalMessages ? .forEveryone : .forLocalPeer).startStandalone() + + } + })) + } + + if canDisplayContextMenu, let contextController = contextController { + contextController.setItems(.single(ContextController.Items(content: .list(contextItems))), minHeight: nil, animated: true) + } else { + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + + if let contextController = contextController { + contextController.dismiss(completion: { [weak self] in + self?.present(actionSheet, in: .window(.root)) + }) + } else { + self.chatDisplayNode.dismissInput() + self.present(actionSheet, in: .window(.root)) + completion(.default) + } + } + }) + } + + func presentClearCacheSuggestion() { + guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return + } + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) + + let actionSheet = ActionSheetController(presentationData: self.presentationData) + var items: [ActionSheetItem] = [] + + items.append(DeleteChatPeerActionSheetItem(context: self.context, peer: EnginePeer(peer), chatPeer: EnginePeer(peer), action: .clearCacheSuggestion, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder)) + + var presented = false + items.append(ActionSheetButtonItem(title: self.presentationData.strings.ClearCache_FreeSpace, color: .accent, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self, !presented { + presented = true + let context = strongSelf.context + strongSelf.push(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in + return storageUsageExceptionsScreen(context: context, category: category) + })) + } + })) + + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + self.chatDisplayNode.dismissInput() + self.presentInGlobalOverlay(actionSheet) + } +} diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 38ae349803..3efc9a574d 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -456,7 +456,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } if let poll = media as? TelegramMediaPoll { var updatedMedia = message.media.filter { !($0 is TelegramMediaPoll) } - updatedMedia.append(TelegramMediaPoll(pollId: poll.pollId, publicity: poll.publicity, kind: poll.kind, text: poll.text, options: poll.options, correctAnswers: poll.correctAnswers, results: TelegramMediaPollResults(voters: nil, totalVoters: nil, recentVoters: [], solution: nil), isClosed: false, deadlineTimeout: nil)) + updatedMedia.append(TelegramMediaPoll(pollId: poll.pollId, publicity: poll.publicity, kind: poll.kind, text: poll.text, textEntities: poll.textEntities, options: poll.options, correctAnswers: poll.correctAnswers, results: TelegramMediaPollResults(voters: nil, totalVoters: nil, recentVoters: [], solution: nil), isClosed: false, deadlineTimeout: nil)) messageMedia = updatedMedia } if let _ = media as? TelegramMediaDice { diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index fb006b8e1a..2f3c2d5e75 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -881,6 +881,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState messageActions = ChatAvailableMessageActions( options: messageActions.options.intersection([.deleteLocally, .deleteGlobally, .forward]), banAuthor: nil, + banAuthors: [], disableDelete: true, isCopyProtected: messageActions.isCopyProtected, setTag: false, @@ -2066,6 +2067,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer var optionsMap: [MessageId: ChatAvailableMessageActionOptions] = [:] var banPeer: Peer? + var banPeers: [Peer] = [] var hadPersonalIncoming = false var hadBanPeerId = false var disableDelete = false @@ -2182,23 +2184,33 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer } if channel.hasPermission(.banMembers), case .group = channel.info { if message.flags.contains(.Incoming) { - if message.author is TelegramUser { - if !hadBanPeerId { + if let author = message.author { + if author is TelegramUser { + if !hadBanPeerId { + hadBanPeerId = true + banPeer = author + } else if banPeer?.id != message.author?.id { + banPeer = nil + } + + if !banPeers.contains(where: { $0.id == author.id }) { + banPeers.append(author) + } + } else if author is TelegramChannel { + if !hadBanPeerId { + hadBanPeerId = true + banPeer = author + } else if banPeer?.id != message.author?.id { + banPeer = nil + } + + if !banPeers.contains(where: { $0.id == author.id }) { + banPeers.append(author) + } + } else { hadBanPeerId = true - banPeer = message.author - } else if banPeer?.id != message.author?.id { banPeer = nil } - } else if message.author is TelegramChannel { - if !hadBanPeerId { - hadBanPeerId = true - banPeer = message.author - } else if banPeer?.id != message.author?.id { - banPeer = nil - } - } else { - hadBanPeerId = true - banPeer = nil } } else { hadBanPeerId = true @@ -2338,9 +2350,9 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer commonTags = nil } - return ChatAvailableMessageActions(options: reducedOptions, banAuthor: banPeer, disableDelete: disableDelete, isCopyProtected: isCopyProtected, setTag: setTag, editTags: commonTags ?? Set()) + return ChatAvailableMessageActions(options: reducedOptions, banAuthor: banPeer, banAuthors: banPeers, disableDelete: disableDelete, isCopyProtected: isCopyProtected, setTag: setTag, editTags: commonTags ?? Set()) } else { - return ChatAvailableMessageActions(options: [], banAuthor: nil, disableDelete: false, isCopyProtected: isCopyProtected, setTag: false, editTags: Set()) + return ChatAvailableMessageActions(options: [], banAuthor: nil, banAuthors: [], disableDelete: false, isCopyProtected: isCopyProtected, setTag: false, editTags: Set()) } } }