diff --git a/Telegram/Telegram-iOS/Resources/Files.tgs b/Telegram/Telegram-iOS/Resources/Files.tgs new file mode 100644 index 0000000000..5a62b5603f Binary files /dev/null and b/Telegram/Telegram-iOS/Resources/Files.tgs differ diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 32f7a84b03..a8b5dfd7f8 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -7267,9 +7267,40 @@ Sorry for the inconvenience."; "Attachment.SelectFromGallery" = "Select from Gallery"; "Attachment.SelectFromFiles" = "Select from Files"; +"Attachment.AllMedia" = "All"; + "Attachment.SelectedMedia_1" = "%@ Selected"; "Attachment.SelectedMedia_2" = "%@ Selected"; "Attachment.SelectedMedia_3_10" = "%@ Selected"; "Attachment.SelectedMedia_any" = "%@ Selected"; "Attachment.SelectedMedia_many" = "%@ Selected"; "Attachment.SelectedMedia_0" = "%@ Selected"; + +"Attachment.SendAsFile" = "Send as File"; +"Attachment.SendAsFiles" = "Send as Files"; + +"Attachment.Grouped" = "Grouped"; +"Attachment.Ungrouped" = "Ungrouped"; + +"Attachment.MessagePreview" = "Message Preview"; +"Attachment.DragToReorder" = "Drag media to reorder"; + +"Attachment.SearchWeb" = "Search Web"; + +"Attachment.RecentlySentFiles" = "Recently Sent Files"; + +"ReportPeer.ReasonIllegalDrugs" = "Illegal Drugs"; +"ReportPeer.ReasonPersonalDetails" = "Personal Details"; + +"Attachment.FilesIntro" = "Send and receive files of any type, up to 2 GB\nin size each, access them instantly\non your other devices."; +"Attachment.FilesSearchPlaceholder" = "Search sent files"; + +"Attachment.MediaAccessTitle" = "Access Your Photos and Videos"; +"Attachment.MediaAccessText" = "Share an unlimited number of photos and videos of up to 2 GB each."; + +"Attachment.LimitedMediaAccessText" = "You have limited Telegram from accessing all of your photos."; +"Attachment.CameraAccessText" = "Telegram needs camera access so that you can take photos and videos."; +"Attachment.Manage" = "Manage"; + +"Attachment.OpenSettings" = "Go to Settings"; +"Attachment.OpenCamera" = "Open Camera"; diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputActionButtonsNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputActionButtonsNode.swift index 5c11c0ca6f..4749f78362 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputActionButtonsNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputActionButtonsNode.swift @@ -15,8 +15,8 @@ final class AttachmentTextInputActionButtonsNode: ASDisplayNode { let sendButton: HighlightTrackingButtonNode var sendButtonHasApplyIcon = false var animatingSendButton = false - let expandMediaInputButton: HighlightableButtonNode - + let textNode: ImmediateTextNode + var sendButtonLongPressed: ((ASDisplayNode, ContextGesture) -> Void)? private var gestureRecognizer: ContextGesture? @@ -40,8 +40,10 @@ final class AttachmentTextInputActionButtonsNode: ASDisplayNode { self.backgroundNode.backgroundColor = theme.chat.inputPanel.actionControlFillColor self.backgroundNode.clipsToBounds = true self.sendButton = HighlightTrackingButtonNode(pointerStyle: .lift) - - self.expandMediaInputButton = HighlightableButtonNode(pointerStyle: .default) + + self.textNode = ImmediateTextNode() + self.textNode.attributedText = NSAttributedString(string: self.strings.MediaPicker_Send, font: Font.semibold(17.0), textColor: theme.chat.inputPanel.actionControlForegroundColor) + self.textNode.isUserInteractionEnabled = false super.init() @@ -71,7 +73,7 @@ final class AttachmentTextInputActionButtonsNode: ASDisplayNode { self.addSubnode(self.sendContainerNode) self.sendContainerNode.addSubnode(self.backgroundNode) self.sendContainerNode.addSubnode(self.sendButton) - self.addSubnode(self.expandMediaInputButton) + self.sendContainerNode.addSubnode(self.textNode) } override func didLoad() { @@ -91,9 +93,9 @@ final class AttachmentTextInputActionButtonsNode: ASDisplayNode { } func updateTheme(theme: PresentationTheme, wallpaper: TelegramWallpaper) { - self.expandMediaInputButton.setImage(PresentationResourcesChat.chatInputPanelExpandButtonImage(theme), for: []) - self.backgroundNode.backgroundColor = theme.chat.inputPanel.actionControlFillColor + + self.textNode.attributedText = NSAttributedString(string: self.strings.MediaPicker_Send, font: Font.semibold(17.0), textColor: theme.chat.inputPanel.actionControlForegroundColor) } private var absoluteRect: (CGRect, CGSize)? @@ -101,22 +103,31 @@ final class AttachmentTextInputActionButtonsNode: ASDisplayNode { self.absoluteRect = (rect, containerSize) } - func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, minimized: Bool, interfaceState: ChatPresentationInterfaceState) -> CGSize { self.validLayout = size - transition.updateFrame(layer: self.sendButton.layer, frame: CGRect(origin: CGPoint(), size: size)) - transition.updateFrame(node: self.sendContainerNode, frame: CGRect(origin: CGPoint(), size: size)) - - let backgroundSize = CGSize(width: 33.0, height: 33.0) - transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - backgroundSize.width) / 2.0), y: floorToScreenPixels((size.height - backgroundSize.height) / 2.0)), size: backgroundSize)) - self.backgroundNode.cornerRadius = backgroundSize.width / 2.0 - - transition.updateFrame(node: self.expandMediaInputButton, frame: CGRect(origin: CGPoint(), size: size)) - var expanded = false - if case let .media(_, maybeExpanded, _) = interfaceState.inputMode, maybeExpanded != nil { - expanded = true + let width: CGFloat + let textSize = self.textNode.updateLayout(CGSize(width: 100.0, height: size.height)) + if minimized { + width = 44.0 + } else { + width = textSize.width + 36.0 } - transition.updateSublayerTransformScale(node: self.expandMediaInputButton, scale: CGPoint(x: 1.0, y: expanded ? 1.0 : -1.0)) + + let buttonSize = CGSize(width: width, height: size.height) + + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - textSize.width) / 2.0), y: floorToScreenPixels((buttonSize.height - textSize.height) / 2.0)), size: textSize)) + transition.updateAlpha(node: self.textNode, alpha: minimized ? 0.0 : 1.0) + transition.updateAlpha(node: self.sendButton.imageNode, alpha: minimized ? 1.0 : 0.0) + + transition.updateFrame(layer: self.sendButton.layer, frame: CGRect(origin: CGPoint(), size: buttonSize)) + transition.updateFrame(node: self.sendContainerNode, frame: CGRect(origin: CGPoint(), size: buttonSize)) + + let backgroundSize = CGSize(width: width - 11.0, height: 33.0) + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - backgroundSize.width) / 2.0), y: floorToScreenPixels((size.height - backgroundSize.height) / 2.0)), size: backgroundSize)) + self.backgroundNode.cornerRadius = backgroundSize.height / 2.0 + + return buttonSize } func updateAccessibility() { diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index a8400f3f1d..6f85142c6b 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -287,7 +287,6 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.actionButtons.sendButton.addTarget(self, action: #selector(self.sendButtonPressed), forControlEvents: .touchUpInside) self.actionButtons.sendButton.alpha = 1.0 - self.actionButtons.expandMediaInputButton.alpha = 0.0 self.actionButtons.updateAccessibility() self.addSubnode(self.textInputContainer) @@ -590,9 +589,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } self.actionButtons.sendButtonHasApplyIcon = sendButtonHasApplyIcon if self.actionButtons.sendButtonHasApplyIcon { - self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelApplyButtonImage(interfaceState.theme), for: []) + self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelApplyIconImage(interfaceState.theme), for: []) } else { - self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelSendButtonImage(interfaceState.theme), for: []) + self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelSendIconImage(interfaceState.theme), for: []) } } } @@ -608,9 +607,6 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS let (_, textFieldHeight) = self.calculateTextFieldMetrics(width: baseWidth, maxHeight: maxHeight, metrics: metrics) var panelHeight = self.panelHeight(textFieldHeight: textFieldHeight, metrics: metrics) - var composeButtonsOffset: CGFloat = 0.0 - let textInputBackgroundWidthOffset: CGFloat = 0.0 - self.updateCounterTextNode(transition: transition) var inputHasText = false @@ -629,7 +625,15 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } if self.isCaption { - if !self.isFocused { + if self.isFocused { + self.oneLineNode.alpha = 0.0 + self.oneLineDustNode?.alpha = 0.0 + self.textInputNode?.alpha = 1.0 + + transition.updateAlpha(node: self.actionButtons, alpha: 1.0) + transition.updateTransformScale(node: self.actionButtons, scale: 1.0) + transition.updateAlpha(node: self.textInputBackgroundImageNode, alpha: 1.0) + } else { panelHeight = minimalHeight transition.updateAlpha(node: self.oneLineNode, alpha: inputHasText ? 1.0 : 0.0) @@ -639,12 +643,12 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS if let textInputNode = self.textInputNode { transition.updateAlpha(node: textInputNode, alpha: inputHasText ? 0.0 : 1.0) } - } else { - self.oneLineNode.alpha = 0.0 - self.oneLineDustNode?.alpha = 0.0 - self.textInputNode?.alpha = 1.0 + + transition.updateAlpha(node: self.actionButtons, alpha: 0.0) + transition.updateTransformScale(node: self.actionButtons, scale: 0.001) + transition.updateAlpha(node: self.textInputBackgroundImageNode, alpha: inputHasText ? 1.0 : 0.0) } - + let oneLineSize = self.oneLineNode.updateLayout(CGSize(width: baseWidth - textFieldInsets.left - textFieldInsets.right, height: CGFloat.greatestFiniteMagnitude)) let oneLineFrame = CGRect(origin: CGPoint(x: leftInset + textFieldInsets.left + self.textInputViewInternalInsets.left, y: textFieldInsets.top + self.textInputViewInternalInsets.top + textInputViewRealInsets.top + UIScreenPixel), size: oneLineSize) self.oneLineNode.frame = oneLineFrame @@ -652,34 +656,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.updateOneLineSpoiler() } self.textPlaceholderNode.isHidden = inputHasText - - if self.isCaption { - if self.isFocused { - transition.updateAlpha(node: self.actionButtons, alpha: 1.0) - transition.updateTransformScale(node: self.actionButtons, scale: 1.0) - composeButtonsOffset = 0.0 - - transition.updateAlpha(node: self.textInputBackgroundImageNode, alpha: 1.0) - } else { - transition.updateAlpha(node: self.actionButtons, alpha: 0.0) - transition.updateTransformScale(node: self.actionButtons, scale: 0.001) - composeButtonsOffset = 36.0 - - transition.updateAlpha(node: self.textInputBackgroundImageNode, alpha: inputHasText ? 1.0 : 0.0) - } - } - - let actionButtonsFrame = CGRect(origin: CGPoint(x: width - rightInset - 43.0 - UIScreenPixel + composeButtonsOffset, y: panelHeight - minimalHeight), size: CGSize(width: 44.0, height: minimalHeight)) - transition.updateFrame(node: self.actionButtons, frame: actionButtonsFrame) - - if let presentationInterfaceState = self.presentationInterfaceState { - self.actionButtons.updateLayout(size: CGSize(width: 44.0, height: minimalHeight), transition: transition, interfaceState: presentationInterfaceState) - } - - let textInputFrame = CGRect(x: leftInset + textFieldInsets.left, y: textFieldInsets.top, width: baseWidth - textFieldInsets.left - textFieldInsets.right + textInputBackgroundWidthOffset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom) - let textInputBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: textInputFrame.size.width + composeButtonsOffset, height: textInputFrame.size.height)) + + let textInputFrame = CGRect(x: leftInset + textFieldInsets.left, y: textFieldInsets.top, width: baseWidth - textFieldInsets.left - textFieldInsets.right, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom) transition.updateFrame(node: self.textInputContainer, frame: textInputFrame) - transition.updateFrame(node: self.textInputContainerBackgroundNode, frame: textInputBackgroundFrame) if let textInputNode = self.textInputNode { let textFieldFrame = CGRect(origin: CGPoint(x: self.textInputViewInternalInsets.left, y: self.textInputViewInternalInsets.top), size: CGSize(width: textInputFrame.size.width - (self.textInputViewInternalInsets.left + self.textInputViewInternalInsets.right), height: textInputFrame.size.height - self.textInputViewInternalInsets.top - textInputViewInternalInsets.bottom)) @@ -690,20 +669,72 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } } + self.updateFieldAndButtonsLayout(inputHasText: inputHasText, panelHeight: panelHeight, transition: transition) + + self.actionButtons.updateAccessibility() + + return panelHeight + } + + private func updateFieldAndButtonsLayout(inputHasText: Bool, panelHeight: CGFloat, transition: ContainedViewLayoutTransition) { + guard let (width, leftInset, rightInset, additionalSideInsets, _, metrics, _) = self.validLayout else { + return + } + var textFieldMinHeight: CGFloat = 33.0 + if let presentationInterfaceState = self.presentationInterfaceState { + textFieldMinHeight = calclulateTextFieldMinHeight(presentationInterfaceState, metrics: metrics) + } + let minimalHeight: CGFloat = 14.0 + textFieldMinHeight + + var panelHeight = panelHeight + var composeButtonsOffset: CGFloat = 0.0 + if self.isCaption { + if self.isFocused { + composeButtonsOffset = 0.0 + } else { + composeButtonsOffset = 36.0 + panelHeight = minimalHeight + } + } + + let baseWidth = width - leftInset - rightInset + let textInputFrame = self.textInputContainer.frame + + var textBackgroundInset: CGFloat = 0.0 + let actionButtonsSize: CGSize + if let presentationInterfaceState = self.presentationInterfaceState { + actionButtonsSize = self.actionButtons.updateLayout(size: CGSize(width: 44.0, height: minimalHeight), transition: transition, minimized: !self.isAttachment || inputHasText, interfaceState: presentationInterfaceState) + textBackgroundInset = 44.0 - actionButtonsSize.width + } else { + actionButtonsSize = CGSize(width: 44.0, height: minimalHeight) + } + + var textFieldInsets = self.textFieldInsets(metrics: metrics) + if additionalSideInsets.right > 0.0 { + textFieldInsets.right += additionalSideInsets.right / 3.0 + } + + let actionButtonsFrame = CGRect(origin: CGPoint(x: width - rightInset - actionButtonsSize.width + 1.0 - UIScreenPixel + composeButtonsOffset, y: panelHeight - minimalHeight), size: actionButtonsSize) + transition.updateFrame(node: self.actionButtons, frame: actionButtonsFrame) + + let textInputBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: textInputFrame.size.width + composeButtonsOffset + textBackgroundInset, height: textInputFrame.size.height)) + transition.updateFrame(node: self.textInputContainerBackgroundNode, frame: textInputBackgroundFrame) + + transition.updateFrame(layer: self.textInputBackgroundNode.layer, frame: CGRect(x: leftInset + textFieldInsets.left, y: textFieldInsets.top, width: baseWidth - textFieldInsets.left - textFieldInsets.right + composeButtonsOffset + textBackgroundInset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) + transition.updateFrame(layer: self.textInputBackgroundImageNode.layer, frame: CGRect(x: 0.0, y: 0.0, width: baseWidth - textFieldInsets.left - textFieldInsets.right + composeButtonsOffset + textBackgroundInset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) + + var textInputViewRealInsets = UIEdgeInsets() + if let presentationInterfaceState = self.presentationInterfaceState { + textInputViewRealInsets = calculateTextFieldRealInsets(presentationInterfaceState) + } + let placeholderFrame: CGRect if self.isCaption && !self.isFocused { placeholderFrame = CGRect(origin: CGPoint(x: textInputFrame.minX + floorToScreenPixels((textInputBackgroundFrame.width - self.textPlaceholderNode.frame.width) / 2.0), y: textFieldInsets.top + self.textInputViewInternalInsets.top + textInputViewRealInsets.top + UIScreenPixel), size: self.textPlaceholderNode.frame.size) } else { placeholderFrame = CGRect(origin: CGPoint(x: leftInset + textFieldInsets.left + self.textInputViewInternalInsets.left, y: textFieldInsets.top + self.textInputViewInternalInsets.top + textInputViewRealInsets.top + UIScreenPixel), size: self.textPlaceholderNode.frame.size) } - transition.updateFrame(node: self.textPlaceholderNode, frame: placeholderFrame) - transition.updateFrame(layer: self.textInputBackgroundNode.layer, frame: CGRect(x: leftInset + textFieldInsets.left, y: textFieldInsets.top, width: baseWidth - textFieldInsets.left - textFieldInsets.right + textInputBackgroundWidthOffset + composeButtonsOffset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) - transition.updateFrame(layer: self.textInputBackgroundImageNode.layer, frame: CGRect(x: 0.0, y: 0.0, width: baseWidth - textFieldInsets.left - textFieldInsets.right + textInputBackgroundWidthOffset + composeButtonsOffset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) - - self.actionButtons.updateAccessibility() - - return panelHeight } private var skipUpdate = false @@ -952,7 +983,10 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.oneLineNode.attributedText = nil } - self.updateTextHeight(animated: animated) + let panelHeight = self.updateTextHeight(animated: animated) + if self.isAttachment, let panelHeight = panelHeight { + self.updateFieldAndButtonsLayout(inputHasText: inputHasText, panelHeight: panelHeight, transition: .animated(duration: 0.2, curve: .easeInOut)) + } } private func updateOneLineSpoiler() { @@ -977,7 +1011,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } } - private func updateTextHeight(animated: Bool) { + private func updateTextHeight(animated: Bool) -> CGFloat? { if let (width, leftInset, rightInset, additionalSideInsets, maxHeight, metrics, _) = self.validLayout { let (_, textFieldHeight) = self.calculateTextFieldMetrics(width: width - leftInset - rightInset - additionalSideInsets.right, maxHeight: maxHeight, metrics: metrics) let panelHeight = self.panelHeight(textFieldHeight: textFieldHeight, metrics: metrics) @@ -985,6 +1019,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.updateHeight(animated) self.heightUpdated?(animated) } + return panelHeight + } else { + return nil } } @@ -1002,12 +1039,12 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS if sendButtonHasApplyIcon != self.actionButtons.sendButtonHasApplyIcon { self.actionButtons.sendButtonHasApplyIcon = sendButtonHasApplyIcon if self.actionButtons.sendButtonHasApplyIcon { - self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelApplyButtonImage(interfaceState.theme), for: []) + self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelApplyIconImage(interfaceState.theme), for: []) } else { if case .scheduledMessages = interfaceState.subject { self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelScheduleButtonImage(interfaceState.theme), for: []) } else { - self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelSendButtonImage(interfaceState.theme), for: []) + self.actionButtons.sendButton.setImage(PresentationResourcesChat.chatInputPanelSendIconImage(interfaceState.theme), for: []) } } } diff --git a/submodules/AttachmentUI/Sources/AttachmentContainer.swift b/submodules/AttachmentUI/Sources/AttachmentContainer.swift index 9e63e249f1..d762dfad90 100644 --- a/submodules/AttachmentUI/Sources/AttachmentContainer.swift +++ b/submodules/AttachmentUI/Sources/AttachmentContainer.swift @@ -44,10 +44,9 @@ final class AttachmentContainer: ASDisplayNode, UIGestureRecognizerDelegate { private var panGestureRecognizer: UIPanGestureRecognizer? - init(presentationData: PresentationData) { + override init() { self.wrappingNode = ASDisplayNode() self.clipNode = ASDisplayNode() - self.clipNode.backgroundColor = presentationData.theme.list.plainBackgroundColor self.container = NavigationContainer(controllerRemoved: { _ in }) self.container.clipsToBounds = true @@ -286,7 +285,6 @@ final class AttachmentContainer: ASDisplayNode, UIGestureRecognizerDelegate { self.validLayout = (layout, controllers, coveredByModalTransition) self.panGestureRecognizer?.isEnabled = (layout.inputHeight == nil || layout.inputHeight == 0.0) -// self.scrollNode.view.isScrollEnabled = (layout.inputHeight == nil || layout.inputHeight == 0.0) && self.isInteractiveDimissEnabled let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : defaultTopInset @@ -344,24 +342,24 @@ final class AttachmentContainer: ASDisplayNode, UIGestureRecognizerDelegate { let effectiveStatusBarHeight: CGFloat? = nil - let inset: CGFloat = 70.0 + let overflowInset: CGFloat = 70.0 var safeInsets = layout.safeInsets - safeInsets.left += inset - safeInsets.right += inset + safeInsets.left += overflowInset + safeInsets.right += overflowInset var intrinsicInsets = layout.intrinsicInsets - intrinsicInsets.left += inset - intrinsicInsets.right += inset + intrinsicInsets.left += overflowInset + intrinsicInsets.right += overflowInset - containerLayout = ContainerViewLayout(size: CGSize(width: layout.size.width + inset * 2.0, height: layout.size.height - containerTopInset), metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: intrinsicInsets.left, bottom: layout.intrinsicInsets.bottom + 49.0, right: intrinsicInsets.right), safeInsets: UIEdgeInsets(top: 0.0, left: safeInsets.left, bottom: safeInsets.bottom, right: safeInsets.right), additionalInsets: layout.additionalInsets, statusBarHeight: effectiveStatusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver) + containerLayout = ContainerViewLayout(size: CGSize(width: layout.size.width + overflowInset * 2.0, height: layout.size.height - containerTopInset), metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: intrinsicInsets.left, bottom: layout.intrinsicInsets.bottom, right: intrinsicInsets.right), safeInsets: UIEdgeInsets(top: 0.0, left: safeInsets.left, bottom: safeInsets.bottom, right: safeInsets.right), additionalInsets: layout.additionalInsets, statusBarHeight: effectiveStatusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver) let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: containerLayout.size) let maxScale: CGFloat = (containerLayout.size.width - 16.0 * 2.0) / containerLayout.size.width containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition let maxScaledTopInset: CGFloat = containerTopInset - 10.0 let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition - containerFrame = unscaledFrame.offsetBy(dx: -inset, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) + containerFrame = unscaledFrame.offsetBy(dx: -overflowInset, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) - clipFrame = CGRect(x: containerFrame.minX + inset, y: containerFrame.minY, width: containerFrame.width - inset * 2.0, height: containerFrame.height) + clipFrame = CGRect(x: containerFrame.minX + overflowInset, y: containerFrame.minY, width: containerFrame.width - overflowInset * 2.0, height: containerFrame.height) } } else { self.clipNode.clipsToBounds = true diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index 2e7c594a3e..e3d71b55f0 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -12,7 +12,6 @@ import TelegramStringFormatting import UIKitRuntimeUtils public enum AttachmentButtonType: Equatable { - case camera case gallery case file case location @@ -25,18 +24,33 @@ public protocol AttachmentContainable: ViewController { var requestAttachmentMenuExpansion: () -> Void { get set } } +public enum AttachmentMediaPickerSendMode { + case media + case files +} + public protocol AttachmentMediaPickerContext { var selectionCount: Signal { get } var caption: Signal { get } func setCaption(_ caption: NSAttributedString) - func send(silently: Bool) + func send(silently: Bool, mode: AttachmentMediaPickerSendMode) func schedule() } public class AttachmentController: ViewController { private let context: AccountContext + private let updatedPresentationData: (initial: PresentationData, signal: Signal)? private let buttons: [AttachmentButtonType] + + public var mediaPickerContext: AttachmentMediaPickerContext? { + get { + return self.node.mediaPickerContext + } + set { + self.node.mediaPickerContext = newValue + } + } private final class Node: ASDisplayNode { private weak var controller: AttachmentController? @@ -53,7 +67,7 @@ public class AttachmentController: ViewController { private let captionDisposable = MetaDisposable() private let mediaSelectionCountDisposable = MetaDisposable() - private var mediaPickerContext: AttachmentMediaPickerContext? { + fileprivate var mediaPickerContext: AttachmentMediaPickerContext? { didSet { if let mediaPickerContext = self.mediaPickerContext { self.captionDisposable.set((mediaPickerContext.caption @@ -82,10 +96,9 @@ public class AttachmentController: ViewController { self.dim.alpha = 0.0 self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) - let presentationData = controller.context.sharedContext.currentPresentationData.with { $0 } - self.container = AttachmentContainer(presentationData: presentationData) + self.container = AttachmentContainer() self.container.canHaveKeyboardFocus = true - self.panel = AttachmentPanel(context: controller.context) + self.panel = AttachmentPanel(context: controller.context, updatedPresentationData: controller.updatedPresentationData) super.init() @@ -133,9 +146,9 @@ public class AttachmentController: ViewController { if let strongSelf = self { switch mode { case .generic: - strongSelf.mediaPickerContext?.send(silently: false) + strongSelf.mediaPickerContext?.send(silently: false, mode: .media) case .silent: - strongSelf.mediaPickerContext?.send(silently: true) + strongSelf.mediaPickerContext?.send(silently: true, mode: .media) case .schedule: strongSelf.mediaPickerContext?.schedule() } @@ -272,6 +285,10 @@ public class AttachmentController: ViewController { transition.animatePositionAdditive(node: self.container, offset: CGPoint(x: 0.0, y: self.bounds.height + self.container.bounds.height / 2.0 - (self.container.position.y - self.bounds.height))) } + func scrollToTop() { + self.currentController?.scrollToTop?() + } + private var isCollapsed: Bool = false private var isUpdatingContainer = false private var switchingController = false @@ -279,31 +296,7 @@ public class AttachmentController: ViewController { self.validLayout = layout transition.updateFrame(node: self.dim, frame: CGRect(origin: CGPoint(), size: layout.size)) - - let containerTransition: ContainedViewLayoutTransition - if self.container.supernode == nil { - containerTransition = .immediate - } else { - containerTransition = transition - } - - if !self.isUpdatingContainer { - self.isUpdatingContainer = true - - let controllers = self.currentController.flatMap { [$0] } ?? [] - containerTransition.updateFrame(node: self.container, frame: CGRect(origin: CGPoint(), size: layout.size)) - self.container.update(layout: layout, controllers: controllers, coveredByModalTransition: 0.0, transition: self.switchingController ? .immediate : transition) - - if self.container.supernode == nil, !controllers.isEmpty && self.container.isReady { - self.addSubnode(self.container) - self.container.addSubnode(self.panel) - - self.animateIn(transition: transition) - } - - self.isUpdatingContainer = false - } - + if self.modalProgress < 0.5 { self.isCollapsed = false } else if self.modalProgress == 1.0 { @@ -317,6 +310,35 @@ public class AttachmentController: ViewController { panelTransition = .animated(duration: 0.25, curve: .easeInOut) } panelTransition.updateFrame(node: self.panel, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - panelHeight), size: CGSize(width: layout.size.width, height: panelHeight))) + + if !self.isUpdatingContainer { + self.isUpdatingContainer = true + + let containerTransition: ContainedViewLayoutTransition + if self.container.supernode == nil { + containerTransition = .immediate + } else { + containerTransition = transition + } + + let controllers = self.currentController.flatMap { [$0] } ?? [] + containerTransition.updateFrame(node: self.container, frame: CGRect(origin: CGPoint(), size: layout.size)) + + var containerInsets = layout.intrinsicInsets + containerInsets.bottom = panelHeight + let containerLayout = layout.withUpdatedIntrinsicInsets(containerInsets) + + self.container.update(layout: containerLayout, controllers: controllers, coveredByModalTransition: 0.0, transition: self.switchingController ? .immediate : transition) + + if self.container.supernode == nil, !controllers.isEmpty && self.container.isReady { + self.addSubnode(self.container) + self.container.addSubnode(self.panel) + + self.animateIn(transition: transition) + } + + self.isUpdatingContainer = false + } } } @@ -324,15 +346,22 @@ public class AttachmentController: ViewController { completion(nil, nil) } - public init(context: AccountContext, buttons: [AttachmentButtonType]) { + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, buttons: [AttachmentButtonType]) { self.context = context self.buttons = buttons + self.updatedPresentationData = updatedPresentationData super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .Ignore self.blocksBackgroundWhenInOverlay = true self.acceptsFocusWhenInOverlay = true + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + strongSelf.node.scrollToTop() + } + } } public required init(coder aDecoder: NSCoder) { diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index e42f61b19c..3cfea2951f 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -3,6 +3,7 @@ import UIKit import AsyncDisplayKit import Display import ComponentFlow +import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData @@ -12,7 +13,7 @@ import ChatPresentationInterfaceState import ChatSendMessageActionUI import ChatTextLinkEditUI -private let buttonSize = CGSize(width: 75.0, height: 49.0) +private let buttonSize = CGSize(width: 88.0, height: 49.0) private let iconSize = CGSize(width: 30.0, height: 30.0) private let sideInset: CGFloat = 0.0 @@ -83,40 +84,29 @@ private final class AttachButtonComponent: CombinedComponent { return { context in let name: String - let animationName: String? let imageName: String? let component = context.component let strings = component.strings switch component.type { - case .camera: - name = strings.Attachment_Camera - animationName = "anim_camera" - imageName = "Chat/Attach Menu/Camera" case .gallery: name = strings.Attachment_Gallery - animationName = "anim_gallery" imageName = "Chat/Attach Menu/Gallery" case .file: name = strings.Attachment_File - animationName = "anim_file" imageName = "Chat/Attach Menu/File" case .location: name = strings.Attachment_Location - animationName = "anim_location" imageName = "Chat/Attach Menu/Location" case .contact: name = strings.Attachment_Contact - animationName = "anim_contact" imageName = "Chat/Attach Menu/Contact" case .poll: name = strings.Attachment_Poll - animationName = "anim_poll" imageName = "Chat/Attach Menu/Poll" case let .app(appName): name = appName - animationName = nil imageName = nil } @@ -129,8 +119,6 @@ private final class AttachButtonComponent: CombinedComponent { transition: context.transition ) - print(animationName ?? "") - let title = title.update( component: Text( text: name, @@ -169,6 +157,7 @@ private final class AttachButtonComponent: CombinedComponent { final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { private let context: AccountContext private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? private var presentationInterfaceState: ChatPresentationInterfaceState private var interfaceInteraction: ChatPanelInterfaceInteraction? @@ -183,7 +172,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { private var textInputPanelNode: AttachmentTextInputPanelNode? private var buttons: [AttachmentButtonType] = [] - private var selectedIndex: Int = 1 + private var selectedIndex: Int = 0 private(set) var isCollapsed: Bool = false private(set) var isSelecting: Bool = false @@ -198,9 +187,9 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { var present: (ViewController) -> Void = { _ in } var presentInGlobalOverlay: (ViewController) -> Void = { _ in } - init(context: AccountContext) { + init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?) { self.context = context - self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } self.presentationInterfaceState = ChatPresentationInterfaceState(chatWallpaper: .builtin(WallpaperSettings()), theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameDisplayOrder: self.presentationData.nameDisplayOrder, limitsConfiguration: self.context.currentLimitsConfiguration.with { $0 }, fontSize: self.presentationData.chatFontSize, bubbleCorners: self.presentationData.chatBubbleCorners, accountPeerId: self.context.account.peerId, mode: .standard(previewing: false), chatLocation: .peer(PeerId(0)), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil) @@ -362,7 +351,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { guard let textInputNode = textInputPanelNode.textInputNode else { return } - let controller = ChatSendMessageActionSheetController(context: strongSelf.context, interfaceState: strongSelf.presentationInterfaceState, gesture: gesture, sourceSendButton: node, textInputNode: textInputNode, completion: { + let controller = ChatSendMessageActionSheetController(context: strongSelf.context, interfaceState: strongSelf.presentationInterfaceState, gesture: gesture, sourceSendButton: node, textInputNode: textInputNode, attachment: true, completion: { }, sendMessage: { [weak textInputPanelNode] silently in textInputPanelNode?.sendMessage(silently ? .silent : .generic) }, schedule: { [weak textInputPanelNode] in @@ -387,6 +376,26 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { }, presentChatRequestAdminInfo: { }, displayCopyProtectionTip: { _, _ in }, statuses: nil) + + self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + strongSelf.presentationData = presentationData + + strongSelf.backgroundNode.backgroundColor = presentationData.theme.actionSheet.itemBackgroundColor + strongSelf.separatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor + + strongSelf.updateChatPresentationInterfaceState({ $0.updatedTheme(presentationData.theme) }) + + if let layout = strongSelf.validLayout { + let _ = strongSelf.update(layout: layout, buttons: strongSelf.buttons, isCollapsed: strongSelf.isCollapsed, isSelecting: strongSelf.isSelecting, transition: .immediate) + } + } + }) + } + + deinit { + self.presentationDataDisposable?.dispose() } override func didLoad() { @@ -407,6 +416,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { effect = UIBlurEffect(style: .dark) } let effectView = UIVisualEffectView(effect: effect) + effectView.frame = self.containerNode.bounds self.effectView = effectView self.containerNode.view.insertSubview(effectView, at: 0) } @@ -644,7 +654,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { containerTransition.updateFrame(node: self.backgroundNode, frame: containerBounds) containerTransition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: UIScreenPixel))) if let effectView = self.effectView { - containerTransition.updateFrame(view: effectView, frame: bounds) + containerTransition.updateFrame(view: effectView, frame: CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: containerFrame.height + 44.0))) } let _ = self.updateScrollLayoutIfNeeded(force: isCollapsedUpdated || isSelectingUpdated, transition: containerTransition) diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift index a86b71509e..5f2b3d4077 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift @@ -19,6 +19,7 @@ public final class ChatSendMessageActionSheetController: ViewController { private let gesture: ContextGesture private let sourceSendButton: ASDisplayNode private let textInputNode: EditableTextNode + private let attachment: Bool private let completion: () -> Void private let sendMessage: (Bool) -> Void private let schedule: () -> Void @@ -32,12 +33,13 @@ public final class ChatSendMessageActionSheetController: ViewController { private let hapticFeedback = HapticFeedback() - public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, interfaceState: ChatPresentationInterfaceState, gesture: ContextGesture, sourceSendButton: ASDisplayNode, textInputNode: EditableTextNode, completion: @escaping () -> Void, sendMessage: @escaping (Bool) -> Void, schedule: @escaping () -> Void) { + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, interfaceState: ChatPresentationInterfaceState, gesture: ContextGesture, sourceSendButton: ASDisplayNode, textInputNode: EditableTextNode, attachment: Bool = false, completion: @escaping () -> Void, sendMessage: @escaping (Bool) -> Void, schedule: @escaping () -> Void) { self.context = context self.interfaceState = interfaceState self.gesture = gesture self.sourceSendButton = sourceSendButton self.textInputNode = textInputNode + self.attachment = attachment self.completion = completion self.sendMessage = sendMessage self.schedule = schedule @@ -69,8 +71,8 @@ public final class ChatSendMessageActionSheetController: ViewController { } override public func loadDisplayNode() { - var forwardedCount = 0 - if let forwardMessageIds = self.interfaceState.interfaceState.forwardMessageIds { + var forwardedCount: Int? + if let forwardMessageIds = self.interfaceState.interfaceState.forwardMessageIds, forwardMessageIds.count > 0 { forwardedCount = forwardMessageIds.count } @@ -83,7 +85,7 @@ public final class ChatSendMessageActionSheetController: ViewController { canSchedule = !isSecret } - self.displayNode = ChatSendMessageActionSheetControllerNode(context: self.context, presentationData: self.presentationData, reminders: reminders, gesture: gesture, sourceSendButton: self.sourceSendButton, textInputNode: self.textInputNode, forwardedCount: forwardedCount, send: { [weak self] in + self.displayNode = ChatSendMessageActionSheetControllerNode(context: self.context, presentationData: self.presentationData, reminders: reminders, gesture: gesture, sourceSendButton: self.sourceSendButton, textInputNode: self.textInputNode, attachment: self.attachment, forwardedCount: forwardedCount, send: { [weak self] in self?.sendMessage(false) self?.dismiss(cancel: false) }, sendSilently: { [weak self] in diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift index 87cdde4c45..e76b1d04a2 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift @@ -156,6 +156,7 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, private let sourceSendButton: ASDisplayNode private let textFieldFrame: CGRect private let textInputNode: EditableTextNode + private let attachment: Bool private let forwardedCount: Int? private let send: (() -> Void)? @@ -180,12 +181,15 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, return self.sourceSendButton.view.convert(self.sourceSendButton.bounds, to: nil) } - init(context: AccountContext, presentationData: PresentationData, reminders: Bool, gesture: ContextGesture, sourceSendButton: ASDisplayNode, textInputNode: EditableTextNode, forwardedCount: Int?, send: (() -> Void)?, sendSilently: (() -> Void)?, schedule: (() -> Void)?, cancel: (() -> Void)?) { + private var animateInputField = false + + init(context: AccountContext, presentationData: PresentationData, reminders: Bool, gesture: ContextGesture, sourceSendButton: ASDisplayNode, textInputNode: EditableTextNode, attachment: Bool, forwardedCount: Int?, send: (() -> Void)?, sendSilently: (() -> Void)?, schedule: (() -> Void)?, cancel: (() -> Void)?) { self.context = context self.presentationData = presentationData self.sourceSendButton = sourceSendButton self.textFieldFrame = textInputNode.convert(textInputNode.bounds, to: nil) self.textInputNode = textInputNode + self.attachment = attachment self.forwardedCount = forwardedCount self.send = send @@ -245,10 +249,10 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, super.init() -// self.sendButtonNode.setImage(PresentationResourcesChat.chatInputPanelSendButtonImage(self.presentationData.theme), for: []) self.sendButtonNode.addTarget(self, action: #selector(self.sendButtonPressed), forControlEvents: .touchUpInside) if let attributedText = textInputNode.attributedText, !attributedText.string.isEmpty { + self.animateInputField = true self.fromMessageTextNode.attributedText = attributedText if let toAttributedText = self.fromMessageTextNode.attributedText?.mutableCopy() as? NSMutableAttributedString { @@ -256,7 +260,10 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, self.toMessageTextNode.attributedText = toAttributedText } } else { - self.fromMessageTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.Conversation_InputTextPlaceholder, attributes: [NSAttributedString.Key.foregroundColor: self.presentationData.theme.chat.inputPanel.inputPlaceholderColor, NSAttributedString.Key.font: Font.regular(self.presentationData.chatFontSize.baseDisplaySize)]) + if let _ = forwardedCount { + self.animateInputField = true + } + self.fromMessageTextNode.attributedText = NSAttributedString(string: self.attachment ? self.presentationData.strings.MediaPicker_AddCaption : self.presentationData.strings.Conversation_InputTextPlaceholder, attributes: [NSAttributedString.Key.foregroundColor: self.presentationData.theme.chat.inputPanel.inputPlaceholderColor, NSAttributedString.Key.font: Font.regular(self.presentationData.chatFontSize.baseDisplaySize)]) self.toMessageTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.ForwardedMessages(Int32(forwardedCount ?? 0)), attributes: [NSAttributedString.Key.foregroundColor: self.presentationData.theme.chat.message.outgoing.primaryTextColor, NSAttributedString.Key.font: Font.regular(self.presentationData.chatFontSize.baseDisplaySize)]) } @@ -359,7 +366,6 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, self.dimNode.backgroundColor = presentationData.theme.contextMenu.dimColor self.contentContainerNode.backgroundColor = self.presentationData.theme.contextMenu.backgroundColor -// self.sendButtonNode.setImage(PresentationResourcesChat.chatInputPanelSendButtonImage(self.presentationData.theme), for: []) if let toAttributedText = self.textInputNode.attributedText?.mutableCopy() as? NSMutableAttributedString { toAttributedText.addAttribute(NSAttributedString.Key.foregroundColor, value: self.presentationData.theme.chat.message.outgoing.primaryTextColor, range: NSMakeRange(0, (toAttributedText.string as NSString).length)) @@ -376,6 +382,10 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, } func animateIn() { + guard let layout = self.validLayout else { + return + } + self.textInputNode.textView.setContentOffset(self.textInputNode.textView.contentOffset, animated: false) UIView.animate(withDuration: 0.2, animations: { @@ -389,61 +399,66 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, self.contentContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.messageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - self.fromMessageTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) - self.toMessageTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, removeOnCompletion: false) - - self.textInputNode.isHidden = true self.sourceSendButton.isHidden = true - - if let layout = self.validLayout { - let duration = 0.4 - - self.sendButtonNode.layer.animateScale(from: 0.75, to: 1.0, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue) - self.sendButtonNode.layer.animatePosition(from: self.sendButtonFrame.center, to: self.sendButtonNode.position, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) - - var initialWidth = self.textFieldFrame.width + 32.0 - if self.textInputNode.textView.attributedText.string.isEmpty { - initialWidth = ceil(layout.size.width - self.textFieldFrame.origin.x - self.sendButtonFrame.width - layout.safeInsets.left - layout.safeInsets.right + 21.0) - } - - let fromFrame = CGRect(origin: CGPoint(), size: CGSize(width: initialWidth, height: self.textFieldFrame.height + 2.0)) - let delta = (fromFrame.height - self.messageClipNode.bounds.height) / 2.0 - - let inputHeight = layout.inputHeight ?? 0.0 - var clipDelta = delta - if inputHeight.isZero || layout.isNonExclusive { - clipDelta -= self.contentContainerNode.frame.height + 16.0 - } - - self.messageClipNode.layer.animateBounds(from: fromFrame, to: self.messageClipNode.bounds, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) - self.messageClipNode.layer.animatePosition(from: CGPoint(x: (self.messageClipNode.bounds.width - initialWidth) / 2.0, y: clipDelta), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.insertSubnode(strongSelf.contentContainerNode, aboveSubnode: strongSelf.scrollNode) - } - }) - - self.messageBackgroundNode.layer.animateBounds(from: fromFrame, to: self.messageBackgroundNode.bounds, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) - self.messageBackgroundNode.layer.animatePosition(from: CGPoint(x: (initialWidth - self.messageClipNode.bounds.width) / 2.0, y: delta), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - - - var textXOffset: CGFloat = 0.0 - let textYOffset = self.textInputNode.textView.contentSize.height - self.textInputNode.textView.contentOffset.y - self.textInputNode.textView.frame.height - if self.textInputNode.textView.numberOfLines == 1 && self.textInputNode.isRTL { - textXOffset = initialWidth - self.messageClipNode.bounds.width - } - self.fromMessageTextNode.layer.animatePosition(from: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - self.toMessageTextNode.layer.animatePosition(from: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - - let contentOffset = CGPoint(x: self.sendButtonFrame.midX - self.contentContainerNode.frame.midX, y: self.sendButtonFrame.midY - self.contentContainerNode.frame.midY) - - let springDuration: Double = 0.42 - let springDamping: CGFloat = 104.0 - self.contentContainerNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping) - self.contentContainerNode.layer.animateSpring(from: NSValue(cgPoint: contentOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, additive: true) + if self.animateInputField { + self.fromMessageTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + self.toMessageTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, removeOnCompletion: false) + self.textInputNode.isHidden = true + } else { + self.messageBackgroundNode.isHidden = true + self.fromMessageTextNode.isHidden = true + self.toMessageTextNode.isHidden = true } + + let duration = 0.4 + self.sendButtonNode.layer.animateScale(from: 0.75, to: 1.0, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue) + self.sendButtonNode.layer.animatePosition(from: self.sendButtonFrame.center, to: self.sendButtonNode.position, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) + + var initialWidth = self.textFieldFrame.width + 32.0 + if self.textInputNode.textView.attributedText.string.isEmpty { + initialWidth = ceil(layout.size.width - self.textFieldFrame.origin.x - self.sendButtonFrame.width - layout.safeInsets.left - layout.safeInsets.right + 21.0) + } + + let fromFrame = CGRect(origin: CGPoint(), size: CGSize(width: initialWidth, height: self.textFieldFrame.height + 2.0)) + let delta = (fromFrame.height - self.messageClipNode.bounds.height) / 2.0 + + let inputHeight = layout.inputHeight ?? 0.0 + var clipDelta = delta + if inputHeight.isZero || layout.isNonExclusive { + clipDelta -= self.contentContainerNode.frame.height + 16.0 + } + + self.messageClipNode.layer.animateBounds(from: fromFrame, to: self.messageClipNode.bounds, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) + self.messageClipNode.layer.animatePosition(from: CGPoint(x: (self.messageClipNode.bounds.width - initialWidth) / 2.0, y: clipDelta), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.insertSubnode(strongSelf.contentContainerNode, aboveSubnode: strongSelf.scrollNode) + } + }) + + self.messageBackgroundNode.layer.animateBounds(from: fromFrame, to: self.messageBackgroundNode.bounds, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) + self.messageBackgroundNode.layer.animatePosition(from: CGPoint(x: (initialWidth - self.messageClipNode.bounds.width) / 2.0, y: delta), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + var textXOffset: CGFloat = 0.0 + let textYOffset = self.textInputNode.textView.contentSize.height - self.textInputNode.textView.contentOffset.y - self.textInputNode.textView.frame.height + if self.textInputNode.textView.numberOfLines == 1 && self.textInputNode.isRTL { + textXOffset = initialWidth - self.messageClipNode.bounds.width + } + self.fromMessageTextNode.layer.animatePosition(from: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.toMessageTextNode.layer.animatePosition(from: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + let contentOffset = CGPoint(x: self.sendButtonFrame.midX - self.contentContainerNode.frame.midX, y: self.sendButtonFrame.midY - self.contentContainerNode.frame.midY) + + let springDuration: Double = 0.42 + let springDamping: CGFloat = 104.0 + self.contentContainerNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping) + self.contentContainerNode.layer.animateSpring(from: NSValue(cgPoint: contentOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, additive: true) } func animateOut(cancel: Bool, completion: @escaping () -> Void) { + guard let layout = self.validLayout else { + return + } + self.isUserInteractionEnabled = false self.scrollNode.view.setContentOffset(self.scrollNode.view.contentOffset, animated: false) @@ -475,75 +490,77 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) self.contentContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in }) - if cancel { - self.fromMessageTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, delay: 0.15, removeOnCompletion: false) - self.toMessageTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.15, removeOnCompletion: false) - self.messageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.15, removeOnCompletion: false, completion: { _ in - completedAlpha = true - intermediateCompletion() - }) + if self.animateInputField { + if cancel { + self.fromMessageTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, delay: 0.15, removeOnCompletion: false) + self.toMessageTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.15, removeOnCompletion: false) + self.messageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.15, removeOnCompletion: false, completion: { _ in + completedAlpha = true + intermediateCompletion() + }) + } else { + self.textInputNode.isHidden = false + self.messageClipNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + completedAlpha = true + intermediateCompletion() + }) + } } else { - self.textInputNode.isHidden = false - self.messageClipNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in - completedAlpha = true - intermediateCompletion() - }) + completedAlpha = true } - if let layout = self.validLayout { - let duration = 0.4 - - self.sendButtonNode.layer.animatePosition(from: self.sendButtonNode.position, to: self.sendButtonFrame.center, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - completedButton = true - intermediateCompletion() - }) - - if !cancel { - self.sourceSendButton.isHidden = false - self.sendButtonNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) - self.sendButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) - } - - var initialWidth = self.textFieldFrame.width + 32.0 - if self.textInputNode.textView.attributedText.string.isEmpty { - initialWidth = ceil(layout.size.width - self.textFieldFrame.origin.x - self.sendButtonFrame.width - layout.safeInsets.left - layout.safeInsets.right + 21.0) - } - - let toFrame = CGRect(origin: CGPoint(), size: CGSize(width: initialWidth, height: self.textFieldFrame.height + 1.0)) - let delta = (toFrame.height - self.messageClipNode.bounds.height) / 2.0 - + let duration = 0.4 + + self.sendButtonNode.layer.animatePosition(from: self.sendButtonNode.position, to: self.sendButtonFrame.center, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completedButton = true + intermediateCompletion() + }) + + if !cancel { + self.sourceSendButton.isHidden = false + self.sendButtonNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) + self.sendButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) + } + + var initialWidth = self.textFieldFrame.width + 32.0 + if self.textInputNode.textView.attributedText.string.isEmpty { + initialWidth = ceil(layout.size.width - self.textFieldFrame.origin.x - self.sendButtonFrame.width - layout.safeInsets.left - layout.safeInsets.right + 21.0) + } + + let toFrame = CGRect(origin: CGPoint(), size: CGSize(width: initialWidth, height: self.textFieldFrame.height + 1.0)) + let delta = (toFrame.height - self.messageClipNode.bounds.height) / 2.0 + + if cancel && self.animateInputField { let inputHeight = layout.inputHeight ?? 0.0 var clipDelta = delta if inputHeight.isZero || layout.isNonExclusive { clipDelta -= self.contentContainerNode.frame.height + 16.0 } - if cancel { - self.messageClipNode.layer.animateBounds(from: self.messageClipNode.bounds, to: toFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - completedBubble = true - intermediateCompletion() - }) - self.messageClipNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: (self.messageClipNode.bounds.width - initialWidth) / 2.0, y: clipDelta + self.scrollNode.view.contentOffset.y), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - - self.messageBackgroundNode.layer.animateBounds(from: self.messageBackgroundNode.bounds, to: toFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) - self.messageBackgroundNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: (initialWidth - self.messageClipNode.bounds.width) / 2.0, y: delta), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - - var textXOffset: CGFloat = 0.0 - let textYOffset = self.textInputNode.textView.contentSize.height - self.textInputNode.textView.contentOffset.y - self.textInputNode.textView.frame.height - if self.textInputNode.textView.numberOfLines == 1 && self.textInputNode.isRTL { - textXOffset = initialWidth - self.messageClipNode.bounds.width - } - self.fromMessageTextNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - self.toMessageTextNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - } else { + self.messageClipNode.layer.animateBounds(from: self.messageClipNode.bounds, to: toFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in completedBubble = true + intermediateCompletion() + }) + self.messageClipNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: (self.messageClipNode.bounds.width - initialWidth) / 2.0, y: clipDelta + self.scrollNode.view.contentOffset.y), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + + self.messageBackgroundNode.layer.animateBounds(from: self.messageBackgroundNode.bounds, to: toFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.messageBackgroundNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: (initialWidth - self.messageClipNode.bounds.width) / 2.0, y: delta), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + + var textXOffset: CGFloat = 0.0 + let textYOffset = self.textInputNode.textView.contentSize.height - self.textInputNode.textView.contentOffset.y - self.textInputNode.textView.frame.height + if self.textInputNode.textView.numberOfLines == 1 && self.textInputNode.isRTL { + textXOffset = initialWidth - self.messageClipNode.bounds.width } - - let contentOffset = CGPoint(x: self.sendButtonFrame.midX - self.contentContainerNode.frame.midX, y: self.sendButtonFrame.midY - self.contentContainerNode.frame.midY) - - self.contentContainerNode.layer.animatePosition(from: CGPoint(), to: contentOffset, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - self.contentContainerNode.layer.animateScale(from: 1.0, to: 0.1, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.fromMessageTextNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + self.toMessageTextNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: textXOffset, y: delta * 2.0 + textYOffset), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + } else { + completedBubble = true } + + let contentOffset = CGPoint(x: self.sendButtonFrame.midX - self.contentContainerNode.frame.midX, y: self.sendButtonFrame.midY - self.contentContainerNode.frame.midY) + + self.contentContainerNode.layer.animatePosition(from: CGPoint(), to: contentOffset, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + self.contentContainerNode.layer.animateScale(from: 1.0, to: 0.1, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -558,7 +575,7 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, transition.updateFrame(view: self.effectView, frame: CGRect(origin: CGPoint(), size: layout.size)) transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - let sideInset: CGFloat = 43.0 + let sideInset: CGFloat = self.sendButtonFrame.width - 1.0 var contentSize = CGSize() contentSize.width = min(layout.size.width - 40.0, 250.0) @@ -578,7 +595,7 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, let contentOffset = self.scrollNode.view.contentOffset.y var contentOrigin = CGPoint(x: layout.size.width - sideInset - contentSize.width - layout.safeInsets.right, y: layout.size.height - 6.0 - insets.bottom - contentSize.height) - if inputHeight > 0.0 && !layout.isNonExclusive { + if inputHeight > 0.0 && !layout.isNonExclusive && self.animateInputField { contentOrigin.y += menuHeightWithInset } contentOrigin.y = min(contentOrigin.y + contentOffset, layout.size.height - 6.0 - layout.intrinsicInsets.bottom - contentSize.height) @@ -593,7 +610,7 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, let initialSendButtonFrame = self.sendButtonFrame var sendButtonFrame = CGRect(origin: CGPoint(x: layout.size.width - initialSendButtonFrame.width + 1.0 - UIScreenPixel - layout.safeInsets.right, y: layout.size.height - insets.bottom - initialSendButtonFrame.height), size: initialSendButtonFrame.size) - if inputHeight.isZero || layout.isNonExclusive { + if (inputHeight.isZero || layout.isNonExclusive) && self.animateInputField { sendButtonFrame.origin.y -= menuHeightWithInset } sendButtonFrame.origin.y = min(sendButtonFrame.origin.y + contentOffset, layout.size.height - layout.intrinsicInsets.bottom - initialSendButtonFrame.height) diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index ac23bcfb93..9cd3e8da41 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -171,7 +171,15 @@ public class CheckNode: ASDisplayNode { context.setLineWidth(borderWidth) let maybeScaleOut = { - if parameters.animatingOut { + let animate: Bool + if case .counter = parameters.content { + animate = true + } else if parameters.animatingOut { + animate = true + } else { + animate = false + } + if animate { context.translateBy(x: bounds.width / 2.0, y: bounds.height / 2.0) context.scaleBy(x: parameters.animationProgress, y: parameters.animationProgress) context.translateBy(x: -bounds.width / 2.0, y: -bounds.height / 2.0) @@ -206,35 +214,41 @@ public class CheckNode: ASDisplayNode { let fillFrame = bounds.insetBy(dx: fillInset, dy: fillInset) context.fillEllipse(in: fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress))) - let scale = (bounds.width - inset) / 18.0 - - let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) - let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) - let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) - let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) - - if !firstSegment.isZero { - if firstSegment < 1.0 { - context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) - context.addLine(to: s) - } else { - let secondSegment = (checkProgress - 0.33) * 1.5 - context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) - context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) - context.addLine(to: s) - } + switch parameters.content { + case .check: + let scale = (bounds.width - inset) / 18.0 + let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) + let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) + let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) + let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) + + if !firstSegment.isZero { + if firstSegment < 1.0 { + context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) + context.addLine(to: s) + } else { + let secondSegment = (checkProgress - 0.33) * 1.5 + context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) + context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) + context.addLine(to: s) + } + } + + context.setStrokeColor(parameters.theme.strokeColor.cgColor) + if parameters.theme.strokeColor == .clear { + context.setBlendMode(.clear) + } + context.setLineWidth(checkWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setMiterLimit(10.0) + + context.strokePath() + case let .counter(number): + let string = NSAttributedString(string: "\(number)", font: Font.with(size: 16.0, design: .round, weight: .semibold), textColor: parameters.theme.strokeColor) + let stringSize = string.boundingRect(with: bounds.size, options: .usesLineFragmentOrigin, context: nil).size + string.draw(at: CGPoint(x: floorToScreenPixels((bounds.width - stringSize.width) / 2.0), y: floorToScreenPixels((bounds.height - stringSize.height) / 2.0))) } - - context.setStrokeColor(parameters.theme.strokeColor.cgColor) - if parameters.theme.strokeColor == .clear { - context.setBlendMode(.clear) - } - context.setLineWidth(checkWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - context.setMiterLimit(10.0) - - context.strokePath() } } @@ -432,35 +446,40 @@ public class CheckLayer: CALayer { let fillFrame = bounds.insetBy(dx: fillInset, dy: fillInset) context.fillEllipse(in: fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress))) - let scale = (bounds.width - inset) / 18.0 + switch parameters.content { + case .check: + let scale = (bounds.width - inset) / 18.0 + let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) + let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) + let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) + let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) - let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) - let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) - let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) - let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) + if !firstSegment.isZero { + if firstSegment < 1.0 { + context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) + context.addLine(to: s) + } else { + let secondSegment = (checkProgress - 0.33) * 1.5 + context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) + context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) + context.addLine(to: s) + } + } - if !firstSegment.isZero { - if firstSegment < 1.0 { - context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) - context.addLine(to: s) - } else { - let secondSegment = (checkProgress - 0.33) * 1.5 - context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) - context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) - context.addLine(to: s) - } + context.setStrokeColor(parameters.theme.strokeColor.cgColor) + if parameters.theme.strokeColor == .clear { + context.setBlendMode(.clear) + } + context.setLineWidth(checkWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setMiterLimit(10.0) + + context.strokePath() + case let .counter(number): + let text = NSAttributedString(string: "\(number)", font: Font.with(size: 16.0, design: .round, weight: .regular, traits: []), textColor: parameters.theme.strokeColor) + text.draw(at: CGPoint()) } - - context.setStrokeColor(parameters.theme.strokeColor.cgColor) - if parameters.theme.strokeColor == .clear { - context.setBlendMode(.clear) - } - context.setLineWidth(checkWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - context.setMiterLimit(10.0) - - context.strokePath() })?.cgImage } } diff --git a/submodules/Display/Source/GridNode.swift b/submodules/Display/Source/GridNode.swift index edb85782ee..fb41e98a32 100644 --- a/submodules/Display/Source/GridNode.swift +++ b/submodules/Display/Source/GridNode.swift @@ -68,13 +68,15 @@ public struct GridNodeLayout: Equatable { public let scrollIndicatorInsets: UIEdgeInsets? public let preloadSize: CGFloat public let type: GridNodeLayoutType + public let cutout: CGRect? - public init(size: CGSize, insets: UIEdgeInsets, scrollIndicatorInsets: UIEdgeInsets? = nil, preloadSize: CGFloat, type: GridNodeLayoutType) { + public init(size: CGSize, insets: UIEdgeInsets, scrollIndicatorInsets: UIEdgeInsets? = nil, preloadSize: CGFloat, type: GridNodeLayoutType, cutout: CGRect? = nil) { self.size = size self.insets = insets self.scrollIndicatorInsets = scrollIndicatorInsets self.preloadSize = preloadSize self.type = type + self.cutout = cutout } } @@ -515,7 +517,6 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { keepSection = false } - if !previousFillsRow && item.fillsRowWithDynamicHeight != nil { keepSection = false } @@ -559,6 +560,10 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { } } + if let cutout = self.gridLayout.cutout, cutout.intersects(CGRect(origin: nextItemOrigin, size: itemSize)) { + nextItemOrigin.x += cutout.width + itemSpacing + } + if !incrementedCurrentRow { incrementedCurrentRow = true contentSize.height += itemSize.height + lineSpacing diff --git a/submodules/Display/Source/ImageNode.swift b/submodules/Display/Source/ImageNode.swift index ecf6f87aea..bacfd28821 100644 --- a/submodules/Display/Source/ImageNode.swift +++ b/submodules/Display/Source/ImageNode.swift @@ -210,5 +210,13 @@ public class ImageNode: ASDisplayNode { self.contents = nil self.disposable.set(nil) } + + public var image: UIImage? { + if let contents = self.contents { + return UIImage(cgImage: contents as! CGImage) + } else { + return nil + } + } } diff --git a/submodules/Display/Source/ListViewItemNode.swift b/submodules/Display/Source/ListViewItemNode.swift index cdb0cc2c80..0abfb04fd9 100644 --- a/submodules/Display/Source/ListViewItemNode.swift +++ b/submodules/Display/Source/ListViewItemNode.swift @@ -623,8 +623,10 @@ open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { (self.supernode as? ListView)?.ensureItemNodeVisible(self, animated: false, overflow: 22.0, allowIntersection: true) } - public func updateFrame(_ frame: CGRect, within containerSize: CGSize) { - self.frame = frame + public func updateFrame(_ frame: CGRect, within containerSize: CGSize, updateFrame: Bool = true) { + if updateFrame { + self.frame = frame + } if frame.maxY < 0.0 || frame.minY > containerSize.height { } else { self.updateAbsoluteRect(frame, within: containerSize) diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index 636286c95b..e77d0ed2ce 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -1104,7 +1104,7 @@ open class NavigationBar: ASDisplayNode { } } else if self.leftButtonNode.supernode != nil { let leftButtonSize = self.leftButtonNode.updateLayout(constrainedSize: CGSize(width: size.width, height: nominalHeight), isLandscape: isLandscape) - leftTitleInset += leftButtonSize.width + leftButtonInset + 1.0 + leftTitleInset = leftButtonSize.width + leftButtonInset + 1.0 self.leftButtonNode.alpha = 1.0 transition.updateFrame(node: self.leftButtonNode, frame: CGRect(origin: CGPoint(x: leftButtonInset, y: contentVerticalOrigin + floor((nominalHeight - leftButtonSize.height) / 2.0)), size: leftButtonSize)) @@ -1116,7 +1116,7 @@ open class NavigationBar: ASDisplayNode { if self.rightButtonNode.supernode != nil { let rightButtonSize = self.rightButtonNode.updateLayout(constrainedSize: (CGSize(width: size.width, height: nominalHeight)), isLandscape: isLandscape) - rightTitleInset += rightButtonSize.width + leftButtonInset + 1.0 + rightTitleInset = rightButtonSize.width + leftButtonInset + 1.0 self.rightButtonNode.alpha = 1.0 transition.updateFrame(node: self.rightButtonNode, frame: CGRect(origin: CGPoint(x: size.width - leftButtonInset - rightButtonSize.width, y: contentVerticalOrigin + floor((nominalHeight - rightButtonSize.height) / 2.0)), size: rightButtonSize)) } diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index 02b5f39361..5c08318248 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -204,15 +204,7 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati return .theme(file) } else if ext == "wav" || ext == "opus" { return .audio(file) - } else if ext == "json", let fileSize = file.size, fileSize < 1024 * 1024 { - if let path = context.account.postbox.mediaBox.completedResourcePath(file.resource), let composition = LOTComposition(filePath: path), composition.timeDuration > 0.0 { - let gallery = GalleryController(context: context, source: .peerMessagesAtId(messageId: message.id, chatLocation: chatLocation ?? ChatLocation.peer(message.id.peerId), chatLocationContextHolder: chatLocationContextHolder ?? Atomic(value: nil)), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: timecode, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in - navigationController?.replaceTopController(controller, animated: false, ready: ready) - }, baseNavigationController: navigationController, actionInteraction: actionInteraction) - return .gallery(.single(gallery)) - } } - if ext == "mkv" { return .document(file, true) } diff --git a/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift b/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift index 2b86040633..c2c0a5dcc0 100644 --- a/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift +++ b/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift @@ -301,7 +301,7 @@ struct InviteRequestsSearchContainerTransition { let query: String } -private func InviteRequestsSearchContainerPreparedRecentTransition(from fromEntries: [InviteRequestsSearchEntry], to toEntries: [InviteRequestsSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: InviteRequestsSearchContainerInteraction) -> InviteRequestsSearchContainerTransition { +private func inviteRequestsSearchContainerPreparedRecentTransition(from fromEntries: [InviteRequestsSearchEntry], to toEntries: [InviteRequestsSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: InviteRequestsSearchContainerInteraction) -> InviteRequestsSearchContainerTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } @@ -534,7 +534,7 @@ public final class InviteRequestsSearchContainerNode: SearchDisplayControllerCon let previousEntries = previousSearchItems.swap(entries) updateActivity(false) let firstTime = previousEntries == nil - let transition = InviteRequestsSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction) + let transition = inviteRequestsSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction) strongSelf.enqueueTransition(transition, firstTime: firstTime) } })) diff --git a/submodules/InviteLinksUI/Sources/ItemListInviteRequestItem.swift b/submodules/InviteLinksUI/Sources/ItemListInviteRequestItem.swift index 305c9c20f5..23adfede45 100644 --- a/submodules/InviteLinksUI/Sources/ItemListInviteRequestItem.swift +++ b/submodules/InviteLinksUI/Sources/ItemListInviteRequestItem.swift @@ -671,7 +671,7 @@ public class ItemListInviteRequestItemNode: ListViewItemNode, ItemListItemNode { strongSelf.topStripeNode.removeFromSupernode() } if strongSelf.bottomStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 0) + strongSelf.addSubnode(strongSelf.bottomStripeNode) } if strongSelf.maskNode.supernode != nil { strongSelf.maskNode.removeFromSupernode() @@ -693,10 +693,10 @@ public class ItemListInviteRequestItemNode: ListViewItemNode, ItemListItemNode { strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) } if strongSelf.bottomStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + strongSelf.addSubnode(strongSelf.bottomStripeNode) } if strongSelf.maskNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + strongSelf.addSubnode(strongSelf.maskNode) } let hasCorners = itemListHasRoundedBlockLayout(params) @@ -764,7 +764,11 @@ public class ItemListInviteRequestItemNode: ListViewItemNode, ItemListItemNode { } else { shimmerNode = ShimmerEffectNode() strongSelf.placeholderNode = shimmerNode - strongSelf.addSubnode(shimmerNode) + if strongSelf.bottomStripeNode.supernode != nil { + strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.bottomStripeNode) + } else { + strongSelf.addSubnode(shimmerNode) + } } shimmerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) if let (rect, size) = strongSelf.absoluteLocation { diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponents.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponents.h index 331d6774e0..15cebcd0c7 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponents.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponents.h @@ -145,6 +145,7 @@ #import #import #import +#import #import #import #import diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGAttachmentCameraView.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGAttachmentCameraView.h index 91ce61daab..408fbf1ccd 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGAttachmentCameraView.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGAttachmentCameraView.h @@ -20,6 +20,8 @@ - (void)resumePreview; - (void)pausePreview; +- (void)removeCorners; + - (void)setZoomedProgress:(CGFloat)progress; - (void)saveStartImage:(void (^)(void))completion; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h index 56cb2a5c4c..1975a39568 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h @@ -51,6 +51,10 @@ - (SSignal *)imageSignalForItem:(NSObject *)item; - (SSignal *)imageSignalForItem:(NSObject *)item withUpdates:(bool)withUpdates; + +- (SSignal *)thumbnailImageSignalForIdentifier:(NSString *)identifier; +- (SSignal *)thumbnailImageSignalForIdentifier:(NSString *)identifier withUpdates:(bool)withUpdates synchronous:(bool)synchronous; + - (SSignal *)thumbnailImageSignalForItem:(NSObject *)item; - (SSignal *)thumbnailImageSignalForItem:(id)item withUpdates:(bool)withUpdates synchronous:(bool)synchronous; - (SSignal *)fastImageSignalForItem:(NSObject *)item withUpdates:(bool)withUpdates; diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGallerySelectedItemsModel.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGallerySelectedItemsModel.h similarity index 100% rename from submodules/LegacyComponents/Sources/TGMediaPickerGallerySelectedItemsModel.h rename to submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGallerySelectedItemsModel.h diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaSelectionContext.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaSelectionContext.h index a982afb9bb..f7d9dd18a4 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaSelectionContext.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaSelectionContext.h @@ -29,6 +29,8 @@ - (bool)toggleItemSelection:(id)item success:(bool *)success; - (bool)toggleItemSelection:(id)item animated:(bool)animated sender:(id)sender success:(bool *)success; +- (void)moveItem:(id)item toIndex:(NSUInteger)index; + - (void)clear; - (bool)isItemSelected:(id)item; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMemoryImageCache.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMemoryImageCache.h index edee1434f6..52f730ad3b 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMemoryImageCache.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMemoryImageCache.h @@ -6,6 +6,9 @@ - (void)setImage:(UIImage *)image forKey:(NSString *)key attributes:(NSDictionary *)attributes; - (UIImage *)imageForKey:(NSString *)key attributes:(__autoreleasing NSDictionary **)attributes; + +- (void)imageForKey:(NSString *)key attributes:(__autoreleasing NSDictionary **)attributes completion:(void (^)(UIImage *))completion; + - (void)setAverageColor:(uint32_t)color forKey:(NSString *)key; - (bool)averageColorForKey:(NSString *)key color:(uint32_t *)color; - (void)clearCache; diff --git a/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.h b/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.h deleted file mode 100644 index 08524a26dc..0000000000 --- a/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.h +++ /dev/null @@ -1,12 +0,0 @@ -#import "TGAttachmentMenuCell.h" -#import "TGAttachmentCameraView.h" - -@interface TGAttachmentCameraCell : TGAttachmentMenuCell - -@property (nonatomic, readonly) TGAttachmentCameraView *cameraView; - -- (void)attachCameraViewIfNeeded:(TGAttachmentCameraView *)cameraView; - -@end - -extern NSString *const TGAttachmentCameraCellIdentifier; diff --git a/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.m b/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.m deleted file mode 100644 index 49a6cc94da..0000000000 --- a/submodules/LegacyComponents/Sources/TGAttachmentCameraCell.m +++ /dev/null @@ -1,18 +0,0 @@ -#import "TGAttachmentCameraCell.h" - -NSString *const TGAttachmentCameraCellIdentifier = @"AttachmentCameraCell"; - -@implementation TGAttachmentCameraCell - -- (void)attachCameraViewIfNeeded:(TGAttachmentCameraView *)cameraView -{ - if (_cameraView == cameraView) - return; - - _cameraView = cameraView; - _cameraView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - _cameraView.frame = self.bounds; - [self addSubview:cameraView]; -} - -@end diff --git a/submodules/LegacyComponents/Sources/TGAttachmentCameraView.m b/submodules/LegacyComponents/Sources/TGAttachmentCameraView.m index 6256244d6d..ffbbf47204 100644 --- a/submodules/LegacyComponents/Sources/TGAttachmentCameraView.m +++ b/submodules/LegacyComponents/Sources/TGAttachmentCameraView.m @@ -1,4 +1,5 @@ #import "TGAttachmentCameraView.h" +#import "TGImageUtils.h" #import "LegacyComponentsInternal.h" @@ -14,6 +15,7 @@ #import + @interface TGAttachmentCameraView () { UIView *_wrapperView; @@ -52,7 +54,7 @@ [_wrapperView addSubview:_previewView]; [camera attachPreviewView:_previewView]; - _iconView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"Editor/Camera"]]; + _iconView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"Chat/Attach Menu/Camera"]]; [self addSubview:_iconView]; [self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapGesture:)]]; @@ -121,6 +123,10 @@ [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidChangeStatusBarOrientationNotification object:nil]; } +- (void)removeCorners { + [_cornersView removeFromSuperview]; +} + - (void)setPallete:(TGMenuSheetPallete *)pallete { _pallete = pallete; @@ -228,6 +234,7 @@ void(^block)(void) = ^ { _wrapperView.transform = CGAffineTransformMakeRotation(-1 * TGRotationForInterfaceOrientation(orientation)); + _wrapperView.frame = self.bounds; }; if (animated) @@ -240,11 +247,13 @@ { [super layoutSubviews]; + _wrapperView.frame = self.bounds; + TGCameraPreviewView *previewView = _previewView; if (previewView.superview == _wrapperView) previewView.frame = self.bounds; - _iconView.frame = CGRectMake((self.frame.size.width - _iconView.frame.size.width) / 2, (self.frame.size.height - _iconView.frame.size.height) / 2, _iconView.frame.size.width, _iconView.frame.size.height); + _iconView.frame = CGRectMake(self.frame.size.width - _iconView.frame.size.width - 3.0, 3.0 - TGScreenPixel, _iconView.frame.size.width, _iconView.frame.size.height); } - (void)saveStartImage:(void (^)(void))completion { diff --git a/submodules/LegacyComponents/Sources/TGMediaAsset.m b/submodules/LegacyComponents/Sources/TGMediaAsset.m index 0b9ae58d4e..83cc49b397 100644 --- a/submodules/LegacyComponents/Sources/TGMediaAsset.m +++ b/submodules/LegacyComponents/Sources/TGMediaAsset.m @@ -147,11 +147,14 @@ if (_cachedDuration == nil) { - return [[TGMediaAssetImageSignals avAssetForVideoAsset:self] map:^id(AVAsset *asset) + NSTimeInterval assetDuration = self.videoDuration; + return [[[TGMediaAssetImageSignals avAssetForVideoAsset:self] map:^id(AVAsset *asset) { NSTimeInterval duration = CMTimeGetSeconds(asset.duration); _cachedDuration = @(duration); return _cachedDuration; + }] catch:^SSignal * _Nonnull(id _Nullable error) { + return [SSignal single:@(assetDuration)]; }]; } diff --git a/submodules/LegacyComponents/Sources/TGMediaEditingContext.m b/submodules/LegacyComponents/Sources/TGMediaEditingContext.m index 344d346a2d..726fe4b36e 100644 --- a/submodules/LegacyComponents/Sources/TGMediaEditingContext.m +++ b/submodules/LegacyComponents/Sources/TGMediaEditingContext.m @@ -240,18 +240,26 @@ - (SSignal *)thumbnailImageSignalForItem:(id)item { - return [self thumbnailImageSignalForItem:item withUpdates:true synchronous:false]; + return [self thumbnailImageSignalForIdentifier:item.uniqueIdentifier]; } - (SSignal *)thumbnailImageSignalForItem:(id)item withUpdates:(bool)withUpdates synchronous:(bool)synchronous { - NSString *itemId = [self _contextualIdForItemId:item.uniqueIdentifier]; + return [self thumbnailImageSignalForIdentifier:item.uniqueIdentifier withUpdates:withUpdates synchronous: synchronous]; +} + +- (SSignal *)thumbnailImageSignalForIdentifier:(NSString *)identifier { + return [self thumbnailImageSignalForIdentifier:identifier withUpdates:true synchronous:false]; +} + +- (SSignal *)thumbnailImageSignalForIdentifier:(NSString *)identifier withUpdates:(bool)withUpdates synchronous:(bool)synchronous { + NSString *itemId = [self _contextualIdForItemId:identifier]; if (itemId == nil) return [SSignal fail:nil]; SSignal *updateSignal = [[_thumbnailImagePipe.signalProducer() filter:^bool(TGMediaImageUpdate *update) { - return [update.item.uniqueIdentifier isEqualToString:item.uniqueIdentifier]; + return [update.item.uniqueIdentifier isEqualToString:identifier]; }] map:^id(TGMediaImageUpdate *update) { return update.representation; @@ -290,25 +298,35 @@ SSignal *signal = [[SSignal alloc] initWithGenerator:^id(SSubscriber *subscriber) { - UIImage *result = [imageCache imageForKey:itemId attributes:NULL]; - if (result == nil) - { - NSData *imageData = [_diskCache getValueForKey:[imageDiskUri dataUsingEncoding:NSUTF8StringEncoding]]; - if (imageData.length > 0) + void (^completionBlock)(UIImage *) = ^(UIImage *result) { + if (result == nil) { - result = [UIImage imageWithData:imageData]; - [imageCache setImage:result forKey:itemId attributes:NULL]; + NSData *imageData = [_diskCache getValueForKey:[imageDiskUri dataUsingEncoding:NSUTF8StringEncoding]]; + if (imageData.length > 0) + { + result = [UIImage imageWithData:imageData]; + [imageCache setImage:result forKey:itemId attributes:NULL]; + } } - } + + if (result != nil) + { + [subscriber putNext:result]; + [subscriber putCompletion]; + } + else + { + [subscriber putError:nil]; + } + }; - if (result != nil) - { - [subscriber putNext:result]; - [subscriber putCompletion]; - } - else - { - [subscriber putError:nil]; + if (synchronous) { + UIImage *result = [imageCache imageForKey:itemId attributes:NULL]; + completionBlock(result); + } else { + [imageCache imageForKey:itemId attributes:NULL completion:^(UIImage *result) { + completionBlock(result); + }]; } return nil; diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItem.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItem.m index bb88e38eef..fd6ca066a1 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItem.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItem.m @@ -159,6 +159,7 @@ backingItem.editingContext = self.editingContext; backingItem.stickersContext = self.stickersContext; backingItem.asFile = self.asFile; + backingItem.immediateThumbnailImage = self.immediateThumbnailImage; _backingItem = backingItem; } return _backingItem; diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItemView.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItemView.m index 59f9091f2e..9378e9fd35 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItemView.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryVideoItemView.m @@ -385,16 +385,27 @@ }]]; } +- (id)item { + if (_fetchItem != nil) { + return _fetchItem; + } else { + return _item; + } +} + - (void)setItem:(TGMediaPickerGalleryVideoItem *)item synchronously:(bool)synchronously { + TGMediaPickerGalleryFetchResultItem *fetchItem; if ([item isKindOfClass:[TGMediaPickerGalleryFetchResultItem class]]) { - _fetchItem = (TGMediaPickerGalleryFetchResultItem *)item; - item = (TGMediaPickerGalleryVideoItem *)[_fetchItem backingItem]; + fetchItem = (TGMediaPickerGalleryFetchResultItem *)item; + item = (TGMediaPickerGalleryVideoItem *)[fetchItem backingItem]; } bool itemChanged = ![item isEqual:self.item]; bool itemIdChanged = item.uniqueId != self.item.uniqueId; + _fetchItem = fetchItem; + [super setItem:item synchronously:synchronously]; if (itemIdChanged) { diff --git a/submodules/LegacyComponents/Sources/TGMediaSelectionContext.m b/submodules/LegacyComponents/Sources/TGMediaSelectionContext.m index 9881c0d674..1c3eae6ed1 100644 --- a/submodules/LegacyComponents/Sources/TGMediaSelectionContext.m +++ b/submodules/LegacyComponents/Sources/TGMediaSelectionContext.m @@ -151,6 +151,15 @@ return newValue; } +- (void)moveItem:(id)item toIndex:(NSUInteger)index { + NSUInteger sourceIndex = [self indexOfItem:item] - 1; + + [_selectedIdentifiers removeObjectAtIndex:sourceIndex]; + [_selectedIdentifiers insertObject:item.uniqueIdentifier atIndex:index - 1]; + + _pipe.sink([TGMediaSelectionChange changeWithItem:item selected:true animated:false sender:nil]); +} + - (SSignal *)itemSelectedSignal:(id)item { return [[self itemInformativeSelectedSignal:item] map:^NSNumber *(TGMediaSelectionChange *change) diff --git a/submodules/LegacyComponents/Sources/TGMemoryImageCache.m b/submodules/LegacyComponents/Sources/TGMemoryImageCache.m index 0074cde5b3..c066446c3f 100644 --- a/submodules/LegacyComponents/Sources/TGMemoryImageCache.m +++ b/submodules/LegacyComponents/Sources/TGMemoryImageCache.m @@ -121,6 +121,28 @@ return result; } +- (void)imageForKey:(NSString *)key attributes:(__autoreleasing NSDictionary **)attributes completion:(void (^)(UIImage *))completion { + if (key == nil) { + completion(nil); + return; + } + + __block id result = nil; + [_queue dispatch:^ + { + TGMemoryImageCacheItem *item = _cache[key]; + if (item != nil) + { + item.timestamp = CFAbsoluteTimeGetCurrent(); + + if (attributes != NULL) + *attributes = item.attributes; + + completion(item.object); + } + }]; +} + - (void)setImage:(UIImage *)image forKey:(NSString *)key attributes:(NSDictionary *)attributes { if (key != nil) diff --git a/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift b/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift index 75e61b79ee..3c8f176ea4 100644 --- a/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift +++ b/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift @@ -104,7 +104,7 @@ public class LegacyAssetPickerContext: AttachmentMediaPickerContext { self.controller?.editingContext.setForcedCaption(caption, skipUpdate: true) } - public func send(silently: Bool) { + public func send(silently: Bool, mode: AttachmentMediaPickerSendMode) { self.controller?.send(silently) } diff --git a/submodules/ListMessageItem/BUILD b/submodules/ListMessageItem/BUILD index c69c49042e..8c143e954d 100644 --- a/submodules/ListMessageItem/BUILD +++ b/submodules/ListMessageItem/BUILD @@ -36,6 +36,7 @@ swift_library( "//submodules/ManagedAnimationNode:ManagedAnimationNode", "//submodules/WallpaperResources:WallpaperResources", "//submodules/Postbox:Postbox", + "//submodules/ShimmerEffect:ShimmerEffect", ], visibility = [ "//visibility:public", diff --git a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift index 85db99f903..b43e226c65 100644 --- a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift +++ b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift @@ -19,13 +19,14 @@ import UniversalMediaPlayer import ContextUI import FileMediaResourceStatus import ManagedAnimationNode +import ShimmerEffect private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:]) -private let redColors: (UInt32, UInt32) = (0xf0625d, 0xde524e) -private let greenColors: (UInt32, UInt32) = (0x72ce76, 0x54b658) -private let blueColors: (UInt32, UInt32) = (0x60b0e8, 0x4597d1) -private let yellowColors: (UInt32, UInt32) = (0xf5c565, 0xe5a64e) +private let redColors: (UInt32, UInt32) = (0xed6b7b, 0xe63f45) +private let greenColors: (UInt32, UInt32) = (0x99de6f, 0x5fb84f) +private let blueColors: (UInt32, UInt32) = (0x72d5fd, 0x2a9ef1) +private let yellowColors: (UInt32, UInt32) = (0xffa24b, 0xed705c) private let extensionColorsMap: [String: (UInt32, UInt32)] = [ "ppt": redColors, @@ -47,14 +48,20 @@ private func generateExtensionImage(colors: (UInt32, UInt32)) -> UIImage? { return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(UIColor(rgb: colors.0).cgColor) - let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 Z ") - + context.saveGState() context.beginPath() let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 ") context.clip() - context.setFillColor(UIColor(rgb: colors.0).withMultipliedBrightnessBy(0.85).cgColor) + let gradientColors = [UIColor(rgb: colors.0).cgColor, UIColor(rgb: colors.1).cgColor] as CFArray + var locations: [CGFloat] = [0.0, 1.0] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + + context.restoreGState() + + context.setFillColor(UIColor(rgb: 0xffffff, alpha: 0.2).cgColor) context.translateBy(x: 40.0 - 14.0, y: 0.0) let _ = try? drawSvgPath(context, path: "M-1,0 L14,0 L14,15 L14,14 C14,12.8954305 13.1045695,12 12,12 L4,12 C2.8954305,12 2,11.1045695 2,10 L2,2 C2,0.8954305 1.1045695,-2.02906125e-16 0,0 L-1,0 L-1,0 Z ") }) @@ -88,6 +95,8 @@ private func extensionImage(fileExtension: String?) -> UIImage? { } } private let extensionFont = Font.with(size: 15.0, design: .round, weight: .bold) +private let mediumExtensionFont = Font.with(size: 14.0, design: .round, weight: .bold) +private let smallExtensionFont = Font.with(size: 12.0, design: .round, weight: .bold) private struct FetchControls { let fetch: () -> Void @@ -158,6 +167,7 @@ public final class ListMessageFileItemNode: ListMessageNode { private var backgroundNode: ASDisplayNode? private let highlightedBackgroundNode: ASDisplayNode public let separatorNode: ASDisplayNode + private let maskNode: ASImageNode private var selectionNode: ItemListSelectableControlNode? @@ -188,6 +198,9 @@ public final class ListMessageFileItemNode: ListMessageNode { private var downloadStatusIconNode: DownloadIconNode? private var linearProgressNode: LinearProgressNode? + private var placeholderNode: ShimmerEffectNode? + private var absoluteLocation: (CGRect, CGSize)? + private var context: AccountContext? private (set) var message: Message? @@ -207,6 +220,9 @@ public final class ListMessageFileItemNode: ListMessageNode { self.separatorNode.displaysAsynchronously = false self.separatorNode.isLayerBacked = true + self.maskNode = ASImageNode() + self.maskNode.isUserInteractionEnabled = false + self.extractedBackgroundImageNode = ASImageNode() self.extractedBackgroundImageNode.displaysAsynchronously = false self.extractedBackgroundImageNode.alpha = 0.0 @@ -276,13 +292,13 @@ public final class ListMessageFileItemNode: ListMessageNode { self.addSubnode(self.separatorNode) self.containerNode.activated = { [weak self] gesture, _ in - guard let strongSelf = self, let item = strongSelf.item else { + guard let strongSelf = self, let item = strongSelf.item, let message = item.message else { return } cancelParentGestures(view: strongSelf.view) - item.interaction.openMessageContextMenu(item.message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture) + item.interaction.openMessageContextMenu(message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture) } self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in @@ -344,6 +360,16 @@ public final class ListMessageFileItemNode: ListMessageNode { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } + + override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + var rect = rect + rect.origin.y += self.insets.top + self.absoluteLocation = (rect, containerSize) + if let shimmerNode = self.placeholderNode { + shimmerNode.updateAbsoluteRect(rect, within: containerSize) + } + } + override public func asyncLayout() -> (_ item: ListMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { let titleNodeMakeLayout = TextNode.asyncLayout(self.titleNode) let textNodeMakeLayout = TextNode.asyncLayout(self.textNode) @@ -361,7 +387,7 @@ public final class ListMessageFileItemNode: ListMessageNode { let selectionNodeLayout = ItemListSelectableControlNode.asyncLayout(self.selectionNode) - return { [weak self] item, params, _, _, dateHeaderAtBottom in + return { [weak self] item, params, mergedTop, mergedBottom, dateHeaderAtBottom in var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme.theme !== item.presentationData.theme.theme { @@ -404,131 +430,163 @@ public final class ListMessageFileItemNode: ListMessageNode { let message = item.message var selectedMedia: Media? - for media in message.media { - if let file = media as? TelegramMediaFile { - selectedMedia = file - - isInstantVideo = file.isInstantVideo - - for attribute in file.attributes { - if case let .Audio(voice, duration, title, performer, _) = attribute { - isAudio = true - isVoice = voice - - titleText = NSAttributedString(string: title ?? (file.fileName ?? "Unknown Track"), font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - - var descriptionString: String - if let performer = performer { - if item.isGlobalSearchResult { - descriptionString = performer + if let message = message { + for media in message.media { + if let file = media as? TelegramMediaFile { + selectedMedia = file + + isInstantVideo = file.isInstantVideo + + for attribute in file.attributes { + if case let .Audio(voice, duration, title, performer, _) = attribute { + isAudio = true + isVoice = voice + + titleText = NSAttributedString(string: title ?? (file.fileName ?? "Unknown Track"), font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + + var descriptionString: String + if let performer = performer { + if item.isGlobalSearchResult { + descriptionString = performer + } else { + descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)" + } + } else if let size = file.size { + descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) } else { - descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)" + descriptionString = "" + } + + if item.isGlobalSearchResult { + let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) + if descriptionString.isEmpty { + descriptionString = authorString + } else { + descriptionString = "\(descriptionString) • \(authorString)" + } + } + + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) + + if !voice { + if file.fileName?.lowercased().hasSuffix(".ogg") == true { + iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: "", performer: "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: "", performer: "", isThumbnail: false))) + } else { + iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: false))) + } + } else { + titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + descriptionText = NSAttributedString(string: message.author.flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) + } + } + } + + if isInstantVideo || isVoice { + var authorName: String + if let author = message.forwardInfo?.author { + if author.id == item.context.account.peerId { + authorName = item.presentationData.strings.DialogList_You + } else { + authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) + } + } else if let signature = message.forwardInfo?.authorSignature { + authorName = signature + } else if let author = message.author { + if author.id == item.context.account.peerId { + authorName = item.presentationData.strings.DialogList_You + } else { + authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } - } else if let size = file.size { - descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) } else { - descriptionString = "" + authorName = " " } if item.isGlobalSearchResult { - let authorString = stringForFullAuthorName(message: EngineMessage(item.message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) + authorName = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) + } + + titleText = NSAttributedString(string: authorName, font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) + var descriptionString: String = "" + if let duration = file.duration { + if item.isGlobalSearchResult || !item.displayFileInfo { + descriptionString = stringForDuration(Int32(duration)) + } else { + descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)" + } + } else { + if !item.isGlobalSearchResult { + descriptionString = dateString + } + } + + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) + iconImage = .roundVideo(file) + } else if !isAudio { + let fileName: String = file.fileName ?? "" + titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + + var fileExtension: String? + if let range = fileName.range(of: ".", options: [.backwards]) { + fileExtension = fileName[range.upperBound...].lowercased() + } + extensionIconImage = extensionImage(fileExtension: fileExtension) + if let fileExtension = fileExtension { + extensionText = NSAttributedString(string: fileExtension, font: fileExtension.count > 3 ? mediumExtensionFont : extensionFont, textColor: UIColor.white) + } + + if let representation = smallestImageRepresentation(file.previewRepresentations) { + iconImage = .imageRepresentation(file, representation) + } + + let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) + + var descriptionString: String = "" + if let size = file.size { + if item.isGlobalSearchResult || !item.displayFileInfo { + descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) + } else { + descriptionString = "\(dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) • \(dateString)" + } + } else { + if !item.isGlobalSearchResult { + descriptionString = "\(dateString)" + } + } + + if item.isGlobalSearchResult { + let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString } else { descriptionString = "\(descriptionString) • \(authorString)" } } - + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) - - if !voice { - if file.fileName?.lowercased().hasSuffix(".ogg") == true { - iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: "", performer: "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: "", performer: "", isThumbnail: false))) - } else { - iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: false))) - } - } else { - titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - descriptionText = NSAttributedString(string: item.message.author.flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) - } - } - } - - if isInstantVideo || isVoice { - var authorName: String - if let author = message.forwardInfo?.author { - if author.id == item.context.account.peerId { - authorName = item.presentationData.strings.DialogList_You - } else { - authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - } - } else if let signature = message.forwardInfo?.authorSignature { - authorName = signature - } else if let author = message.author { - if author.id == item.context.account.peerId { - authorName = item.presentationData.strings.DialogList_You - } else { - authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - } - } else { - authorName = " " } - if item.isGlobalSearchResult { - authorName = stringForFullAuthorName(message: EngineMessage(item.message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) - } + break + } else if let image = media as? TelegramMediaImage { + selectedMedia = image - titleText = NSAttributedString(string: authorName, font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) - var descriptionString: String = "" - if let duration = file.duration { - if item.isGlobalSearchResult { - descriptionString = stringForDuration(Int32(duration)) - } else { - descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)" - } - } else { - if !item.isGlobalSearchResult { - descriptionString = dateString - } - } - - descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) - iconImage = .roundVideo(file) - } else if !isAudio { - let fileName: String = file.fileName ?? "" + //TODO:localize + let fileName: String = "Photo" titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - var fileExtension: String? - if let range = fileName.range(of: ".", options: [.backwards]) { - fileExtension = fileName[range.upperBound...].lowercased() - } - extensionIconImage = extensionImage(fileExtension: fileExtension) - if let fileExtension = fileExtension { - extensionText = NSAttributedString(string: fileExtension, font: extensionFont, textColor: UIColor.white) + if let representation = smallestImageRepresentation(image.representations) { + iconImage = .imageRepresentation(image, representation) } - if let representation = smallestImageRepresentation(file.previewRepresentations) { - iconImage = .imageRepresentation(file, representation) - } - - let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) + let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" - if let size = file.size { - if item.isGlobalSearchResult { - descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) - } else { - descriptionString = "\(dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) • \(dateString)" - } - } else { - if !item.isGlobalSearchResult { - descriptionString = "\(dateString)" - } + if !item.isGlobalSearchResult { + descriptionString = "\(dateString)" } if item.isGlobalSearchResult { - let authorString = stringForFullAuthorName(message: EngineMessage(item.message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) + let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString } else { @@ -538,44 +596,17 @@ public final class ListMessageFileItemNode: ListMessageNode { descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } - - break - } else if let image = media as? TelegramMediaImage { - selectedMedia = image - - //TODO:localize - let fileName: String = "Photo" - titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - - if let representation = smallestImageRepresentation(image.representations) { - iconImage = .imageRepresentation(image, representation) - } - - let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) - - var descriptionString: String = "" - if !item.isGlobalSearchResult { - descriptionString = "\(dateString)" - } - - if item.isGlobalSearchResult { - let authorString = stringForFullAuthorName(message: EngineMessage(item.message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) - if descriptionString.isEmpty { - descriptionString = authorString - } else { - descriptionString = "\(descriptionString) • \(authorString)" - } - } + } - descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) - } - } - - for attribute in message.attributes { - if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { - isRestricted = true - break + for attribute in message.attributes { + if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { + isRestricted = true + break + } } + } else { + titleText = NSAttributedString(string: " ", font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + descriptionText = NSAttributedString(string: " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } var mediaUpdated = false @@ -590,11 +621,11 @@ public final class ListMessageFileItemNode: ListMessageNode { } var statusUpdated = mediaUpdated - if currentMessage?.id != message.id || currentMessage?.flags != message.flags { + if currentMessage?.id != message?.id || currentMessage?.flags != message?.flags { statusUpdated = true } - if let selectedMedia = selectedMedia { + if let message = message, let selectedMedia = selectedMedia { if mediaUpdated { let context = item.context updatedFetchControls = FetchControls(fetch: { [weak self] in @@ -614,7 +645,7 @@ public final class ListMessageFileItemNode: ListMessageNode { }) } - if statusUpdated { + if statusUpdated && item.displayFileInfo { if let file = selectedMedia as? TelegramMediaFile { updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: file, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult) |> mapToSignal { value -> Signal in @@ -655,7 +686,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } var chatListSearchResult: CachedChatListSearchResult? - let messageText = foldLineBreaks(item.message.text) + let messageText = foldLineBreaks(item.message?.text ?? "") if let searchQuery = item.interaction.searchTextHighightState { if let cached = currentSearchResult, cached.matches(text: messageText, searchQuery: searchQuery) { @@ -695,7 +726,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) - let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message.timestamp, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) + let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message?.timestamp ?? 0, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) let dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) let (dateNodeLayout, dateNodeApply) = dateNodeMakeLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 12.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) @@ -706,7 +737,11 @@ public final class ListMessageFileItemNode: ListMessageNode { let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(TextNodeLayoutArguments(attributedString: descriptionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 30.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - let (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + var (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + if extensionTextLayout.truncated, let text = extensionText?.string { + extensionText = NSAttributedString(string: text, font: smallExtensionFont, textColor: .white, paragraphAlignment: .center) + (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + } var iconImageApply: (() -> Void)? if let iconImage = iconImage { @@ -729,24 +764,26 @@ public final class ListMessageFileItemNode: ListMessageNode { } } - if currentIconImage != iconImage { - if let iconImage = iconImage { - switch iconImage { - case let .imageRepresentation(media, representation): - if let file = media as? TelegramMediaFile { - updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, mediaReference: FileMediaReference.message(message: MessageReference(message), media: file).abstract, representation: representation) - } else if let image = media as? TelegramMediaImage { - updateIconImageSignal = mediaGridMessagePhoto(account: item.context.account, photoReference: ImageMediaReference.message(message: MessageReference(message), media: image)) - } else { - updateIconImageSignal = .complete() - } - case let .albumArt(file, albumArt): - updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, engine: item.context.engine, fileReference: .message(message: MessageReference(message), media: file), albumArt: albumArt, thumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3), emptyColor: item.presentationData.theme.theme.list.itemAccentColor) - case let .roundVideo(file): - updateIconImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, videoReference: FileMediaReference.message(message: MessageReference(message), media: file), autoFetchFullSizeThumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3)) + if let message = message { + if currentIconImage != iconImage { + if let iconImage = iconImage { + switch iconImage { + case let .imageRepresentation(media, representation): + if let file = media as? TelegramMediaFile { + updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, mediaReference: FileMediaReference.message(message: MessageReference(message), media: file).abstract, representation: representation) + } else if let image = media as? TelegramMediaImage { + updateIconImageSignal = mediaGridMessagePhoto(account: item.context.account, photoReference: ImageMediaReference.message(message: MessageReference(message), media: image)) + } else { + updateIconImageSignal = .complete() + } + case let .albumArt(file, albumArt): + updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, engine: item.context.engine, fileReference: .message(message: MessageReference(message), media: file), albumArt: albumArt, thumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3), emptyColor: item.presentationData.theme.theme.list.itemAccentColor) + case let .roundVideo(file): + updateIconImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, videoReference: FileMediaReference.message(message: MessageReference(message), media: file), autoFetchFullSizeThumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3)) + } + } else { + updateIconImageSignal = .complete() } - } else { - updateIconImageSignal = .complete() } } @@ -754,6 +791,9 @@ public final class ListMessageFileItemNode: ListMessageNode { if dateHeaderAtBottom, let header = item.header { insets.top += header.height } + if !mergedBottom, case .blocks = item.style { + insets.bottom += 35.0 + } let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 8.0 * 2.0 + titleNodeLayout.size.height + 3.0 + descriptionNodeLayout.size.height + (textNodeLayout.size.height > 0.0 ? textNodeLayout.size.height + 3.0 : 0.0)), insets: insets) @@ -764,7 +804,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } let transition: ContainedViewLayoutTransition - if animation.isAnimated { + if animation.isAnimated && currentItem?.message != nil { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate @@ -789,8 +829,9 @@ public final class ListMessageFileItemNode: ListMessageNode { strongSelf.extractedBackgroundImageNode.frame = nonExtractedRect } strongSelf.contextSourceNode.contentRect = extractedRect + strongSelf.containerNode.isGestureEnabled = item.displayFileInfo - strongSelf.currentIsRestricted = isRestricted + strongSelf.currentIsRestricted = isRestricted || item.message == nil strongSelf.currentMedia = selectedMedia strongSelf.message = message strongSelf.context = item.context @@ -842,10 +883,40 @@ public final class ListMessageFileItemNode: ListMessageNode { } transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset + leftOffset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: params.width - leftInset - leftOffset, height: UIScreenPixel))) - strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel - nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel)) + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel - nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.contentSize.height + UIScreenPixel)) if let backgroundNode = strongSelf.backgroundNode { - backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height)) + backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.contentSize.height)) + } + + switch item.style { + case .plain: + if strongSelf.maskNode.supernode != nil { + strongSelf.maskNode.removeFromSupernode() + } + case .blocks: + if strongSelf.maskNode.supernode == nil { + strongSelf.addSubnode(strongSelf.maskNode) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + + if !mergedTop { + hasTopCorners = true + } + if !mergedBottom { + hasBottomCorners = true + strongSelf.separatorNode.isHidden = hasCorners + } else { + strongSelf.separatorNode.isHidden = false + } + + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil + if let backgroundNode = strongSelf.backgroundNode { + strongSelf.maskNode.frame = backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) + } } transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: 9.0), size: titleNodeLayout.size)) @@ -876,17 +947,11 @@ public final class ListMessageFileItemNode: ListMessageNode { transition.updateFrame(node: strongSelf.dateNode, frame: CGRect(origin: CGPoint(x: params.width - rightInset - dateNodeLayout.size.width, y: 11.0), size: dateNodeLayout.size)) strongSelf.dateNode.isHidden = !item.isGlobalSearchResult - let iconFrame: CGRect - if isAudio { - let iconSize = CGSize(width: 40.0, height: 40.0) - iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) - } else { - let iconSize = CGSize(width: 40.0, height: 40.0) - iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) - } + let iconSize = CGSize(width: 40.0, height: 40.0) + let iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) transition.updateFrame(node: strongSelf.extensionIconNode, frame: iconFrame) strongSelf.extensionIconNode.image = extensionIconImage - transition.updateFrame(node: strongSelf.extensionIconText, frame: CGRect(origin: CGPoint(x: iconFrame.minX + floor((iconFrame.width - extensionTextLayout.size.width) / 2.0), y: iconFrame.minY + 2.0 + floor((iconFrame.height - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size)) + transition.updateFrame(node: strongSelf.extensionIconText, frame: CGRect(origin: CGPoint(x: iconFrame.minX + floor((iconFrame.width - extensionTextLayout.size.width) / 2.0), y: iconFrame.minY + 7.0 + floor((iconFrame.height - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size)) transition.updateFrame(node: strongSelf.iconStatusNode, frame: iconFrame) @@ -954,6 +1019,44 @@ public final class ListMessageFileItemNode: ListMessageNode { } strongSelf.updateStatus(transition: transition) + + if item.message == nil { + let shimmerNode: ShimmerEffectNode + if let current = strongSelf.placeholderNode { + shimmerNode = current + } else { + shimmerNode = ShimmerEffectNode() + strongSelf.placeholderNode = shimmerNode + if strongSelf.separatorNode.supernode != nil { + strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.separatorNode) + } else { + strongSelf.addSubnode(shimmerNode) + } + } + shimmerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) + if let (rect, size) = strongSelf.absoluteLocation { + shimmerNode.updateAbsoluteRect(rect, within: size) + } + + var shapes: [ShimmerEffectNode.Shape] = [] + + let titleLineWidth: CGFloat = 120.0 + let descriptionLineWidth: CGFloat = 60.0 + let lineDiameter: CGFloat = 8.0 + + let titleFrame = strongSelf.titleNode.frame + shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floor((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter)) + + let descriptionFrame = strongSelf.descriptionNode.frame + shapes.append(.roundedRectLine(startPoint: CGPoint(x: descriptionFrame.minX, y: descriptionFrame.minY + floor((descriptionFrame.height - lineDiameter) / 2.0)), width: descriptionLineWidth, diameter: lineDiameter)) + + shapes.append(.roundedRect(rect: iconFrame, cornerRadius: 6.0)) + + shimmerNode.update(backgroundColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor, foregroundColor: item.presentationData.theme.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: nodeLayout.contentSize) + } else if let shimmerNode = strongSelf.placeholderNode { + strongSelf.placeholderNode = nil + shimmerNode.removeFromSupernode() + } } }) } @@ -1023,7 +1126,11 @@ public final class ListMessageFileItemNode: ListMessageNode { if highlighted, let item = self.item, case .none = item.selection { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { - self.insertSubnode(self.highlightedBackgroundNode, at: 0) + if let backgroundNode = self.backgroundNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: backgroundNode) + } else { + self.insertSubnode(self.highlightedBackgroundNode, at: 0) + } } } else { if self.highlightedBackgroundNode.supernode != nil { @@ -1044,7 +1151,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } override public func transitionNode(id: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - if let item = self.item, item.message.id == id, self.iconImageNode.supernode != nil { + if let item = self.item, item.message?.id == id, self.iconImageNode.supernode != nil { let iconImageNode = self.iconImageNode return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in return (iconImageNode?.view.snapshotContentTree(unhide: true), nil) @@ -1054,7 +1161,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } override public func updateHiddenMedia() { - if let interaction = self.interaction, let item = self.item, interaction.getHiddenMedia()[item.message.id] != nil { + if let interaction = self.interaction, let item = self.item, let message = item.message, interaction.getHiddenMedia()[message.id] != nil { self.iconImageNode.isHidden = true } else { self.iconImageNode.isHidden = false @@ -1202,8 +1309,8 @@ public final class ListMessageFileItemNode: ListMessageNode { fetch() } case .Local: - if let item = self.item, let interaction = self.interaction { - let _ = interaction.openMessage(item.message, .default) + if let item = self.item, let interaction = self.interaction, let message = item.message { + let _ = interaction.openMessage(message, .default) } } case .playbackStatus: diff --git a/submodules/ListMessageItem/Sources/ListMessageItem.swift b/submodules/ListMessageItem/Sources/ListMessageItem.swift index e62933525c..bfb9417677 100644 --- a/submodules/ListMessageItem/Sources/ListMessageItem.swift +++ b/submodules/ListMessageItem/Sources/ListMessageItem.swift @@ -8,6 +8,7 @@ import Postbox import TelegramPresentationData import AccountContext import TelegramUIPreferences +import ItemListUI public final class ListMessageItemInteraction { public let openMessage: (Message, ChatControllerInteractionOpenMessageMode) -> Bool @@ -47,17 +48,19 @@ public final class ListMessageItem: ListViewItem { let context: AccountContext let chatLocation: ChatLocation let interaction: ListMessageItemInteraction - let message: Message + let message: Message? public let selection: ChatHistoryMessageSelection let hintIsLink: Bool let isGlobalSearchResult: Bool + let displayFileInfo: Bool let displayBackground: Bool + let style: ItemListStyle let header: ListViewItemHeader? public let selectable: Bool = true - public init(presentationData: ChatPresentationData, context: AccountContext, chatLocation: ChatLocation, interaction: ListMessageItemInteraction, message: Message, selection: ChatHistoryMessageSelection, displayHeader: Bool, customHeader: ListViewItemHeader? = nil, hintIsLink: Bool = false, isGlobalSearchResult: Bool = false, displayBackground: Bool = false) { + public init(presentationData: ChatPresentationData, context: AccountContext, chatLocation: ChatLocation, interaction: ListMessageItemInteraction, message: Message?, selection: ChatHistoryMessageSelection, displayHeader: Bool, customHeader: ListViewItemHeader? = nil, hintIsLink: Bool = false, isGlobalSearchResult: Bool = false, displayFileInfo: Bool = true, displayBackground: Bool = false, style: ItemListStyle = .plain) { self.presentationData = presentationData self.context = context self.chatLocation = chatLocation @@ -65,7 +68,7 @@ public final class ListMessageItem: ListViewItem { self.message = message if let header = customHeader { self.header = header - } else if displayHeader { + } else if displayHeader, let message = message { self.header = ListMessageDateHeader(timestamp: message.timestamp, theme: presentationData.theme.theme, strings: presentationData.strings, fontSize: presentationData.fontSize) } else { self.header = nil @@ -73,21 +76,27 @@ public final class ListMessageItem: ListViewItem { self.selection = selection self.hintIsLink = hintIsLink self.isGlobalSearchResult = isGlobalSearchResult + self.displayFileInfo = displayFileInfo self.displayBackground = displayBackground + self.style = style } public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { var viewClassName: AnyClass = ListMessageSnippetItemNode.self if !self.hintIsLink { - for media in self.message.media { - if let _ = media as? TelegramMediaFile { - viewClassName = ListMessageFileItemNode.self - break - } else if let _ = media as? TelegramMediaImage { - viewClassName = ListMessageFileItemNode.self - break + if let message = self.message { + for media in message.media { + if let _ = media as? TelegramMediaFile { + viewClassName = ListMessageFileItemNode.self + break + } else if let _ = media as? TelegramMediaImage { + viewClassName = ListMessageFileItemNode.self + break + } } + } else { + viewClassName = ListMessageFileItemNode.self } } @@ -97,7 +106,7 @@ public final class ListMessageItem: ListViewItem { node.setupItem(self) let nodeLayout = node.asyncLayout() - let (top, bottom, dateAtBottom) = (previousItem != nil, nextItem != nil, self.getDateAtBottom(top: previousItem, bottom: nextItem)) + let (top, bottom, dateAtBottom) = (previousItem != nil && !(previousItem is ItemListItem), nextItem != nil, self.getDateAtBottom(top: previousItem, bottom: nextItem)) let (layout, apply) = nodeLayout(self, params, top, bottom, dateAtBottom) node.updateSelectionState(animated: false) @@ -130,7 +139,7 @@ public final class ListMessageItem: ListViewItem { let nodeLayout = nodeValue.asyncLayout() async { - let (top, bottom, dateAtBottom) = (previousItem != nil, nextItem != nil, self.getDateAtBottom(top: previousItem, bottom: nextItem)) + let (top, bottom, dateAtBottom) = (previousItem != nil && !(previousItem is ItemListItem), nextItem != nil, self.getDateAtBottom(top: previousItem, bottom: nextItem)) let (layout, apply) = nodeLayout(self, params, top, bottom, dateAtBottom) Queue.mainQueue().async { @@ -148,17 +157,25 @@ public final class ListMessageItem: ListViewItem { public func selected(listView: ListView) { listView.clearHighlightAnimated(true) + guard let message = self.message else { + return + } + if case let .selectable(selected) = self.selection { - self.interaction.toggleMessagesSelection([self.message.id], !selected) + self.interaction.toggleMessagesSelection([message.id], !selected) } else { - listView.forEachItemNode { itemNode in - if let itemNode = itemNode as? ListMessageFileItemNode { - if let messageId = itemNode.item?.message.id, messageId == self.message.id { - itemNode.activateMedia() - } - } else if let itemNode = itemNode as? ListMessageSnippetItemNode { - if let messageId = itemNode.item?.message.id, messageId == self.message.id { - itemNode.activateMedia() + if !self.displayFileInfo { + let _ = self.interaction.openMessage(message, .default) + } else { + listView.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListMessageFileItemNode { + if let messageId = itemNode.item?.message?.id, messageId == message.id { + itemNode.activateMedia() + } + } else if let itemNode = itemNode as? ListMessageSnippetItemNode { + if let messageId = itemNode.item?.message?.id, messageId == message.id { + itemNode.activateMedia() + } } } } @@ -179,6 +196,10 @@ public final class ListMessageItem: ListViewItem { } public var description: String { - return "(ListMessageItem id: \(self.message.id), text: \"\(self.message.text)\")" + if let message = self.message { + return "(ListMessageItem id: \(message.id), text: \"\(message.text)\")" + } else { + return "(ListMessageItem empty)" + } } } diff --git a/submodules/ListMessageItem/Sources/ListMessageSnippetItemNode.swift b/submodules/ListMessageItem/Sources/ListMessageSnippetItemNode.swift index 90bda009a6..98b70499f8 100644 --- a/submodules/ListMessageItem/Sources/ListMessageSnippetItemNode.swift +++ b/submodules/ListMessageItem/Sources/ListMessageSnippetItemNode.swift @@ -122,11 +122,11 @@ public final class ListMessageSnippetItemNode: ListMessageNode { self.offsetContainerNode.addSubnode(self.authorNode) self.containerNode.activated = { [weak self] gesture, _ in - guard let strongSelf = self, let item = strongSelf.item else { + guard let strongSelf = self, let item = strongSelf.item, let message = item.message else { return } - item.interaction.openMessageContextMenu(item.message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture) + item.interaction.openMessageContextMenu(message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture) } self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in @@ -259,209 +259,212 @@ public final class ListMessageSnippetItemNode: ListMessageNode { var selectedMedia: TelegramMediaWebpage? var processed = false - for media in item.message.media { - if let webpage = media as? TelegramMediaWebpage { - selectedMedia = webpage - - if case let .Loaded(content) = webpage.content { - if content.instantPage != nil && instantPageType(of: content) != .album { - isInstantView = true - } - - let (parsedUrl, _) = parseUrl(url: content.url, wasConcealed: false) - - primaryUrl = parsedUrl - - processed = true - var hostName: String = "" - if let url = URL(string: parsedUrl), let host = url.host, !host.isEmpty { - hostName = host - iconText = NSAttributedString(string: host[.. nsString.length { - range.location = max(0, nsString.length - range.length) - range.length = nsString.length - range.location + if let message = item.message { + for media in message.media { + if let webpage = media as? TelegramMediaWebpage { + selectedMedia = webpage + + if case let .Loaded(content) = webpage.content { + if content.instantPage != nil && instantPageType(of: content) != .album { + isInstantView = true + } + + let (parsedUrl, _) = parseUrl(url: content.url, wasConcealed: false) + + primaryUrl = parsedUrl + + processed = true + var hostName: String = "" + if let url = URL(string: parsedUrl), let host = url.host, !host.isEmpty { + hostName = host + iconText = NSAttributedString(string: host[.. nsString.length { + range.location = max(0, nsString.length - range.length) + range.length = nsString.length - range.location + } + let tempUrlString = nsString.substring(with: range) + + var (urlString, concealed) = parseUrl(url: tempUrlString, wasConcealed: false) + + let rawUrlString = urlString + var parsedUrl = URL(string: urlString) + if (parsedUrl == nil || parsedUrl!.host == nil || parsedUrl!.host!.isEmpty) && !urlString.contains("@") { + urlString = "http://" + urlString + parsedUrl = URL(string: urlString) + } + let host: String? = concealed ? urlString : parsedUrl?.host + if let url = parsedUrl, let host = host { + primaryUrl = urlString + if url.path.hasPrefix("/addstickers/") { + title = NSAttributedString(string: urlString, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + + iconText = NSAttributedString(string: "S", font: iconFont, textColor: UIColor.white) + } else { + iconText = NSAttributedString(string: host[.. nsString.length { - range.location = max(0, nsString.length - range.length) - range.length = nsString.length - range.location - } - let tempTitleString = (nsString.substring(with: range) as String).trimmingCharacters(in: .whitespacesAndNewlines) - - var (urlString, concealed) = parseUrl(url: url, wasConcealed: false) - let rawUrlString = urlString - var parsedUrl = URL(string: urlString) - if (parsedUrl == nil || parsedUrl!.host == nil || parsedUrl!.host!.isEmpty) && !urlString.contains("@") { - urlString = "http://" + urlString - parsedUrl = URL(string: urlString) - } - let host: String? = concealed ? urlString : parsedUrl?.host - if let url = parsedUrl, let host = host { - primaryUrl = urlString - title = NSAttributedString(string: tempTitleString as String, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) - if url.path.hasPrefix("/addstickers/") { - iconText = NSAttributedString(string: "S", font: iconFont, textColor: UIColor.white) - } else { - iconText = NSAttributedString(string: host[.. nsString.length { + range.location = max(0, nsString.length - range.length) + range.length = nsString.length - range.location } - - let urlAttributedString = NSMutableAttributedString() - urlAttributedString.append(NSAttributedString(string: urlString.replacingOccurrences(of: "https://", with: ""), font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemAccentColor)) - if item.presentationData.theme.theme.list.itemAccentColor.isEqual(item.presentationData.theme.theme.list.itemPrimaryTextColor) { - urlAttributedString.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: NSMakeRange(0, urlAttributedString.length)) + let tempTitleString = (nsString.substring(with: range) as String).trimmingCharacters(in: .whitespacesAndNewlines) + + var (urlString, concealed) = parseUrl(url: url, wasConcealed: false) + let rawUrlString = urlString + var parsedUrl = URL(string: urlString) + if (parsedUrl == nil || parsedUrl!.host == nil || parsedUrl!.host!.isEmpty) && !urlString.contains("@") { + urlString = "http://" + urlString + parsedUrl = URL(string: urlString) } - urlAttributedString.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.URL), value: urlString, range: NSMakeRange(0, urlAttributedString.length)) - linkText = urlAttributedString - - descriptionText = mutableDescriptionText - } - break loop - default: - break + let host: String? = concealed ? urlString : parsedUrl?.host + if let url = parsedUrl, let host = host { + primaryUrl = urlString + title = NSAttributedString(string: tempTitleString as String, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) + if url.path.hasPrefix("/addstickers/") { + iconText = NSAttributedString(string: "S", font: iconFont, textColor: UIColor.white) + } else { + iconText = NSAttributedString(string: host[..= 0 && stringRange.location + stringRange.length <= text.length { @@ -496,7 +499,7 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) - let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message.timestamp, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) + let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message?.timestamp ?? 0, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) let dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) let (dateNodeLayout, dateNodeApply) = dateNodeMakeLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 12.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) @@ -536,8 +539,8 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } var authorString = "" - if item.isGlobalSearchResult { - authorString = stringForFullAuthorName(message: EngineMessage(item.message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) + if item.isGlobalSearchResult, let message = item.message { + authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) } let authorText = NSAttributedString(string: authorString, font: authorFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) @@ -716,7 +719,7 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } override public func transitionNode(id: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - if let item = self.item, item.message.id == id, self.iconImageNode.supernode != nil { + if let item = self.item, item.message?.id == id, self.iconImageNode.supernode != nil { let iconImageNode = self.iconImageNode return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in return (iconImageNode?.view.snapshotContentTree(unhide: true), nil) @@ -726,7 +729,7 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } override public func updateHiddenMedia() { - if let interaction = self.interaction, let item = self.item, interaction.getHiddenMedia()[item.message.id] != nil { + if let interaction = self.interaction, let item = self.item, let message = item.message, interaction.getHiddenMedia()[message.id] != nil { self.iconImageNode.isHidden = true } else { self.iconImageNode.isHidden = false @@ -737,18 +740,18 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } func activateMedia() { - if let item = self.item, let currentPrimaryUrl = self.currentPrimaryUrl { + if let item = self.item, let message = item.message, let currentPrimaryUrl = self.currentPrimaryUrl { if let webpage = self.currentMedia as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { if content.instantPage != nil { if websiteType(of: content.websiteName) == .instagram { - if !item.interaction.openMessage(item.message, .default) { - item.interaction.openInstantPage(item.message, nil) + if !item.interaction.openMessage(message, .default) { + item.interaction.openInstantPage(message, nil) } } else { - item.interaction.openInstantPage(item.message, nil) + item.interaction.openInstantPage(message, nil) } } else { - if isTelegramMeLink(content.url) || !item.interaction.openMessage(item.message, .link) { + if isTelegramMeLink(content.url) || !item.interaction.openMessage(message, .link) { item.interaction.openUrl(currentPrimaryUrl, false, false, nil) } } @@ -801,11 +804,11 @@ public final class ListMessageSnippetItemNode: ListMessageNode { if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap, .longTap: - if let item = self.item, let url = self.urlAtPoint(location) { + if let item = self.item, let message = item.message, let url = self.urlAtPoint(location) { if case .longTap = gesture { - item.interaction.longTap(ChatControllerInteractionLongTapAction.url(url), item.message) + item.interaction.longTap(ChatControllerInteractionLongTapAction.url(url), message) } else if url == self.currentPrimaryUrl { - if !item.interaction.openMessage(item.message, .default) { + if !item.interaction.openMessage(message, .default) { item.interaction.openUrl(url, false, false, nil) } } else { @@ -824,7 +827,7 @@ public final class ListMessageSnippetItemNode: ListMessageNode { } private func updateTouchesAtPoint(_ point: CGPoint?) { - if let item = self.item { + if let item = self.item, let message = item.message { var rects: [CGRect]? if let point = point { let textNodeFrame = self.linkNode.frame @@ -846,7 +849,7 @@ public final class ListMessageSnippetItemNode: ListMessageNode { if let current = self.linkHighlightingNode { linkHighlightingNode = current } else { - linkHighlightingNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor) + linkHighlightingNode = LinkHighlightingNode(color: message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor) self.linkHighlightingNode = linkHighlightingNode self.offsetContainerNode.insertSubnode(linkHighlightingNode, belowSubnode: self.linkNode) } diff --git a/submodules/MediaPickerUI/BUILD b/submodules/MediaPickerUI/BUILD new file mode 100644 index 0000000000..e4f652c6e6 --- /dev/null +++ b/submodules/MediaPickerUI/BUILD @@ -0,0 +1,43 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MediaPickerUI", + module_name = "MediaPickerUI", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/TelegramUIPreferences:TelegramUIPreferences", + "//submodules/AccountContext:AccountContext", + "//submodules/TelegramStringFormatting:TelegramStringFormatting", + "//submodules/AppBundle:AppBundle", + "//submodules/CheckNode:CheckNode", + "//submodules/MergeLists:MergeLists", + "//submodules/LegacyComponents:LegacyComponents", + "//submodules/LegacyUI:LegacyUI", + "//submodules/LegacyMediaPickerUI:LegacyMediaPickerUI", + "//submodules/AttachmentUI:AttachmentUI", + "//submodules/SegmentedControlNode:SegmentedControlNode", + "//submodules/ManagedAnimationNode:ManagedAnimationNode", + "//submodules/PhotoResources:PhotoResources", + "//submodules/ContextUI:ContextUI", + "//submodules/MosaicLayout:MosaicLayout", + "//submodules/AnimatedStickerNode:AnimatedStickerNode", + "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", + "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", + "//submodules/PresentationDataUtils:PresentationDataUtils", + "//submodules/WallpaperBackgroundNode:WallpaperBackgroundNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/MediaPickerUI/Sources/FetchAssets.swift b/submodules/MediaPickerUI/Sources/FetchAssets.swift new file mode 100644 index 0000000000..affa8dbc4c --- /dev/null +++ b/submodules/MediaPickerUI/Sources/FetchAssets.swift @@ -0,0 +1,61 @@ +import Foundation +import UIKit +import Photos +import SwiftSignalKit + +private let imageManager = PHCachingImageManager() + +func assetImage(fetchResult: PHFetchResult, index: Int, targetSize: CGSize, exact: Bool) -> Signal { + let asset = fetchResult[index] + return assetImage(asset: asset, targetSize: targetSize, exact: exact) +} + +func assetImage(asset: PHAsset, targetSize: CGSize, exact: Bool) -> Signal { + return Signal { subscriber in + let options = PHImageRequestOptions() + if exact { + options.resizeMode = .exact + } + let token = imageManager.requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFill, options: options) { (image, info) in + var degraded = false + + if let info = info { + if let cancelled = info[PHImageCancelledKey] as? Bool, cancelled { + return + } + if let degradedValue = info[PHImageResultIsDegradedKey] as? Bool, degradedValue { + degraded = true + } + } + + if let image = image { + subscriber.putNext(image) + if !degraded { + subscriber.putCompletion() + } + } + } + return ActionDisposable { + imageManager.cancelImageRequest(token) + } + } +} + +func assetVideo(fetchResult: PHFetchResult, index: Int) -> Signal { + return Signal { subscriber in + let asset = fetchResult[index] + + let options = PHVideoRequestOptions() + + let token = imageManager.requestAVAsset(forVideo: asset, options: options) { (avAsset, _, info) in + if let avAsset = avAsset { + subscriber.putNext(avAsset) + subscriber.putCompletion() + } + } + + return ActionDisposable { + imageManager.cancelImageRequest(token) + } + } +} diff --git a/submodules/MediaPickerUI/Sources/LegacyMediaPickerGallery.swift b/submodules/MediaPickerUI/Sources/LegacyMediaPickerGallery.swift new file mode 100644 index 0000000000..85e8baf38b --- /dev/null +++ b/submodules/MediaPickerUI/Sources/LegacyMediaPickerGallery.swift @@ -0,0 +1,284 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import Postbox +import SSignalKit +import TelegramPresentationData +import AccountContext +import LegacyComponents +import LegacyUI +import LegacyMediaPickerUI +import Photos + +private func galleryFetchResultItems(fetchResult: PHFetchResult, index: Int, selectionContext: TGMediaSelectionContext?, editingContext: TGMediaEditingContext, stickersContext: TGPhotoPaintStickersContext, immediateThumbnail: UIImage?) -> ([TGModernGalleryItem], TGModernGalleryItem?) { + var focusItem: TGModernGalleryItem? + var galleryItems: [TGModernGalleryItem] = [] + + let legacyFetchResult = TGMediaAssetFetchResult(phFetchResult: fetchResult as? PHFetchResult, reversed: true) + + for i in 0 ..< fetchResult.count { + if let galleryItem = TGMediaPickerGalleryFetchResultItem(fetchResult: legacyFetchResult, index: UInt(i)) { + galleryItem.selectionContext = selectionContext + galleryItem.editingContext = editingContext + galleryItem.stickersContext = stickersContext + galleryItems.append(galleryItem) + + if i == index { + galleryItem.immediateThumbnailImage = immediateThumbnail + focusItem = galleryItem + } + } + } + return (galleryItems, focusItem) +} + +private func gallerySelectionItems(item: TGMediaSelectableItem, selectionContext: TGMediaSelectionContext?, editingContext: TGMediaEditingContext, stickersContext: TGPhotoPaintStickersContext, immediateThumbnail: UIImage?) -> ([TGModernGalleryItem], TGModernGalleryItem?) { + var focusItem: TGModernGalleryItem? + var galleryItems: [TGModernGalleryItem] = [] + + if let selectionContext = selectionContext { + for case let selectedItem as TGMediaSelectableItem in selectionContext.selectedItems() { + if let asset = selectedItem as? TGMediaAsset { + let galleryItem: (TGModernGallerySelectableItem & TGModernGalleryEditableItem) + switch asset.type { + case TGMediaAssetVideoType: + galleryItem = TGMediaPickerGalleryVideoItem(asset: asset) + case TGMediaAssetGifType: + let convertedAsset = TGCameraCapturedVideo(asset: asset, livePhoto: false) + galleryItem = TGMediaPickerGalleryVideoItem(asset: convertedAsset) + default: + galleryItem = TGMediaPickerGalleryPhotoItem(asset: asset) + } + galleryItem.selectionContext = selectionContext + galleryItem.editingContext = editingContext + galleryItem.stickersContext = stickersContext + galleryItems.append(galleryItem) + + if selectedItem.uniqueIdentifier == item.uniqueIdentifier { + if let galleryItem = galleryItem as? TGMediaPickerGalleryItem { + galleryItem.immediateThumbnailImage = immediateThumbnail + } + focusItem = galleryItem + } + } + } + } + + return (galleryItems, focusItem) +} + +enum LegacyMediaPickerGallerySource { + case fetchResult(fetchResult: PHFetchResult, index: Int) + case selection(item: TGMediaSelectableItem) +} + +func presentLegacyMediaPickerGallery(context: AccountContext, peer: EnginePeer?, chatLocation: ChatLocation?, presentationData: PresentationData, source: LegacyMediaPickerGallerySource, immediateThumbnail: UIImage?, selectionContext: TGMediaSelectionContext?, editingContext: TGMediaEditingContext, hasSilentPosting: Bool, hasSchedule: Bool, hasTimer: Bool, updateHiddenMedia: @escaping (String?) -> Void, initialLayout: ContainerViewLayout?, transitionHostView: @escaping () -> UIView?, transitionView: @escaping (String) -> UIView?, completed: @escaping (TGMediaSelectableItem & TGMediaEditableItem, Bool, Int32?) -> Void, presentStickers: ((@escaping (TelegramMediaFile, Bool, UIView, CGRect) -> Void) -> TGPhotoPaintStickersScreen?)?, presentSchedulePicker: @escaping (Bool, @escaping (Int32) -> Void) -> Void, presentTimerPicker: @escaping (@escaping (Int32) -> Void) -> Void, getCaptionPanelView: @escaping () -> TGCaptionPanelView?, present: @escaping (ViewController, Any?) -> Void) { + let reminder = peer?.id == context.account.peerId + + let legacyController = LegacyController(presentation: .custom, theme: presentationData.theme, initialLayout: nil) + legacyController.statusBar.statusBarStyle = presentationData.theme.rootController.statusBarStyle.style + + let paintStickersContext = LegacyPaintStickersContext(context: context) + paintStickersContext.captionPanelView = { + return getCaptionPanelView() + } + paintStickersContext.presentStickersController = { completion in + if let presentStickers = presentStickers { + return presentStickers({ file, animated, view, rect in + let coder = PostboxEncoder() + coder.encodeRootObject(file) + completion?(coder.makeData(), animated, view, rect) + }) + } else { + return nil + } + } + + let controller = TGModernGalleryController(context: legacyController.context)! + controller.asyncTransitionIn = true + legacyController.bind(controller: controller) + + let (items, focusItem): ([TGModernGalleryItem], TGModernGalleryItem?) + switch source { + case let .fetchResult(fetchResult, index): + (items, focusItem) = galleryFetchResultItems(fetchResult: fetchResult, index: index, selectionContext: selectionContext, editingContext: editingContext, stickersContext: paintStickersContext, immediateThumbnail: immediateThumbnail) + case let .selection(item): + (items, focusItem) = gallerySelectionItems(item: item, selectionContext: selectionContext, editingContext: editingContext, stickersContext: paintStickersContext, immediateThumbnail: immediateThumbnail) + } + + let model = TGMediaPickerGalleryModel(context: legacyController.context, items: items, focus: focusItem, selectionContext: selectionContext, editingContext: editingContext, hasCaptions: true, allowCaptionEntities: true, hasTimer: hasTimer, onlyCrop: false, inhibitDocumentCaptions: false, hasSelectionPanel: true, hasCamera: false, recipientName: peer?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder))! + model.stickersContext = paintStickersContext + controller.model = model + model.controller = controller + model.willFinishEditingItem = { item, adjustments, representation, hasChanges in + if hasChanges { + editingContext.setAdjustments(adjustments, for: item) + editingContext.setTemporaryRep(representation, for: item) + } + + if let selectionContext = selectionContext, adjustments != nil, let item = item as? TGMediaSelectableItem { + selectionContext.setItem(item, selected: true) + } + } + model.didFinishEditingItem = { item, adjustments, result, thumbnail in + editingContext.setImage(result, thumbnailImage: thumbnail, for: item, synchronous: false) + } + model.saveItemCaption = { item, caption in + editingContext.setCaption(caption, for: item) + if let selectionContext = selectionContext, let caption = caption, caption.length > 0, let item = item as? TGMediaSelectableItem { + selectionContext.setItem(item, selected: true) + } + } + model.didFinishRenderingFullSizeImage = { item, result in + editingContext.setFullSizeImage(result, for: item) + } + model.requestAdjustments = { item in + return editingContext.adjustments(for: item) + } + if let selectionContext = selectionContext { + model.interfaceView.updateSelectionInterface(selectionContext.count(), counterVisible: selectionContext.count() > 0, animated: false) + } + controller.transitionHost = { + return transitionHostView() + } + var transitionedIn = false + controller.itemFocused = { item in + if let item = item as? TGMediaPickerGalleryItem, transitionedIn { + updateHiddenMedia(item.asset.uniqueIdentifier) + } + } + controller.beginTransitionIn = { item, itemView in + if let item = item as? TGMediaPickerGalleryItem { + if let itemView = itemView as? TGMediaPickerGalleryVideoItemView { + itemView.setIsCurrent(true) + } + + return transitionView(item.asset.uniqueIdentifier) + } else { + return nil + } + } + + controller.startedTransitionIn = { + transitionedIn = true + if let focusItem = focusItem as? TGModernGallerySelectableItem { + updateHiddenMedia(focusItem.selectableMediaItem().uniqueIdentifier) + } + } + controller.beginTransitionOut = { item, itemView in + if let item = item as? TGMediaPickerGalleryItem { + if let itemView = itemView as? TGMediaPickerGalleryVideoItemView { + itemView.stop() + } + return transitionView(item.asset.uniqueIdentifier) + } else { + return nil + } + } + controller.finishedTransitionIn = { [weak model] _, _ in + model?.interfaceView.setSelectedItemsModel(model?.selectedItemsModel) + } + controller.completedTransitionOut = { [weak legacyController] in + updateHiddenMedia(nil) + legacyController?.dismiss() + } + model.interfaceView.donePressed = { [weak controller] item in + if let item = item as? TGMediaPickerGalleryItem { + controller?.dismissWhenReady(animated: true) + completed(item.asset, false, nil) + } + } + model.interfaceView.doneLongPressed = { [weak selectionContext, weak editingContext, weak legacyController, weak model] item in + if let legacyController = legacyController, let item = item as? TGMediaPickerGalleryItem, let model = model, let selectionContext = selectionContext { + var effectiveHasSchedule = hasSchedule + + if let editingContext = editingContext { + for item in selectionContext.selectedItems() { + if let editableItem = item as? TGMediaEditableItem, let timer = editingContext.timer(for: editableItem)?.intValue, timer > 0 { + effectiveHasSchedule = false + break + } + } + } + + let legacySheetController = LegacyController(presentation: .custom, theme: presentationData.theme, initialLayout: nil) + + let controller = TGMediaPickerSendActionSheetController(context: legacyController.context, isDark: true, sendButtonFrame: model.interfaceView.doneButtonFrame, canSendSilently: hasSilentPosting, canSchedule: effectiveHasSchedule, reminder: reminder, hasTimer: hasTimer) + let dismissImpl = { [weak model] in + model?.dismiss(true, false) + } + controller.send = { + dismissImpl() + completed(item.asset, false, nil) + } + controller.sendSilently = { + dismissImpl() + completed(item.asset, true, nil) + } + controller.schedule = { + presentSchedulePicker(true, { time in + dismissImpl() + completed(item.asset, false, time) + }) + } + controller.sendWithTimer = { + presentTimerPicker { time in + dismissImpl() + + var items = selectionContext.selectedItems() ?? [] + items.append(item.asset as Any) + + for case let item as TGMediaEditableItem in items { + editingContext?.setTimer(time as NSNumber, for: item) + } + + completed(item.asset, false, nil) + } + } + controller.customDismissBlock = { [weak legacySheetController] in + legacySheetController?.dismiss() + } + legacySheetController.bind(controller: controller) + + present(legacySheetController, nil) + } + } + model.interfaceView.setThumbnailSignalForItem { item in + let imageSignal = SSignal(generator: { subscriber in + var asset: PHAsset? + if let item = item as? TGCameraCapturedVideo { + asset = item.originalAsset.backingAsset + } else if let item = item as? TGMediaAsset { + asset = item.backingAsset + } + var disposable: Disposable? + if let asset = asset { + let scale = min(2.0, UIScreenScale) + disposable = assetImage(asset: asset, targetSize: CGSize(width: 128.0 * scale, height: 128.0 * scale), exact: false).start(next: { image in + subscriber.putNext(image) + }, completed: { + subscriber.putCompletion() + }) + } else { + subscriber.putCompletion() + } + return SBlockDisposable(block: { + disposable?.dispose() + }) + }) + if let item = item as? TGMediaEditableItem { + return editingContext.thumbnailImageSignal(for: item).map(toSignal: { result in + if let result = result { + return SSignal.single(result) + } else { + return imageSignal + } + }) + } else { + return imageSignal + } + } + present(legacyController, nil) +} diff --git a/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift b/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift new file mode 100644 index 0000000000..9126d30cf2 --- /dev/null +++ b/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift @@ -0,0 +1,264 @@ +import Foundation +import UIKit +import Display +import TelegramCore +import SwiftSignalKit +import AsyncDisplayKit +import Postbox +import AccountContext +import TelegramPresentationData +import TelegramStringFormatting +import Photos +import CheckNode +import LegacyComponents +import PhotoResources + +enum MediaPickerGridItemContent: Equatable { + case asset(PHFetchResult, Int) +} + +final class MediaPickerGridItem: GridItem { + let content: MediaPickerGridItemContent + let interaction: MediaPickerInteraction + let theme: PresentationTheme + + let section: GridSection? = nil + + init(content: MediaPickerGridItemContent, interaction: MediaPickerInteraction, theme: PresentationTheme) { + self.content = content + self.interaction = interaction + self.theme = theme + } + + func node(layout: GridNodeLayout, synchronousLoad: Bool) -> GridItemNode { + switch self.content { + case let .asset(fetchResult, index): + let node = MediaPickerGridItemNode() + node.setup(interaction: self.interaction, fetchResult: fetchResult, index: index, theme: self.theme) + return node + } + } + + func update(node: GridItemNode) { + switch self.content { + case let .asset(fetchResult, index): + guard let node = node as? MediaPickerGridItemNode else { + assertionFailure() + return + } + node.setup(interaction: self.interaction, fetchResult: fetchResult, index: index, theme: self.theme) + } + } +} + +private let maskImage = generateImage(CGSize(width: 1.0, height: 24.0), opaque: false, rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + let gradientColors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.6).cgColor] as CFArray + + var locations: [CGFloat] = [0.0, 1.0] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) +}) + +final class MediaPickerGridItemNode: GridItemNode { + var currentState: (PHFetchResult, Int)? + private let imageNode: ImageNode + private var checkNode: InteractiveCheckNode? + private let gradientNode: ASImageNode + private let typeIconNode: ASImageNode + private let durationNode: ImmediateTextNode + + private var interaction: MediaPickerInteraction? + private var theme: PresentationTheme? + + private var currentIsPreviewing = false + + var selected: (() -> Void)? + + override init() { + self.imageNode = ImageNode() + self.imageNode.clipsToBounds = true + self.imageNode.contentMode = .scaleAspectFill + self.imageNode.isLayerBacked = false + + self.gradientNode = ASImageNode() + self.gradientNode.displaysAsynchronously = false + self.gradientNode.displayWithoutProcessing = true + self.gradientNode.image = maskImage + + self.typeIconNode = ASImageNode() + self.typeIconNode.displaysAsynchronously = false + self.typeIconNode.displayWithoutProcessing = true + + self.durationNode = ImmediateTextNode() + + super.init() + + self.addSubnode(self.imageNode) + } + + var identifier: String { + return self.asset?.localIdentifier ?? "" + } + + private var asset: PHAsset? { + if let (fetchResult, index) = self.currentState { + return fetchResult[index] + } else { + return nil + } + } + + func updateSelectionState() { + if self.checkNode == nil, let _ = self.interaction?.selectionState, let theme = self.theme { + let checkNode = InteractiveCheckNode(theme: CheckNodeTheme(theme: theme, style: .overlay)) + checkNode.valueChanged = { [weak self] value in + if let strongSelf = self, let asset = strongSelf.asset, let interaction = strongSelf.interaction { + if let legacyAsset = TGMediaAsset(phAsset: asset) { + interaction.toggleSelection(legacyAsset, value) + } + } + } + self.addSubnode(checkNode) + self.checkNode = checkNode + self.setNeedsLayout() + } + + if let asset = self.asset, let interaction = self.interaction, let selectionState = interaction.selectionState { + let selected = selectionState.isIdentifierSelected(asset.localIdentifier) + if let legacyAsset = TGMediaAsset(phAsset: asset) { + let index = selectionState.index(of: legacyAsset) + if index != NSNotFound { + self.checkNode?.content = .counter(Int(index)) + } + } + self.checkNode?.setSelected(selected, animated: false) + } + } + + func updateHiddenMedia() { + if let asset = self.asset { + let wasHidden = self.isHidden + self.isHidden = self.interaction?.hiddenMediaId == asset.localIdentifier + if !self.isHidden && wasHidden { + self.checkNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.gradientNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.typeIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.durationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageNodeTap(_:)))) + } + + func setup(interaction: MediaPickerInteraction, fetchResult: PHFetchResult, index: Int, theme: PresentationTheme) { + self.interaction = interaction + self.theme = theme + + if self.currentState == nil || self.currentState!.0 !== fetchResult || self.currentState!.1 != index { + let editingContext = interaction.editingState + let asset = fetchResult.object(at: index) + + let editedSignal = Signal { subscriber in + if let signal = editingContext.thumbnailImageSignal(forIdentifier: asset.localIdentifier) { + let disposable = signal.start(next: { next in + if let image = next as? UIImage { + subscriber.putNext(image) + } else { + subscriber.putNext(nil) + } + }, error: { _ in + }, completed: nil)! + + return ActionDisposable { + disposable.dispose() + } + } else { + return EmptyDisposable + } + } + + let scale = min(2.0, UIScreenScale) + let targetSize = CGSize(width: 140.0 * scale, height: 140.0 * scale) + let originalSignal = assetImage(fetchResult: fetchResult, index: index, targetSize: targetSize, exact: false) + let imageSignal: Signal = editedSignal + |> mapToSignal { result in + if let result = result { + return .single(result) + } else { + return originalSignal + } + } + self.imageNode.setSignal(imageSignal) + + if asset.mediaType == .video { + if asset.mediaSubtypes.contains(.videoHighFrameRate) { + self.typeIconNode.image = UIImage(bundleImageName: "Media Editor/MediaSlomo") + } else if asset.mediaSubtypes.contains(.videoTimelapse) { + self.typeIconNode.image = UIImage(bundleImageName: "Media Editor/MediaTimelapse") + } else { + self.typeIconNode.image = UIImage(bundleImageName: "Media Editor/MediaVideo") + } + + if self.typeIconNode.supernode == nil { + self.durationNode.attributedText = NSAttributedString(string: stringForDuration(Int32(asset.duration)), font: Font.semibold(12.0), textColor: .white) + + self.addSubnode(self.gradientNode) + self.addSubnode(self.typeIconNode) + self.addSubnode(self.durationNode) + self.setNeedsLayout() + } + } else { + if self.typeIconNode.supernode != nil { + self.gradientNode.removeFromSupernode() + self.typeIconNode.removeFromSupernode() + self.durationNode.removeFromSupernode() + } + } + + self.currentState = (fetchResult, index) + self.setNeedsLayout() + } + + self.updateSelectionState() + self.updateHiddenMedia() + } + + override func layout() { + super.layout() + + self.imageNode.frame = self.bounds + self.gradientNode.frame = CGRect(x: 0.0, y: self.bounds.height - 24.0, width: self.bounds.width, height: 24.0) + self.typeIconNode.frame = CGRect(x: 0.0, y: self.bounds.height - 20.0, width: 19.0, height: 19.0) + + if self.durationNode.supernode != nil { + let durationSize = self.durationNode.updateLayout(self.bounds.size) + self.durationNode.frame = CGRect(origin: CGPoint(x: self.bounds.size.width - durationSize.width - 7.0, y: self.bounds.height - durationSize.height - 5.0), size: durationSize) + } + + let checkSize = CGSize(width: 29.0, height: 29.0) + self.checkNode?.frame = CGRect(origin: CGPoint(x: self.bounds.width - checkSize.width - 3.0, y: 3.0), size: checkSize) + } + + func transitionView() -> UIView { + let view = self.imageNode.view.snapshotContentTree(unhide: true, keepTransform: true)! + view.frame = self.convert(self.bounds, to: nil) + return view + } + + @objc func imageNodeTap(_ recognizer: UITapGestureRecognizer) { + guard let (fetchResult, index) = self.currentState else { + return + } + self.interaction?.openMedia(fetchResult, index, self.imageNode.image) + } +} + diff --git a/submodules/MediaPickerUI/Sources/MediaPickerManageNode.swift b/submodules/MediaPickerUI/Sources/MediaPickerManageNode.swift new file mode 100644 index 0000000000..2be6981d24 --- /dev/null +++ b/submodules/MediaPickerUI/Sources/MediaPickerManageNode.swift @@ -0,0 +1,74 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import SolidRoundedButtonNode + +final class MediaPickerManageNode: ASDisplayNode { + enum Subject { + case limitedMedia + case camera + } + + private let textNode: ImmediateTextNode + private let measureButtonNode: ImmediateTextNode + private let buttonNode: SolidRoundedButtonNode + + var pressed: () -> Void = {} + + override init() { + self.textNode = ImmediateTextNode() + self.textNode.isUserInteractionEnabled = false + self.textNode.textAlignment = .left + self.textNode.maximumNumberOfLines = 0 + + self.measureButtonNode = ImmediateTextNode() + + self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), fontSize: 15.0, height: 28.0, cornerRadius: 14.0) + + super.init() + + self.addSubnode(self.textNode) + self.addSubnode(self.buttonNode) + + self.buttonNode.pressed = { [weak self] in + self?.pressed() + } + } + + private var theme: PresentationTheme? + func update(layout: ContainerViewLayout, theme: PresentationTheme, strings: PresentationStrings, subject: Subject, transition: ContainedViewLayoutTransition) -> CGFloat { + let themeUpdated = self.theme != theme + self.theme = theme + + let text: String + switch subject { + case .limitedMedia: + text = strings.Attachment_LimitedMediaAccessText + case .camera: + text = strings.Attachment_CameraAccessText + } + + let title = strings.Attachment_Manage.uppercased() + self.measureButtonNode.attributedText = NSAttributedString(string: title, font: Font.semibold(15.0), textColor: .white, paragraphAlignment: .center) + let measureButtonSize = self.measureButtonNode.updateLayout(layout.size) + + let buttonWidth = measureButtonSize.width + 26.0 + + self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(15.0), textColor: theme.list.freeTextColor, paragraphAlignment: .left) + let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 16.0 - buttonWidth - 26.0, height: layout.size.height)) + let panelHeight = max(64.0, textSize.height + 10.0) + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + 16.0, y: floorToScreenPixels((panelHeight - textSize.height) / 2.0) - 5.0), size: textSize)) + + if themeUpdated { + self.buttonNode.updateTheme(SolidRoundedButtonTheme(theme: theme)) + } + + self.buttonNode.title = title + let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition) + transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.size.width - layout.safeInsets.right - buttonWidth - 10.0, y: floorToScreenPixels((panelHeight - buttonHeight) / 2.0) - 5.0), size: CGSize(width: buttonWidth, height: buttonHeight))) + + return panelHeight + } +} diff --git a/submodules/MediaPickerUI/Sources/MediaPickerPlaceholderNode.swift b/submodules/MediaPickerUI/Sources/MediaPickerPlaceholderNode.swift new file mode 100644 index 0000000000..90ceb0c4e9 --- /dev/null +++ b/submodules/MediaPickerUI/Sources/MediaPickerPlaceholderNode.swift @@ -0,0 +1,150 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import SolidRoundedButtonNode +import PresentationDataUtils + +final class MediaPickerPlaceholderNode: ASDisplayNode { + private var animationNode: AnimatedStickerNode + private let titleNode: ImmediateTextNode + private let textNode: ImmediateTextNode + private let buttonNode: SolidRoundedButtonNode + private var validLayout: ContainerViewLayout? + + private var cameraButtonNode: HighlightTrackingButtonNode + private var cameraTextNode: ImmediateTextNode + private var cameraIconNode: ASImageNode + + var settingsPressed: () -> Void = {} + var cameraPressed: () -> Void = {} + + override init() { + self.animationNode = AnimatedStickerNode() + self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "Files"), width: 320, height: 320, playbackMode: .loop, mode: .direct(cachePathPrefix: nil)) + self.animationNode.visibility = true + + self.titleNode = ImmediateTextNode() + self.titleNode.isUserInteractionEnabled = false + self.titleNode.textAlignment = .center + self.titleNode.maximumNumberOfLines = 1 + + self.textNode = ImmediateTextNode() + self.textNode.isUserInteractionEnabled = false + self.textNode.lineSpacing = 0.1 + self.textNode.textAlignment = .center + self.textNode.maximumNumberOfLines = 0 + + self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), height: 50.0, cornerRadius: 12.0, gloss: true) + + self.cameraButtonNode = HighlightTrackingButtonNode() + self.cameraButtonNode.alpha = 0.0 + self.cameraButtonNode.isUserInteractionEnabled = false + + self.cameraTextNode = ImmediateTextNode() + self.cameraTextNode.isUserInteractionEnabled = false + + self.cameraIconNode = ASImageNode() + self.cameraIconNode.displaysAsynchronously = false + self.cameraIconNode.isUserInteractionEnabled = false + + super.init() + + self.addSubnode(self.animationNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.textNode) + self.addSubnode(self.buttonNode) + + self.addSubnode(self.cameraButtonNode) + self.cameraButtonNode.addSubnode(self.cameraTextNode) + self.cameraButtonNode.addSubnode(self.cameraIconNode) + + self.cameraButtonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.cameraTextNode.layer.removeAnimation(forKey: "opacity") + strongSelf.cameraTextNode.alpha = 0.4 + strongSelf.cameraIconNode.layer.removeAnimation(forKey: "opacity") + strongSelf.cameraIconNode.alpha = 0.4 + } else { + strongSelf.cameraTextNode.alpha = 1.0 + strongSelf.cameraTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.cameraIconNode.alpha = 1.0 + strongSelf.cameraIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + self.cameraButtonNode.addTarget(self, action: #selector(self.cameraButtonPressed), forControlEvents: .touchUpInside) + + self.buttonNode.pressed = { [weak self] in + self?.settingsPressed() + } + } + + @objc private func cameraButtonPressed() { + self.cameraPressed() + } + + private var theme: PresentationTheme? + func update(layout: ContainerViewLayout, theme: PresentationTheme, strings: PresentationStrings, hasCamera: Bool, transition: ContainedViewLayoutTransition) { + self.validLayout = layout + let themeUpdated = self.theme != theme + self.theme = theme + + var insets = layout.insets(options: []) + insets.top += -160.0 + + let imageSpacing: CGFloat = 12.0 + let textSpacing: CGFloat = 16.0 + let buttonSpacing: CGFloat = 15.0 + let cameraSpacing: CGFloat = 13.0 + + let imageSize = CGSize(width: 144.0, height: 144.0) + let imageHeight = layout.size.width < layout.size.height ? imageSize.height + imageSpacing : 0.0 + + self.animationNode.frame = CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: -10.0), size: imageSize) + self.animationNode.updateLayout(size: imageSize) + + if themeUpdated { + self.buttonNode.updateTheme(SolidRoundedButtonTheme(theme: theme)) + self.cameraIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Attach Menu/OpenCamera"), color: theme.list.itemAccentColor) + } + self.buttonNode.title = strings.Attachment_OpenSettings + let buttonWidth: CGFloat = 248.0 + let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition) + + self.titleNode.attributedText = NSAttributedString(string: strings.Attachment_MediaAccessTitle, font: Font.medium(17.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .center) + self.textNode.attributedText = NSAttributedString(string: strings.Attachment_MediaAccessText, font: Font.regular(15.0), textColor: theme.list.freeTextColor, paragraphAlignment: .center) + self.cameraTextNode.attributedText = NSAttributedString(string: strings.Attachment_OpenCamera, font: Font.regular(17.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .center) + + let titleSize = self.titleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 70.0, height: max(1.0, layout.size.height - insets.top - insets.bottom))) + let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 70.0, height: max(1.0, layout.size.height - insets.top - insets.bottom))) + let cameraSize = self.cameraTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 70.0, height: max(1.0, layout.size.height - insets.top - insets.bottom))) + + let totalHeight = imageHeight + titleSize.height + textSpacing + textSize.height + buttonSpacing + buttonHeight + cameraSpacing + cameraSize.height + let topOffset = insets.top + floor((layout.size.height - insets.top - insets.bottom - totalHeight) / 2.0) + + transition.updateAlpha(node: self.animationNode, alpha: imageHeight > 0.0 ? 1.0 : 0.0) + transition.updateFrame(node: self.animationNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: topOffset), size: imageSize)) + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - titleSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: topOffset + imageHeight), size: titleSize)) + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - textSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.titleNode.frame.maxY + textSpacing), size: textSize)) + + transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - buttonWidth - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.textNode.frame.maxY + buttonSpacing), size: CGSize(width: buttonWidth, height: buttonHeight))) + + if let image = self.cameraIconNode.image { + let cameraTotalSize = CGSize(width: cameraSize.width + image.size.width + 10.0, height: 44.0) + transition.updateFrame(node: self.cameraIconNode, frame: CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((cameraTotalSize.height - image.size.height) / 2.0)), size: image.size)) + transition.updateFrame(node: self.cameraTextNode, frame: CGRect(origin: CGPoint(x: cameraTotalSize.width - cameraSize.width, y: floorToScreenPixels((cameraTotalSize.height - cameraSize.height) / 2.0)), size: cameraSize)) + + transition.updateFrame(node: self.cameraButtonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - cameraTotalSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.buttonNode.frame.maxY + cameraSpacing), size: cameraTotalSize)) + } + + self.cameraButtonNode.isUserInteractionEnabled = hasCamera + transition.updateAlpha(node: self.cameraButtonNode, alpha: hasCamera ? 1.0 : 0.0) + } +} + + diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift new file mode 100644 index 0000000000..3ada396f79 --- /dev/null +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -0,0 +1,1198 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import AccountContext +import TelegramPresentationData +import TelegramUIPreferences +import MergeLists +import Photos +import PhotosUI +import LegacyComponents +import AttachmentUI +import SegmentedControlNode +import ManagedAnimationNode +import ContextUI +import LegacyMediaPickerUI + +private class MediaAssetsContext: NSObject, PHPhotoLibraryChangeObserver { + private var registeredChangeObserver = false + private let changeSink = ValuePipe() + private let mediaAccessSink = ValuePipe() + private let cameraAccessSink = ValuePipe() + + override init() { + super.init() + + if PHPhotoLibrary.authorizationStatus() == .authorized { + PHPhotoLibrary.shared().register(self) + self.registeredChangeObserver = true + } + } + + deinit { + if self.registeredChangeObserver { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + } + + func photoLibraryDidChange(_ changeInstance: PHChange) { + self.changeSink.putNext(changeInstance) + } + + func fetchResultAssets(_ initialFetchResult: PHFetchResult) -> Signal?, NoError> { + let fetchResult = Atomic>(value: initialFetchResult) + return .single(initialFetchResult) + |> then( + self.changeSink.signal() + |> mapToSignal { change in + if let updatedFetchResult = change.changeDetails(for: fetchResult.with { $0 })?.fetchResultAfterChanges { + let _ = fetchResult.modify { _ in return updatedFetchResult } + return .single(updatedFetchResult) + } else { + return .complete() + } + } + ) + } + + func recentAssets() -> Signal?, NoError> { + let collections = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: nil) + if let collection = collections.firstObject { + let initialFetchResult = PHAsset.fetchAssets(in: collection, options: nil) + return fetchResultAssets(initialFetchResult) + } else { + return .single(nil) + } + } + + func mediaAccess() -> Signal { + let initialStatus: PHAuthorizationStatus + if #available(iOS 14.0, *) { + initialStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + } else { + initialStatus = PHPhotoLibrary.authorizationStatus() + } + return .single(initialStatus) + |> then( + self.mediaAccessSink.signal() + ) + } + + func requestMediaAccess() -> Void { + PHPhotoLibrary.requestAuthorization { [weak self] status in + self?.mediaAccessSink.putNext(status) + } + } + + func cameraAccess() -> Signal { +#if targetEnvironment(simulator) + return .single(.authorized) +#else + if UIImagePickerController.isSourceTypeAvailable(.camera) { + return .single(AVCaptureDevice.authorizationStatus(for: .video)) + |> then( + self.cameraAccessSink.signal() + ) + } else { + return .single(nil) + } +#endif + } + + func requestCameraAccess() -> Void { + AVCaptureDevice.requestAccess(for: .video, completionHandler: { [weak self] result in + if result { + self?.cameraAccessSink.putNext(.authorized) + } else { + self?.cameraAccessSink.putNext(.denied) + } + }) + } +} + +final class MediaPickerInteraction { + let openMedia: (PHFetchResult, Int, UIImage?) -> Void + let openSelectedMedia: (TGMediaSelectableItem, UIImage?) -> Void + let toggleSelection: (TGMediaSelectableItem, Bool) -> Void + let sendSelected: (TGMediaSelectableItem?, Bool, Int32?, Bool) -> Void + let schedule: () -> Void + let selectionState: TGMediaSelectionContext? + let editingState: TGMediaEditingContext + var hiddenMediaId: String? + + init(openMedia: @escaping (PHFetchResult, Int, UIImage?) -> Void, openSelectedMedia: @escaping (TGMediaSelectableItem, UIImage?) -> Void, toggleSelection: @escaping (TGMediaSelectableItem, Bool) -> Void, sendSelected: @escaping (TGMediaSelectableItem?, Bool, Int32?, Bool) -> Void, schedule: @escaping () -> Void, selectionState: TGMediaSelectionContext?, editingState: TGMediaEditingContext) { + self.openMedia = openMedia + self.openSelectedMedia = openSelectedMedia + self.toggleSelection = toggleSelection + self.sendSelected = sendSelected + self.schedule = schedule + self.selectionState = selectionState + self.editingState = editingState + } +} + +private struct MediaPickerGridEntry: Comparable, Identifiable { + let stableId: Int + let content: MediaPickerGridItemContent + + static func <(lhs: MediaPickerGridEntry, rhs: MediaPickerGridEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(account: Account, interaction: MediaPickerInteraction, theme: PresentationTheme) -> MediaPickerGridItem { + return MediaPickerGridItem(content: self.content, interaction: interaction, theme: theme) + } +} + +private struct MediaPickerGridTransaction { + let deletions: [Int] + let insertions: [GridNodeInsertItem] + let updates: [GridNodeUpdateItem] + let scrollToItem: GridNodeScrollToItem? + + init(previousList: [MediaPickerGridEntry], list: [MediaPickerGridEntry], account: Account, interaction: MediaPickerInteraction, theme: PresentationTheme, scrollToItem: GridNodeScrollToItem?) { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: previousList, rightList: list) + + self.deletions = deleteIndices + self.insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(account: account, interaction: interaction, theme: theme), previousIndex: $0.2) } + self.updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, interaction: interaction, theme: theme)) } + + self.scrollToItem = scrollToItem + } +} + +private final class MediaPickerSegmentedTitleView: UIView { + private let titleNode: ImmediateTextNode + private let segmentedControlNode: SegmentedControlNode + + public var theme: PresentationTheme { + didSet { + self.titleNode.attributedText = NSAttributedString(string: self.title, font: NavigationBar.titleFont, textColor: theme.rootController.navigationBar.primaryTextColor) + self.segmentedControlNode.updateTheme(SegmentedControlTheme(theme: self.theme)) + } + } + + public var title: String = "" { + didSet { + if self.title != oldValue { + self.titleNode.attributedText = NSAttributedString(string: self.title, font: NavigationBar.titleFont, textColor: theme.rootController.navigationBar.primaryTextColor) + self.setNeedsLayout() + } + } + } + + public var segmentsHidden = true { + didSet { + if self.segmentsHidden != oldValue { + let transition = ContainedViewLayoutTransition.animated(duration: 0.21, curve: .easeInOut) + transition.updateAlpha(node: self.titleNode, alpha: self.segmentsHidden ? 1.0 : 0.0) + transition.updateAlpha(node: self.segmentedControlNode, alpha: self.segmentsHidden ? 0.0 : 1.0) + self.segmentedControlNode.isUserInteractionEnabled = !self.segmentsHidden + } + } + } + + public var segments: [String] { + didSet { + if self.segments != oldValue { + self.segmentedControlNode.items = self.segments.map { SegmentedControlItem(title: $0) } + self.setNeedsLayout() + } + } + } + + public var index: Int { + get { + return self.segmentedControlNode.selectedIndex + } + set { + self.segmentedControlNode.selectedIndex = newValue + } + } + + public var indexUpdated: ((Int) -> Void)? + + public init(theme: PresentationTheme, segments: [String], selectedIndex: Int) { + self.theme = theme + self.segments = segments + + self.titleNode = ImmediateTextNode() + self.titleNode.displaysAsynchronously = false + + self.segmentedControlNode = SegmentedControlNode(theme: SegmentedControlTheme(theme: theme), items: segments.map { SegmentedControlItem(title: $0) }, selectedIndex: selectedIndex) + self.segmentedControlNode.alpha = 0.0 + self.segmentedControlNode.isUserInteractionEnabled = false + + super.init(frame: CGRect()) + + self.segmentedControlNode.selectedIndexChanged = { [weak self] index in + self?.indexUpdated?(index) + } + + self.addSubnode(self.titleNode) + self.addSubnode(self.segmentedControlNode) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func layoutSubviews() { + super.layoutSubviews() + + let size = self.bounds.size + let controlSize = self.segmentedControlNode.updateLayout(.stretchToFill(width: min(300.0, size.width - 36.0)), transition: .immediate) + self.segmentedControlNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - controlSize.width) / 2.0), y: floorToScreenPixels((size.height - controlSize.height) / 2.0)), size: controlSize) + + let titleSize = self.titleNode.updateLayout(CGSize(width: 160.0, height: 44.0)) + self.titleNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) + } +} + +private final class MediaPickerMoreButtonNode: ASDisplayNode { + fileprivate final class MoreIconNode: ManagedAnimationNode { + enum State: Equatable { + case more + case search + } + + private let duration: Double = 0.21 + var iconState: State = .search + + init() { + super.init(size: CGSize(width: 30.0, height: 30.0)) + + self.trackTo(item: ManagedAnimationItem(source: .local("anim_moretosearch"), frames: .range(startFrame: 90, endFrame: 90), duration: 0.0)) + } + + func play() { + if case .more = self.iconState { + self.trackTo(item: ManagedAnimationItem(source: .local("anim_moredots"), frames: .range(startFrame: 0, endFrame: 46), duration: 0.76)) + } + } + + func enqueueState(_ state: State, animated: Bool) { + guard self.iconState != state else { + return + } + + let previousState = self.iconState + self.iconState = state + + let source = ManagedAnimationSource.local("anim_moretosearch") + + let totalLength: Int = 90 + if animated { + switch previousState { + case .more: + switch state { + case .more: + break + case .search: + self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: totalLength), duration: self.duration)) + } + case .search: + switch state { + case .more: + self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: 0), duration: self.duration)) + case .search: + break + } + } + } else { + switch state { + case .more: + self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: 0), duration: 0.0)) + case .search: + self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: totalLength), duration: 0.0)) + } + } + } + } + + var action: ((ASDisplayNode, ContextGesture?) -> Void)? + + private let containerNode: ContextControllerSourceNode + let contextSourceNode: ContextReferenceContentNode + private let buttonNode: HighlightableButtonNode + let iconNode: MoreIconNode + + var theme: PresentationTheme { + didSet { + self.iconNode.customColor = self.theme.rootController.navigationBar.buttonColor + } + } + + init(theme: PresentationTheme) { + self.theme = theme + + self.contextSourceNode = ContextReferenceContentNode() + self.containerNode = ContextControllerSourceNode() + self.containerNode.animateScale = false + + self.buttonNode = HighlightableButtonNode() + self.iconNode = MoreIconNode() + self.iconNode.customColor = self.theme.rootController.navigationBar.buttonColor + + super.init() + + self.addSubnode(self.buttonNode) + + self.buttonNode.addSubnode(self.containerNode) + self.containerNode.addSubnode(self.contextSourceNode) + self.contextSourceNode.addSubnode(self.iconNode) + + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + + self.containerNode.activated = { [weak self] gesture, _ in + guard let strongSelf = self else { + return + } + strongSelf.action?(strongSelf.contextSourceNode, gesture) + } + } + + @objc private func buttonPressed() { + self.action?(self.contextSourceNode, nil) + if case .more = self.iconNode.iconState { + self.iconNode.play() + } + } + + override public func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + let animationSize = CGSize(width: 30.0, height: 30.0) + let inset: CGFloat = 0.0 + self.iconNode.frame = CGRect(origin: CGPoint(x: inset + 4.0, y: floor((constrainedSize.height - animationSize.height) / 2.0)), size: animationSize) + + let size = CGSize(width: animationSize.width + inset * 2.0, height: constrainedSize.height) + let bounds = CGRect(origin: CGPoint(), size: size) + self.buttonNode.frame = bounds + self.containerNode.frame = bounds + self.contextSourceNode.frame = bounds + return size + } +} + +public final class MediaPickerScreen: ViewController, AttachmentContainable { + private let context: AccountContext + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private let peer: EnginePeer? + private let chatLocation: ChatLocation? + + private let titleView: MediaPickerSegmentedTitleView + private let moreButtonNode: MediaPickerMoreButtonNode + + public var openCamera: ((TGAttachmentCameraView?) -> Void)? + public var presentStickers: ((@escaping (TelegramMediaFile, Bool, UIView, CGRect) -> Void) -> TGPhotoPaintStickersScreen?)? + public var presentSchedulePicker: (Bool, @escaping (Int32) -> Void) -> Void = { _, _ in } + public var presentTimerPicker: (@escaping (Int32) -> Void) -> Void = { _ in } + public var presentWebSearch: () -> Void = {} + public var getCaptionPanelView: () -> TGCaptionPanelView? = { return nil } + + public var legacyCompletion: (_ signals: [Any], _ silently: Bool, _ scheduleTime: Int32?) -> Void = { _, _, _ in } + + public var requestAttachmentMenuExpansion: () -> Void = {} + + private class Node: ViewControllerTracingNode { + enum DisplayMode { + case all + case selected + } + + enum State { + case noAccess(cameraAccess: AVAuthorizationStatus?) + case assets(fetchResult: PHFetchResult?, mediaAccess: PHAuthorizationStatus, cameraAccess: AVAuthorizationStatus?) + } + + private weak var controller: MediaPickerScreen? + fileprivate var interaction: MediaPickerInteraction? + private var presentationData: PresentationData + private let mediaAssetsContext: MediaAssetsContext + + private let gridNode: GridNode + private var cameraView: TGAttachmentCameraView? + private var placeholderNode: MediaPickerPlaceholderNode? + private var manageNode: MediaPickerManageNode? + + private let selectionNode: MediaPickerSelectedListNode + + private var nextStableId: Int = 1 + private var currentEntries: [MediaPickerGridEntry] = [] + private var enqueuedTransactions: [MediaPickerGridTransaction] = [] + private var state: State? + + private var itemsDisposable: Disposable? + private var selectionChangedDisposable: Disposable? + private var itemsDimensionsUpdatedDisposable: Disposable? + private var hiddenMediaDisposable: Disposable? + + private let hiddenMediaId = Promise(nil) + + private var didSetReady = false + private let _ready = Promise() + var ready: Promise { + return self._ready + } + + private var validLayout: (ContainerViewLayout, CGFloat)? + + init(controller: MediaPickerScreen) { + self.controller = controller + self.presentationData = controller.presentationData + + let mediaAssetsContext = MediaAssetsContext() + self.mediaAssetsContext = mediaAssetsContext + + self.gridNode = GridNode() + + self.selectionNode = MediaPickerSelectedListNode(context: controller.context) + self.selectionNode.alpha = 0.0 + self.selectionNode.isUserInteractionEnabled = false + + super.init() + + self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + + self.addSubnode(self.gridNode) + self.addSubnode(self.selectionNode) + + self.interaction = MediaPickerInteraction(openMedia: { [weak self] fetchResult, index, immediateThumbnail in + self?.openMedia(fetchResult: fetchResult, index: index, immediateThumbnail: immediateThumbnail) + }, openSelectedMedia: { [weak self] item, immediateThumbnail in + self?.openSelectedMedia(item: item, immediateThumbnail: immediateThumbnail) + }, toggleSelection: { [weak self] item, value in + if let strongSelf = self { + strongSelf.interaction?.selectionState?.setItem(item, selected: value) + } + }, sendSelected: { [weak self] currentItem, silently, scheduleTime, animated in + if let strongSelf = self, let selectionState = strongSelf.interaction?.selectionState { + if let currentItem = currentItem { + selectionState.setItem(currentItem, selected: true) + } + strongSelf.send(silently: silently, scheduleTime: scheduleTime, animated: animated) + } + }, schedule: { [weak self] in + if let strongSelf = self { + strongSelf.controller?.presentSchedulePicker(false, { [weak self] time in + self?.interaction?.sendSelected(nil, false, time, true) + }) + } + }, selectionState: TGMediaSelectionContext(), editingState: TGMediaEditingContext()) + self.interaction?.selectionState?.grouping = true + + let updatedState = combineLatest(mediaAssetsContext.mediaAccess(), mediaAssetsContext.cameraAccess()) + |> mapToSignal { mediaAccess, cameraAccess -> Signal in + if case .notDetermined = mediaAccess { + return .single(.assets(fetchResult: nil, mediaAccess: mediaAccess, cameraAccess: cameraAccess)) + } else if [.restricted, .denied].contains(mediaAccess) { + return .single(.noAccess(cameraAccess: cameraAccess)) + } else { + return mediaAssetsContext.recentAssets() + |> map { fetchResult in + return .assets(fetchResult: fetchResult, mediaAccess: mediaAccess, cameraAccess: cameraAccess) + } + } + } + + self.itemsDisposable = (updatedState + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + strongSelf.updateState(state) + }) + + self.gridNode.scrollingInitiated = { [weak self] in + self?.dismissInput() + } + + self.hiddenMediaDisposable = (self.hiddenMediaId.get() + |> deliverOnMainQueue).start(next: { [weak self] id in + if let strongSelf = self { + strongSelf.interaction?.hiddenMediaId = id + + strongSelf.gridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? MediaPickerGridItemNode { + itemNode.updateHiddenMedia() + } + } + + strongSelf.selectionNode.updateHiddenMedia() + } + }) + + if let selectionState = self.interaction?.selectionState { + func selectionChangedSignal(selectionState: TGMediaSelectionContext) -> Signal { + return Signal { subscriber in + let disposable = selectionState.selectionChangedSignal()?.start(next: { next in + subscriber.putNext(Void()) + }, completed: {}) + return ActionDisposable { + disposable?.dispose() + } + } + } + + self.selectionChangedDisposable = (selectionChangedSignal(selectionState: selectionState) + |> deliverOnMainQueue).start(next: { [weak self] _ in + if let strongSelf = self { + strongSelf.updateSelectionState() + } + }) + } + + if let editingState = self.interaction?.editingState { + func itemsDimensionsUpdatedSignal(editingState: TGMediaEditingContext) -> Signal { + return Signal { subscriber in + let disposable = editingState.cropAdjustmentsUpdatedSignal()?.start(next: { next in + subscriber.putNext(Void()) + }, completed: {}) + return ActionDisposable { + disposable?.dispose() + } + } + } + + self.itemsDimensionsUpdatedDisposable = (itemsDimensionsUpdatedSignal(editingState: editingState) + |> deliverOnMainQueue).start(next: { [weak self] _ in + if let strongSelf = self { + strongSelf.updateSelectionState() + } + }) + } + + self.selectionNode.interaction = self.interaction + } + + deinit { + self.itemsDisposable?.dispose() + self.hiddenMediaDisposable?.dispose() + self.selectionChangedDisposable?.dispose() + self.itemsDimensionsUpdatedDisposable?.dispose() + } + + override func didLoad() { + super.didLoad() + + self.gridNode.scrollView.alwaysBounceVertical = true + self.gridNode.scrollView.showsVerticalScrollIndicator = false + + let cameraView = TGAttachmentCameraView(forSelfPortrait: false)! + cameraView.clipsToBounds = true + cameraView.removeCorners() + cameraView.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.controller?.openCamera?(strongSelf.cameraView) + } + } + self.cameraView = cameraView + cameraView.startPreview() + + self.gridNode.scrollView.addSubview(cameraView) + } + + private func dismissInput() { + self.view.window?.endEditing(true) + } + + private var requestedMediaAccess = false + private var requestedCameraAccess = false + + private func updateState(_ state: State) { + guard let interaction = self.interaction, let controller = self.controller else { + return + } + + let previousState = self.state + self.state = state + + var stableId: Int = 0 + var entries: [MediaPickerGridEntry] = [] + + var updateLayout = false + + switch state { + case let .noAccess(cameraAccess): + if case .assets = previousState { + updateLayout = true + } else if case let .noAccess(previousCameraAccess) = previousState, previousCameraAccess != cameraAccess { + updateLayout = true + } + if case .notDetermined = cameraAccess, !self.requestedCameraAccess { + self.requestedCameraAccess = true + self.mediaAssetsContext.requestCameraAccess() + } + case let .assets(fetchResult, mediaAccess, cameraAccess): + if let fetchResult = fetchResult { + for i in 0 ..< fetchResult.count { + entries.append(MediaPickerGridEntry(stableId: stableId, content: .asset(fetchResult, fetchResult.count - i - 1))) + stableId += 1 + } + + if case let .assets(previousFetchResult, _, previousCameraAccess) = previousState, previousFetchResult == nil || previousCameraAccess != cameraAccess { + updateLayout = true + } + + if case .notDetermined = cameraAccess, !self.requestedCameraAccess { + self.requestedCameraAccess = true + self.mediaAssetsContext.requestCameraAccess() + } + } else if case .notDetermined = mediaAccess, !self.requestedMediaAccess { + self.requestedMediaAccess = true + self.mediaAssetsContext.requestMediaAccess() + } + } + + let previousEntries = self.currentEntries + self.currentEntries = entries + + let transaction = MediaPickerGridTransaction(previousList: previousEntries, list: entries, account: controller.context.account, interaction: interaction, theme: self.presentationData.theme, scrollToItem: nil) + self.enqueueTransaction(transaction) + + if updateLayout, let (layout, navigationBarHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: previousState == nil ? .immediate : .animated(duration: 0.2, curve: .easeInOut)) + } + } + + private func updateSelectionState() { + self.gridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? MediaPickerGridItemNode { + itemNode.updateSelectionState() + } + } + self.selectionNode.updateSelectionState() + + let count = Int32(self.interaction?.selectionState?.count() ?? 0) + self.controller?.updateSelectionState(count: count) + + if let (layout, navigationBarHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + } + + func updatePresentationData(_ presentationData: PresentationData) { + self.presentationData = presentationData + + self.backgroundColor = presentationData.theme.list.plainBackgroundColor + } + + private var currentDisplayMode: DisplayMode = .all + func updateMode(_ displayMode: DisplayMode) { + self.currentDisplayMode = displayMode + + let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) + self.gridNode.isUserInteractionEnabled = displayMode == .all + + transition.updateAlpha(node: self.selectionNode, alpha: displayMode == .selected ? 1.0 : 0.0) + self.selectionNode.isUserInteractionEnabled = displayMode == .selected + } + + private func openMedia(fetchResult: PHFetchResult, index: Int, immediateThumbnail: UIImage?) { + guard let controller = self.controller, let interaction = self.interaction, let (layout, _) = self.validLayout else { + return + } + + let index = fetchResult.count - index - 1 + presentLegacyMediaPickerGallery(context: controller.context, peer: controller.peer, chatLocation: controller.chatLocation, presentationData: self.presentationData, source: .fetchResult(fetchResult: fetchResult, index: index), immediateThumbnail: immediateThumbnail, selectionContext: interaction.selectionState, editingContext: interaction.editingState, hasSilentPosting: true, hasSchedule: true, hasTimer: true, updateHiddenMedia: { [weak self] id in + self?.hiddenMediaId.set(.single(id)) + }, initialLayout: layout, transitionHostView: { [weak self] in + return self?.gridNode.view + }, transitionView: { [weak self] identifier in + return self?.transitionView(for: identifier) + }, completed: { [weak self] result, silently, scheduleTime in + if let strongSelf = self { + strongSelf.interaction?.sendSelected(result, silently, scheduleTime, false) + } + }, presentStickers: controller.presentStickers, presentSchedulePicker: controller.presentSchedulePicker, presentTimerPicker: controller.presentTimerPicker, getCaptionPanelView: controller.getCaptionPanelView, present: { [weak self] c, a in + self?.controller?.present(c, in: .window(.root), with: a) + }) + } + + private func openSelectedMedia(item: TGMediaSelectableItem, immediateThumbnail: UIImage?) { + guard let controller = self.controller, let interaction = self.interaction, let (layout, _) = self.validLayout else { + return + } + presentLegacyMediaPickerGallery(context: controller.context, peer: controller.peer, chatLocation: controller.chatLocation, presentationData: self.presentationData, source: .selection(item: item), immediateThumbnail: immediateThumbnail, selectionContext: interaction.selectionState, editingContext: interaction.editingState, hasSilentPosting: true, hasSchedule: true, hasTimer: true, updateHiddenMedia: { [weak self] id in + self?.hiddenMediaId.set(.single(id)) + }, initialLayout: layout, transitionHostView: { [weak self] in + return self?.selectionNode.view + }, transitionView: { [weak self] identifier in + return self?.transitionView(for: identifier) + }, completed: { [weak self] result, silently, scheduleTime in + if let strongSelf = self { + strongSelf.interaction?.sendSelected(result, silently, scheduleTime, false) + } + }, presentStickers: controller.presentStickers, presentSchedulePicker: controller.presentSchedulePicker, presentTimerPicker: controller.presentTimerPicker, getCaptionPanelView: controller.getCaptionPanelView, present: { [weak self] c, a in + self?.controller?.present(c, in: .window(.root), with: a) + }) + } + + fileprivate func send(asFile: Bool = false, silently: Bool, scheduleTime: Int32?, animated: Bool) { + guard let signals = TGMediaAssetsController.resultSignals(for: self.interaction?.selectionState, editingContext: self.interaction?.editingState, intent: asFile ? TGMediaAssetsControllerSendFileIntent : TGMediaAssetsControllerSendMediaIntent, currentItem: nil, storeAssets: true, convertToJpeg: false, descriptionGenerator: legacyAssetPickerItemGenerator(), saveEditedPhotos: true) else { + return + } + self.controller?.legacyCompletion(signals, silently, scheduleTime) + self.controller?.dismiss(animated: animated) + } + + private func openLimitedMediaOptions() { + let presentationData = self.presentationData + let controller = ActionSheetController(presentationData: self.presentationData) + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Media_LimitedAccessSelectMore, color: .accent, action: { [weak self] in + dismissAction() + if #available(iOS 14.0, *), let strongController = self?.controller { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: strongController) + } + }), + ActionSheetButtonItem(title: presentationData.strings.Media_LimitedAccessChangeSettings, color: .accent, action: { [weak self] in + dismissAction() + self?.controller?.context.sharedContext.applicationBindings.openSettings() + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })]) + ]) + self.controller?.present(controller, in: .window(.root)) + } + + private func transitionView(for identifier: String) -> UIView? { + if self.selectionNode.alpha > 0.0 { + return self.selectionNode.transitionView(for: identifier) + } else { + var transitionNode: MediaPickerGridItemNode? + self.gridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? MediaPickerGridItemNode, itemNode.identifier == identifier { + transitionNode = itemNode + } + } + return transitionNode?.transitionView() + } + } + + private func enqueueTransaction(_ transaction: MediaPickerGridTransaction) { + self.enqueuedTransactions.append(transaction) + + if let _ = self.validLayout { + self.dequeueTransaction() + } + } + + private func dequeueTransaction() { + if self.enqueuedTransactions.isEmpty { + return + } + let transaction = self.enqueuedTransactions.removeFirst() + self.gridNode.transaction(GridNodeTransaction(deleteItems: transaction.deletions, insertItems: transaction.insertions, updateItems: transaction.updates, scrollToItem: transaction.scrollToItem, updateLayout: nil, itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + } + + func scrollToTop(animated: Bool = false) { + if self.selectionNode.alpha > 0.0 { + self.selectionNode.scrollToTop(animated: animated) + } else { + self.gridNode.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.gridNode.scrollView.contentInset.top), animated: animated) + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + let firstTime = self.validLayout == nil + self.validLayout = (layout, navigationBarHeight) + + var insets = layout.insets(options: []) + insets.top += navigationBarHeight + + let bounds = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: layout.size.height)) + + let itemsPerRow: Int + if case .compact = layout.metrics.widthClass { + switch layout.orientation { + case .portrait: + itemsPerRow = 3 + case .landscape: + itemsPerRow = 5 + } + } else { + itemsPerRow = 3 + } + let width = layout.size.width - layout.safeInsets.left - layout.safeInsets.right + let itemSpacing: CGFloat = 1.0 + let itemWidth = floorToScreenPixels((width - itemSpacing * CGFloat(itemsPerRow - 1)) / CGFloat(itemsPerRow)) + + var cameraRect: CGRect? = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: 0.0), size: CGSize(width: itemWidth, height: itemWidth * 2.0 + 1.0)) + + var manageHeight: CGFloat = 0.0 + if case let .assets(_, mediaAccess, cameraAccess) = self.state { + if cameraAccess == nil { + cameraRect = nil + } + if case .notDetermined = mediaAccess { + + } else { + if case .limited = mediaAccess { + let manageNode: MediaPickerManageNode + if let current = self.manageNode { + manageNode = current + } else { + manageNode = MediaPickerManageNode() + manageNode.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.openLimitedMediaOptions() + } + } + self.manageNode = manageNode + self.gridNode.addSubnode(manageNode) + } + manageHeight = manageNode.update(layout: layout, theme: self.presentationData.theme, strings: self.presentationData.strings, subject: .limitedMedia, transition: transition) + transition.updateFrame(node: manageNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -manageHeight), size: CGSize(width: layout.size.width, height: manageHeight))) + } else if [.denied, .restricted].contains(cameraAccess) { + cameraRect = nil + + let manageNode: MediaPickerManageNode + if let current = self.manageNode { + manageNode = current + } else { + manageNode = MediaPickerManageNode() + manageNode.pressed = { [weak self] in + self?.controller?.context.sharedContext.applicationBindings.openSettings() + } + self.manageNode = manageNode + self.gridNode.addSubnode(manageNode) + } + manageHeight = manageNode.update(layout: layout, theme: self.presentationData.theme, strings: self.presentationData.strings, subject: .camera, transition: transition) + transition.updateFrame(node: manageNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -manageHeight), size: CGSize(width: layout.size.width, height: manageHeight))) + } else if let manageNode = self.manageNode { + self.manageNode = nil + manageNode.removeFromSupernode() + } + } + } else { + cameraRect = nil + } + + let cleanGridInsets = UIEdgeInsets(top: insets.top, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right) + let gridInsets = UIEdgeInsets(top: insets.top + manageHeight, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right) + transition.updateFrame(node: self.gridNode, frame: bounds) + + self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: bounds.size, insets: gridInsets, scrollIndicatorInsets: nil, preloadSize: 200.0, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth), fillWidth: true, lineSpacing: itemSpacing, itemSpacing: itemSpacing), cutout: cameraRect), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil, updateOpaqueState: nil, synchronousLoads: false), completion: { [weak self] _ in + guard let strongSelf = self else { + return + } + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + Queue.mainQueue().justDispatch { + strongSelf._ready.set(.single(true)) + } + } + }) + + let selectedItems = self.interaction?.selectionState?.selectedItems() as? [TGMediaSelectableItem] ?? [] + let updateSelectionNode = { + self.selectionNode.updateLayout(size: bounds.size, insets: cleanGridInsets, items: selectedItems, grouped: self.controller?.groupedValue ?? true, theme: self.presentationData.theme, wallpaper: self.presentationData.chatWallpaper, bubbleCorners: self.presentationData.chatBubbleCorners, transition: transition) + } + + if selectedItems.count < 1 && self.currentDisplayMode == .selected { + self.updateMode(.all) + Queue.mainQueue().after(0.3, updateSelectionNode) + } else { + updateSelectionNode() + } + transition.updateFrame(node: self.selectionNode, frame: bounds) + + if let cameraView = self.cameraView { + if let cameraRect = cameraRect { + transition.updateFrame(view: cameraView, frame: cameraRect) + cameraView.isHidden = false + } else { + cameraView.isHidden = true + } + } + + if firstTime { + while !self.enqueuedTransactions.isEmpty { + self.dequeueTransaction() + } + } + + if case let .noAccess(cameraAccess) = self.state { + let placeholderNode: MediaPickerPlaceholderNode + if let current = self.placeholderNode { + placeholderNode = current + } else { + placeholderNode = MediaPickerPlaceholderNode() + placeholderNode.settingsPressed = { [weak self] in + self?.controller?.context.sharedContext.applicationBindings.openSettings() + } + placeholderNode.cameraPressed = { [weak self] in + self?.controller?.openCamera?(nil) + } + self.insertSubnode(placeholderNode, aboveSubnode: self.selectionNode) + self.placeholderNode = placeholderNode + } + placeholderNode.update(layout: layout, theme: self.presentationData.theme, strings: self.presentationData.strings, hasCamera: cameraAccess == .authorized, transition: transition) + transition.updateFrame(node: placeholderNode, frame: bounds) + } else if let placeholderNode = self.placeholderNode { + self.placeholderNode = nil + placeholderNode.removeFromSupernode() + } + } + } + + private var validLayout: ContainerViewLayout? + + private var controllerNode: Node { + return self.displayNode as! Node + } + + private let _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private var groupedValue: Bool = true { + didSet { + self.groupedPromise.set(self.groupedValue) + self.controllerNode.interaction?.selectionState?.grouping = self.groupedValue + + if let layout = self.validLayout { + self.containerLayoutUpdated(layout, transition: .immediate) + } + } + } + private let groupedPromise = ValuePromise(true) + + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peer: EnginePeer?, chatLocation: ChatLocation?) { + self.context = context + self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } + self.peer = peer + self.chatLocation = chatLocation + + self.titleView = MediaPickerSegmentedTitleView(theme: self.presentationData.theme, segments: [self.presentationData.strings.Attachment_AllMedia, self.presentationData.strings.Attachment_SelectedMedia(1)], selectedIndex: 0) + self.titleView.title = self.presentationData.strings.Attachment_Gallery + + self.moreButtonNode = MediaPickerMoreButtonNode(theme: self.presentationData.theme) + + super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: presentationData)) + + self.statusBar.statusBarStyle = .Ignore + + self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings() + } + } + }) + + self.titleView.indexUpdated = { [weak self] index in + if let strongSelf = self { + strongSelf.controllerNode.updateMode(index == 0 ? .all : .selected) + } + } + + self.navigationItem.titleView = self.titleView + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.moreButtonNode) + + self.moreButtonNode.action = { [weak self] _, gesture in + if let strongSelf = self { + strongSelf.searchOrMorePressed(node: strongSelf.moreButtonNode.contextSourceNode, gesture: gesture) + } + } + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + strongSelf.controllerNode.scrollToTop(animated: true) + } + } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + override public func loadDisplayNode() { + self.displayNode = Node(controller: self) + + self._ready.set(self.controllerNode.ready.get()) + + super.displayNodeDidLoad() + } + + private var selectionCount: Int32 = 0 + fileprivate func updateSelectionState(count: Int32) { + self.selectionCount = count + if count > 0 { + self.titleView.segments = [self.presentationData.strings.Attachment_AllMedia, self.presentationData.strings.Attachment_SelectedMedia(count)] + self.titleView.segmentsHidden = false + self.moreButtonNode.iconNode.enqueueState(.more, animated: true) + } else { + self.titleView.segmentsHidden = true + self.moreButtonNode.iconNode.enqueueState(.search, animated: true) + + if self.titleView.index != 0 { + Queue.mainQueue().after(0.3) { + self.titleView.index = 0 + } + } + } + } + + private func updateThemeAndStrings() { + self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData)) + self.titleView.theme = self.presentationData.theme + self.moreButtonNode.theme = self.presentationData.theme + self.controllerNode.updatePresentationData(self.presentationData) + } + + @objc private func cancelPressed() { + self.dismiss() + } + + @objc private func searchOrMorePressed(node: ContextReferenceContentNode, gesture: ContextGesture?) { + switch self.moreButtonNode.iconNode.iconState { + case .search: + self.requestAttachmentMenuExpansion() + self.presentWebSearch() + case .more: + let strings = self.presentationData.strings + let selectionCount = self.selectionCount + + let items: Signal = self.groupedPromise.get() + |> deliverOnMainQueue + |> map { [weak self] grouped -> ContextController.Items in + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: selectionCount > 1 ? strings.Attachment_SendAsFiles : strings.Attachment_SendAsFile, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/File"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.controllerNode.send(asFile: true, silently: false, scheduleTime: nil, animated: true) + }))) + + if selectionCount > 1 { + items.append(.separator) + + items.append(.action(ContextMenuActionItem(text: strings.Attachment_Grouped, icon: { theme in + if !grouped { + return nil + } + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.groupedValue = true + }))) + items.append(.action(ContextMenuActionItem(text: strings.Attachment_Ungrouped, icon: { theme in + if grouped { + return nil + } + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.groupedValue = false + }))) + } + + return ContextController.Items(content: .list(items)) + } + + let contextController = ContextController(account: self.context.account, presentationData: self.presentationData, source: .reference(MediaPickerContextReferenceContentSource(controller: self, sourceNode: node)), items: items, gesture: gesture) + self.presentInGlobalOverlay(contextController) + } + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.validLayout = layout + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) + } + + public var mediaPickerContext: MediaPickerContext? { + if let interaction = self.controllerNode.interaction { + return MediaPickerContext(interaction: interaction) + } else { + return nil + } + } +} + +public class MediaPickerContext: AttachmentMediaPickerContext { + private weak var interaction: MediaPickerInteraction? + + public var selectionCount: Signal { + return Signal { [weak self] subscriber in + let disposable = self?.interaction?.selectionState?.selectionChangedSignal().start(next: { [weak self] value in + subscriber.putNext(Int(self?.interaction?.selectionState?.count() ?? 0)) + }, error: { _ in }, completed: { }) + return ActionDisposable { + disposable?.dispose() + } + } + } + + public var caption: Signal { + return Signal { [weak self] subscriber in + let disposable = self?.interaction?.editingState.forcedCaption().start(next: { caption in + if let caption = caption as? NSAttributedString { + subscriber.putNext(caption) + } else { + subscriber.putNext(nil) + } + }, error: { _ in }, completed: { }) + return ActionDisposable { + disposable?.dispose() + } + } + } + + init(interaction: MediaPickerInteraction) { + self.interaction = interaction + } + + public func setCaption(_ caption: NSAttributedString) { + self.interaction?.editingState.setForcedCaption(caption, skipUpdate: true) + } + + public func send(silently: Bool, mode: AttachmentMediaPickerSendMode) { + self.interaction?.sendSelected(nil, silently, nil, true) + } + + public func schedule() { + self.interaction?.schedule() + } +} + +private final class MediaPickerContextReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceNode: ContextReferenceContentNode + + init(controller: ViewController, sourceNode: ContextReferenceContentNode) { + self.controller = controller + self.sourceNode = sourceNode + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceNode: self.sourceNode, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift new file mode 100644 index 0000000000..b6d35746ee --- /dev/null +++ b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift @@ -0,0 +1,886 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import LegacyComponents +import CheckNode +import MosaicLayout +import WallpaperBackgroundNode +import AccountContext + +private class MediaPickerSelectedItemNode: ASDisplayNode { + let asset: TGMediaAsset + private let interaction: MediaPickerInteraction? + + private let imageNode: ImageNode + private var checkNode: InteractiveCheckNode? + private var durationBackgroundNode: ASDisplayNode? + private var durationTextNode: ImmediateTextNode? + + private var theme: PresentationTheme? + + private var validLayout: CGSize? + + var corners: CACornerMask = [] { + didSet { + if #available(iOS 13.0, *) { + self.layer.cornerCurve = .circular + } + if #available(iOS 11.0, *) { + self.layer.maskedCorners = corners + } + } + } + + var radius: CGFloat = 0.0 { + didSet { + self.layer.cornerRadius = radius + } + } + + init(asset: TGMediaAsset, interaction: MediaPickerInteraction?) { + self.imageNode = ImageNode() + self.imageNode.contentMode = .scaleAspectFill + self.imageNode.clipsToBounds = true + + self.asset = asset + self.interaction = interaction + + super.init() + + self.clipsToBounds = true + + self.addSubnode(self.imageNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap))) + } + + @objc private func tap() { + self.interaction?.openSelectedMedia(asset, self.imageNode.image) + } + + func setup(size: CGSize) { + let editingState = self.interaction?.editingState + let editedSignal = Signal { subscriber in + if let editingState = editingState, let signal = editingState.thumbnailImageSignal(forIdentifier: self.asset.uniqueIdentifier) { + let disposable = signal.start(next: { next in + if let image = next as? UIImage { + subscriber.putNext(image) + } else { + subscriber.putNext(nil) + } + }, error: { _ in + }, completed: nil)! + + return ActionDisposable { + disposable.dispose() + } + } else { + return EmptyDisposable + } + } + + let dimensions: CGSize + if let adjustments = self.interaction?.editingState.adjustments(for: self.asset), adjustments.cropApplied(forAvatar: false) { + dimensions = adjustments.cropRect.size + } else { + dimensions = self.asset.dimensions + } + + let scale = min(2.0, UIScreenScale) + let scaledDimensions = dimensions.aspectFilled(CGSize(width: 320.0, height: 320.0)) + let targetSize = CGSize(width: scaledDimensions.width * scale, height: scaledDimensions.height * scale) + + let originalSignal = assetImage(asset: self.asset.backingAsset, targetSize: targetSize, exact: false) + let imageSignal: Signal = editedSignal + |> mapToSignal { result in + if let result = result { + return .single(result) + } else { + return originalSignal + } + } + self.imageNode.setSignal(imageSignal) + } + + func updateSelectionState() { + if self.checkNode == nil, let _ = self.interaction?.selectionState, let theme = self.theme { + let checkNode = InteractiveCheckNode(theme: CheckNodeTheme(theme: theme, style: .overlay)) + checkNode.valueChanged = { [weak self] value in + if let strongSelf = self, let interaction = strongSelf.interaction { + interaction.toggleSelection(strongSelf.asset, value) + } + } + self.addSubnode(checkNode) + self.checkNode = checkNode + + if let size = self.validLayout { + self.updateLayout(size: size, transition: .immediate) + } + } + + if let interaction = self.interaction, let selectionState = interaction.selectionState, let identifier = self.asset.uniqueIdentifier { + let selected = selectionState.isIdentifierSelected(identifier) + let index = selectionState.index(of: self.asset) + if index != NSNotFound { + self.checkNode?.content = .counter(Int(index)) + } + self.checkNode?.setSelected(selected, animated: false) + + if let checkNode = self.checkNode { + let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) + transition.updateAlpha(node: checkNode, alpha: selectionState.count() < 2 ? 0.0 : 1.0) + } + } + } + + func updateHiddenMedia() { + let wasHidden = self.isHidden + self.isHidden = self.interaction?.hiddenMediaId == asset.uniqueIdentifier + if !self.isHidden && wasHidden { + if let checkNode = self.checkNode, checkNode.alpha > 0.0 { + checkNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + func update(theme: PresentationTheme) { + var updatedTheme = false + if self.theme != theme { + self.theme = theme + updatedTheme = true + } + + if updatedTheme { + self.checkNode?.theme = CheckNodeTheme(theme: theme, style: .overlay) + } + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.validLayout = size + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) + + let checkSize = CGSize(width: 29.0, height: 29.0) + if let checkNode = self.checkNode { + transition.updateFrame(node: checkNode, frame: CGRect(origin: CGPoint(x: size.width - checkSize.width - 3.0, y: 3.0), size: checkSize)) + } + } + + func transitionView() -> UIView { + let view = self.imageNode.view.snapshotContentTree(unhide: true, keepTransform: true)! + if #available(iOS 13.0, *) { + view.layer.cornerCurve = self.layer.cornerCurve + } + if #available(iOS 11.0, *) { + view.layer.maskedCorners = self.layer.maskedCorners + view.layer.cornerRadius = self.layer.cornerRadius + } + view.frame = self.convert(self.bounds, to: nil) + return view + } +} + +final class MediaPickerSelectedListNode: ASDisplayNode { + private let context: AccountContext + + fileprivate let wallpaperBackgroundNode: WallpaperBackgroundNode + private let scrollNode: ASScrollNode + private var backgroundNodes: [Int: ASImageNode] = [:] + private var itemNodes: [String: MediaPickerSelectedItemNode] = [:] + + private var reorderFeedback: HapticFeedback? + private var reorderNode: ReorderingItemNode? + private var isReordering = false + + private var graphics: PrincipalThemeEssentialGraphics? + + var interaction: MediaPickerInteraction? + + private var validLayout: (size: CGSize, insets: UIEdgeInsets, items: [TGMediaSelectableItem], grouped: Bool, theme: PresentationTheme, wallpaper: TelegramWallpaper, bubbleCorners: PresentationChatBubbleCorners)? + + init(context: AccountContext) { + self.context = context + self.wallpaperBackgroundNode = createWallpaperBackgroundNode(context: context, forChatDisplay: true, useSharedAnimationPhase: false, useExperimentalImplementation: context.sharedContext.immediateExperimentalUISettings.experimentalBackground) + self.scrollNode = ASScrollNode() + + super.init() + + self.addSubnode(self.wallpaperBackgroundNode) + self.addSubnode(self.scrollNode) + } + + override func didLoad() { + super.didLoad() + + if #available(iOS 11.0, *) { + self.scrollNode.view.contentInsetAdjustmentBehavior = .never + } + + self.view.addGestureRecognizer(ReorderingGestureRecognizer(shouldBegin: { [weak self] point in + if let strongSelf = self, !strongSelf.scrollNode.view.isTracking { + for (_, itemNode) in strongSelf.itemNodes { + if itemNode.frame.contains(point) { + return (true, true, itemNode) + } + } + return (false, false, nil) + } + return (false, false, nil) + }, willBegin: { _ in +// self?.willBeginReorder(point) + }, began: { [weak self] itemNode in + self?.beginReordering(itemNode: itemNode) + }, ended: { [weak self] point in + self?.endReordering(point: point) + }, moved: { [weak self] offset in + self?.updateReordering(offset: offset) + })) + } + + func scrollToTop(animated: Bool) { + self.scrollNode.view.setContentOffset(CGPoint(), animated: animated) + } + + private func beginReordering(itemNode: MediaPickerSelectedItemNode) { + self.isReordering = true + + if let reorderNode = self.reorderNode { + reorderNode.removeFromSupernode() + } + + let reorderNode = ReorderingItemNode(itemNode: itemNode, initialLocation: itemNode.frame.origin) + self.reorderNode = reorderNode + self.addSubnode(reorderNode) + + itemNode.isHidden = true + + if self.reorderFeedback == nil { + self.reorderFeedback = HapticFeedback() + } + self.reorderFeedback?.impact() + } + + private func endReordering(point: CGPoint?) { + if let reorderNode = self.reorderNode { + self.reorderNode = nil + + if let itemNode = reorderNode.itemNode, let point = point { + var targetNode: MediaPickerSelectedItemNode? + for (_, node) in self.itemNodes { + if node.frame.contains(point) { + targetNode = node + break + } + } + + if let targetNode = targetNode, let targetIndex = self.interaction?.selectionState?.index(of: targetNode.asset) { + self.interaction?.selectionState?.move(itemNode.asset, to: targetIndex) + } + reorderNode.animateCompletion(completion: { [weak reorderNode] in + reorderNode?.removeFromSupernode() + }) + self.reorderFeedback?.tap() + } else { + reorderNode.removeFromSupernode() + reorderNode.itemNode?.isHidden = false + } + } + + self.isReordering = false + } + + private func updateReordering(offset: CGPoint) { + if let reorderNode = self.reorderNode { + reorderNode.updateOffset(offset: offset) + } + } + + private var messageNodes: [ListViewItemNode]? + private func updateItems(transition: ContainedViewLayoutTransition) { + guard let (size, insets, items, grouped, theme, wallpaper, bubbleCorners) = self.validLayout else { + return + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(1)) + var peers = SimpleDictionary() + peers[peerId] = TelegramUser(id: peerId, accessHash: nil, firstName: "", lastName: "", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: []) + + let previewMessage = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [TelegramMediaAction(action: .customText(text: presentationData.strings.Attachment_MessagePreview, entities: []))], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: []) + let previewItem = self.context.sharedContext.makeChatMessagePreviewItem(context: context, messages: [previewMessage], theme: theme, strings: presentationData.strings, wallpaper: wallpaper, fontSize: presentationData.chatFontSize, chatBubbleCorners: bubbleCorners, dateTimeFormat: presentationData.dateTimeFormat, nameOrder: presentationData.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: self.wallpaperBackgroundNode, availableReactions: nil, isCentered: true) + + let dragMessage = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [TelegramMediaAction(action: .customText(text: presentationData.strings.Attachment_DragToReorder, entities: []))], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: []) + let dragItem = self.context.sharedContext.makeChatMessagePreviewItem(context: context, messages: [dragMessage], theme: theme, strings: presentationData.strings, wallpaper: wallpaper, fontSize: presentationData.chatFontSize, chatBubbleCorners: bubbleCorners, dateTimeFormat: presentationData.dateTimeFormat, nameOrder: presentationData.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: self.wallpaperBackgroundNode, availableReactions: nil, isCentered: true) + + let headerItems: [ListViewItem] = [previewItem, dragItem] + + let params = ListViewItemLayoutParams(width: size.width, leftInset: insets.left, rightInset: insets.right, availableHeight: size.height) + if let messageNodes = self.messageNodes { + for i in 0 ..< headerItems.count { + let itemNode = messageNodes[i] + headerItems[i].updateNode(async: { $0() }, node: { + return itemNode + }, params: params, previousItem: nil, nextItem: nil, animation: .None, completion: { (layout, apply) in + let nodeFrame = CGRect(origin: itemNode.frame.origin, size: CGSize(width: size.width, height: layout.size.height)) + + itemNode.contentSize = layout.contentSize + itemNode.insets = layout.insets + itemNode.frame = nodeFrame + itemNode.isUserInteractionEnabled = false + + apply(ListViewItemApply(isOnScreen: true)) + }) + } + } else { + var messageNodes: [ListViewItemNode] = [] + for i in 0 ..< headerItems.count { + var itemNode: ListViewItemNode? + headerItems[i].nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { node, apply in + itemNode = node + apply().1(ListViewItemApply(isOnScreen: true)) + }) + itemNode!.subnodeTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0) + itemNode!.isUserInteractionEnabled = false + messageNodes.append(itemNode!) + self.scrollNode.addSubnode(itemNode!) + } + self.messageNodes = messageNodes + } + + var itemSizes: [CGSize] = [] + + let sideInset: CGFloat = 34.0 + let boundingWidth = min(320.0, size.width - insets.left - insets.right - sideInset * 2.0) + + var validIds: [String] = [] + for item in items { + guard let asset = item as? TGMediaAsset, let identifier = asset.uniqueIdentifier else { + continue + } + + validIds.append(identifier) + let itemNode: MediaPickerSelectedItemNode + if let current = self.itemNodes[identifier] { + itemNode = current + } else { + itemNode = MediaPickerSelectedItemNode(asset: asset, interaction: self.interaction) + self.itemNodes[identifier] = itemNode + self.scrollNode.addSubnode(itemNode) + + itemNode.setup(size: CGSize(width: boundingWidth, height: boundingWidth)) + } + itemNode.update(theme: theme) + itemNode.updateSelectionState() + if !self.isReordering { + itemNode.updateHiddenMedia() + } + + if let adjustments = self.interaction?.editingState.adjustments(for: asset), adjustments.cropApplied(forAvatar: false) { + itemSizes.append(adjustments.cropRect.size) + } else { + itemSizes.append(asset.dimensions) + } + } + + let boundingSize = CGSize(width: boundingWidth, height: boundingWidth) + var groupLayouts: [([(TGMediaSelectableItem, CGRect, MosaicItemPosition)], CGSize)] = [] + if grouped && items.count > 1 { + let groupSize = 10 + for i in stride(from: 0, to: itemSizes.count, by: groupSize) { + let sizes = itemSizes[i ..< min(i + groupSize, itemSizes.count)] + let items = items[i ..< min(i + groupSize, items.count)] + let (mosaicLayout, size) = chatMessageBubbleMosaicLayout(maxSize: boundingSize, itemSizes: Array(sizes), spacing: 1.0, fillWidth: true) + let layout = zip(items, mosaicLayout).map { ($0, $1.0, $1.1) } + groupLayouts.append((layout, size)) + } + } else { + for i in 0 ..< itemSizes.count { + let item = items[i] + var itemSize = itemSizes[i] + if itemSize.width > itemSize.height { + itemSize = itemSize.aspectFitted(boundingSize) + } else { + itemSize = boundingSize + } + let itemRect = CGRect(origin: CGPoint(), size: itemSize) + let position: MosaicItemPosition = [.top, .bottom, .left, .right] + groupLayouts.append(([(item, itemRect, position)], itemRect.size)) + } + } + + let spacing: CGFloat = 8.0 + var contentHeight: CGFloat = 60.0 + + if let previewNode = self.messageNodes?.first { + transition.updateFrame(node: previewNode, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top + 28.0), size: previewNode.frame.size)) + + var previewNodeFrame = previewNode.frame + previewNodeFrame.origin.y = size.height - previewNodeFrame.origin.y - previewNodeFrame.size.height + + previewNode.updateFrame(previewNodeFrame, within: size, updateFrame: false) + } + + var groupIndex = 0 + for (items, groupSize) in groupLayouts { + let groupRect = CGRect(origin: CGPoint(x: insets.left + floorToScreenPixels((size.width - insets.left - insets.right - groupSize.width) / 2.0), y: insets.top + contentHeight), size: groupSize) + + let groupBackgroundNode: ASImageNode + if let current = self.backgroundNodes[groupIndex] { + groupBackgroundNode = current + } else { + groupBackgroundNode = ASImageNode() + groupBackgroundNode.displaysAsynchronously = false + self.backgroundNodes[groupIndex] = groupBackgroundNode + self.scrollNode.insertSubnode(groupBackgroundNode, at: 0) + } + groupBackgroundNode.image = self.graphics?.chatMessageBackgroundOutgoingExtractedImage + transition.updateFrame(node: groupBackgroundNode, frame: groupRect.insetBy(dx: -6.0, dy: -3.0).offsetBy(dx: 3.0, dy: 0.0)) + + for (item, itemRect, itemPosition) in items { + if let identifier = item.uniqueIdentifier, let itemNode = self.itemNodes[identifier] { + var corners: CACornerMask = [] + if itemPosition.contains(.top) && itemPosition.contains(.left) { + corners.insert(.layerMinXMinYCorner) + } + if itemPosition.contains(.top) && itemPosition.contains(.right) { + corners.insert(.layerMaxXMinYCorner) + } + if itemPosition.contains(.bottom) && itemPosition.contains(.left) { + corners.insert(.layerMinXMaxYCorner) + } + if itemPosition.contains(.bottom) && itemPosition.contains(.right) { + corners.insert(.layerMaxXMaxYCorner) + } + itemNode.corners = corners + itemNode.radius = bubbleCorners.mainRadius + + itemNode.updateLayout(size: itemRect.size, transition: transition) + transition.updateFrame(node: itemNode, frame: itemRect.offsetBy(dx: groupRect.minX, dy: groupRect.minY)) + } + } + + contentHeight += groupSize.height + spacing + groupIndex += 1 + } + + if let dragNode = self.messageNodes?.last { + transition.updateAlpha(node: dragNode, alpha: items.count > 1 ? 1.0 : 0.0) + transition.updateFrame(node: dragNode, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top + contentHeight + 1.0), size: dragNode.frame.size)) + + var dragNodeFrame = dragNode.frame + dragNodeFrame.origin.y = size.height - dragNodeFrame.origin.y - dragNodeFrame.size.height + + dragNode.updateFrame(dragNodeFrame, within: size, updateFrame: false) + contentHeight += 60.0 + } + + contentHeight += insets.top + contentHeight += insets.bottom + + var removeIds: [String] = [] + for id in self.itemNodes.keys { + if !validIds.contains(id) { + removeIds.append(id) + } + } + for id in removeIds { + if let itemNode = self.itemNodes.removeValue(forKey: id) { + if transition.isAnimated { + itemNode.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) + itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak itemNode] _ in + itemNode?.removeFromSupernode() + }) + } else { + itemNode.removeFromSupernode() + } + } + } + + for id in self.backgroundNodes.keys { + if id > groupLayouts.count - 1 { + if let itemNode = self.backgroundNodes.removeValue(forKey: id) { + if transition.isAnimated { + itemNode.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) + itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak itemNode] _ in + itemNode?.removeFromSupernode() + }) + } else { + itemNode.removeFromSupernode() + } + } + } + } + + if case let .animated(duration, curve) = transition, self.scrollNode.view.contentSize.height > contentHeight { + let maxContentOffset = max(0.0, contentHeight - self.scrollNode.frame.height) + if self.scrollNode.view.contentOffset.y > maxContentOffset { + let updatedBounds = CGRect(origin: CGPoint(x: 0.0, y: maxContentOffset), size: self.scrollNode.bounds.size) + let previousBounds = self.scrollNode.bounds + self.scrollNode.bounds = updatedBounds + self.scrollNode.layer.animateBounds(from: previousBounds, to: updatedBounds, duration: duration, timingFunction: curve.timingFunction) + } + } + + self.scrollNode.view.contentSize = CGSize(width: size.width, height: contentHeight) + } + + func updateSelectionState() { + for (_, itemNode) in self.itemNodes { + itemNode.updateSelectionState() + } + } + + func updateHiddenMedia() { + for (_, itemNode) in self.itemNodes { + itemNode.updateHiddenMedia() + } + } + + func updateLayout(size: CGSize, insets: UIEdgeInsets, items: [TGMediaSelectableItem], grouped: Bool, theme: PresentationTheme, wallpaper: TelegramWallpaper, bubbleCorners: PresentationChatBubbleCorners, transition: ContainedViewLayoutTransition) { + let previous = self.validLayout + self.validLayout = (size, insets, items, grouped, theme, wallpaper, bubbleCorners) + + if previous?.theme !== theme || previous?.wallpaper != wallpaper || previous?.bubbleCorners != bubbleCorners { + self.graphics = PresentationResourcesChat.principalGraphics(theme: theme, wallpaper: wallpaper, bubbleCorners: bubbleCorners) + } + + var itemsTransition = transition + if previous?.grouped != grouped { + if let snapshotView = self.view.snapshotView(afterScreenUpdates: false) { + self.view.addSubview(snapshotView) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + itemsTransition = .immediate + } + + let inset: CGFloat = insets.left == 70 ? insets.left : 0.0 + self.wallpaperBackgroundNode.update(wallpaper: wallpaper) + self.wallpaperBackgroundNode.updateBubbleTheme(bubbleTheme: theme, bubbleCorners: bubbleCorners) + transition.updateFrame(node: self.wallpaperBackgroundNode, frame: CGRect(origin: CGPoint(x: inset, y: 0.0), size: CGSize(width: size.width - inset * 2.0, height: size.height))) + self.wallpaperBackgroundNode.updateLayout(size: CGSize(width: size.width - inset * 2.0, height: size.height), transition: transition) + + self.updateItems(transition: itemsTransition) + + let bounds = CGRect(origin: CGPoint(), size: size) + transition.updateFrame(node: self.scrollNode, frame: bounds) + } + + func transitionView(for identifier: String) -> UIView? { + for (_, itemNode) in self.itemNodes { + if itemNode.asset.uniqueIdentifier == identifier { + return itemNode.transitionView() + } + } + return nil + } +} + +private class ReorderingGestureRecognizer: UIGestureRecognizer { + private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, itemNode: MediaPickerSelectedItemNode?) + private let willBegin: (CGPoint) -> Void + private let began: (MediaPickerSelectedItemNode) -> Void + private let ended: (CGPoint?) -> Void + private let moved: (CGPoint) -> Void + + private var initialLocation: CGPoint? + private var longTapTimer: SwiftSignalKit.Timer? + private var longPressTimer: SwiftSignalKit.Timer? + + private var itemNode: MediaPickerSelectedItemNode? + + public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, itemNode: MediaPickerSelectedItemNode?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (MediaPickerSelectedItemNode) -> Void, ended: @escaping (CGPoint?) -> Void, moved: @escaping (CGPoint) -> Void) { + self.shouldBegin = shouldBegin + self.willBegin = willBegin + self.began = began + self.ended = ended + self.moved = moved + + super.init(target: nil, action: nil) + } + + deinit { + self.longTapTimer?.invalidate() + self.longPressTimer?.invalidate() + } + + private func startLongTapTimer() { + self.longTapTimer?.invalidate() + let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in + self?.longTapTimerFired() + }, queue: Queue.mainQueue()) + self.longTapTimer = longTapTimer + longTapTimer.start() + } + + private func stopLongTapTimer() { + self.itemNode = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + } + + private func startLongPressTimer() { + self.longPressTimer?.invalidate() + let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in + self?.longPressTimerFired() + }, queue: Queue.mainQueue()) + self.longPressTimer = longPressTimer + longPressTimer.start() + } + + private func stopLongPressTimer() { + self.itemNode = nil + self.longPressTimer?.invalidate() + self.longPressTimer = nil + } + + override public func reset() { + super.reset() + + self.itemNode = nil + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + } + + private func longTapTimerFired() { + guard let location = self.initialLocation else { + return + } + + self.longTapTimer?.invalidate() + self.longTapTimer = nil + + self.willBegin(location) + } + + private func longPressTimerFired() { + guard let _ = self.initialLocation else { + return + } + + self.state = .began + self.longPressTimer?.invalidate() + self.longPressTimer = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + if let itemNode = self.itemNode { + self.began(itemNode) + } + } + + override public func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if self.numberOfTouches > 1 { + self.state = .failed + self.ended(nil) + return + } + + if self.state == .possible { + if let location = touches.first?.location(in: self.view) { + let (allowed, requiresLongPress, itemNode) = self.shouldBegin(location) + if allowed { + self.itemNode = itemNode + self.initialLocation = location + if requiresLongPress { + self.startLongTapTimer() + self.startLongPressTimer() + } else { + self.state = .began + if let itemNode = self.itemNode { + self.began(itemNode) + } + } + } else { + self.state = .failed + } + } else { + self.state = .failed + } + } + } + + override public func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.stopLongPressTimer() + self.state = .failed + } + if self.state == .began || self.state == .changed { + if let location = touches.first?.location(in: self.view) { + self.ended(location) + } else { + self.ended(nil) + } + self.state = .failed + } + } + + override public func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.stopLongPressTimer() + self.state = .failed + } + if self.state == .began || self.state == .changed { + self.ended(nil) + self.state = .failed + } + } + + override public func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { + self.state = .changed + self.moved(CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y)) + } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { + let touchLocation = touch.location(in: self.view) + let dX = touchLocation.x - initialTapLocation.x + let dY = touchLocation.y - initialTapLocation.y + + if dX * dX + dY * dY > 3.0 * 3.0 { + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + self.state = .failed + } + } + } +} + + +private func generateShadowImage(corners: CACornerMask, radius: CGFloat) -> UIImage? { + return generateImage(CGSize(width: 120.0, height: 120), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + +// context.saveGState() + context.setShadow(offset: CGSize(), blur: 28.0, color: UIColor(white: 0.0, alpha: 0.4).cgColor) + + var rectCorners: UIRectCorner = [] + if corners.contains(.layerMinXMinYCorner) { + rectCorners.insert(.topLeft) + } + if corners.contains(.layerMaxXMinYCorner) { + rectCorners.insert(.topRight) + } + if corners.contains(.layerMinXMaxYCorner) { + rectCorners.insert(.bottomLeft) + } + if corners.contains(.layerMaxXMaxYCorner) { + rectCorners.insert(.bottomRight) + } + + let path = UIBezierPath(roundedRect: CGRect(x: 30.0, y: 30.0, width: 60.0, height: 60.0), byRoundingCorners: rectCorners, cornerRadii: CGSize(width: radius, height: radius)).cgPath + context.addPath(path) + context.fillPath() +// context.restoreGState() + +// context.setBlendMode(.clear) +// context.addPath(path) +// context.fillPath() + })?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 60) +} + +private final class CopyView: UIView { + let shadow: UIImageView + var snapshotView: UIView? + + init(frame: CGRect, corners: CACornerMask, radius: CGFloat) { + self.shadow = UIImageView() + self.shadow.contentMode = .scaleToFill + + super.init(frame: frame) + + self.shadow.image = generateShadowImage(corners: corners, radius: radius) + + self.addSubview(self.shadow) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ReorderingItemNode: ASDisplayNode { + weak var itemNode: MediaPickerSelectedItemNode? + + var currentState: (Int, Int)? + + private let copyView: CopyView + private let initialLocation: CGPoint + + init(itemNode: MediaPickerSelectedItemNode, initialLocation: CGPoint) { + self.itemNode = itemNode + self.copyView = CopyView(frame: CGRect(), corners: itemNode.corners, radius: itemNode.radius) + let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) + self.initialLocation = initialLocation + + super.init() + + if let snapshotView = snapshotView { + snapshotView.frame = CGRect(origin: CGPoint(), size: itemNode.bounds.size) + snapshotView.bounds.origin = itemNode.bounds.origin + self.copyView.addSubview(snapshotView) + self.copyView.snapshotView = snapshotView + } + self.view.addSubview(self.copyView) + self.copyView.frame = CGRect(origin: CGPoint(x: initialLocation.x, y: initialLocation.y), size: itemNode.bounds.size) + self.copyView.shadow.frame = CGRect(origin: CGPoint(x: -30.0, y: -30.0), size: CGSize(width: itemNode.bounds.size.width + 60.0, height: itemNode.bounds.size.height + 60.0)) + self.copyView.shadow.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + + self.copyView.snapshotView?.layer.animateScale(from: 1.0, to: 1.05, duration: 0.25, removeOnCompletion: false) + self.copyView.shadow.layer.animateScale(from: 1.0, to: 1.05, duration: 0.25, removeOnCompletion: false) + } + + func updateOffset(offset: CGPoint) { + self.copyView.frame = CGRect(origin: CGPoint(x: initialLocation.x + offset.x, y: initialLocation.y + offset.y), size: copyView.bounds.size) + } + + func currentOffset() -> CGFloat? { + return self.copyView.center.y + } + + func animateCompletion(completion: @escaping () -> Void) { + if let itemNode = self.itemNode { + itemNode.view.superview?.bringSubviewToFront(itemNode.view) + + itemNode.layer.animateScale(from: 1.05, to: 1.0, duration: 0.25, removeOnCompletion: false) + + let sourceFrame = self.view.convert(self.copyView.frame, to: itemNode.supernode?.view) + let targetFrame = itemNode.frame + itemNode.updateLayout(size: sourceFrame.size, transition: .immediate) + itemNode.layer.animateFrame(from: sourceFrame, to: targetFrame, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + completion() + }) + itemNode.updateLayout(size: targetFrame.size, transition: .animated(duration: 0.3, curve: .spring)) + + itemNode.isHidden = false + self.copyView.isHidden = true + } else { + completion() + } + } +} diff --git a/submodules/MosaicLayout/BUILD b/submodules/MosaicLayout/BUILD index 76cc9fbe05..91330d0440 100644 --- a/submodules/MosaicLayout/BUILD +++ b/submodules/MosaicLayout/BUILD @@ -9,6 +9,9 @@ swift_library( copts = [ "-warnings-as-errors", ], + deps = [ + "//submodules/Display:Display", + ], visibility = [ "//visibility:public", ], diff --git a/submodules/MosaicLayout/Sources/ChatMessageBubbleMosaicLayout.swift b/submodules/MosaicLayout/Sources/ChatMessageBubbleMosaicLayout.swift index a96c94f818..134a758464 100644 --- a/submodules/MosaicLayout/Sources/ChatMessageBubbleMosaicLayout.swift +++ b/submodules/MosaicLayout/Sources/ChatMessageBubbleMosaicLayout.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import Display public struct MosaicItemPosition: OptionSet { public var rawValue: Int32 @@ -34,9 +35,7 @@ private struct MosaicLayoutAttempt { let heights: [CGFloat] } -public func chatMessageBubbleMosaicLayout(maxSize: CGSize, itemSizes: [CGSize]) -> ([(CGRect, MosaicItemPosition)], CGSize) { - let spacing: CGFloat = 2.0 - +public func chatMessageBubbleMosaicLayout(maxSize: CGSize, itemSizes: [CGSize], spacing: CGFloat = 1.0, fillWidth: Bool = false) -> ([(CGRect, MosaicItemPosition)], CGSize) { var proportions = "" var averageAspectRatio: CGFloat = 1.0 var forceCalc = false @@ -103,9 +102,14 @@ public func chatMessageBubbleMosaicLayout(maxSize: CGSize, itemSizes: [CGSize]) let thirdHeight = min((maxSize.height - spacing) * 0.5, round(itemInfos[1].aspectRatio * (maxSize.width - spacing) / (itemInfos[2].aspectRatio + itemInfos[1].aspectRatio))) let secondHeight = maxSize.height - thirdHeight - spacing - let rightWidth = max(minWidth, min((maxSize.width - spacing) * 0.5, round(min(thirdHeight * itemInfos[2].aspectRatio, secondHeight * itemInfos[1].aspectRatio)))) - - let leftWidth = round(min(firstHeight * itemInfos[0].aspectRatio, (maxSize.width - spacing - rightWidth))) + var rightWidth = max(minWidth, min((maxSize.width - spacing) * 0.5, round(min(thirdHeight * itemInfos[2].aspectRatio, secondHeight * itemInfos[1].aspectRatio)))) + if fillWidth { + rightWidth = floorToScreenPixels(maxSize.width / 2.0) + } + var leftWidth = round(min(firstHeight * itemInfos[0].aspectRatio, (maxSize.width - spacing - rightWidth))) + if fillWidth { + leftWidth = maxSize.width - spacing - rightWidth + } itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: leftWidth, height: firstHeight) itemInfos[0].position = [.top, .left, .bottom] diff --git a/submodules/PeerInfoUI/Sources/PeerReportController.swift b/submodules/PeerInfoUI/Sources/PeerReportController.swift index bd18b1a8cd..23796bf3c3 100644 --- a/submodules/PeerInfoUI/Sources/PeerReportController.swift +++ b/submodules/PeerInfoUI/Sources/PeerReportController.swift @@ -29,6 +29,8 @@ public enum PeerReportOption { case copyright case pornography case childAbuse + case illegalDrugs + case personalDetails case other } @@ -59,6 +61,12 @@ public func presentPeerReportOptions(context: AccountContext, parent: ViewContro case .copyright: title = presentationData.strings.ReportPeer_ReasonCopyright icon = UIImage(bundleImageName: "Chat/Context Menu/ReportCopyright") + case .illegalDrugs: + title = presentationData.strings.ReportPeer_ReasonIllegalDrugs + icon = UIImage(bundleImageName: "Chat/Context Menu/ReportCopyright") + case .personalDetails: + title = presentationData.strings.ReportPeer_ReasonPersonalDetails + icon = UIImage(bundleImageName: "Chat/Context Menu/ReportCopyright") case .other: title = presentationData.strings.ReportPeer_ReasonOther icon = UIImage(bundleImageName: "Chat/Context Menu/Report") @@ -82,6 +90,10 @@ public func presentPeerReportOptions(context: AccountContext, parent: ViewContro reportReason = .childAbuse case .copyright: reportReason = .copyright + case .illegalDrugs: + reportReason = .illegalDrugs + case .personalDetails: + reportReason = .personalDetails case .other: reportReason = .custom } @@ -191,6 +203,10 @@ public func peerReportOptionsController(context: AccountContext, subject: PeerRe title = presentationData.strings.ReportPeer_ReasonChildAbuse case .copyright: title = presentationData.strings.ReportPeer_ReasonCopyright + case .illegalDrugs: + title = presentationData.strings.ReportPeer_ReasonIllegalDrugs + case .personalDetails: + title = presentationData.strings.ReportPeer_ReasonPersonalDetails case .other: title = presentationData.strings.ReportPeer_ReasonOther } @@ -209,6 +225,10 @@ public func peerReportOptionsController(context: AccountContext, subject: PeerRe reportReason = .childAbuse case .copyright: reportReason = .copyright + case .illegalDrugs: + reportReason = .illegalDrugs + case .personalDetails: + reportReason = .personalDetails case .other: reportReason = .custom } diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index 0d7e667934..c2982a9ff6 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -1835,6 +1835,11 @@ public func chatWebpageSnippetFile(account: Account, mediaReference: AnyMediaRef context.withFlippedContext { c in c.setBlendMode(.copy) + if let emptyColor = arguments.emptyColor { + c.setFillColor(emptyColor.cgColor) + c.fill(arguments.drawingRect) + } + if arguments.boundingSize.width > arguments.imageSize.width || arguments.boundingSize.height > arguments.imageSize.height { c.fill(arguments.drawingRect) } @@ -1847,7 +1852,21 @@ public func chatWebpageSnippetFile(account: Account, mediaReference: AnyMediaRef return context } else { - return nil + if let emptyColor = arguments.emptyColor { + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + context.withFlippedContext { c in + c.setBlendMode(.copy) + c.setFillColor(emptyColor.cgColor) + c.fill(arguments.drawingRect) + } + + addCorners(context, arguments: arguments) + + return context + } else { + return nil + } } } } diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 250feb01f5..2fc4aafa3d 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -137,6 +137,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1278304028] = { return Api.storage.FileType.parse_fileMp4($0) } dict[276907596] = { return Api.storage.FileType.parse_fileWebp($0) } dict[1338747336] = { return Api.messages.ArchivedStickers.parse_archivedStickers($0) } + dict[-2132064081] = { return Api.GroupCallStreamChannel.parse_groupCallStreamChannel($0) } dict[406307684] = { return Api.InputEncryptedFile.parse_inputEncryptedFileEmpty($0) } dict[1690108678] = { return Api.InputEncryptedFile.parse_inputEncryptedFileUploaded($0) } dict[1511503333] = { return Api.InputEncryptedFile.parse_inputEncryptedFile($0) } @@ -146,6 +147,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-341428482] = { return Api.GroupCallParticipant.parse_groupCallParticipant($0) } dict[1443858741] = { return Api.messages.SentEncryptedMessage.parse_sentEncryptedMessage($0) } dict[-1802240206] = { return Api.messages.SentEncryptedMessage.parse_sentEncryptedFile($0) } + dict[-790330702] = { return Api.phone.GroupCallStreamChannels.parse_groupCallStreamChannels($0) } dict[289586518] = { return Api.SavedContact.parse_savedPhoneContact($0) } dict[1571494644] = { return Api.ExportedMessageLink.parse_exportedMessageLink($0) } dict[872119224] = { return Api.auth.Authorization.parse_authorization($0) } @@ -514,6 +516,8 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1685456582] = { return Api.ReportReason.parse_inputReportReasonCopyright($0) } dict[-606798099] = { return Api.ReportReason.parse_inputReportReasonGeoIrrelevant($0) } dict[-170010905] = { return Api.ReportReason.parse_inputReportReasonFake($0) } + dict[177124030] = { return Api.ReportReason.parse_inputReportReasonIllegalDrugs($0) } + dict[-1631091139] = { return Api.ReportReason.parse_inputReportReasonPersonalDetails($0) } dict[-247351839] = { return Api.InputEncryptedChat.parse_inputEncryptedChat($0) } dict[-524237339] = { return Api.PageTableRow.parse_pageTableRow($0) } dict[453805082] = { return Api.DraftMessage.parse_draftMessageEmpty($0) } @@ -1098,6 +1102,8 @@ public struct Api { _1.serialize(buffer, boxed) case let _1 as Api.messages.ArchivedStickers: _1.serialize(buffer, boxed) + case let _1 as Api.GroupCallStreamChannel: + _1.serialize(buffer, boxed) case let _1 as Api.InputEncryptedFile: _1.serialize(buffer, boxed) case let _1 as Api.account.Takeout: @@ -1108,6 +1114,8 @@ public struct Api { _1.serialize(buffer, boxed) case let _1 as Api.messages.SentEncryptedMessage: _1.serialize(buffer, boxed) + case let _1 as Api.phone.GroupCallStreamChannels: + _1.serialize(buffer, boxed) case let _1 as Api.SavedContact: _1.serialize(buffer, boxed) case let _1 as Api.ExportedMessageLink: diff --git a/submodules/TelegramApi/Sources/Api2.swift b/submodules/TelegramApi/Sources/Api2.swift index 786fb00bd7..b634b7e837 100644 --- a/submodules/TelegramApi/Sources/Api2.swift +++ b/submodules/TelegramApi/Sources/Api2.swift @@ -3568,6 +3568,48 @@ public extension Api { } } + } + public enum GroupCallStreamChannel: TypeConstructorDescription { + case groupCallStreamChannel(channel: Int32, scale: Int32, lastTimestampMs: Int64) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .groupCallStreamChannel(let channel, let scale, let lastTimestampMs): + if boxed { + buffer.appendInt32(-2132064081) + } + serializeInt32(channel, buffer: buffer, boxed: false) + serializeInt32(scale, buffer: buffer, boxed: false) + serializeInt64(lastTimestampMs, buffer: buffer, boxed: false) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .groupCallStreamChannel(let channel, let scale, let lastTimestampMs): + return ("groupCallStreamChannel", [("channel", channel), ("scale", scale), ("lastTimestampMs", lastTimestampMs)]) + } + } + + public static func parse_groupCallStreamChannel(_ reader: BufferReader) -> GroupCallStreamChannel? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Int32? + _2 = reader.readInt32() + var _3: Int64? + _3 = reader.readInt64() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + if _c1 && _c2 && _c3 { + return Api.GroupCallStreamChannel.groupCallStreamChannel(channel: _1!, scale: _2!, lastTimestampMs: _3!) + } + else { + return nil + } + } + } public enum InputEncryptedFile: TypeConstructorDescription { case inputEncryptedFileEmpty @@ -12968,6 +13010,8 @@ public extension Api { case inputReportReasonCopyright case inputReportReasonGeoIrrelevant case inputReportReasonFake + case inputReportReasonIllegalDrugs + case inputReportReasonPersonalDetails public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { @@ -13018,6 +13062,18 @@ public extension Api { buffer.appendInt32(-170010905) } + break + case .inputReportReasonIllegalDrugs: + if boxed { + buffer.appendInt32(177124030) + } + + break + case .inputReportReasonPersonalDetails: + if boxed { + buffer.appendInt32(-1631091139) + } + break } } @@ -13040,6 +13096,10 @@ public extension Api { return ("inputReportReasonGeoIrrelevant", []) case .inputReportReasonFake: return ("inputReportReasonFake", []) + case .inputReportReasonIllegalDrugs: + return ("inputReportReasonIllegalDrugs", []) + case .inputReportReasonPersonalDetails: + return ("inputReportReasonPersonalDetails", []) } } @@ -13067,6 +13127,12 @@ public extension Api { public static func parse_inputReportReasonFake(_ reader: BufferReader) -> ReportReason? { return Api.ReportReason.inputReportReasonFake } + public static func parse_inputReportReasonIllegalDrugs(_ reader: BufferReader) -> ReportReason? { + return Api.ReportReason.inputReportReasonIllegalDrugs + } + public static func parse_inputReportReasonPersonalDetails(_ reader: BufferReader) -> ReportReason? { + return Api.ReportReason.inputReportReasonPersonalDetails + } } public enum InputEncryptedChat: TypeConstructorDescription { diff --git a/submodules/TelegramApi/Sources/Api4.swift b/submodules/TelegramApi/Sources/Api4.swift index a5e27a13fd..6550b65bc2 100644 --- a/submodules/TelegramApi/Sources/Api4.swift +++ b/submodules/TelegramApi/Sources/Api4.swift @@ -1722,6 +1722,46 @@ public struct photos { } public extension Api { public struct phone { + public enum GroupCallStreamChannels: TypeConstructorDescription { + case groupCallStreamChannels(channels: [Api.GroupCallStreamChannel]) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .groupCallStreamChannels(let channels): + if boxed { + buffer.appendInt32(-790330702) + } + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(channels.count)) + for item in channels { + item.serialize(buffer, true) + } + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .groupCallStreamChannels(let channels): + return ("groupCallStreamChannels", [("channels", channels)]) + } + } + + public static func parse_groupCallStreamChannels(_ reader: BufferReader) -> GroupCallStreamChannels? { + var _1: [Api.GroupCallStreamChannel]? + if let _ = reader.readInt32() { + _1 = Api.parseVector(reader, elementSignature: 0, elementType: Api.GroupCallStreamChannel.self) + } + let _c1 = _1 != nil + if _c1 { + return Api.phone.GroupCallStreamChannels.groupCallStreamChannels(channels: _1!) + } + else { + return nil + } + } + + } public enum JoinAsPeers: TypeConstructorDescription { case joinAsPeers(peers: [Api.Peer], chats: [Api.Chat], users: [Api.User]) @@ -4657,6 +4697,22 @@ public extension Api { return result }) } + + public static func searchSentMedia(q: String, filter: Api.MessagesFilter, limit: Int32) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(276705696) + serializeString(q, buffer: buffer, boxed: false) + filter.serialize(buffer, true) + serializeInt32(limit, buffer: buffer, boxed: false) + return (FunctionDescription(name: "messages.searchSentMedia", parameters: [("q", q), ("filter", filter), ("limit", limit)]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.Messages? in + let reader = BufferReader(buffer) + var result: Api.messages.Messages? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.messages.Messages + } + return result + }) + } } public struct channels { public static func readHistory(channel: Api.InputChannel, maxId: Int32) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { @@ -6208,6 +6264,20 @@ public extension Api { return result }) } + + public static func resolvePhone(phone: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(-1963375804) + serializeString(phone, buffer: buffer, boxed: false) + return (FunctionDescription(name: "contacts.resolvePhone", parameters: [("phone", phone)]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.contacts.ResolvedPeer? in + let reader = BufferReader(buffer) + var result: Api.contacts.ResolvedPeer? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.contacts.ResolvedPeer + } + return result + }) + } } public struct help { public static func test() -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { @@ -8603,6 +8673,20 @@ public extension Api { return result }) } + + public static func getGroupCallStreamChannels(call: Api.InputGroupCall) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(447879488) + call.serialize(buffer, true) + return (FunctionDescription(name: "phone.getGroupCallStreamChannels", parameters: [("call", call)]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.phone.GroupCallStreamChannels? in + let reader = BufferReader(buffer) + var result: Api.phone.GroupCallStreamChannels? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.phone.GroupCallStreamChannels + } + return result + }) + } } } } diff --git a/submodules/TelegramCore/Sources/State/Serialization.swift b/submodules/TelegramCore/Sources/State/Serialization.swift index 13969ad1e9..a94c7fa7ca 100644 --- a/submodules/TelegramCore/Sources/State/Serialization.swift +++ b/submodules/TelegramCore/Sources/State/Serialization.swift @@ -210,7 +210,7 @@ public class BoxedMessage: NSObject { public class Serialization: NSObject, MTSerialization { public func currentLayer() -> UInt { - return 138 + return 139 } public func parseMessage(_ data: Data!) -> Any! { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/LoadMessagesIfNecessary.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/LoadMessagesIfNecessary.swift index daff840c21..052613f22c 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/LoadMessagesIfNecessary.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/LoadMessagesIfNecessary.swift @@ -7,10 +7,10 @@ import MtProtoKit public enum GetMessagesStrategy { case local - case cloud + case cloud(skipLocal: Bool) } -func _internal_getMessagesLoadIfNecessary(_ messageIds: [MessageId], postbox: Postbox, network: Network, accountPeerId: PeerId, strategy: GetMessagesStrategy = .cloud) -> Signal <[Message], NoError> { +func _internal_getMessagesLoadIfNecessary(_ messageIds: [MessageId], postbox: Postbox, network: Network, accountPeerId: PeerId, strategy: GetMessagesStrategy = .cloud(skipLocal: false)) -> Signal <[Message], NoError> { let postboxSignal = postbox.transaction { transaction -> ([Message], Set, SimpleDictionary) in var ids = messageIds @@ -22,21 +22,29 @@ func _internal_getMessagesLoadIfNecessary(_ messageIds: [MessageId], postbox: Po var messages:[Message] = [] var missingMessageIds:Set = Set() - var supportPeers:SimpleDictionary = SimpleDictionary() + var supportPeers: SimpleDictionary = SimpleDictionary() for messageId in ids { - if let message = transaction.getMessage(messageId) { - messages.append(message) - } else { + if case let .cloud(skipLocal) = strategy, skipLocal { missingMessageIds.insert(messageId) if let peer = transaction.getPeer(messageId.peerId) { supportPeers[messageId.peerId] = peer } + } else { + if let message = transaction.getMessage(messageId) { + messages.append(message) + + } else { + missingMessageIds.insert(messageId) + if let peer = transaction.getPeer(messageId.peerId) { + supportPeers[messageId.peerId] = peer + } + } } } return (messages, missingMessageIds, supportPeers) } - if strategy == .cloud { + if case .cloud = strategy { return postboxSignal |> mapToSignal { (existMessages, missingMessageIds, supportPeers) in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift index 2ab9f5a231..0f2c44af65 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SearchMessages.swift @@ -10,7 +10,7 @@ public enum SearchMessagesLocation: Equatable { case group(groupId: PeerGroupId, tags: MessageTags?, minDate: Int32?, maxDate: Int32?) case peer(peerId: PeerId, fromId: PeerId?, tags: MessageTags?, topMsgId: MessageId?, minDate: Int32?, maxDate: Int32?) case publicForwards(messageId: MessageId, datacenterId: Int?) - case recentDocuments + case sentMedia(tags: MessageTags?) } private struct SearchMessagesPeerState: Equatable { @@ -339,42 +339,20 @@ func _internal_searchMessages(account: Account, location: SearchMessagesLocation return .single((nil, nil)) } } - case .recentDocuments: - let filter: Api.MessagesFilter = messageFilterForTagMask(.file) ?? .inputMessagesFilterEmpty - let peerId = account.peerId + case let .sentMedia(tags): + let filter: Api.MessagesFilter = tags.flatMap { messageFilterForTagMask($0) } ?? .inputMessagesFilterEmpty - remoteSearchResult = account.postbox.transaction { transaction -> Peer? in - guard let peer = transaction.getPeer(peerId) else { - return nil + let peerMessages: Signal + if let completed = state?.main.completed, completed { + peerMessages = .single(nil) + } else { + peerMessages = account.network.request(Api.functions.messages.searchSentMedia(q: query, filter: filter, limit: limit)) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) } - return peer - } - |> mapToSignal { peer -> Signal<(Api.messages.Messages?, Api.messages.Messages?), NoError> in - guard let peer = peer else { - return .single((nil, nil)) - } - let inputPeer = Api.InputPeer.inputPeerEmpty - - var flags: Int32 = 0 - - let fromInputPeer = apiInputPeer(peer) - flags |= (1 << 0) - - let peerMessages: Signal - if let completed = state?.main.completed, completed { - peerMessages = .single(nil) - } else { - let lowerBound = state?.main.messages.last.flatMap({ $0.index }) - let signal = account.network.request(Api.functions.messages.search(flags: flags, peer: inputPeer, q: query, fromId: fromInputPeer, topMsgId: nil, filter: filter, minDate: 0, maxDate: (Int32.max - 1), offsetId: lowerBound?.id.id ?? 0, addOffset: 0, limit: limit, maxId: Int32.max - 1, minId: 0, hash: 0)) - - peerMessages = signal - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - } - return combineLatest(peerMessages, .single(nil)) } + remoteSearchResult = combineLatest(peerMessages, .single(nil)) } return remoteSearchResult diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 23681e12cf..4e454657b6 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -120,7 +120,7 @@ public extension TelegramEngine { return _internal_markAllChatsAsRead(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager) } - public func getMessagesLoadIfNecessary(_ messageIds: [MessageId], strategy: GetMessagesStrategy = .cloud) -> Signal <[Message], NoError> { + public func getMessagesLoadIfNecessary(_ messageIds: [MessageId], strategy: GetMessagesStrategy = .cloud(skipLocal: false)) -> Signal <[Message], NoError> { return _internal_getMessagesLoadIfNecessary(messageIds, postbox: self.account.postbox, network: self.account.network, accountPeerId: self.account.peerId, strategy: strategy) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift index 73b3242312..0777db7d45 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift @@ -83,6 +83,8 @@ public enum ReportReason: Equatable { case childAbuse case copyright case irrelevantLocation + case illegalDrugs + case personalDetails case custom } @@ -103,6 +105,10 @@ private extension ReportReason { return .inputReportReasonCopyright case .irrelevantLocation: return .inputReportReasonGeoIrrelevant + case .illegalDrugs: + return .inputReportReasonIllegalDrugs + case .personalDetails: + return .inputReportReasonPersonalDetails case .custom: return .inputReportReasonOther } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 1cb00bb78c..ff61a7197e 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -263,6 +263,7 @@ swift_library( "//submodules/Pasteboard:Pasteboard", "//submodules/ChatSendMessageActionUI:ChatSendMessageActionUI", "//submodules/ChatTextLinkEditUI:ChatTextLinkEditUI", + "//submodules/MediaPickerUI:MediaPickerUI", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Contents.json index b63884fcb4..364c182b89 100644 --- a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Icon-3.pdf", + "filename" : "Icon-5.pdf", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-3.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-3.pdf deleted file mode 100644 index 480dc55c21..0000000000 --- a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-3.pdf +++ /dev/null @@ -1,107 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 3.000000 5.000000 cm -0.635294 0.635294 0.635294 scn -0.000000 12.652475 m -0.000000 13.439779 0.000000 13.833430 0.049142 14.163219 c -0.340950 16.121538 1.878462 17.659050 3.836780 17.950859 c -4.166570 18.000000 4.560222 18.000000 5.347525 18.000000 c -5.881966 18.000000 l -6.567171 18.000000 7.193567 18.387135 7.500000 19.000000 c -7.806434 19.612865 8.432830 20.000000 9.118035 20.000000 c -14.881966 20.000000 l -15.567171 20.000000 16.193567 19.612865 16.500000 19.000000 c -16.806433 18.387135 17.432829 18.000000 18.118034 18.000000 c -18.652475 18.000000 l -19.439775 18.000000 19.833426 18.000000 20.163214 17.950859 c -22.121534 17.659052 23.659048 16.121538 23.950855 14.163218 c -23.999996 13.833429 23.999996 13.439779 23.999996 12.652477 c -23.999996 7.200001 l -23.999996 4.679764 23.999996 3.419645 23.509525 2.457043 c -23.078094 1.610313 22.389683 0.921902 21.542953 0.490471 c -20.580351 0.000000 19.320232 0.000000 16.799995 0.000000 c -7.199999 0.000000 l -4.679763 0.000000 3.419646 0.000000 2.457043 0.490471 c -1.610313 0.921902 0.921901 1.610313 0.490471 2.457043 c -0.000000 3.419645 0.000000 4.679764 0.000000 7.200000 c -0.000000 12.652475 l -h -12.000000 12.375000 m -10.136039 12.375000 8.625000 10.863961 8.625000 9.000000 c -8.625000 7.136039 10.136039 5.625000 12.000000 5.625000 c -13.863961 5.625000 15.375000 7.136039 15.375000 9.000000 c -15.375000 10.863961 13.863961 12.375000 12.000000 12.375000 c -h -6.375000 9.000000 m -6.375000 12.106602 8.893398 14.625000 12.000000 14.625000 c -15.106602 14.625000 17.625000 12.106602 17.625000 9.000000 c -17.625000 5.893398 15.106602 3.375000 12.000000 3.375000 c -8.893398 3.375000 6.375000 5.893398 6.375000 9.000000 c -h -19.500000 12.000000 m -20.328426 12.000000 21.000000 12.671573 21.000000 13.500000 c -21.000000 14.328427 20.328426 15.000000 19.500000 15.000000 c -18.671574 15.000000 18.000000 14.328427 18.000000 13.500000 c -18.000000 12.671573 18.671574 12.000000 19.500000 12.000000 c -h -f* -n -Q - -endstream -endobj - -3 0 obj - 2018 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Pages 5 0 R - /Type /Catalog - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000002108 00000 n -0000002131 00000 n -0000002304 00000 n -0000002378 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -2437 -%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-5.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-5.pdf new file mode 100644 index 0000000000..4d66595ab5 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Camera.imageset/Icon-5.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Contents.json new file mode 100644 index 0000000000..7bcea847a4 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Icon-7.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Icon-7.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Icon-7.pdf new file mode 100644 index 0000000000..6c292676a7 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/OpenCamera.imageset/Icon-7.pdf @@ -0,0 +1,180 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.334961 4.334656 cm +0.000000 0.478431 1.000000 scn +7.922659 16.330362 m +7.990485 16.330341 l +11.339519 16.330341 l +11.407344 16.330362 l +11.830773 16.330563 12.150443 16.330715 12.458707 16.256708 c +12.730634 16.191423 12.990590 16.083746 13.229033 15.937628 c +13.499336 15.771986 13.725266 15.545843 14.024528 15.246298 c +14.024536 15.246290 l +14.072486 15.198310 l +14.523190 14.747606 l +14.550632 14.720162 14.558728 14.712102 14.566200 14.704887 c +14.809235 14.470261 15.132179 14.336493 15.469935 14.330548 c +15.480320 14.330365 15.491745 14.330341 15.530556 14.330341 c +15.544931 14.330341 l +15.655980 14.330346 15.729312 14.330349 15.793977 14.328072 c +17.716871 14.260361 19.260023 12.717211 19.327732 10.794315 c +19.330008 10.729712 19.330006 10.656460 19.330002 10.545589 c +19.330002 10.545401 l +19.330002 10.530893 l +19.330002 5.465342 l +19.330002 5.436434 l +19.330002 5.436368 l +19.330008 4.620798 19.330011 3.968183 19.286921 3.440796 c +19.242689 2.899414 19.149759 2.431705 18.930542 2.001467 c +18.579166 1.311852 18.018492 0.751179 17.328878 0.399803 c +16.898640 0.180586 16.430929 0.087656 15.889549 0.043423 c +15.362137 0.000332 14.709483 0.000336 13.893861 0.000341 c +13.865003 0.000341 l +5.465001 0.000341 l +5.436143 0.000341 l +4.620519 0.000336 3.967866 0.000332 3.440454 0.043423 c +2.899074 0.087656 2.431364 0.180586 2.001126 0.399803 c +1.311511 0.751179 0.750838 1.311852 0.399462 2.001467 c +0.180244 2.431705 0.087314 2.899414 0.043082 3.440796 c +-0.000010 3.968214 -0.000005 4.620880 0.000001 5.436520 c +0.000001 5.465342 l +0.000001 10.530894 l +0.000000 10.545269 l +-0.000004 10.656319 -0.000006 10.729650 0.002271 10.794315 c +0.069981 12.717211 1.613132 14.260361 3.536026 14.328072 c +3.600692 14.330349 3.674023 14.330346 3.785072 14.330341 c +3.799448 14.330341 l +3.838259 14.330341 3.849683 14.330365 3.860067 14.330548 c +4.197824 14.336493 4.520769 14.470261 4.763803 14.704887 c +4.771275 14.712102 4.779370 14.720162 4.806813 14.747605 c +5.257516 15.198309 l +5.305459 15.246283 l +5.604728 15.545835 5.830662 15.771983 6.100970 15.937628 c +6.339413 16.083746 6.599370 16.191423 6.871296 16.256708 c +7.179560 16.330715 7.499230 16.330563 7.922659 16.330362 c +h +7.990485 15.000341 m +7.469651 15.000341 7.317619 14.996068 7.181778 14.963455 c +7.045560 14.930752 6.915338 14.876812 6.795893 14.803617 c +6.676778 14.730623 6.566254 14.626143 6.197969 14.257857 c +5.747265 13.807154 l +5.743656 13.803544 l +5.721325 13.781212 5.704509 13.764395 5.687559 13.748032 c +5.202402 13.279657 4.557722 13.012622 3.883473 13.000754 c +3.859918 13.000340 3.836137 13.000340 3.804559 13.000341 c +3.804546 13.000341 l +3.804533 13.000341 l +3.799448 13.000341 l +3.668953 13.000341 3.620954 13.000237 3.582830 12.998896 c +2.357739 12.955757 1.374586 11.972603 1.331447 10.747512 c +1.330105 10.709388 1.330001 10.661388 1.330001 10.530894 c +1.330001 5.465342 l +1.330001 4.614290 1.330518 4.015995 1.368665 3.549099 c +1.406177 3.089968 1.476738 2.816771 1.584501 2.605274 c +1.808365 2.165915 2.165575 1.808706 2.604933 1.584841 c +2.816429 1.477079 3.089627 1.406519 3.548759 1.369005 c +4.015654 1.330858 4.613949 1.330341 5.465001 1.330341 c +13.865003 1.330341 l +14.716054 1.330341 15.314349 1.330858 15.781244 1.369005 c +16.240376 1.406519 16.513573 1.477079 16.725071 1.584841 c +17.164429 1.808706 17.521639 2.165915 17.745502 2.605274 c +17.853266 2.816771 17.923826 3.089968 17.961338 3.549099 c +17.999485 4.015995 18.000002 4.614290 18.000002 5.465342 c +18.000002 10.530893 l +18.000002 10.661388 17.999899 10.709388 17.998556 10.747512 c +17.955418 11.972603 16.972263 12.955757 15.747173 12.998896 c +15.709049 13.000237 15.661050 13.000341 15.530556 13.000341 c +15.525471 13.000341 l +15.525459 13.000341 l +15.525448 13.000341 l +15.493867 13.000340 15.470086 13.000340 15.446530 13.000754 c +14.772282 13.012622 14.127602 13.279657 13.642445 13.748032 c +13.625524 13.764366 13.608735 13.781154 13.586460 13.803428 c +13.586380 13.803510 l +13.586320 13.803570 l +13.582737 13.807155 l +13.132033 14.257858 l +12.763749 14.626143 12.653225 14.730623 12.534110 14.803617 c +12.414665 14.876812 12.284443 14.930752 12.148225 14.963455 c +12.012384 14.996068 11.860353 15.000341 11.339519 15.000341 c +7.990485 15.000341 l +h +9.665002 10.500341 m +7.961203 10.500341 6.580001 9.119140 6.580001 7.415341 c +6.580001 5.711544 7.961203 4.330341 9.665002 4.330341 c +11.368801 4.330341 12.750002 5.711544 12.750002 7.415341 c +12.750002 9.119140 11.368801 10.500341 9.665002 10.500341 c +h +5.250001 7.415341 m +5.250001 9.853679 7.226664 11.830341 9.665002 11.830341 c +12.103339 11.830341 14.080002 9.853679 14.080002 7.415341 c +14.080002 4.977005 12.103339 3.000341 9.665002 3.000341 c +7.226664 3.000341 5.250001 4.977005 5.250001 7.415341 c +h +15.665001 9.665341 m +16.217285 9.665341 16.665001 10.113056 16.665001 10.665341 c +16.665001 11.217627 16.217285 11.665341 15.665001 11.665341 c +15.112716 11.665341 14.665001 11.217627 14.665001 10.665341 c +14.665001 10.113056 15.112716 9.665341 15.665001 9.665341 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 5070 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000005160 00000 n +0000005183 00000 n +0000005356 00000 n +0000005430 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +5489 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Contents.json index 85d93ec670..1c55b3fd69 100644 --- a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Icon-4.pdf", + "filename" : "Type=Default-5.pdf", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Icon-4.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Icon-4.pdf deleted file mode 100644 index 6f32987e94..0000000000 --- a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Icon-4.pdf +++ /dev/null @@ -1,157 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 4.500000 4.499939 cm -0.635294 0.635294 0.635294 scn -8.325001 21.000061 m -8.276846 21.000061 l -7.057586 21.000072 6.074163 21.000082 5.277799 20.935015 c -4.457843 20.868023 3.737631 20.726484 3.071304 20.386972 c -2.012892 19.847685 1.152376 18.987169 0.613089 17.928757 c -0.273578 17.262430 0.132038 16.542217 0.065045 15.722262 c --0.000020 14.925898 -0.000011 13.942474 0.000000 12.723215 c -0.000000 12.675060 l -0.000000 8.325061 l -0.000000 8.276906 l --0.000011 7.057647 -0.000020 6.074224 0.065045 5.277859 c -0.132038 4.457905 0.273578 3.737692 0.613089 3.071363 c -1.152376 2.012953 2.012892 1.152437 3.071304 0.613150 c -3.737631 0.273638 4.457843 0.132099 5.277799 0.065105 c -6.074152 0.000040 7.057559 0.000050 8.276797 0.000061 c -8.276814 0.000061 l -8.325001 0.000061 l -12.675000 0.000061 l -12.723186 0.000061 l -12.723204 0.000061 l -13.942441 0.000050 14.925848 0.000040 15.722202 0.065105 c -16.542156 0.132099 17.262369 0.273638 17.928698 0.613150 c -18.987108 1.152437 19.847624 2.012953 20.386911 3.071363 c -20.726423 3.737692 20.867962 4.457905 20.934956 5.277860 c -21.000021 6.074213 21.000011 7.057621 21.000000 8.276858 c -21.000000 8.276875 l -21.000000 8.325062 l -21.000000 12.675060 l -21.000000 12.723248 l -21.000000 12.723265 l -21.000011 13.942501 21.000021 14.925909 20.934956 15.722262 c -20.867962 16.542217 20.726423 17.262430 20.386911 17.928757 c -19.847624 18.987169 18.987108 19.847685 17.928698 20.386972 c -17.262369 20.726484 16.542156 20.868023 15.722201 20.935015 c -14.925837 21.000082 13.942413 21.000072 12.723154 21.000061 c -12.674999 21.000061 l -8.325001 21.000061 l -h -9.000000 16.800060 m -9.000000 17.220100 9.000000 17.430120 9.081745 17.590553 c -9.153650 17.731674 9.268385 17.846411 9.409508 17.918316 c -9.569941 18.000061 9.779961 18.000061 10.200000 18.000061 c -10.799999 18.000061 l -11.220039 18.000061 11.430059 18.000061 11.590492 17.918316 c -11.731615 17.846411 11.846350 17.731674 11.918255 17.590553 c -12.000000 17.430120 12.000000 17.220100 12.000000 16.800060 c -12.000000 4.200062 l -12.000000 3.780022 12.000000 3.570002 11.918255 3.409569 c -11.846350 3.268448 11.731615 3.153711 11.590492 3.081806 c -11.430059 3.000061 11.220039 3.000061 10.800000 3.000061 c -10.200001 3.000061 l -9.779961 3.000061 9.569941 3.000061 9.409508 3.081806 c -9.268385 3.153711 9.153650 3.268448 9.081745 3.409569 c -9.000000 3.570002 9.000000 3.780022 9.000000 4.200062 c -9.000000 16.800060 l -h -3.750000 12.300061 m -3.750000 12.720100 3.750000 12.930120 3.831745 13.090553 c -3.903650 13.231675 4.018386 13.346411 4.159507 13.418316 c -4.319942 13.500061 4.529961 13.500061 4.950000 13.500061 c -5.550001 13.500061 l -5.970040 13.500061 6.180060 13.500061 6.340494 13.418316 c -6.481615 13.346411 6.596350 13.231675 6.668255 13.090553 c -6.750000 12.930120 6.750000 12.720100 6.750000 12.300060 c -6.750000 4.200062 l -6.750000 3.780022 6.750000 3.570002 6.668255 3.409569 c -6.596350 3.268448 6.481615 3.153711 6.340494 3.081806 c -6.180060 3.000061 5.970040 3.000061 5.550001 3.000061 c -4.950000 3.000061 l -4.529961 3.000061 4.319942 3.000061 4.159507 3.081806 c -4.018386 3.153711 3.903650 3.268448 3.831745 3.409569 c -3.750000 3.570002 3.750000 3.780022 3.750000 4.200062 c -3.750000 12.300061 l -h -14.331745 8.590554 m -14.250000 8.430120 14.250000 8.220100 14.250000 7.800061 c -14.250000 4.200062 l -14.250000 3.780022 14.250000 3.570002 14.331745 3.409569 c -14.403650 3.268448 14.518386 3.153711 14.659507 3.081806 c -14.819941 3.000061 15.029961 3.000061 15.450000 3.000061 c -16.049999 3.000061 l -16.470039 3.000061 16.680059 3.000061 16.840492 3.081806 c -16.981613 3.153711 17.096350 3.268448 17.168255 3.409569 c -17.250000 3.570002 17.250000 3.780022 17.250000 4.200062 c -17.250000 7.800061 l -17.250000 8.220100 17.250000 8.430120 17.168255 8.590554 c -17.096350 8.731675 16.981613 8.846411 16.840492 8.918316 c -16.680059 9.000061 16.470039 9.000061 16.049999 9.000061 c -15.450000 9.000061 l -15.029961 9.000061 14.819941 9.000061 14.659507 8.918316 c -14.518386 8.846411 14.403650 8.731675 14.331745 8.590554 c -h -f* -n -Q - -endstream -endobj - -3 0 obj - 4096 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Pages 5 0 R - /Type /Catalog - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000004186 00000 n -0000004209 00000 n -0000004382 00000 n -0000004456 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -4515 -%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Type=Default-5.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Type=Default-5.pdf new file mode 100644 index 0000000000..3a2c273f95 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Poll.imageset/Type=Default-5.pdf @@ -0,0 +1,113 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 3.000000 cm +0.000000 0.000000 0.000000 scn +0.653961 20.723944 m +0.000000 19.440472 0.000000 17.760315 0.000000 14.400000 c +0.000000 9.600000 l +0.000000 6.239685 0.000000 4.559526 0.653961 3.276056 c +1.229201 2.147085 2.147084 1.229200 3.276057 0.653961 c +4.559527 0.000000 6.239685 0.000000 9.600000 0.000000 c +14.400000 0.000000 l +17.760315 0.000000 19.440474 0.000000 20.723944 0.653961 c +21.852915 1.229200 22.770800 2.147085 23.346039 3.276056 c +24.000000 4.559526 24.000000 6.239685 24.000000 9.600000 c +24.000000 14.400000 l +24.000000 17.760315 24.000000 19.440472 23.346039 20.723944 c +22.770800 21.852915 21.852915 22.770800 20.723944 23.346039 c +19.440474 24.000000 17.760315 24.000000 14.400000 24.000000 c +9.600000 24.000000 l +6.239685 24.000000 4.559527 24.000000 3.276057 23.346039 c +2.147084 22.770800 1.229201 21.852915 0.653961 20.723944 c +h +11.500000 20.000000 m +10.947716 20.000000 10.500000 19.552284 10.500000 19.000000 c +10.500000 5.000000 l +10.500000 4.447716 10.947715 4.000000 11.500000 4.000000 c +12.500000 4.000000 l +13.052284 4.000000 13.500000 4.447716 13.500000 5.000000 c +13.500000 19.000000 l +13.500000 19.552284 13.052284 20.000000 12.500000 20.000000 c +11.500000 20.000000 l +h +5.000000 14.000000 m +4.447715 14.000000 4.000000 13.552284 4.000000 13.000000 c +4.000000 5.000000 l +4.000000 4.447716 4.447715 4.000000 5.000000 4.000000 c +6.000000 4.000000 l +6.552284 4.000000 7.000000 4.447716 7.000000 5.000000 c +7.000000 13.000000 l +7.000000 13.552285 6.552285 14.000000 6.000000 14.000000 c +5.000000 14.000000 l +h +17.000000 9.000000 m +17.000000 9.552284 17.447716 10.000000 18.000000 10.000000 c +19.000000 10.000000 l +19.552284 10.000000 20.000000 9.552284 20.000000 9.000000 c +20.000000 5.000000 l +20.000000 4.447716 19.552284 4.000000 19.000000 4.000000 c +18.000000 4.000000 l +17.447716 4.000000 17.000000 4.447716 17.000000 5.000000 c +17.000000 9.000000 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1975 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002065 00000 n +0000002088 00000 n +0000002261 00000 n +0000002335 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2394 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Contents.json new file mode 100644 index 0000000000..17e9707f2e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Icon-6.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Icon-6.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Icon-6.pdf new file mode 100644 index 0000000000..100a3df29e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/File.imageset/Icon-6.pdf @@ -0,0 +1,152 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 5.334961 3.839600 cm +0.000000 0.000000 0.000000 scn +5.500002 14.986980 m +5.376047 14.998394 5.192461 15.000357 4.839518 15.000357 c +3.865001 15.000357 l +3.293976 15.000357 2.905700 14.999840 2.605557 14.975317 c +2.313177 14.951428 2.163465 14.908116 2.058924 14.854851 c +1.807728 14.726860 1.603498 14.522631 1.475508 14.271434 c +1.422241 14.166893 1.378930 14.017180 1.355041 13.724800 c +1.330518 13.424658 1.330001 13.036383 1.330001 12.465357 c +1.330001 3.865356 l +1.330001 3.294331 1.330518 2.906055 1.355041 2.605912 c +1.378929 2.313533 1.422241 2.163820 1.475508 2.059279 c +1.603498 1.808083 1.807728 1.603854 2.058924 1.475863 c +2.163465 1.422597 2.313177 1.379285 2.605557 1.355396 c +2.905700 1.330873 3.293976 1.330357 3.865001 1.330357 c +9.465001 1.330357 l +10.036027 1.330357 10.424302 1.330873 10.724444 1.355396 c +11.016825 1.379285 11.166537 1.422597 11.271078 1.475863 c +11.522275 1.603854 11.726503 1.808083 11.854495 2.059279 c +11.907761 2.163820 11.951073 2.313533 11.974961 2.605913 c +11.999484 2.906056 12.000001 3.294332 12.000001 3.865356 c +12.000001 7.839873 l +12.000001 8.192817 11.998038 8.376402 11.986625 8.500357 c +9.365002 8.500357 l +9.337519 8.500357 l +8.800825 8.500348 8.357979 8.500341 7.997253 8.529814 c +7.622625 8.560422 7.278400 8.626103 6.955117 8.790825 c +6.453665 9.046327 6.045972 9.454020 5.790470 9.955472 c +5.625749 10.278755 5.560067 10.622980 5.529459 10.997608 c +5.499986 11.358337 5.499993 11.801186 5.500001 12.337883 c +5.500002 12.365356 l +5.500002 14.986980 l +h +11.059549 9.830357 m +6.830002 14.059904 l +6.830002 12.365356 l +6.830002 11.794331 6.830519 11.406055 6.855042 11.105913 c +6.878930 10.813533 6.922242 10.663820 6.975508 10.559279 c +7.103499 10.308083 7.307728 10.103853 7.558925 9.975863 c +7.663465 9.922597 7.813178 9.879285 8.105557 9.855396 c +8.405701 9.830873 8.793976 9.830357 9.365002 9.830357 c +11.059549 9.830357 l +h +5.958706 16.256723 m +5.650443 16.330730 5.330773 16.330578 4.907343 16.330378 c +4.839518 16.330357 l +3.865001 16.330357 l +3.837527 16.330357 l +3.300830 16.330366 2.857981 16.330372 2.497253 16.300900 c +2.122625 16.270292 1.778399 16.204611 1.455117 16.039888 c +0.953665 15.784387 0.545971 15.376694 0.290469 14.875241 c +0.125748 14.551959 0.060067 14.207733 0.029458 13.833105 c +-0.000014 13.472384 -0.000007 13.029548 0.000000 12.492866 c +0.000000 12.492830 l +0.000000 12.465357 l +0.000000 3.865356 l +0.000000 3.837883 l +0.000000 3.837849 l +-0.000007 3.301166 -0.000014 2.858329 0.029458 2.497608 c +0.060067 2.122980 0.125748 1.778755 0.290469 1.455472 c +0.545971 0.954020 0.953665 0.546327 1.455117 0.290825 c +1.778399 0.126104 2.122625 0.060423 2.497253 0.029814 c +2.857968 0.000343 3.300798 0.000349 3.837470 0.000357 c +3.837543 0.000357 l +3.865001 0.000357 l +9.465001 0.000357 l +9.492460 0.000357 l +9.492532 0.000357 l +10.029204 0.000349 10.472034 0.000343 10.832749 0.029814 c +11.207377 0.060423 11.551602 0.126104 11.874886 0.290825 c +12.376338 0.546327 12.784031 0.954020 13.039534 1.455472 c +13.204254 1.778755 13.269936 2.122980 13.300544 2.497608 c +13.330016 2.858333 13.330009 3.301176 13.330001 3.837869 c +13.330001 3.837897 l +13.330001 3.865356 l +13.330001 7.839873 l +13.330022 7.907694 l +13.330022 7.907717 l +13.330222 8.331139 13.330374 8.650803 13.256367 8.959062 c +13.191083 9.230988 13.083405 9.490944 12.937287 9.729388 c +12.771642 9.999696 12.545493 10.225630 12.245939 10.524900 c +12.197968 10.572842 l +7.572486 15.198324 l +7.524543 15.246297 l +7.524540 15.246299 l +7.225273 15.545851 6.999340 15.771998 6.729033 15.937643 c +6.490589 16.083761 6.230633 16.191439 5.958706 16.256723 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 3669 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003759 00000 n +0000003782 00000 n +0000003955 00000 n +0000004029 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4088 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/AttachmentFileController.swift b/submodules/TelegramUI/Sources/AttachmentFileController.swift index 37347b0261..cc0763cd5d 100644 --- a/submodules/TelegramUI/Sources/AttachmentFileController.swift +++ b/submodules/TelegramUI/Sources/AttachmentFileController.swift @@ -13,14 +13,19 @@ import AccountContext import ItemListPeerActionItem import AttachmentUI import TelegramStringFormatting +import ListMessageItem private final class AttachmentFileControllerArguments { + let context: AccountContext let openGallery: () -> Void let openFiles: () -> Void + let send: (Message) -> Void - init(openGallery: @escaping () -> Void, openFiles: @escaping () -> Void) { + init(context: AccountContext, openGallery: @escaping () -> Void, openFiles: @escaping () -> Void, send: @escaping (Message) -> Void) { + self.context = context self.openGallery = openGallery self.openFiles = openFiles + self.send = send } } @@ -29,7 +34,10 @@ private enum AttachmentFileSection: Int32 { case recent } -private func areMessagesEqual(_ lhsMessage: Message, _ rhsMessage: Message) -> Bool { +private func areMessagesEqual(_ lhsMessage: Message?, _ rhsMessage: Message?) -> Bool { + guard let lhsMessage = lhsMessage, let rhsMessage = rhsMessage else { + return lhsMessage == nil && rhsMessage == nil + } if lhsMessage.stableVersion != rhsMessage.stableVersion { return false } @@ -44,7 +52,7 @@ private enum AttachmentFileEntry: ItemListNodeEntry { case selectFromFiles(PresentationTheme, String) case recentHeader(PresentationTheme, String) - case file(Int32, PresentationTheme, Message) + case file(Int32, PresentationTheme, Message?) var section: ItemListSectionId { switch self { @@ -89,7 +97,7 @@ private enum AttachmentFileEntry: ItemListNodeEntry { return false } case let .file(lhsIndex, lhsTheme, lhsMessage): - if case let .file(rhsIndex, rhsTheme, rhsMessage) = rhs, lhsIndex != rhsIndex, lhsTheme === rhsTheme, areMessagesEqual(lhsMessage, rhsMessage) { + if case let .file(rhsIndex, rhsTheme, rhsMessage) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, areMessagesEqual(lhsMessage, rhsMessage) { return true } else { return false @@ -115,33 +123,34 @@ private enum AttachmentFileEntry: ItemListNodeEntry { case let .recentHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .file(_, _, message): - let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile - let label: String - if let file = file { - label = dataSizeString(file.size ?? 0, formatting: DataSizeStringFormatting(strings: presentationData.strings, decimalSeparator: ".")) - } else { - label = "" - } - return ItemListDisclosureItem(presentationData: presentationData, title: file?.fileName ?? "", label: label, sectionId: self.section, style: .blocks, action: { - - }) + let interaction = ListMessageItemInteraction(openMessage: { message, _ in + arguments.send(message) + return false + }, openMessageContextMenu: { _, _, _, _, _ in }, toggleMessagesSelection: { _, _ in }, openUrl: { _, _, _, _ in }, openInstantPage: { _, _ in }, longTap: { _, _ in }, getHiddenMedia: { return [:] }) + return ListMessageItem(presentationData: ChatPresentationData(presentationData: arguments.context.sharedContext.currentPresentationData.with({$0})), context: arguments.context, chatLocation: .peer(PeerId(0)), interaction: interaction, message: message, selection: .none, displayHeader: false, displayFileInfo: false, displayBackground: true, style: .blocks) } } } private func attachmentFileControllerEntries(presentationData: PresentationData, recentDocuments: [Message]?) -> [AttachmentFileEntry] { var entries: [AttachmentFileEntry] = [] - entries.append(.selectFromGallery(presentationData.theme, presentationData.strings.Attachment_SelectFromGallery)) entries.append(.selectFromFiles(presentationData.theme, presentationData.strings.Attachment_SelectFromFiles)) - if let _ = recentDocuments { -// entries.append(.recentHeader(presentationData.theme, "RECENTLY SENT FILES".uppercased())) -// var i: Int32 = 0 -// for file in recentDocuments { -// entries.append(.file(i, presentationData.theme, file)) -// i += 1 -// } + if let recentDocuments = recentDocuments { + if recentDocuments.count > 0 { + entries.append(.recentHeader(presentationData.theme, presentationData.strings.Attachment_RecentlySentFiles.uppercased())) + var i: Int32 = 0 + for file in recentDocuments { + entries.append(.file(i, presentationData.theme, file)) + i += 1 + } + } + } else { + entries.append(.recentHeader(presentationData.theme, presentationData.strings.Attachment_RecentlySentFiles.uppercased())) + for i in 0 ..< 8 { + entries.append(.file(Int32(i), presentationData.theme, nil)) + } } return entries @@ -151,30 +160,98 @@ private class AttachmentFileControllerImpl: ItemListController, AttachmentContai public var requestAttachmentMenuExpansion: () -> Void = {} } -public func attachmentFileController(context: AccountContext, presentGallery: @escaping () -> Void, presentFiles: @escaping () -> Void) -> AttachmentContainable { +private struct AttachmentFileControllerState: Equatable { + var searching: Bool +} + +public func attachmentFileController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, presentGallery: @escaping () -> Void, presentFiles: @escaping () -> Void, send: @escaping (AnyMediaReference) -> Void) -> AttachmentContainable { let actionsDisposable = DisposableSet() + let statePromise = ValuePromise(AttachmentFileControllerState(searching: false), ignoreRepeated: true) + let stateValue = Atomic(value: AttachmentFileControllerState(searching: false)) + let updateState: ((AttachmentFileControllerState) -> AttachmentFileControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var expandImpl: (() -> Void)? var dismissImpl: (() -> Void)? - let arguments = AttachmentFileControllerArguments(openGallery: { - presentGallery() - }, openFiles: { - presentFiles() - }) + var dismissInputImpl: (() -> Void)? + let arguments = AttachmentFileControllerArguments( + context: context, + openGallery: { + presentGallery() + }, + openFiles: { + presentFiles() + }, + send: { message in + let _ = (context.engine.messages.getMessagesLoadIfNecessary([message.id], strategy: .cloud(skipLocal: true)) + |> deliverOnMainQueue).start(next: { messages in + if let message = messages.first, let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile { + send(.message(message: MessageReference(message), media: file)) + } + dismissImpl?() + }) + } + ) let recentDocuments: Signal<[Message]?, NoError> = .single(nil) |> then( - context.engine.messages.searchMessages(location: .recentDocuments, query: "", state: nil) + context.engine.messages.searchMessages(location: .sentMedia(tags: [.file]), query: "", state: nil) |> map { result -> [Message]? in return result.0.messages } ) + + let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData - let signal = combineLatest(queue: Queue.mainQueue(), context.sharedContext.presentationData, recentDocuments) - |> map { presentationData, recentDocuments -> (ItemListControllerState, (ItemListNodeState, Any)) in + let previousRecentDocuments = Atomic<[Message]?>(value: nil) + let signal = combineLatest(queue: Queue.mainQueue(), presentationData, recentDocuments, statePromise.get()) + |> map { presentationData, recentDocuments, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let previousRecentDocuments = previousRecentDocuments.swap(recentDocuments) + let crossfade = previousRecentDocuments == nil && recentDocuments != nil + var animateChanges = false + if let previousRecentDocuments = previousRecentDocuments, let recentDocuments = recentDocuments, !previousRecentDocuments.isEmpty && !recentDocuments.isEmpty, !crossfade { + animateChanges = true + } + + var rightNavigationButton: ItemListNavigationButton? + if let recentDocuments = recentDocuments, recentDocuments.count > 10 { + rightNavigationButton = ItemListNavigationButton(content: .icon(.search), style: .regular, enabled: true, action: { + expandImpl?() + updateState { state in + var updatedState = state + updatedState.searching = true + return updatedState + } + }) + } + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.Attachment_File), leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { dismissImpl?() - }), rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: attachmentFileControllerEntries(presentationData: presentationData, recentDocuments: recentDocuments), style: .blocks, emptyStateItem: nil, animateChanges: false) + }), rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) + + var emptyItem: AttachmentFileEmptyStateItem? + if let recentDocuments = recentDocuments, recentDocuments.isEmpty { + emptyItem = AttachmentFileEmptyStateItem(context: context, theme: presentationData.theme, strings: presentationData.strings) + } + + var searchItem: ItemListControllerSearch? + if state.searching { + searchItem = AttachmentFileSearchItem(context: context, presentationData: presentationData, cancel: { + updateState { state in + var updatedState = state + updatedState.searching = false + return updatedState + } + }, send: { message in + arguments.send(message) + }, dismissInput: { + dismissInputImpl?() + }) + } + + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: attachmentFileControllerEntries(presentationData: presentationData, recentDocuments: recentDocuments), style: .blocks, emptyStateItem: emptyItem, searchItem: searchItem, crossfadeState: crossfade, animateChanges: animateChanges) return (controllerState, (listState, arguments)) } |> afterDisposed { @@ -185,5 +262,11 @@ public func attachmentFileController(context: AccountContext, presentGallery: @e dismissImpl = { [weak controller] in controller?.dismiss(animated: true) } + dismissInputImpl = { [weak controller] in + controller?.view.endEditing(true) + } + expandImpl = { [weak controller] in + controller?.requestAttachmentMenuExpansion() + } return controller } diff --git a/submodules/TelegramUI/Sources/AttachmentFileEmptyItem.swift b/submodules/TelegramUI/Sources/AttachmentFileEmptyItem.swift new file mode 100644 index 0000000000..9b381b665a --- /dev/null +++ b/submodules/TelegramUI/Sources/AttachmentFileEmptyItem.swift @@ -0,0 +1,103 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import ItemListUI +import PresentationDataUtils +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import AccountContext + +final class AttachmentFileEmptyStateItem: ItemListControllerEmptyStateItem { + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings) { + self.context = context + self.theme = theme + self.strings = strings + } + + func isEqual(to: ItemListControllerEmptyStateItem) -> Bool { + if let item = to as? AttachmentFileEmptyStateItem { + return self.theme === item.theme && self.strings === item.strings + } else { + return false + } + } + + func node(current: ItemListControllerEmptyStateItemNode?) -> ItemListControllerEmptyStateItemNode { + if let current = current as? AttachmentFileEmptyStateItemNode { + current.item = self + return current + } else { + return AttachmentFileEmptyStateItemNode(item: self) + } + } +} + +final class AttachmentFileEmptyStateItemNode: ItemListControllerEmptyStateItemNode { + private var animationNode: AnimatedStickerNode + private let textNode: ASTextNode + private var validLayout: (ContainerViewLayout, CGFloat)? + + var item: AttachmentFileEmptyStateItem { + didSet { + self.updateThemeAndStrings(theme: self.item.theme, strings: self.item.strings) + if let (layout, navigationHeight) = self.validLayout { + self.updateLayout(layout: layout, navigationBarHeight: navigationHeight, transition: .immediate) + } + } + } + + init(item: AttachmentFileEmptyStateItem) { + self.item = item + + self.animationNode = AnimatedStickerNode() + self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "Files"), width: 320, height: 320, playbackMode: .loop, mode: .direct(cachePathPrefix: nil)) + self.animationNode.visibility = true + + self.textNode = ASTextNode() + self.textNode.isUserInteractionEnabled = false + self.textNode.lineSpacing = 0.1 + self.textNode.textAlignment = .center + + super.init() + + self.isUserInteractionEnabled = false + + self.addSubnode(self.animationNode) + self.addSubnode(self.textNode) + + self.updateThemeAndStrings(theme: self.item.theme, strings: self.item.strings) + } + + private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.textNode.attributedText = NSAttributedString(string: strings.Attachment_FilesIntro, font: Font.regular(15.0), textColor: theme.list.freeTextColor, paragraphAlignment: .center) + } + + override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.validLayout = (layout, navigationBarHeight) + var insets = layout.insets(options: []) + insets.top += navigationBarHeight - 92.0 + + let imageSpacing: CGFloat = 12.0 + + let imageSize = CGSize(width: 144.0, height: 144.0) + let imageHeight = layout.size.width < layout.size.height ? imageSize.height + imageSpacing : 0.0 + + self.animationNode.frame = CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: -10.0), size: imageSize) + self.animationNode.updateLayout(size: imageSize) + + let textSize = self.textNode.measure(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 70.0, height: max(1.0, layout.size.height - insets.top - insets.bottom))) + + let totalHeight = imageHeight + textSize.height + let topOffset = insets.top + floor((layout.size.height - insets.top - insets.bottom - totalHeight) / 2.0) + + transition.updateAlpha(node: self.animationNode, alpha: imageHeight > 0.0 ? 1.0 : 0.0) + transition.updateFrame(node: self.animationNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: topOffset), size: imageSize)) + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - textSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: topOffset + imageHeight), size: textSize)) + } +} diff --git a/submodules/TelegramUI/Sources/AttachmentFileSearchItem.swift b/submodules/TelegramUI/Sources/AttachmentFileSearchItem.swift new file mode 100644 index 0000000000..d758cdab1f --- /dev/null +++ b/submodules/TelegramUI/Sources/AttachmentFileSearchItem.swift @@ -0,0 +1,566 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import ItemListUI +import PresentationDataUtils +import TelegramPresentationData +import TelegramUIPreferences +import AccountContext +import SearchBarNode +import MergeLists +import ChatListSearchItemHeader +import ItemListUI +import SearchUI +import ContextUI +import ListMessageItem + +private let searchBarFont = Font.regular(17.0) + +private final class AttachmentFileSearchNavigationContentNode: NavigationBarContentNode, ItemListControllerSearchNavigationContentNode { + private var theme: PresentationTheme + private let strings: PresentationStrings + + private let cancel: () -> Void + + private let searchBar: SearchBarNode + + private var queryUpdated: ((String) -> Void)? + var activity: Bool = false { + didSet { + self.searchBar.activity = activity + } + } + init(theme: PresentationTheme, strings: PresentationStrings, cancel: @escaping () -> Void, updateActivity: @escaping(@escaping(Bool)->Void) -> Void) { + self.theme = theme + self.strings = strings + + self.cancel = cancel + + self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: strings, fieldStyle: .modern, displayBackground: false) + + super.init() + + self.addSubnode(self.searchBar) + + self.searchBar.cancel = { [weak self] in + self?.searchBar.deactivate(clear: false) + self?.cancel() + } + + self.searchBar.textUpdated = { [weak self] query, _ in + self?.queryUpdated?(query) + } + + updateActivity({ [weak self] value in + self?.activity = value + }) + + self.updatePlaceholder() + } + + func setQueryUpdated(_ f: @escaping (String) -> Void) { + self.queryUpdated = f + } + + func updateTheme(_ theme: PresentationTheme) { + self.theme = theme + self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: self.theme), strings: self.strings) + self.updatePlaceholder() + } + + func updatePlaceholder() { + self.searchBar.placeholderString = NSAttributedString(string: self.strings.Attachment_FilesSearchPlaceholder, font: searchBarFont, textColor: self.theme.rootController.navigationSearchBar.inputPlaceholderTextColor) + } + + override var nominalHeight: CGFloat { + return 56.0 + } + + override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { + let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 56.0)) + self.searchBar.frame = searchBarFrame + self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition) + } + + func activate() { + self.searchBar.activate() + } + + func deactivate() { + self.searchBar.deactivate(clear: false) + } +} + + +final class AttachmentFileSearchItem: ItemListControllerSearch { + let context: AccountContext + let presentationData: PresentationData + let cancel: () -> Void + let send: (Message) -> Void + let dismissInput: () -> Void + + private var updateActivity: ((Bool) -> Void)? + private var activity: ValuePromise = ValuePromise(ignoreRepeated: false) + private let activityDisposable = MetaDisposable() + + init(context: AccountContext, presentationData: PresentationData, cancel: @escaping () -> Void, send: @escaping (Message) -> Void, dismissInput: @escaping () -> Void) { + self.context = context + self.presentationData = presentationData + self.cancel = cancel + self.send = send + self.dismissInput = dismissInput + self.activityDisposable.set((activity.get() |> mapToSignal { value -> Signal in + if value { + return .single(value) |> delay(0.2, queue: Queue.mainQueue()) + } else { + return .single(value) + } + }).start(next: { [weak self] value in + self?.updateActivity?(value) + })) + } + + deinit { + self.activityDisposable.dispose() + } + + func isEqual(to: ItemListControllerSearch) -> Bool { + if let to = to as? AttachmentFileSearchItem { + if self.context !== to.context { + return false + } + return true + } else { + return false + } + } + + func titleContentNode(current: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> NavigationBarContentNode & ItemListControllerSearchNavigationContentNode { + let presentationData = self.presentationData + if let current = current as? AttachmentFileSearchNavigationContentNode { + current.updateTheme(presentationData.theme) + return current + } else { + return AttachmentFileSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, cancel: self.cancel, updateActivity: { [weak self] value in + self?.updateActivity = value + }) + } + } + + func node(current: ItemListControllerSearchNode?, titleContentNode: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> ItemListControllerSearchNode { + return AttachmentFileSearchItemNode(context: self.context, send: self.send, cancel: self.cancel, updateActivity: { [weak self] value in + self?.activity.set(value) + }, dismissInput: self.dismissInput) + } +} + +private final class AttachmentFileSearchItemNode: ItemListControllerSearchNode { + private let containerNode: AttachmentFileSearchContainerNode + + init(context: AccountContext, send: @escaping (Message) -> Void, cancel: @escaping () -> Void, updateActivity: @escaping(Bool) -> Void, dismissInput: @escaping () -> Void) { + self.containerNode = AttachmentFileSearchContainerNode(context: context, forceTheme: nil, send: { message in + send(message) + }, updateActivity: updateActivity) + self.containerNode.cancel = { + cancel() + } + + super.init() + + self.addSubnode(self.containerNode) + + self.containerNode.dismissInput = { + dismissInput() + } + } + + override func queryUpdated(_ query: String) { + self.containerNode.searchTextUpdated(text: query) + } + + override func scrollToTop() { + self.containerNode.scrollToTop() + } + + override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight))) + self.containerNode.containerLayoutUpdated(layout.withUpdatedSize(CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)), navigationBarHeight: 0.0, transition: transition) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let result = self.containerNode.hitTest(self.view.convert(point, to: self.containerNode.view), with: event) { + return result + } + + return super.hitTest(point, with: event) + } +} + + +private final class AttachmentFileSearchContainerInteraction { + let context: AccountContext + let send: (Message) -> Void + + init(context: AccountContext, send: @escaping (Message) -> Void) { + self.context = context + self.send = send + } +} + +private enum AttachmentFileSearchEntryId: Hashable { + case placeholder(Int) + case message(MessageId) +} + +private func areMessagesEqual(_ lhsMessage: Message?, _ rhsMessage: Message?) -> Bool { + guard let lhsMessage = lhsMessage, let rhsMessage = rhsMessage else { + return lhsMessage == nil && rhsMessage == nil + } + if lhsMessage.stableVersion != rhsMessage.stableVersion { + return false + } + if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags { + return false + } + return true +} + +private final class AttachmentFileSearchEntry: Comparable, Identifiable { + let index: Int + let message: Message? + + init(index: Int, message: Message?) { + self.index = index + self.message = message + } + + var stableId: AttachmentFileSearchEntryId { + if let message = self.message { + return .message(message.id) + } else { + return .placeholder(self.index) + } + } + + static func ==(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool { + return lhs.index == rhs.index && areMessagesEqual(lhs.message, rhs.message) + } + + static func <(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: AttachmentFileSearchContainerInteraction) -> ListViewItem { + let itemInteraction = ListMessageItemInteraction(openMessage: { message, _ in + interaction.send(message) + return false + }, openMessageContextMenu: { _, _, _, _, _ in }, toggleMessagesSelection: { _, _ in }, openUrl: { _, _, _, _ in }, openInstantPage: { _, _ in }, longTap: { _, _ in }, getHiddenMedia: { return [:] }) + return ListMessageItem(presentationData: ChatPresentationData(presentationData: interaction.context.sharedContext.currentPresentationData.with({$0})), context: interaction.context, chatLocation: .peer(PeerId(0)), interaction: itemInteraction, message: message, selection: .none, displayHeader: true, displayFileInfo: false, displayBackground: true, style: .plain) + } +} + +struct AttachmentFileSearchContainerTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let isSearching: Bool + let isEmpty: Bool + let query: String +} + +private func attachmentFileSearchContainerPreparedRecentTransition(from fromEntries: [AttachmentFileSearchEntry], to toEntries: [AttachmentFileSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: AttachmentFileSearchContainerInteraction) -> AttachmentFileSearchContainerTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, interaction: interaction), directionHint: nil) } + + return AttachmentFileSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query) +} + + +public final class AttachmentFileSearchContainerNode: SearchDisplayControllerContentNode { + private let context: AccountContext + private let send: (Message) -> Void + + private let dimNode: ASDisplayNode + private let listNode: ListView + + private let emptyResultsTitleNode: ImmediateTextNode + private let emptyResultsTextNode: ImmediateTextNode + + private var enqueuedTransitions: [(AttachmentFileSearchContainerTransition, Bool)] = [] + private var validLayout: (ContainerViewLayout, CGFloat)? + + private let searchQuery = Promise() + private let emptyQueryDisposable = MetaDisposable() + private let searchDisposable = MetaDisposable() + + private let forceTheme: PresentationTheme? + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private let presentationDataPromise: Promise + + private var _hasDim: Bool = false + override public var hasDim: Bool { + return _hasDim + } + + public init(context: AccountContext, forceTheme: PresentationTheme?, send: @escaping (Message) -> Void, updateActivity: @escaping (Bool) -> Void) { + self.context = context + self.send = send + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.presentationData = presentationData + + self.forceTheme = forceTheme + if let forceTheme = self.forceTheme { + self.presentationData = self.presentationData.withUpdated(theme: forceTheme) + } + self.presentationDataPromise = Promise(self.presentationData) + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5) + + self.listNode = ListView() + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + self.emptyResultsTitleNode = ImmediateTextNode() + self.emptyResultsTitleNode.displaysAsynchronously = false + self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.ChatList_Search_NoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor) + self.emptyResultsTitleNode.textAlignment = .center + self.emptyResultsTitleNode.isHidden = true + + self.emptyResultsTextNode = ImmediateTextNode() + self.emptyResultsTextNode.displaysAsynchronously = false + self.emptyResultsTextNode.maximumNumberOfLines = 0 + self.emptyResultsTextNode.textAlignment = .center + self.emptyResultsTextNode.isHidden = true + + super.init() + + self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor + self.listNode.isHidden = true + + self._hasDim = true + + self.addSubnode(self.dimNode) + self.addSubnode(self.listNode) + + self.addSubnode(self.emptyResultsTitleNode) + self.addSubnode(self.emptyResultsTextNode) + + + let interaction = AttachmentFileSearchContainerInteraction(context: context, send: { [weak self] message in + send(message) + self?.listNode.clearHighlightAnimated(true) + }) + + let presentationDataPromise = self.presentationDataPromise + + let searchQuery = self.searchQuery.get() + |> mapToSignal { query -> Signal in + if let query = query, !query.isEmpty { + return (.complete() |> delay(0.6, queue: Queue.mainQueue())) + |> then(.single(query)) + } else { + return .single(query) + } + } + + let foundItems = searchQuery + |> mapToSignal { query -> Signal<[AttachmentFileSearchEntry]?, NoError> in + guard let query = query, !query.isEmpty else { + return .single(nil) + } + + let signal: Signal<[Message]?, NoError> = .single(nil) + |> then( + context.engine.messages.searchMessages(location: .sentMedia(tags: [.file]), query: query, state: nil) + |> map { result -> [Message]? in + return result.0.messages + } + ) + updateActivity(true) + + return combineLatest(signal, presentationDataPromise.get()) + |> mapToSignal { messages, presentationData -> Signal<[AttachmentFileSearchEntry]?, NoError> in + var entries: [AttachmentFileSearchEntry] = [] + var index = 0 + if let messages = messages { + for message in messages { + entries.append(AttachmentFileSearchEntry(index: index, message: message)) + index += 1 + } + } else { + for _ in 0 ..< 2 { + entries.append(AttachmentFileSearchEntry(index: index, message: nil)) + index += 1 + } + } + return .single(entries) + } + } + + let previousSearchItems = Atomic<[AttachmentFileSearchEntry]?>(value: nil) + self.searchDisposable.set((combineLatest(searchQuery, foundItems, self.presentationDataPromise.get()) + |> deliverOnMainQueue).start(next: { [weak self] query, entries, presentationData in + if let strongSelf = self { + let previousEntries = previousSearchItems.swap(entries) + updateActivity(false) + let firstTime = previousEntries == nil + let transition = attachmentFileSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction) + strongSelf.enqueueTransition(transition, firstTime: firstTime) + } + })) + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + var presentationData = presentationData + + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + if let forceTheme = strongSelf.forceTheme { + presentationData = presentationData.withUpdated(theme: forceTheme) + } + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings(theme: presentationData.theme, strings: presentationData.strings) + } + } + }) + + self.listNode.beganInteractiveDragging = { [weak self] _ in + self?.dismissInput?() + } + } + + deinit { + self.searchDisposable.dispose() + self.presentationDataDisposable?.dispose() + } + + override public func didLoad() { + super.didLoad() + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.listNode.backgroundColor = theme.chatList.backgroundColor + } + + override public func searchTextUpdated(text: String) { + if text.isEmpty { + self.searchQuery.set(.single(nil)) + } else { + self.searchQuery.set(.single(text)) + } + } + + private func enqueueTransition(_ transition: AttachmentFileSearchContainerTransition, firstTime: Bool) { + self.enqueuedTransitions.append((transition, firstTime)) + + if let _ = self.validLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + private func dequeueTransition() { + if let (transition, _) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + options.insert(.PreferSynchronousDrawing) + options.insert(.PreferSynchronousResourceLoading) + + let isSearching = transition.isSearching + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + guard let strongSelf = self else { + return + } + + strongSelf.listNode.isHidden = !isSearching + strongSelf.dimNode.isHidden = transition.isSearching + + strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.ChatList_Search_NoResultsQueryDescription(transition.query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor) + + let emptyResults = transition.isSearching && transition.isEmpty + strongSelf.emptyResultsTitleNode.isHidden = !emptyResults + strongSelf.emptyResultsTextNode.isHidden = !emptyResults + + if let (layout, navigationBarHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + } + }) + } + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + + let hadValidLayout = self.validLayout == nil + self.validLayout = (layout, navigationBarHeight) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + + var insets = layout.insets(options: [.input]) + insets.top += navigationBarHeight + + let topInset = navigationBarHeight + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset))) + + self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + let padding: CGFloat = 16.0 + let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude)) + let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude)) + + let emptyTextSpacing: CGFloat = 8.0 + let emptyTotalHeight = emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing + let emptyTitleY = navigationBarHeight + floorToScreenPixels((layout.size.height - navigationBarHeight - max(insets.bottom, layout.intrinsicInsets.bottom) - emptyTotalHeight) / 2.0) + + transition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyTitleY), size: emptyTitleSize)) + transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyTitleY + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize)) + + if !hadValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + override public func scrollToTop() { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancel?() + } + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = self.view.hitTest(point, with: event) else { + return nil + } + if result === self.view { + return nil + } + return result + } +} diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index b7419b80eb..a2e8dd14d5 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -70,6 +70,7 @@ import LottieMeshSwift import ReactionListContextMenuContent import AttachmentUI import AttachmentTextInputPanelNode +import MediaPickerUI import ChatPresentationInterfaceState import Pasteboard import ChatSendMessageActionUI @@ -459,6 +460,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G private let chatLocationContextHolder: Atomic + private weak var attachmentController: AttachmentController? + private weak var currentImportMessageTooltip: UndoOverlayController? public override var customData: Any? { @@ -583,7 +586,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self, strongSelf.isNodeLoaded else { return } - strongSelf.chatDisplayNode.scrollToTop() + if let attachmentController = strongSelf.attachmentController { + attachmentController.scrollToTop?() + } else { + strongSelf.chatDisplayNode.scrollToTop() + } } self.attemptNavigation = { [weak self] action in @@ -3166,7 +3173,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) } else { - strongSelf.presentMediaPicker(fileMode: false, editingMedia: true, present: { [weak self] c, _ in + strongSelf.presentOldMediaPicker(fileMode: false, editingMedia: true, present: { [weak self] c, _ in self?.effectiveNavigationController?.pushViewController(c) }, completion: { signals, _, _ in self?.interfaceInteraction?.setupEditMessage(messageId, { _ in }) @@ -3587,6 +3594,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G title = presentationInterfaceState.strings.ReportPeer_ReasonChildAbuse case .copyright: title = presentationInterfaceState.strings.ReportPeer_ReasonCopyright + case .illegalDrugs: + title = presentationInterfaceState.strings.ReportPeer_ReasonIllegalDrugs + case .personalDetails: + title = presentationInterfaceState.strings.ReportPeer_ReasonPersonalDetails case .custom: title = presentationInterfaceState.strings.ReportPeer_ReasonOther case .irrelevantLocation: @@ -6203,7 +6214,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, reportMessages: { [weak self] messages, contextController in if let strongSelf = self, !messages.isEmpty { - presentPeerReportOptions(context: strongSelf.context, parent: strongSelf, contextController: contextController, subject: .messages(messages.map({ $0.id }).sorted()), completion: { _, _ in }) + let options: [PeerReportOption] = [.spam, .violence, .pornography, .childAbuse, .copyright, .illegalDrugs, .personalDetails, .other] + presentPeerReportOptions(context: strongSelf.context, parent: strongSelf, contextController: contextController, subject: .messages(messages.map({ $0.id }).sorted()), options: options, completion: { _, _ in }) } }, blockMessageAuthor: { [weak self] message, contextController in contextController?.dismiss(completion: { @@ -10228,66 +10240,78 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return inputPanelNode } - private func openCamera() { + private func openCamera(cameraView: TGAttachmentCameraView? = nil) { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return } - var photoOnly = false - if let callManager = self.context.sharedContext.callManager as? PresentationCallManagerImpl, callManager.hasActiveCall { - photoOnly = true + let _ = (self.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in + let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) + return entry ?? GeneratedMediaStoreSettings.defaultSettings } - - let storeEditedPhotos = false - let inputText = self.presentationInterfaceState.interfaceState.effectiveInputState.inputText - - presentedLegacyCamera(context: self.context, peer: peer, chatLocation: self.chatLocation, cameraView: nil, menuController: nil, parentController: self, editingMedia: false, saveCapturedPhotos: storeEditedPhotos, mediaGrouping: true, initialCaption: inputText.string, hasSchedule: self.presentationInterfaceState.subject != .scheduledMessages && peer.id.namespace != Namespaces.Peer.SecretChat, photoOnly: photoOnly, sendMessagesWithSignals: { [weak self] signals, silentPosting, scheduleTime in - if let strongSelf = self { -// if editMediaOptions != nil { -// strongSelf.editMessageMediaWithLegacySignals(signals!) -// } else { - strongSelf.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) -// } - if !inputText.string.isEmpty { - strongSelf.clearInputText() - } + |> deliverOnMainQueue).start(next: { [weak self] settings in + guard let strongSelf = self else { + return } - }, recognizedQRCode: { [weak self] code in - if let strongSelf = self { - if let (host, port, username, password, secret) = parseProxyUrl(code) { - strongSelf.openResolved(result: ResolvedUrl.proxy(host: host, port: port, username: username, password: password, secret: secret), sourceMessageId: nil) - } + + var photoOnly = false + if let callManager = strongSelf.context.sharedContext.callManager as? PresentationCallManagerImpl, callManager.hasActiveCall { + photoOnly = true } - }, presentSchedulePicker: { [weak self] _, done in - if let strongSelf = self { - strongSelf.presentScheduleTimePicker(style: .media, completion: { [weak self] time in - if let strongSelf = self { - done(time) - if strongSelf.presentationInterfaceState.subject != .scheduledMessages && time != scheduleWhenOnlineTimestamp { - strongSelf.openScheduledMessages() - } + + let storeEditedPhotos = settings.storeEditedPhotos + let inputText = strongSelf.presentationInterfaceState.interfaceState.effectiveInputState.inputText + + presentedLegacyCamera(context: strongSelf.context, peer: peer, chatLocation: strongSelf.chatLocation, cameraView: cameraView, menuController: nil, parentController: strongSelf, editingMedia: false, saveCapturedPhotos: storeEditedPhotos, mediaGrouping: true, initialCaption: inputText.string, hasSchedule: strongSelf.presentationInterfaceState.subject != .scheduledMessages && peer.id.namespace != Namespaces.Peer.SecretChat, photoOnly: photoOnly, sendMessagesWithSignals: { [weak self] signals, silentPosting, scheduleTime in + if let strongSelf = self { + // if editMediaOptions != nil { + // strongSelf.editMessageMediaWithLegacySignals(signals!) + // } else { + strongSelf.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) + // } + if !inputText.string.isEmpty { + strongSelf.clearInputText() } - }) - } - }, presentTimerPicker: { [weak self] done in - if let strongSelf = self { - strongSelf.presentTimerPicker(style: .media, completion: { time in - done(time) - }) - } - }, presentStickers: { [weak self] completion in - if let strongSelf = self { - let controller = DrawingStickersScreen(context: strongSelf.context, selectSticker: { fileReference, node, rect in - completion(fileReference.media, fileReference.media.isAnimatedSticker || fileReference.media.isVideoSticker, node.view, rect) - return true - }) - strongSelf.present(controller, in: .window(.root)) - return controller - } else { - return nil - } - }, getCaptionPanelView: { [weak self] in - return self?.getCaptionPanelView() + } + }, recognizedQRCode: { [weak self] code in + if let strongSelf = self { + if let (host, port, username, password, secret) = parseProxyUrl(code) { + strongSelf.openResolved(result: ResolvedUrl.proxy(host: host, port: port, username: username, password: password, secret: secret), sourceMessageId: nil) + } + } + }, presentSchedulePicker: { [weak self] _, done in + if let strongSelf = self { + strongSelf.presentScheduleTimePicker(style: .media, completion: { [weak self] time in + if let strongSelf = self { + done(time) + if strongSelf.presentationInterfaceState.subject != .scheduledMessages && time != scheduleWhenOnlineTimestamp { + strongSelf.openScheduledMessages() + } + } + }) + } + }, presentTimerPicker: { [weak self] done in + if let strongSelf = self { + strongSelf.presentTimerPicker(style: .media, completion: { time in + done(time) + }) + } + }, presentStickers: { [weak self] completion in + if let strongSelf = self { + let controller = DrawingStickersScreen(context: strongSelf.context, selectSticker: { fileReference, node, rect in + completion(fileReference.media, fileReference.media.isAnimatedSticker || fileReference.media.isVideoSticker, node.view, rect) + return true + }) + strongSelf.present(controller, in: .window(.root)) + return controller + } else { + return nil + } + }, getCaptionPanelView: { [weak self] in + return self?.getCaptionPanelView() + }, dismissedWithResult: { [weak self] in + self?.attachmentController?.dismiss(animated: false, completion: nil) + }) }) } @@ -10297,7 +10321,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.chatDisplayNode.dismissInput() - let currentLocationController = Atomic(value: nil) + let currentFilesController = Atomic(value: nil) + let currentLocationController = Atomic(value: nil) var canSendPolls = true if let _ = peer as? TelegramUser { @@ -10312,44 +10337,59 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } - var buttons: [AttachmentButtonType] = [.camera, .gallery, .file, .location, .contact] + var availableTabs: [AttachmentButtonType] = [.gallery, .file, .location, .contact] if canSendPolls { - buttons.append(.poll) + availableTabs.append(.poll) } let inputText = self.presentationInterfaceState.interfaceState.effectiveInputState.inputText - let attachmentController = AttachmentController(context: self.context, buttons: buttons) + let attachmentController = AttachmentController(context: self.context, updatedPresentationData: self.updatedPresentationData, buttons: availableTabs) attachmentController.requestController = { [weak self, weak attachmentController] type, completion in guard let strongSelf = self else { return } switch type { - case .camera: - completion(nil, nil) - attachmentController?.dismiss(animated: true) - strongSelf.openCamera() - strongSelf.controllerNavigationDisposable.set(nil) case .gallery: - strongSelf.presentMediaPicker(fileMode: false, editingMedia: editMediaOptions != nil, present: { controller, mediaPickerContext in + strongSelf.presentMediaPicker(present: { controller, mediaPickerContext in completion(controller, mediaPickerContext) + }, updateMediaPickerContext: { [weak attachmentController] mediaPickerContext in + attachmentController?.mediaPickerContext = mediaPickerContext }, completion: { [weak self] signals, silentPosting, scheduleTime in if !inputText.string.isEmpty { self?.clearInputText() } - self?.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) + self?.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime) }) strongSelf.controllerNavigationDisposable.set(nil) case .file: - let controller = attachmentFileController(context: strongSelf.context, presentGallery: { [weak self, weak attachmentController] in + strongSelf.controllerNavigationDisposable.set(nil) + let existingController = currentFilesController.with { $0 } + if let controller = existingController { + completion(controller, nil) + return + } + let controller = attachmentFileController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, presentGallery: { [weak self, weak attachmentController] in attachmentController?.dismiss(animated: true) self?.presentFileGallery() }, presentFiles: { [weak self, weak attachmentController] in attachmentController?.dismiss(animated: true) self?.presentICloudFileGallery() + }, send: { [weak self] mediaReference in + guard let strongSelf = self else { + return + } + let peerId = strongSelf.chatLocation.peerId + let message: EnqueueMessage = .message(text: "", attributes: [], mediaReference: mediaReference, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil) + let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peerId, messages: strongSelf.transformEnqueueMessages([message])) + |> deliverOnMainQueue).start(next: { [weak self] _ in + if let strongSelf = self, strongSelf.presentationInterfaceState.subject != .scheduledMessages { + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() + } + }) }) + let _ = currentFilesController.swap(controller) completion(controller, nil) - strongSelf.controllerNavigationDisposable.set(nil) case .location: strongSelf.controllerNavigationDisposable.set(nil) let existingController = currentLocationController.with { $0 } @@ -10528,6 +10568,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } self.present(attachmentController, in: .window(.root)) + self.attachmentController = attachmentController } private func oldPresentAttachmentMenu(editMediaOptions: MessageMediaEditingOptions?, editMediaReference: AnyMediaReference?) { @@ -10628,7 +10669,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } let controller = legacyAttachmentMenu(context: strongSelf.context, peer: peer, chatLocation: strongSelf.chatLocation, editMediaOptions: menuEditMediaOptions, saveEditedPhotos: settings.storeEditedPhotos, allowGrouping: true, hasSchedule: strongSelf.presentationInterfaceState.subject != .scheduledMessages && peer.id.namespace != Namespaces.Peer.SecretChat, canSendPolls: canSendPolls, updatedPresentationData: strongSelf.updatedPresentationData, parentController: legacyController, recentlyUsedInlineBots: strongSelf.recentlyUsedInlineBotsValue, initialCaption: inputText, openGallery: { - self?.presentMediaPicker(fileMode: false, editingMedia: editMediaOptions != nil, present: { [weak self] c, _ in + self?.presentOldMediaPicker(fileMode: false, editingMedia: editMediaOptions != nil, present: { [weak self] c, _ in self?.effectiveNavigationController?.pushViewController(c) }, completion: { signals, silentPosting, scheduleTime in if !inputText.string.isEmpty { @@ -10698,8 +10739,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, openFileGallery: { self?.presentFileMediaPickerOptions(editingMessage: editMediaOptions != nil) - }, openWebSearch: { - self?.presentWebSearch(editingMessage : editMediaOptions != nil) + }, openWebSearch: { [weak self] in + self?.presentWebSearch(editingMessage: editMediaOptions != nil, attachment: false, present: { [weak self] c, a in + self?.present(c, in: .window(.root), with: a) + }) }, openMap: { self?.presentLocationPicker() }, openContacts: { @@ -10814,7 +10857,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } private func presentFileGallery(editingMessage: Bool = false) { - self.presentMediaPicker(fileMode: true, editingMedia: editingMessage, present: { [weak self] c, _ in + self.presentOldMediaPicker(fileMode: true, editingMedia: editingMessage, present: { [weak self] c, _ in self?.effectiveNavigationController?.pushViewController(c) }, completion: { [weak self] signals, silentPosting, scheduleTime in if editingMessage { @@ -10930,7 +10973,67 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(actionSheet, in: .window(.root)) } - private func presentMediaPicker(fileMode: Bool, editingMedia: Bool, present: @escaping (AttachmentContainable, AttachmentMediaPickerContext) -> Void, completion: @escaping ([Any], Bool, Int32) -> Void) { + private func presentMediaPicker(present: @escaping (AttachmentContainable, AttachmentMediaPickerContext?) -> Void, updateMediaPickerContext: @escaping (AttachmentMediaPickerContext?) -> Void, completion: @escaping ([Any], Bool, Int32?) -> Void) { + guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return + } + let controller = MediaPickerScreen(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), chatLocation: self.chatLocation) + let mediaPickerContext = controller.mediaPickerContext + controller.openCamera = { [weak self] cameraView in + self?.openCamera(cameraView: cameraView) + } + controller.presentWebSearch = { [weak self, weak controller] in + self?.presentWebSearch(editingMessage: false, attachment: true, present: { [weak controller] c, a in + controller?.present(c, in: .current) + if let webSearchController = c as? WebSearchController { + webSearchController.dismissed = { + updateMediaPickerContext(mediaPickerContext) + } + updateMediaPickerContext(webSearchController.mediaPickerContext) + } + }) + } + controller.presentStickers = { [weak self] completion in + if let strongSelf = self { + let controller = DrawingStickersScreen(context: strongSelf.context, selectSticker: { fileReference, node, rect in + completion(fileReference.media, fileReference.media.isAnimatedSticker || fileReference.media.isVideoSticker, node.view, rect) + return true + }) + strongSelf.present(controller, in: .window(.root)) + return controller + } else { + return nil + } + } + controller.presentSchedulePicker = { [weak self] media, done in + if let strongSelf = self { + strongSelf.presentScheduleTimePicker(style: media ? .media : .default, completion: { [weak self] time in + if let strongSelf = self { + done(time) + if strongSelf.presentationInterfaceState.subject != .scheduledMessages && time != scheduleWhenOnlineTimestamp { + strongSelf.openScheduledMessages() + } + } + }) + } + } + controller.presentTimerPicker = { [weak self] done in + if let strongSelf = self { + strongSelf.presentTimerPicker(style: .media, completion: { time in + done(time) + }) + } + } + controller.getCaptionPanelView = { [weak self] in + return self?.getCaptionPanelView() + } + controller.legacyCompletion = { signals, silently, scheduleTime in + completion(signals, silently, scheduleTime) + } + present(controller, mediaPickerContext) + } + + private func presentOldMediaPicker(fileMode: Bool, editingMedia: Bool, present: @escaping (AttachmentContainable, AttachmentMediaPickerContext) -> Void, completion: @escaping ([Any], Bool, Int32) -> Void) { let postbox = self.context.account.postbox let _ = (self.context.sharedContext.accountManager.transaction { transaction -> Signal<(GeneratedMediaStoreSettings, SearchBotsConfiguration), NoError> in let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) @@ -10968,7 +11071,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G configureLegacyAssetPicker(controller, context: strongSelf.context, peer: peer, chatLocation: strongSelf.chatLocation, initialCaption: inputText, hasSchedule: strongSelf.presentationInterfaceState.subject != .scheduledMessages && peer.id.namespace != Namespaces.Peer.SecretChat, presentWebSearch: editingMedia ? nil : { [weak self, weak legacyController] in if let strongSelf = self { - let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: searchBotsConfiguration, mode: .media(completion: { results, selectionState, editingState, silentPosting in + let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: searchBotsConfiguration, mode: .media(attachment: false, completion: { results, selectionState, editingState, silentPosting in if let legacyController = legacyController { legacyController.dismiss() } @@ -11066,7 +11169,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } - private func presentWebSearch(editingMessage: Bool) { + private func presentWebSearch(editingMessage: Bool, attachment: Bool, present: @escaping (ViewController, Any?) -> Void) { guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { return } @@ -11080,7 +11183,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } |> deliverOnMainQueue).start(next: { [weak self] configuration in if let strongSelf = self { - let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: configuration, mode: .media(completion: { [weak self] results, selectionState, editingState, silentPosting in + let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: configuration, mode: .media(attachment: attachment, completion: { [weak self] results, selectionState, editingState, silentPosting in legacyEnqueueWebSearchMessages(selectionState, editingState, enqueueChatContextResult: { [weak self] result in if let strongSelf = self { strongSelf.enqueueChatContextResult(results, result, hideVia: true) @@ -11110,7 +11213,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G controller.getCaptionPanelView = { [weak self] in return self?.getCaptionPanelView() } - strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + present(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) } }) } diff --git a/submodules/TelegramUI/Sources/ContactSelectionController.swift b/submodules/TelegramUI/Sources/ContactSelectionController.swift index f54ba80199..52f212d60b 100644 --- a/submodules/TelegramUI/Sources/ContactSelectionController.swift +++ b/submodules/TelegramUI/Sources/ContactSelectionController.swift @@ -11,6 +11,7 @@ import AccountContext import ContactListUI import SearchUI import AttachmentUI +import SearchBarNode class ContactSelectionControllerImpl: ViewController, ContactSelectionController, PresentableController, AttachmentContainable { private let context: AccountContext @@ -56,7 +57,7 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController private var presentationData: PresentationData private var presentationDataDisposable: Disposable? - private var searchContentNode: NavigationBarSearchContentNode? + private var searchContentNode: NavigationBarContentNode? var displayNavigationActivity: Bool = false { didSet { @@ -98,10 +99,10 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController self.scrollToTop = { [weak self] in if let strongSelf = self { - if let searchContentNode = strongSelf.searchContentNode { + if let searchContentNode = strongSelf.searchContentNode as? NavigationBarSearchContentNode { searchContentNode.updateExpansionProgress(1.0, animated: true) } - strongSelf.contactsNode.contactListNode.scrollToTop() + strongSelf.contactsNode.scrollToTop() } } @@ -127,7 +128,7 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController } if params.multipleSelection { - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Select, style: .plain, target: self, action: #selector(self.beginSelection)) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.beginSearch)) } } @@ -140,6 +141,11 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController self.presentationDataDisposable?.dispose() } + @objc private func beginSearch() { + self.requestAttachmentMenuExpansion() + self.activateSearch() + } + @objc private func beginSelection() { self.navigationItem.rightBarButtonItem = nil self.contactsNode.beginSelection() @@ -148,7 +154,7 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController private func updateThemeAndStrings() { self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData)) - self.searchContentNode?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search) + (self.searchContentNode as? NavigationBarSearchContentNode)?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search) self.title = self.titleProducer(self.presentationData.strings) self.tabBarItem.title = self.presentationData.strings.Contacts_Title self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) @@ -196,13 +202,13 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController } self.contactsNode.contactListNode.contentOffsetChanged = { [weak self] offset in - if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode { + if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode as? NavigationBarSearchContentNode { searchContentNode.updateListVisibleContentOffset(offset) } } self.contactsNode.contactListNode.contentScrollingEnded = { [weak self] listView in - if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode { + if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode as? NavigationBarSearchContentNode { return fixNavigationSearchableListNodeScrolling(listView, searchNode: searchContentNode) } else { return false @@ -268,10 +274,27 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController private func activateSearch() { if self.displayNavigationBar { - if let searchContentNode = self.searchContentNode { + if let searchContentNode = self.searchContentNode as? NavigationBarSearchContentNode { self.contactsNode.activateSearch(placeholderNode: searchContentNode.placeholderNode) + self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring)) + } else if self.multipleSelection { + let contentNode = ContactsSearchNavigationContentNode(presentationData: self.presentationData, dismissSearch: { [weak self] in + if let strongSelf = self, let navigationBar = strongSelf.navigationBar, let searchContentNode = strongSelf.searchContentNode as? ContactsSearchNavigationContentNode { + searchContentNode.deactivate() + strongSelf.searchContentNode = nil + navigationBar.setContentNode(nil, animated: true) + strongSelf.contactsNode.deactivateOverlaySearch() + } + }, updateSearchQuery: { [weak self] query in + if let strongSelf = self { + strongSelf.contactsNode.searchContainerNode?.searchTextUpdated(text: query) + } + }) + self.searchContentNode = contentNode + self.navigationBar?.setContentNode(contentNode, animated: true) + self.contactsNode.activateOverlaySearch() + contentNode.activate() } - self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring)) } } @@ -279,7 +302,7 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController if !self.displayNavigationBar { self.contactsNode.prepareDeactivateSearch() self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring)) - if let searchContentNode = self.searchContentNode { + if let searchContentNode = self.searchContentNode as? NavigationBarSearchContentNode { self.contactsNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode) } } @@ -303,3 +326,57 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController self.deactivateSearch() } } + +private let searchBarFont = Font.regular(17.0) + +final class ContactsSearchNavigationContentNode: NavigationBarContentNode { + private var presentationData: PresentationData + + private let searchBar: SearchBarNode + + init(presentationData: PresentationData, dismissSearch: @escaping () -> Void, updateSearchQuery: @escaping (String) -> Void) { + self.presentationData = presentationData + + self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: presentationData.theme, hasSeparator: false), strings: presentationData.strings, fieldStyle: .modern) + self.searchBar.placeholderString = NSAttributedString(string: presentationData.strings.Common_Search, font: searchBarFont, textColor: presentationData.theme.rootController.navigationSearchBar.inputPlaceholderTextColor) + + super.init() + + self.addSubnode(self.searchBar) + + self.searchBar.cancel = { [weak self] in + self?.searchBar.deactivate(clear: false) + dismissSearch() + } + self.searchBar.textUpdated = { query, _ in + updateSearchQuery(query) + } + } + + override var nominalHeight: CGFloat { + return 56.0 + } + + override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { + let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 56.0)) + self.searchBar.frame = searchBarFrame + self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition) + } + + func activate() { + self.searchBar.activate() + } + + func deactivate() { + self.searchBar.deactivate(clear: false) + } + + func updateActivity(_ activity: Bool) { + self.searchBar.activity = activity + } + + func updatePresentationData(_ presentationData: PresentationData) { + self.presentationData = presentationData + self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: presentationData.theme, hasSeparator: false), strings: presentationData.strings) + } +} diff --git a/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift index f6068c1737..27fa9c968c 100644 --- a/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift @@ -50,6 +50,8 @@ final class ContactSelectionControllerNode: ASDisplayNode { private var selectionState: ContactListNodeGroupSelectionState? + var searchContainerNode: ContactsSearchContainerNode? + init(context: AccountContext, presentationData: PresentationData, options: [ContactListAdditionalOption], displayDeviceContacts: Bool, displayCallIcons: Bool, multipleSelection: Bool) { self.context = context self.presentationData = presentationData @@ -143,6 +145,80 @@ final class ContactSelectionControllerNode: ASDisplayNode { if let searchDisplayController = self.searchDisplayController { searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } + + if let searchContainerNode = self.searchContainerNode { + searchContainerNode.frame = CGRect(origin: CGPoint(), size: layout.size) + searchContainerNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: LayoutMetrics(), deviceMetrics: layout.deviceMetrics, intrinsicInsets: layout.intrinsicInsets, safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver), navigationBarHeight: navigationBarHeight, transition: transition) + } + } + + func scrollToTop() { + if let searchContainerNode = self.searchContainerNode { + searchContainerNode.scrollToTop() + } else { + self.contactListNode.scrollToTop() + } + } + + func activateOverlaySearch() { + guard let (containerLayout, navigationBarHeight, actualNavigationBarHeight) = self.containerLayout, let navigationBar = self.navigationBar, self.searchDisplayController == nil else { + return + } + + var categories: ContactsSearchCategories = [.cloudContacts] + if self.displayDeviceContacts { + categories.insert(.deviceContacts) + } else { + categories.insert(.global) + } + + let searchContainerNode = ContactsSearchContainerNode(context: self.context, updatedPresentationData: (self.presentationData, self.presentationDataPromise.get()), onlyWriteable: false, categories: categories, addContact: nil, openPeer: { [weak self] peer in + if let strongSelf = self { + var updated = false + strongSelf.contactListNode.updateSelectionState { state -> ContactListNodeGroupSelectionState? in + if let state = state { + updated = true + var foundPeers = state.foundPeers + var selectedPeerMap = state.selectedPeerMap + selectedPeerMap[peer.id] = peer + var exists = false + for foundPeer in foundPeers { + if peer.id == foundPeer.id { + exists = true + break + } + } + if !exists { + foundPeers.insert(peer, at: 0) + } + return state.withToggledPeerId(peer.id).withFoundPeers(foundPeers).withSelectedPeerMap(selectedPeerMap) + } else { + return nil + } + } + if updated { + strongSelf.requestDeactivateSearch?() + } else { + strongSelf.requestOpenPeerFromSearch?(peer) + } + } + }, contextAction: nil) + self.insertSubnode(searchContainerNode, belowSubnode: navigationBar) + self.searchContainerNode = searchContainerNode + + searchContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + + self.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, actualNavigationBarHeight: actualNavigationBarHeight, transition: .immediate) + } + + func deactivateOverlaySearch() { + guard let searchContainerNode = self.searchContainerNode else { + return + } + searchContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak searchContainerNode] _ in + searchContainerNode?.removeFromSupernode() + }) + self.searchContainerNode = nil } func activateSearch(placeholderNode: SearchBarPlaceholderNode) { diff --git a/submodules/TelegramUI/Sources/LegacyCamera.swift b/submodules/TelegramUI/Sources/LegacyCamera.swift index 16913d426a..a09ea9005c 100644 --- a/submodules/TelegramUI/Sources/LegacyCamera.swift +++ b/submodules/TelegramUI/Sources/LegacyCamera.swift @@ -10,7 +10,7 @@ import ShareController import LegacyUI import LegacyMediaPickerUI -func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: ChatLocation, cameraView: TGAttachmentCameraView?, menuController: TGMenuSheetController?, parentController: ViewController, editingMedia: Bool, saveCapturedPhotos: Bool, mediaGrouping: Bool, initialCaption: String, hasSchedule: Bool, photoOnly: Bool, sendMessagesWithSignals: @escaping ([Any]?, Bool, Int32) -> Void, recognizedQRCode: @escaping (String) -> Void = { _ in }, presentSchedulePicker: @escaping (Bool, @escaping (Int32) -> Void) -> Void, presentTimerPicker: @escaping (@escaping (Int32) -> Void) -> Void, presentStickers: @escaping (@escaping (TelegramMediaFile, Bool, UIView, CGRect) -> Void) -> TGPhotoPaintStickersScreen?, getCaptionPanelView: @escaping () -> TGCaptionPanelView?) { +func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: ChatLocation, cameraView: TGAttachmentCameraView?, menuController: TGMenuSheetController?, parentController: ViewController, editingMedia: Bool, saveCapturedPhotos: Bool, mediaGrouping: Bool, initialCaption: String, hasSchedule: Bool, photoOnly: Bool, sendMessagesWithSignals: @escaping ([Any]?, Bool, Int32) -> Void, recognizedQRCode: @escaping (String) -> Void = { _ in }, presentSchedulePicker: @escaping (Bool, @escaping (Int32) -> Void) -> Void, presentTimerPicker: @escaping (@escaping (Int32) -> Void) -> Void, presentStickers: @escaping (@escaping (TelegramMediaFile, Bool, UIView, CGRect) -> Void) -> TGPhotoPaintStickersScreen?, getCaptionPanelView: @escaping () -> TGCaptionPanelView?, dismissedWithResult: @escaping () -> Void = {}) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let legacyController = LegacyController(presentation: .custom, theme: presentationData.theme) legacyController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .portrait, compactSize: .portrait) @@ -93,8 +93,12 @@ func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: Ch let screenSize = parentController.view.bounds.size var startFrame = CGRect(x: 0, y: screenSize.height, width: screenSize.width, height: screenSize.height) - if let cameraView = cameraView, let menuController = menuController { - startFrame = menuController.view.convert(cameraView.previewView()!.frame, from: cameraView) + if let cameraView = cameraView { + if let menuController = menuController { + startFrame = menuController.view.convert(cameraView.previewView()!.frame, from: cameraView) + } else { + startFrame = parentController.view.convert(cameraView.previewView()!.frame, from: cameraView) + } } legacyController.bind(controller: controller) @@ -133,6 +137,7 @@ func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: Ch menuController?.dismiss(animated: false) legacyController?.dismissWithAnimation() + dismissedWithResult() } controller.finishedWithPhoto = { [weak menuController, weak legacyController] overlayController, image, caption, stickers, timer in @@ -150,6 +155,7 @@ func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: Ch menuController?.dismiss(animated: false) legacyController?.dismissWithAnimation() + dismissedWithResult() } controller.finishedWithVideo = { [weak menuController, weak legacyController] overlayController, videoURL, previewImage, duration, dimensions, adjustments, caption, stickers, timer in @@ -174,6 +180,7 @@ func presentedLegacyCamera(context: AccountContext, peer: Peer, chatLocation: Ch } menuController?.dismiss(animated: false) legacyController?.dismissWithAnimation() + dismissedWithResult() } controller.recognizedQRCode = { code in diff --git a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift index 3068098ce6..25bef393a7 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift @@ -452,9 +452,15 @@ final class PeerSelectionControllerNode: ASDisplayNode { self.searchDisplayController?.updatePresentationData(self.presentationData) self.chatListNode.updateThemeAndStrings(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true) + self.updateChatPresentationInterfaceState({ $0.updatedTheme(self.presentationData.theme) }) + self.toolbarBackgroundNode?.updateColor(color: self.presentationData.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) self.toolbarSeparatorNode?.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor self.segmentedControlNode?.updateTheme(SegmentedControlTheme(theme: self.presentationData.theme)) + + if let (layout, navigationBarHeight, actualNavigationBarHeight) = self.containerLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, actualNavigationBarHeight: actualNavigationBarHeight, transition: .immediate) + } } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, actualNavigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { diff --git a/submodules/WebSearchUI/BUILD b/submodules/WebSearchUI/BUILD index 358bc24569..77b829ca44 100644 --- a/submodules/WebSearchUI/BUILD +++ b/submodules/WebSearchUI/BUILD @@ -31,6 +31,7 @@ swift_library( "//submodules/SegmentedControlNode:SegmentedControlNode", "//submodules/AppBundle:AppBundle", "//submodules/PresentationDataUtils:PresentationDataUtils", + "//submodules/AttachmentUI:AttachmentUI", ], visibility = [ "//visibility:public", diff --git a/submodules/WebSearchUI/Sources/WebSearchController.swift b/submodules/WebSearchUI/Sources/WebSearchController.swift index ec493852d1..ca0142147a 100644 --- a/submodules/WebSearchUI/Sources/WebSearchController.swift +++ b/submodules/WebSearchUI/Sources/WebSearchController.swift @@ -8,6 +8,7 @@ import LegacyComponents import TelegramUIPreferences import TelegramPresentationData import AccountContext +import AttachmentUI public func requestContextResults(context: AccountContext, botId: EnginePeer.Id, query: String, peerId: EnginePeer.Id, offset: String = "", existingResults: ChatContextResultCollection? = nil, incompleteResults: Bool = false, staleCachedResults: Bool = false, limit: Int = 60) -> Signal { return context.engine.messages.requestChatContextResults(botId: botId, peerId: peerId, query: query, offset: offset, incompleteResults: incompleteResults, staleCachedResults: staleCachedResults) @@ -60,7 +61,7 @@ public enum WebSearchMode { } public enum WebSearchControllerMode { - case media(completion: (ChatContextResultCollection, TGMediaSelectionContext, TGMediaEditingContext, Bool) -> Void) + case media(attachment: Bool, completion: (ChatContextResultCollection, TGMediaSelectionContext, TGMediaEditingContext, Bool) -> Void) case avatar(initialQuery: String?, completion: (UIImage) -> Void) var mode: WebSearchMode { @@ -161,6 +162,8 @@ public final class WebSearchController: ViewController { } } + public var dismissed: () -> Void = { } + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peer: EnginePeer?, chatLocation: ChatLocation?, configuration: SearchBotsConfiguration, mode: WebSearchControllerMode) { self.context = context self.mode = mode @@ -225,13 +228,26 @@ public final class WebSearchController: ViewController { } }) - let navigationContentNode = WebSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings) + var attachment = false + if case let .media(attachmentValue, _) = mode { + attachment = attachmentValue + } + let navigationContentNode = WebSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, attachment: attachment) self.navigationContentNode = navigationContentNode navigationContentNode.setQueryUpdated { [weak self] query in if let strongSelf = self, strongSelf.isNodeLoaded { strongSelf.updateSearchQuery(query) } } + navigationContentNode.cancel = { [weak self] in + if let strongSelf = self { + strongSelf.controllerNode.dismissInput?() + strongSelf.controllerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in + self?.dismissed() + self?.dismiss() + }) + } + } self.navigationBar?.setContentNode(navigationContentNode, animated: false) if let query = searchQuery { navigationContentNode.setQuery(query) @@ -274,7 +290,7 @@ public final class WebSearchController: ViewController { let currentItem = LegacyWebSearchItem(result: current) selectionState.setItem(currentItem, selected: true) } - if case let .media(sendSelected) = mode { + if case let .media(_, sendSelected) = mode { sendSelected(results, selectionState, editingState, false) } } @@ -322,11 +338,19 @@ public final class WebSearchController: ViewController { if case let .avatar(initialQuery, _) = mode, let _ = initialQuery { select = true } + if case let .media(attachment, _) = mode, attachment && !self.didPlayPresentationAnimation { + self.didPlayPresentationAnimation = true + self.controllerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } self.navigationContentNode?.activate(select: select) } override public func loadDisplayNode() { - self.displayNode = WebSearchControllerNode(context: self.context, presentationData: self.interfaceState.presentationData, controllerInteraction: self.controllerInteraction!, peer: self.peer, chatLocation: self.chatLocation, mode: self.mode.mode) + var attachment: Bool = false + if case let .media(attachmentValue, _) = self.mode, attachmentValue { + attachment = true + } + self.displayNode = WebSearchControllerNode(context: self.context, presentationData: self.interfaceState.presentationData, controllerInteraction: self.controllerInteraction!, peer: self.peer, chatLocation: self.chatLocation, mode: self.mode.mode, attachment: attachment) self.controllerNode.requestUpdateInterfaceState = { [weak self] animated, f in if let strongSelf = self { strongSelf.updateInterfaceState(f) @@ -501,6 +525,14 @@ public final class WebSearchController: ViewController { } } + public var mediaPickerContext: WebSearchPickerContext? { + if let interaction = self.controllerInteraction { + return WebSearchPickerContext(interaction: interaction) + } else { + return nil + } + } + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) @@ -509,3 +541,49 @@ public final class WebSearchController: ViewController { self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } } + +public class WebSearchPickerContext: AttachmentMediaPickerContext { + private weak var interaction: WebSearchControllerInteraction? + + public var selectionCount: Signal { + return Signal { [weak self] subscriber in + let disposable = self?.interaction?.selectionState?.selectionChangedSignal().start(next: { [weak self] value in + subscriber.putNext(Int(self?.interaction?.selectionState?.count() ?? 0)) + }, error: { _ in }, completed: { }) + return ActionDisposable { + disposable?.dispose() + } + } + } + + public var caption: Signal { + return Signal { [weak self] subscriber in + let disposable = self?.interaction?.editingState.forcedCaption().start(next: { caption in + if let caption = caption as? NSAttributedString { + subscriber.putNext(caption) + } else { + subscriber.putNext(nil) + } + }, error: { _ in }, completed: { }) + return ActionDisposable { + disposable?.dispose() + } + } + } + + init(interaction: WebSearchControllerInteraction) { + self.interaction = interaction + } + + public func setCaption(_ caption: NSAttributedString) { + self.interaction?.editingState.setForcedCaption(caption, skipUpdate: true) + } + + public func send(silently: Bool, mode: AttachmentMediaPickerSendMode) { +// self.interaction?.sendSelected(nil, silently, nil, true) + } + + public func schedule() { +// self.interaction?.schedule() + } +} diff --git a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift index 313e263d48..c2a32becc2 100644 --- a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift +++ b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift @@ -64,8 +64,8 @@ private func preparedTransition(from fromEntries: [WebSearchEntry], to toEntries return WebSearchTransition(deleteItems: deleteIndices, insertItems: insertions, updateItems: updates, entryCount: toEntries.count, hasMore: hasMore) } -private func gridNodeLayoutForContainerLayout(size: CGSize) -> GridNodeLayoutType { - let side = floorToScreenPixels((size.width - 3.0) / 4.0) +private func gridNodeLayoutForContainerLayout(size: CGSize, insets: UIEdgeInsets) -> GridNodeLayoutType { + let side = floorToScreenPixels((size.width - insets.left - insets.right - 3.0) / 4.0) return .fixed(itemSize: CGSize(width: side, height: side), fillWidth: true, lineSpacing: 1.0, itemSpacing: 1.0) } @@ -123,6 +123,7 @@ class WebSearchControllerNode: ASDisplayNode { private var strings: PresentationStrings private var presentationData: PresentationData private let mode: WebSearchMode + private let attachment: Bool private let controllerInteraction: WebSearchControllerInteraction private var webSearchInterfaceState: WebSearchInterfaceState @@ -172,7 +173,7 @@ class WebSearchControllerNode: ASDisplayNode { var presentStickers: ((@escaping (TelegramMediaFile, Bool, UIView, CGRect) -> Void) -> TGPhotoPaintStickersScreen?)? var getCaptionPanelView: () -> TGCaptionPanelView? = { return nil } - init(context: AccountContext, presentationData: PresentationData, controllerInteraction: WebSearchControllerInteraction, peer: EnginePeer?, chatLocation: ChatLocation?, mode: WebSearchMode) { + init(context: AccountContext, presentationData: PresentationData, controllerInteraction: WebSearchControllerInteraction, peer: EnginePeer?, chatLocation: ChatLocation?, mode: WebSearchMode, attachment: Bool) { self.context = context self.theme = presentationData.theme self.strings = presentationData.strings @@ -181,6 +182,7 @@ class WebSearchControllerNode: ASDisplayNode { self.peer = peer self.chatLocation = chatLocation self.mode = mode + self.attachment = attachment self.webSearchInterfaceState = WebSearchInterfaceState(presentationData: context.sharedContext.currentPresentationData.with { $0 }) self.webSearchInterfaceStatePromise = ValuePromise(self.webSearchInterfaceState, ignoreRepeated: true) @@ -220,18 +222,22 @@ class WebSearchControllerNode: ASDisplayNode { }) self.addSubnode(self.gridNode) - self.addSubnode(self.recentQueriesNode) + if !attachment { + self.addSubnode(self.recentQueriesNode) + } self.addSubnode(self.segmentedBackgroundNode) self.addSubnode(self.segmentedSeparatorNode) if case .media = mode { self.addSubnode(self.segmentedControlNode) } - self.addSubnode(self.toolbarBackgroundNode) - self.addSubnode(self.toolbarSeparatorNode) - self.addSubnode(self.cancelButton) - self.addSubnode(self.sendButton) - self.addSubnode(self.attributionNode) - self.addSubnode(self.badgeNode) + if !attachment { + self.addSubnode(self.toolbarBackgroundNode) + self.addSubnode(self.toolbarSeparatorNode) + self.addSubnode(self.cancelButton) + self.addSubnode(self.sendButton) + self.addSubnode(self.attributionNode) + self.addSubnode(self.badgeNode) + } self.segmentedControlNode.selectedIndexChanged = { [weak self] index in if let strongSelf = self, let scope = WebSearchScope(rawValue: Int32(index)) { @@ -255,25 +261,27 @@ class WebSearchControllerNode: ASDisplayNode { } })) - let previousRecentItems = Atomic<[WebSearchRecentQueryEntry]?>(value: nil) - self.recentDisposable = (combineLatest(webSearchRecentQueries(postbox: self.context.account.postbox), self.webSearchInterfaceStatePromise.get()) - |> deliverOnMainQueue).start(next: { [weak self] queries, interfaceState in - if let strongSelf = self { - var entries: [WebSearchRecentQueryEntry] = [] - for i in 0 ..< queries.count { - entries.append(WebSearchRecentQueryEntry(index: i, query: queries[i])) + if !attachment { + let previousRecentItems = Atomic<[WebSearchRecentQueryEntry]?>(value: nil) + self.recentDisposable = (combineLatest(webSearchRecentQueries(postbox: self.context.account.postbox), self.webSearchInterfaceStatePromise.get()) + |> deliverOnMainQueue).start(next: { [weak self] queries, interfaceState in + if let strongSelf = self { + var entries: [WebSearchRecentQueryEntry] = [] + for i in 0 ..< queries.count { + entries.append(WebSearchRecentQueryEntry(index: i, query: queries[i])) + } + + let header = ChatListSearchItemHeader(type: .recentPeers, theme: interfaceState.presentationData.theme, strings: interfaceState.presentationData.strings, actionTitle: interfaceState.presentationData.strings.WebSearch_RecentSectionClear, action: { + _ = clearRecentWebSearchQueries(postbox: strongSelf.context.account.postbox).start() + }) + + let previousEntries = previousRecentItems.swap(entries) + + let transition = preparedWebSearchRecentTransition(from: previousEntries ?? [], to: entries, account: strongSelf.context.account, theme: interfaceState.presentationData.theme, strings: interfaceState.presentationData.strings, controllerInteraction: strongSelf.controllerInteraction, header: header) + strongSelf.enqueueRecentTransition(transition, firstTime: previousEntries == nil) } - - let header = ChatListSearchItemHeader(type: .recentPeers, theme: interfaceState.presentationData.theme, strings: interfaceState.presentationData.strings, actionTitle: interfaceState.presentationData.strings.WebSearch_RecentSectionClear, action: { - _ = clearRecentWebSearchQueries(postbox: strongSelf.context.account.postbox).start() - }) - - let previousEntries = previousRecentItems.swap(entries) - - let transition = preparedWebSearchRecentTransition(from: previousEntries ?? [], to: entries, account: strongSelf.context.account, theme: interfaceState.presentationData.theme, strings: interfaceState.presentationData.strings, controllerInteraction: strongSelf.controllerInteraction, header: header) - strongSelf.enqueueRecentTransition(transition, firstTime: previousEntries == nil) - } - }) + }) + } self.recentQueriesNode.beganInteractiveDragging = { [weak self] _ in self?.dismissInput?() @@ -458,7 +466,7 @@ class WebSearchControllerNode: ASDisplayNode { insets.top += segmentedHeight insets.bottom += toolbarHeight - self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: layout.size, insets: insets, preloadSize: 400.0, type: gridNodeLayoutForContainerLayout(size: layout.size)), transition: .immediate), itemTransition: .immediate, stationaryItems: .none,updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: layout.size, insets: insets, preloadSize: 400.0, type: gridNodeLayoutForContainerLayout(size: layout.size, insets: insets)), transition: .immediate), itemTransition: .immediate, stationaryItems: .none,updateFirstIndexInSectionOffset: nil), completion: { _ in }) let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) @@ -530,7 +538,11 @@ class WebSearchControllerNode: ASDisplayNode { self?.dismissInput?() } - self.insertSubnode(gridNode, belowSubnode: self.recentQueriesNode) + if self.recentQueriesNode.supernode != nil { + self.insertSubnode(gridNode, belowSubnode: self.recentQueriesNode) + } else { + self.addSubnode(gridNode) + } self.gridNode = gridNode self.currentEntries = nil let directionMultiplier: CGFloat diff --git a/submodules/WebSearchUI/Sources/WebSearchNavigationContentNode.swift b/submodules/WebSearchUI/Sources/WebSearchNavigationContentNode.swift index 0b75b15ef1..98f5fe6f19 100644 --- a/submodules/WebSearchUI/Sources/WebSearchNavigationContentNode.swift +++ b/submodules/WebSearchUI/Sources/WebSearchNavigationContentNode.swift @@ -16,13 +16,15 @@ final class WebSearchNavigationContentNode: NavigationBarContentNode { private var queryUpdated: ((String) -> Void)? - init(theme: PresentationTheme, strings: PresentationStrings) { + var cancel: (() -> Void)? + + init(theme: PresentationTheme, strings: PresentationStrings, attachment: Bool) { self.theme = theme self.strings = strings self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: strings, fieldStyle: .modern) - self.searchBar.hasCancelButton = false - self.searchBar.placeholderString = NSAttributedString(string: strings.Common_Search, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor) + self.searchBar.hasCancelButton = attachment + self.searchBar.placeholderString = NSAttributedString(string: attachment ? strings.Attachment_SearchWeb : strings.Common_Search, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor) super.init() @@ -36,6 +38,9 @@ final class WebSearchNavigationContentNode: NavigationBarContentNode { self?.queryUpdated?(query) } } + self.searchBar.cancel = { [weak self] in + self?.cancel?() + } } func setQueryUpdated(_ f: @escaping (String) -> Void) {