diff --git a/Telegram/Share/ShareRootController.swift b/Telegram/Share/ShareRootController.swift index f030a358c4..919e1692c8 100644 --- a/Telegram/Share/ShareRootController.swift +++ b/Telegram/Share/ShareRootController.swift @@ -69,6 +69,13 @@ class ShareRootController: UIViewController { }), getExtensionContext: { [weak self] in return self?.extensionContext }) + + self.impl?.openUrl = { [weak self] url in + guard let self, let url = URL(string: url) else { + return + } + let _ = self.openURL(url) + } } self.impl?.loadView() @@ -93,4 +100,20 @@ class ShareRootController: UIViewController { super.viewDidLayoutSubviews() self.impl?.viewDidLayoutSubviews(view: self.view, traitCollection: self.traitCollection) } + + @objc func openURL(_ url: URL) -> Bool { + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + if #available(iOS 18.0, *) { + application.open(url, options: [:], completionHandler: nil) + return true + } else { + return application.perform(#selector(openURL(_:)), with: url) != nil + } + } + responder = responder?.next + } + return false + } } diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index cfaa4dcb9b..81dd4dd822 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -30,6 +30,7 @@ public enum AccessType { case denied case restricted case unreachable + case limited } public enum TelegramAppBuildType { @@ -323,6 +324,7 @@ public enum ResolvedUrl { case collectible(gift: StarGift.UniqueGift?) case messageLink(link: TelegramResolvedMessageLink?) case stars + case shareStory(Int64) } public enum ResolveUrlResult { @@ -803,6 +805,9 @@ public protocol MediaPickerScreen: ViewController { func dismissAnimated() } +public protocol ChatQrCodeScreen: ViewController { +} + public protocol MediaEditorScreenResult { var target: Stories.PendingTarget { get } } @@ -1155,7 +1160,7 @@ public protocol SharedAccountContext: AnyObject { func makeMediaPickerScreen(context: AccountContext, hasSearch: Bool, completion: @escaping (Any) -> Void) -> ViewController - func makeStoryMediaEditorScreen(context: AccountContext, source: Any?, text: String?, link: (url: String, name: String?)?, completion: @escaping (MediaEditorScreenResult, @escaping (@escaping () -> Void) -> Void) -> Void) -> ViewController + func makeStoryMediaEditorScreen(context: AccountContext, source: Any?, text: String?, link: (url: String, name: String?)?, completion: @escaping ([MediaEditorScreenResult], @escaping (@escaping () -> Void) -> Void) -> Void) -> ViewController func makeBotPreviewEditorScreen(context: AccountContext, source: Any?, target: Stories.PendingTarget, transitionArguments: (UIView, CGRect, UIImage?)?, transitionOut: @escaping () -> BotPreviewEditorTransitionOut?, externalState: MediaEditorTransitionOutExternalState, completion: @escaping (MediaEditorScreenResult, @escaping (@escaping () -> Void) -> Void) -> Void, cancelled: @escaping () -> Void) -> ViewController @@ -1228,6 +1233,8 @@ public protocol SharedAccountContext: AnyObject { @available(iOS 13.0, *) func makePostSuggestionsSettingsScreen(context: AccountContext, peerId: EnginePeer.Id) async -> ViewController + func makeForumSettingsScreen(context: AccountContext, peerId: EnginePeer.Id) -> ViewController + func makeDebugSettingsController(context: AccountContext?) -> ViewController? func openCreateGroupCallUI(context: AccountContext, peerIds: [EnginePeer.Id], parentController: ViewController) diff --git a/submodules/AccountContext/Sources/MediaManager.swift b/submodules/AccountContext/Sources/MediaManager.swift index f9451544bb..8f12b31b2b 100644 --- a/submodules/AccountContext/Sources/MediaManager.swift +++ b/submodules/AccountContext/Sources/MediaManager.swift @@ -136,6 +136,16 @@ public enum MediaManagerPlayerType { case file } +public struct AudioRecorderResumeData { + public let compressedData: Data + public let resumeData: Data + + public init(compressedData: Data, resumeData: Data) { + self.compressedData = compressedData + self.resumeData = resumeData + } +} + public protocol MediaManager: AnyObject { var audioSession: ManagedAudioSession { get } var galleryHiddenMediaManager: GalleryHiddenMediaManager { get } @@ -157,7 +167,12 @@ public protocol MediaManager: AnyObject { func setOverlayVideoNode(_ node: OverlayMediaItemNode?) func hasOverlayVideoNode(_ node: OverlayMediaItemNode) -> Bool - func audioRecorder(beginWithTone: Bool, applicationBindings: TelegramApplicationBindings, beganWithTone: @escaping (Bool) -> Void) -> Signal + func audioRecorder( + resumeData: AudioRecorderResumeData?, + beginWithTone: Bool, + applicationBindings: TelegramApplicationBindings, + beganWithTone: @escaping (Bool) -> Void + ) -> Signal } public enum GalleryHiddenMediaId: Hashable { @@ -215,13 +230,17 @@ public enum AudioRecordingState: Equatable { public struct RecordedAudioData { public let compressedData: Data + public let resumeData: Data? public let duration: Double public let waveform: Data? + public let trimRange: Range? - public init(compressedData: Data, duration: Double, waveform: Data?) { + public init(compressedData: Data, resumeData: Data?, duration: Double, waveform: Data?, trimRange: Range?) { self.compressedData = compressedData + self.resumeData = resumeData self.duration = duration self.waveform = waveform + self.trimRange = trimRange } } @@ -235,4 +254,6 @@ public protocol ManagedAudioRecorder: AnyObject { func resume() func stop() func takenRecordedData() -> Signal + + func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) } diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift index 49b9c5a1f9..e410007a36 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift @@ -76,6 +76,8 @@ final class AuthorizationSequencePaymentScreenComponent: Component { private var productsDisposable: Disposable? private var inProgress = false + private var paymentDisposable = MetaDisposable() + override init(frame: CGRect) { super.init(frame: frame) @@ -87,11 +89,12 @@ final class AuthorizationSequencePaymentScreenComponent: Component { } deinit { + self.paymentDisposable.dispose() self.productsDisposable?.dispose() } private func proceed() { - guard let component = self.component, let storeProduct = self.products.first(where: { $0.id == component.storeProduct }) else { + guard let component = self.component, let storeProduct = self.products.first(where: { $0.id == component.storeProduct }), !self.inProgress else { return } @@ -107,10 +110,12 @@ final class AuthorizationSequencePaymentScreenComponent: Component { } let presentationData = component.presentationData if available { - let _ = (component.inAppPurchaseManager.buyProduct(storeProduct, quantity: 1, purpose: purpose) + self.paymentDisposable.set((component.inAppPurchaseManager.buyProduct(storeProduct, quantity: 1, purpose: purpose) |> deliverOnMainQueue).start(next: { [weak self] status in - let _ = status - let _ = self + guard let self else { + return + } + self.inProgress = false }, error: { [weak self] error in guard let self, let controller = self.environment?.controller() else { return @@ -144,7 +149,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { //let alertController = textAlertController(context: component.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) //controller.present(alertController, in: .window(.root)) } - }) + })) } else { self.inProgress = false self.state?.updated(transition: .immediate) diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index e03b3a67c7..c72505e5a1 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -25,6 +25,7 @@ private let anonymousSavedMessagesIcon = generateTintedImage(image: UIImage(bund private let anonymousSavedMessagesDarkIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/AnonymousSenderIcon"), color: UIColor(white: 1.0, alpha: 0.4)) private let myNotesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/MyNotesIcon"), color: .white) private let cameraIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/CameraIcon"), color: .white) +private let storyIcon = generateTintedImage(image: UIImage(bundleImageName: "Share/Story"), color: .white) public func avatarPlaceholderFont(size: CGFloat) -> UIFont { return Font.with(size: size, design: .round, weight: .bold) @@ -96,6 +97,8 @@ public func calculateAvatarColors(context: AccountContext?, explicitColorIndex: colors = AvatarNode.savedMessagesColors } else if case .repostIcon = icon { colors = AvatarNode.repostColors + } else if case .storyIcon = icon { + colors = AvatarNode.repostColors } else if case .repliesIcon = icon { colors = AvatarNode.savedMessagesColors } else if case let .anonymousSavedMessagesIcon(isColored) = icon { @@ -200,6 +203,7 @@ public enum AvatarNodeIcon: Equatable { case phoneIcon case repostIcon case cameraIcon + case storyIcon } public enum AvatarNodeImageOverride: Equatable { @@ -215,6 +219,7 @@ public enum AvatarNodeImageOverride: Equatable { case phoneIcon case repostIcon case cameraIcon + case storyIcon } public enum AvatarNodeColorOverride { @@ -575,6 +580,9 @@ public final class AvatarNode: ASDisplayNode { case .cameraIcon: representation = nil icon = .cameraIcon + case .storyIcon: + representation = nil + icon = .storyIcon } } else if peer?.restrictionText(platform: "ios", contentSettings: contentSettings) == nil { representation = peer?.smallProfileImage @@ -755,6 +763,9 @@ public final class AvatarNode: ASDisplayNode { case .cameraIcon: representation = nil icon = .cameraIcon + case .storyIcon: + representation = nil + icon = .storyIcon } } else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil { representation = peer?.smallProfileImage @@ -1007,6 +1018,15 @@ public final class AvatarNode: ASDisplayNode { if let cameraIcon = cameraIcon { context.draw(cameraIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - cameraIcon.size.width) / 2.0), y: floor((bounds.size.height - cameraIcon.size.height) / 2.0)), size: cameraIcon.size)) } + } else if case .storyIcon = parameters.icon { + let factor = bounds.size.width / 60.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let storyIcon = storyIcon { + context.draw(storyIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - storyIcon.size.width) / 2.0), y: floor((bounds.size.height - storyIcon.size.height) / 2.0)), size: storyIcon.size)) + } } else if case .editAvatarIcon = parameters.icon, let theme = parameters.theme, !parameters.hasImage { context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) diff --git a/submodules/Camera/Sources/CameraMetrics.swift b/submodules/Camera/Sources/CameraMetrics.swift index 01d68996e1..3b1a8a374b 100644 --- a/submodules/Camera/Sources/CameraMetrics.swift +++ b/submodules/Camera/Sources/CameraMetrics.swift @@ -14,7 +14,7 @@ public extension Camera { case iPhone15ProMax case unknown - init(model: DeviceModel) { + public init(model: DeviceModel) { switch model { case .iPodTouch1, .iPodTouch2, .iPodTouch3, .iPodTouch4, .iPodTouch5, .iPodTouch6, .iPodTouch7: self = .singleCamera diff --git a/submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift b/submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift index bf5f3dfe82..084a592c11 100644 --- a/submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift +++ b/submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift @@ -284,17 +284,23 @@ public enum ChatInterfaceMediaDraftState: Codable, Equatable { public let fileSize: Int32 public let duration: Int32 public let waveform: AudioWaveform + public let trimRange: Range? + public let resumeData: Data? public init( resource: LocalFileMediaResource, fileSize: Int32, duration: Int32, - waveform: AudioWaveform + waveform: AudioWaveform, + trimRange: Range?, + resumeData: Data? ) { self.resource = resource self.fileSize = fileSize self.duration = duration self.waveform = waveform + self.trimRange = trimRange + self.resumeData = resumeData } public init(from decoder: Decoder) throws { @@ -309,6 +315,14 @@ public enum ChatInterfaceMediaDraftState: Codable, Equatable { let waveformData = try container.decode(Data.self, forKey: "wd") let waveformPeak = try container.decode(Int32.self, forKey: "wp") self.waveform = AudioWaveform(samples: waveformData, peak: waveformPeak) + + if let trimLowerBound = try container.decodeIfPresent(Double.self, forKey: "tl"), let trimUpperBound = try container.decodeIfPresent(Double.self, forKey: "tu") { + self.trimRange = trimLowerBound ..< trimUpperBound + } else { + self.trimRange = nil + } + + self.resumeData = try container.decode(Data.self, forKey: "rd") } public func encode(to encoder: Encoder) throws { @@ -319,6 +333,15 @@ public enum ChatInterfaceMediaDraftState: Codable, Equatable { try container.encode(self.duration, forKey: "d") try container.encode(self.waveform.samples, forKey: "wd") try container.encode(self.waveform.peak, forKey: "wp") + + if let trimRange = self.trimRange { + try container.encode(trimRange.lowerBound, forKey: "tl") + try container.encode(trimRange.upperBound, forKey: "tu") + } + + if let resumeData = self.resumeData { + try container.encode(resumeData, forKey: "rd") + } } public static func ==(lhs: Audio, rhs: Audio) -> Bool { @@ -334,6 +357,12 @@ public enum ChatInterfaceMediaDraftState: Codable, Equatable { if lhs.waveform != rhs.waveform { return false } + if lhs.trimRange != rhs.trimRange { + return false + } + if lhs.resumeData != rhs.resumeData { + return false + } return true } } diff --git a/submodules/ContactListUI/Sources/ContactListNode.swift b/submodules/ContactListUI/Sources/ContactListNode.swift index 22d59abf0e..fe89cebbd1 100644 --- a/submodules/ContactListUI/Sources/ContactListNode.swift +++ b/submodules/ContactListUI/Sources/ContactListNode.swift @@ -48,7 +48,7 @@ private enum ContactListNodeEntrySection: Int { private enum ContactListNodeEntryId: Hashable { case search case sort - case permission(action: Bool) + case permission(index: Int) case option(index: Int) case peerId(peerId: Int64, section: ContactListNodeEntrySection) case deviceContact(DeviceContactStableId) @@ -64,10 +64,11 @@ private final class ContactListNodeInteraction { fileprivate let openStories: (EnginePeer, ASDisplayNode) -> Void fileprivate let deselectAll: () -> Void fileprivate let toggleSelection: ([EnginePeer], Bool) -> Void + fileprivate let openContactAccessPicker: () -> Void let itemHighlighting = ContactItemHighlighting() - init(activateSearch: @escaping () -> Void, authorize: @escaping () -> Void, suppressWarning: @escaping () -> Void, openPeer: @escaping (ContactListPeer, ContactListAction, ASDisplayNode?, ContextGesture?) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?, Bool) -> Void)?, openStories: @escaping (EnginePeer, ASDisplayNode) -> Void, deselectAll: @escaping () -> Void, toggleSelection: @escaping ([EnginePeer], Bool) -> Void) { + init(activateSearch: @escaping () -> Void, authorize: @escaping () -> Void, suppressWarning: @escaping () -> Void, openPeer: @escaping (ContactListPeer, ContactListAction, ASDisplayNode?, ContextGesture?) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?, Bool) -> Void)?, openStories: @escaping (EnginePeer, ASDisplayNode) -> Void, deselectAll: @escaping () -> Void, toggleSelection: @escaping ([EnginePeer], Bool) -> Void, openContactAccessPicker: @escaping () -> Void) { self.activateSearch = activateSearch self.authorize = authorize self.suppressWarning = suppressWarning @@ -77,6 +78,7 @@ private final class ContactListNodeInteraction { self.openStories = openStories self.deselectAll = deselectAll self.toggleSelection = toggleSelection + self.openContactAccessPicker = openContactAccessPicker } } @@ -97,6 +99,7 @@ private enum ContactListNodeEntry: Comparable, Identifiable { case sort(PresentationTheme, PresentationStrings, ContactsSortOrder) case permissionInfo(PresentationTheme, String, String, Bool) case permissionEnable(PresentationTheme, String) + case permissionLimited(PresentationTheme, PresentationStrings) case option(Int, ContactListAdditionalOption, ListViewItemHeader?, PresentationTheme, PresentationStrings) case peer(Int, ContactListPeer, EnginePeer.Presence?, ListViewItemHeader?, ContactsPeerItemSelection, PresentationTheme, PresentationStrings, PresentationDateTimeFormat, PresentationPersonNameOrder, PresentationPersonNameOrder, Bool, Bool, Bool, StoryData?, Bool, String?) @@ -107,9 +110,11 @@ private enum ContactListNodeEntry: Comparable, Identifiable { case .sort: return .sort case .permissionInfo: - return .permission(action: false) + return .permission(index: 0) case .permissionEnable: - return .permission(action: true) + return .permission(index: 1) + case .permissionLimited: + return .permission(index: 2) case let .option(index, _, _, _, _): return .option(index: index) case let .peer(_, peer, _, _, _, _, _, _, _, _, _, _, _, storyData, _, _): @@ -143,6 +148,11 @@ private enum ContactListNodeEntry: Comparable, Identifiable { return ContactListActionItem(presentationData: ItemListPresentationData(presentationData), title: text, icon: .none, header: nil, action: { interaction.authorize() }) + case .permissionLimited: + //TODO:localize + return LimitedPermissionItem(presentationData: ItemListPresentationData(presentationData), text: "You have limited Telegram from accessing all of your contacts.", action: { + interaction.openContactAccessPicker() + }) case let .option(_, option, header, _, _): let style: ContactListActionItem.Style let height: ContactListActionItem.Height @@ -268,6 +278,12 @@ private enum ContactListNodeEntry: Comparable, Identifiable { } else { return false } + case let .permissionLimited(lhsTheme, lhsStrings): + if case let .permissionLimited(rhsTheme, rhsStrings) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings { + return true + } else { + return false + } case let .option(lhsIndex, lhsOption, lhsHeader, lhsTheme, lhsStrings): if case let .option(rhsIndex, rhsOption, rhsHeader, rhsTheme, rhsStrings) = rhs, lhsIndex == rhsIndex, lhsOption == rhsOption, lhsHeader?.id == rhsHeader?.id, lhsTheme === rhsTheme, lhsStrings === rhsStrings { return true @@ -361,9 +377,16 @@ private enum ContactListNodeEntry: Comparable, Identifiable { default: return true } - case let .option(lhsIndex, _, _, _, _): + case .permissionLimited: switch rhs { case .search, .sort, .permissionInfo, .permissionEnable: + return false + default: + return true + } + case let .option(lhsIndex, _, _, _, _): + switch rhs { + case .search, .sort, .permissionInfo, .permissionEnable, .permissionLimited: return false case let .option(rhsIndex, _, _, _, _): return lhsIndex < rhsIndex @@ -372,7 +395,7 @@ private enum ContactListNodeEntry: Comparable, Identifiable { } case let .peer(lhsIndex, _, _, _, _, _, _, _, _, _, _, _, _, lhsStoryData, _, _): switch rhs { - case .search, .sort, .permissionInfo, .permissionEnable, .option: + case .search, .sort, .permissionInfo, .permissionEnable, .permissionLimited, .option: return false case let .peer(rhsIndex, _, _, _, _, _, _, _, _, _, _, _, _, rhsStoryData, _, _): if (lhsStoryData == nil) != (rhsStoryData == nil) { @@ -402,6 +425,8 @@ private func contactListNodeEntries(accountPeer: EnginePeer?, peers: [ContactLis let title = strings.Contacts_PermissionsTitle let text = strings.Contacts_PermissionsText switch authorizationStatus { + case .limited: + entries.append(.permissionLimited(theme, strings)) case .denied: entries.append(.permissionInfo(theme, title, text, suppressed)) entries.append(.permissionEnable(theme, strings.Permissions_ContactsAllowInSettings_v0)) @@ -1119,6 +1144,7 @@ public final class ContactListNode: ASDisplayNode { public var suppressPermissionWarning: (() -> Void)? private let contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?, Bool) -> Void)? public var openStories: ((EnginePeer, ASDisplayNode) -> Void)? + public var openContactAccessPicker: (() -> Void)? private let previousEntries = Atomic<[ContactListNodeEntry]?>(value: nil) private let disposable = MetaDisposable() @@ -1272,6 +1298,8 @@ public final class ContactListNode: ASDisplayNode { return state }) self.updatedSelection?(peers, value) + }, openContactAccessPicker: { [weak self] in + self?.openContactAccessPicker?() }) self.indexNode.indexSelected = { [weak self] section in diff --git a/submodules/ContactListUI/Sources/ContactsControllerNode.swift b/submodules/ContactListUI/Sources/ContactsControllerNode.swift index cccd381c9b..6a9ad102a4 100644 --- a/submodules/ContactListUI/Sources/ContactsControllerNode.swift +++ b/submodules/ContactListUI/Sources/ContactsControllerNode.swift @@ -15,6 +15,8 @@ import ContextUI import ChatListHeaderComponent import ChatListTitleView import ComponentFlow +import SwiftUI +import ContactsUI private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController @@ -242,6 +244,10 @@ final class ContactsControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } self.openStories?(peer, sourceNode) } + + self.contactListNode.openContactAccessPicker = { + presentContactAccessPicker(context: context) + } } deinit { @@ -540,3 +546,44 @@ private final class ContactContextExtractedContentSource: ContextExtractedConten return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } } + +private func presentContactAccessPicker(context: AccountContext) { + if #available(iOS 18.0, *), let rootViewController = context.sharedContext.mainWindow?.viewController?.view.window?.rootViewController { + var dismissImpl: (() -> Void)? + let pickerView = ContactAccessPickerHostingView(completionHandler: { [weak rootViewController] ids in + DispatchQueue.main.async(execute: { + guard let presentedController = rootViewController?.presentedViewController, presentedController.isBeingDismissed == false else { return } + dismissImpl?() + }) + }) + let hostingController = UIHostingController(rootView: pickerView) + hostingController.view.isHidden = true + hostingController.modalPresentationStyle = .overCurrentContext + rootViewController.present(hostingController, animated: true) + dismissImpl = { [weak hostingController] in + Queue.mainQueue().after(0.4, { + hostingController?.dismiss(animated: false) + }) + } + } +} + +@available(iOS 18.0, *) +struct ContactAccessPickerHostingView: View { + @State var presented = true + var handler: ([String]) -> () + + init(completionHandler: @escaping ([String]) -> ()) { + self.handler = completionHandler + } + + var body: some View { + Spacer() + .contactAccessPicker(isPresented: $presented, completionHandler: handler) + .onChange(of: presented) { newValue in + if newValue == false { + handler([]) + } + } + } +} diff --git a/submodules/ContactListUI/Sources/LimitedPermissionItem.swift b/submodules/ContactListUI/Sources/LimitedPermissionItem.swift new file mode 100644 index 0000000000..d60b6dc794 --- /dev/null +++ b/submodules/ContactListUI/Sources/LimitedPermissionItem.swift @@ -0,0 +1,256 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import TextFormat +import Markdown +import ItemListUI + +public class LimitedPermissionItem: ListViewItem { + public let selectable: Bool = false + + let presentationData: ItemListPresentationData + let text: String + let action: (() -> Void)? + + public init( + presentationData: ItemListPresentationData, + text: String, + action: (() -> Void)? + ) { + self.presentationData = presentationData + self.text = text + self.action = action + } + + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = LimitedPermissionItemNode() + let (layout, apply) = node.asyncLayout()(self, params, nil) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? LimitedPermissionItemNode { + let makeLayout = nodeValue.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, params, nil) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } +} + +public class LimitedPermissionItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + + private let actionButton: HighlightableButtonNode + private let actionButtonTitleNode: TextNode + private let actionButtonBackgroundNode: ASImageNode + + private let textNode: TextNode + + private let activateArea: AccessibilityAreaNode + + private var item: LimitedPermissionItem? + + public override var canBeSelected: Bool { + return false + } + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + + self.textNode = TextNode() + self.textNode.isUserInteractionEnabled = false + + self.activateArea = AccessibilityAreaNode() + self.activateArea.accessibilityTraits = .staticText + + self.actionButton = HighlightableButtonNode() + + self.actionButtonBackgroundNode = ASImageNode() + self.actionButtonBackgroundNode.displaysAsynchronously = false + + self.actionButtonTitleNode = TextNode() + self.actionButtonTitleNode.isUserInteractionEnabled = false + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.textNode) + self.addSubnode(self.activateArea) + self.addSubnode(self.actionButtonBackgroundNode) + self.addSubnode(self.actionButtonTitleNode) + self.addSubnode(self.actionButton) + + self.actionButton.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.actionButtonBackgroundNode.layer.removeAnimation(forKey: "opacity") + strongSelf.actionButtonBackgroundNode.alpha = 0.4 + strongSelf.actionButtonTitleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.actionButtonTitleNode.alpha = 0.4 + } else { + strongSelf.actionButtonBackgroundNode.alpha = 1.0 + strongSelf.actionButtonBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.actionButtonTitleNode.alpha = 1.0 + strongSelf.actionButtonTitleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + + self.actionButton.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + } + + func asyncLayout() -> (_ item: LimitedPermissionItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors?) -> (ListViewItemNodeLayout, () -> Void) { + let makeTextLayout = TextNode.asyncLayout(self.textNode) + let makeButtonTitleLayout = TextNode.asyncLayout(self.actionButtonTitleNode) + + let currentItem = self.item + + return { item, params, neighbors in + let leftInset: CGFloat = 16.0 + params.leftInset + let rightInset: CGFloat = 16.0 + params.rightInset + + let textFont = Font.regular(15.0) + + var updatedTheme: PresentationTheme? + if currentItem?.presentationData.theme !== item.presentationData.theme { + updatedTheme = item.presentationData.theme + } + + let insets: UIEdgeInsets + if let neighbors = neighbors { + insets = itemListNeighborsGroupedInsets(neighbors, params) + } else { + insets = UIEdgeInsets() + } + let separatorHeight = UIScreenPixel + + let itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor + let itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor + + let attributedText = NSAttributedString(string: item.text, font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor) + + //TODO:localize + let (buttonTextLayout, buttonTextApply) = makeButtonTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "MANAGE", font: Font.semibold(15.0), textColor: item.presentationData.theme.list.itemCheckColors.foregroundColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - buttonTextLayout.size.width - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let contentSize = CGSize(width: params.width, height: textLayout.size.height + 20.0) + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + + return (layout, { [weak self] in + if let strongSelf = self { + strongSelf.item = item + + strongSelf.accessibilityLabel = attributedText.string + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height)) + strongSelf.activateArea.accessibilityLabel = strongSelf.accessibilityLabel + + if let _ = updatedTheme { + strongSelf.topStripeNode.backgroundColor = itemSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor + strongSelf.backgroundNode.backgroundColor = itemBackgroundColor + + strongSelf.actionButtonBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 14.0 * 2.0, color: item.presentationData.theme.list.itemCheckColors.fillColor, strokeColor: nil, strokeWidth: nil, backgroundColor: nil) + } + + let _ = textApply() + let _ = buttonTextApply() + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + + if let neighbors = neighbors { + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = true + } + } + let bottomStripeInset: CGFloat + if let neighbors = neighbors { + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + strongSelf.bottomStripeNode.isHidden = false + default: + bottomStripeInset = 0.0 + strongSelf.bottomStripeNode.isHidden = true + } + } else { + bottomStripeInset = leftInset + strongSelf.topStripeNode.isHidden = true + } + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - bottomStripeInset, height: separatorHeight)) + + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 10.0), size: textLayout.size) + + let actionButtonSize = CGSize(width: max(buttonTextLayout.size.width + 26.0, 40.0), height: 28.0) + let actionButtonFrame = CGRect(origin: CGPoint(x: params.width - params.rightInset - actionButtonSize.width - 10.0, y: floor((layout.size.height - actionButtonSize.height) / 2.0)), size: actionButtonSize) + strongSelf.actionButton.frame = actionButtonFrame + strongSelf.actionButtonBackgroundNode.frame = actionButtonFrame + strongSelf.actionButtonTitleNode.frame = CGRect(origin: CGPoint(x: actionButtonFrame.minX + floorToScreenPixels((actionButtonFrame.width - buttonTextLayout.size.width) / 2.0), y: actionButtonFrame.minY + floorToScreenPixels((actionButtonFrame.height - buttonTextLayout.size.height) / 2.0) + 1.0 - UIScreenPixel), size: buttonTextLayout.size) + } + }) + } + } + + public override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + public override func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + public override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + @objc func buttonPressed() { + if let item = self.item { + item.action?() + } + } +} diff --git a/submodules/DeviceAccess/Sources/DeviceAccess.swift b/submodules/DeviceAccess/Sources/DeviceAccess.swift index 1b8b3e4d11..42d428fef0 100644 --- a/submodules/DeviceAccess/Sources/DeviceAccess.swift +++ b/submodules/DeviceAccess/Sources/DeviceAccess.swift @@ -161,6 +161,8 @@ public final class DeviceAccess { subscriber.putNext(.notDetermined) case .authorized: subscriber.putNext(.allowed) + case .limited: + subscriber.putNext(.limited) default: subscriber.putNext(.denied) } @@ -181,7 +183,7 @@ public final class DeviceAccess { return status |> then(self.contacts |> mapToSignal { authorized -> Signal in - if let authorized = authorized { + if let authorized { return .single(authorized ? .allowed : .denied) } else { return .complete() @@ -540,6 +542,9 @@ public final class DeviceAccess { case .authorized: self.contactsPromise.set(.single(true)) completion(true) + case .limited: + self.contactsPromise.set(.single(true)) + completion(true) default: self.contactsPromise.set(.single(false)) completion(false) diff --git a/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegOpusTrimmer.h b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegOpusTrimmer.h new file mode 100644 index 0000000000..87e6760fd3 --- /dev/null +++ b/submodules/FFMpegBinding/Public/FFMpegBinding/FFMpegOpusTrimmer.h @@ -0,0 +1,15 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FFMpegOpusTrimmer : NSObject + ++ (bool)trim:(NSString * _Nonnull)path + to:(NSString * _Nonnull)outputPath + start:(double)start + end:(double)end; + +@end + +NS_ASSUME_NONNULL_END diff --git a/submodules/FFMpegBinding/Sources/FFMpegOpusTrimmer.m b/submodules/FFMpegBinding/Sources/FFMpegOpusTrimmer.m new file mode 100644 index 0000000000..4f72cdc4ee --- /dev/null +++ b/submodules/FFMpegBinding/Sources/FFMpegOpusTrimmer.m @@ -0,0 +1,108 @@ +#import + +#import "libavformat/avformat.h" +#import "libavutil/avutil.h" + +@implementation FFMpegOpusTrimmer + ++ (bool)trim:(NSString * _Nonnull)inputPath + to:(NSString * _Nonnull)outputPath + start:(double)start + end:(double)end +{ + AVFormatContext *inCtx = NULL; + int ret; + if ((ret = avformat_open_input(&inCtx, inputPath.UTF8String, NULL, NULL)) < 0) { + return false; + } + + if ((ret = avformat_find_stream_info(inCtx, NULL)) < 0) { + return false; + } + + int audioIdx = -1; + for (unsigned i = 0; i < inCtx->nb_streams; ++i) { + if (inCtx->streams[i]->codecpar->codec_id == AV_CODEC_ID_OPUS) { + audioIdx = (int)i; break; + } + } + if (audioIdx == -1) { + avformat_close_input(&inCtx); + return false; + } + AVStream *inSt = inCtx->streams[audioIdx]; + AVRational tb = inSt->time_base; + + AVFormatContext *outCtx = NULL; + avformat_alloc_output_context2(&outCtx, NULL, "ogg", + outputPath.UTF8String); + if (!outCtx) { + avformat_close_input(&inCtx); + return false; + } + + AVStream *outSt = avformat_new_stream(outCtx, NULL); + avcodec_parameters_copy(outSt->codecpar, inSt->codecpar); + outSt->time_base = tb; + + if (!(outCtx->oformat->flags & AVFMT_NOFILE)) { + if (avio_open(&outCtx->pb, outputPath.UTF8String, AVIO_FLAG_WRITE) < 0) { + avformat_free_context(outCtx); + avformat_close_input(&inCtx); + return false; + } + } + + ret = avformat_write_header(outCtx, NULL); + + int64_t startTs = (int64_t)(start / av_q2d(tb)); + int64_t endTs = (int64_t)(end / av_q2d(tb)); + //int64_t span = MAX(endTs - startTs, 1); + av_seek_frame(inCtx, audioIdx, startTs, AVSEEK_FLAG_BACKWARD); + + AVPacket *pkt = nil; + pkt = av_packet_alloc(); + //double lastPct = 0.0; + + int64_t firstPts = startTs; + + while (av_read_frame(inCtx, pkt) >= 0) { + if (pkt->stream_index != audioIdx) { av_packet_unref(pkt); continue; } + if (pkt->pts < startTs) { av_packet_unref(pkt); continue; } + if (pkt->pts > endTs) { av_packet_unref(pkt); break; } + + //double pct = (double)(pkt.pts - startTs) / (double)span; + //if (pct - lastPct >= 0.01 && progress) { + // lastPct = pct; + //dispatch_async(dispatch_get_main_queue(), ^{ progress(pct); }); + //} + + pkt->pts = av_rescale_q(pkt->pts - firstPts, tb, outSt->time_base); + pkt->dts = av_rescale_q(pkt->dts - firstPts, tb, outSt->time_base); + pkt->duration = av_rescale_q(pkt->duration, tb, outSt->time_base); + pkt->pos = -1; + pkt->stream_index = 0; + + if (av_interleaved_write_frame(outCtx, pkt) != 0) { + av_packet_unref(pkt); + av_write_trailer(outCtx); + avio_closep(&outCtx->pb); + avformat_free_context(outCtx); + avformat_close_input(&inCtx); + return false; + } + av_packet_unref(pkt); + } + + av_write_trailer(outCtx); + avio_closep(&outCtx->pb); + avformat_free_context(outCtx); + avformat_close_input(&inCtx); + + return true; + // if (progress) { + // dispatch_async(dispatch_get_main_queue(), ^{ progress(1.0); }); + // } +} + +@end diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponentsContext.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponentsContext.h index a7c7db0a09..7bd5d94516 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponentsContext.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/LegacyComponentsContext.h @@ -64,6 +64,8 @@ typedef enum { - (TGMediaAssetsPallete *)mediaAssetsPallete; - (TGCheckButtonPallete *)checkButtonPallete; +- (NSArray *)cameraZoomLevels; + @required - (CGFloat)applicationStatusBarAlpha; @@ -89,7 +91,9 @@ typedef enum { - (NSDictionary *)serverMediaDataForAssetUrl:(NSString *)url; - (void)presentActionSheet:(NSArray *)actions view:(UIView *)view completion:(void (^)(LegacyComponentsActionSheetAction *))completion; -- (void)presentActionSheet:(NSArray *)actions view:(UIView *)view sourceRect:(CGRect (^)(void))sourceRect completion:(void (^)(LegacyComponentsActionSheetAction *))completion ; +- (void)presentActionSheet:(NSArray *)actions view:(UIView *)view sourceRect:(CGRect (^)(void))sourceRect completion:(void (^)(LegacyComponentsActionSheetAction *))completion; + +- (void)presentTooltip:(NSString *)text icon:(UIImage *)icon sourceRect:(CGRect)sourceRect; - (id)makeOverlayWindowManager; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCamera.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCamera.h index b20f65fd31..095d4fcc99 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCamera.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCamera.h @@ -85,8 +85,6 @@ typedef enum @property (nonatomic, assign) CGFloat zoomLevel; @property (nonatomic, readonly) CGFloat minZoomLevel; @property (nonatomic, readonly) CGFloat maxZoomLevel; -@property (nonatomic, readonly) int32_t maxMarkZoomValue; -@property (nonatomic, readonly) int32_t secondMarkZoomValue; - (void)setZoomLevel:(CGFloat)zoomLevel animated:(bool)animated; @@ -101,6 +99,8 @@ typedef enum @property (nonatomic, assign) bool autoStartVideoRecording; +@property (nonatomic, strong) NSArray *zoomLevels; + - (instancetype)initWithMode:(PGCameraMode)mode position:(PGCameraPosition)position; - (void)attachPreviewView:(TGCameraPreviewView *)previewView; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCameraCaptureSession.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCameraCaptureSession.h index 04ef8a5a7a..422e8db6e4 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCameraCaptureSession.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/PGCameraCaptureSession.h @@ -30,8 +30,7 @@ @property (nonatomic, readonly) CGFloat minZoomLevel; @property (nonatomic, readonly) CGFloat maxZoomLevel; -@property (nonatomic, readonly) int32_t maxMarkZoomValue; -@property (nonatomic, readonly) int32_t secondMarkZoomValue; +@property (nonatomic, strong) NSArray *zoomLevels; - (void)setZoomLevel:(CGFloat)zoomLevel animated:(bool)animated; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraMainView.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraMainView.h index 3854bd1dfa..cc78e5f445 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraMainView.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraMainView.h @@ -68,7 +68,7 @@ @property (nonatomic, assign) CGRect previewViewFrame; -- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera camera:(PGCamera *)camera; +- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault camera:(PGCamera *)camera; - (void)setDocumentFrameHidden:(bool)hidden; - (void)setCameraMode:(PGCameraMode)mode; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraZoomView.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraZoomView.h index 69a21795fb..20efbbad83 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraZoomView.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGCameraZoomView.h @@ -29,7 +29,7 @@ - (void)panGesture:(UIPanGestureRecognizer *)gestureRecognizer; -- (instancetype)initWithFrame:(CGRect)frame hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera minZoomLevel:(CGFloat)minZoomLevel maxZoomLevel:(CGFloat)maxZoomLevel; +- (instancetype)initWithFrame:(CGRect)frame zoomLevels:(NSArray *)zoomLevels minZoomLevel:(CGFloat)minZoomLevel maxZoomLevel:(CGFloat)maxZoomLevel; @end diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h index ba44c28837..6e8224ca8a 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaEditingContext.h @@ -110,11 +110,13 @@ - (bool)setPaintingData:(NSData *)data entitiesData:(NSData *)entitiesData image:(UIImage *)image stillImage:(UIImage *)stillImage forItem:(NSObject *)item dataUrl:(NSURL **)dataOutUrl entitiesDataUrl:(NSURL **)entitiesDataOutUrl imageUrl:(NSURL **)imageOutUrl forVideo:(bool)video; - (void)clearPaintingData; - - (bool)isCaptionAbove; - (SSignal *)captionAbove; - (void)setCaptionAbove:(bool)captionAbove; +- (bool)isHighQualityPhoto; +- (SSignal *)highQualityPhoto; +- (void)setHighQualityPhoto:(bool)highQualityPhoto; - (SSignal *)facesForItem:(NSObject *)item; - (void)setFaces:(NSArray *)faces forItem:(NSObject *)item; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGalleryInterfaceView.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGalleryInterfaceView.h index 075896120d..db7d269dfc 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGalleryInterfaceView.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGMediaPickerGalleryInterfaceView.h @@ -50,6 +50,8 @@ - (void)setAllInterfaceHidden:(bool)hidden delay:(NSTimeInterval)__unused delay animated:(bool)animated; - (void)setToolbarsHidden:(bool)hidden animated:(bool)animated; +- (void)showPhotoQualityTooltip:(bool)hd; + - (void)immediateEditorTransitionIn; - (void)editorTransitionIn; - (void)editorTransitionOut; diff --git a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGPhotoEditorInterfaceAssets.h b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGPhotoEditorInterfaceAssets.h index 1c10e40842..182f7d7e91 100644 --- a/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGPhotoEditorInterfaceAssets.h +++ b/submodules/LegacyComponents/PublicHeaders/LegacyComponents/TGPhotoEditorInterfaceAssets.h @@ -29,6 +29,7 @@ + (UIImage *)gifActiveIcon; + (UIImage *)muteIcon; + (UIImage *)muteActiveIcon; ++ (UIImage *)qualityIconForHighQuality:(bool)highQuality filled:(bool)filled; + (UIImage *)qualityIconForPreset:(TGMediaVideoConversionPreset)preset; + (UIImage *)timerIconForValue:(NSInteger)value; + (UIImage *)eraserIcon; diff --git a/submodules/LegacyComponents/Sources/PGCamera.m b/submodules/LegacyComponents/Sources/PGCamera.m index c06c9db451..60b9064cc1 100644 --- a/submodules/LegacyComponents/Sources/PGCamera.m +++ b/submodules/LegacyComponents/Sources/PGCamera.m @@ -760,19 +760,12 @@ NSString *const PGCameraAdjustingFocusKey = @"adjustingFocus"; { if (self.disabled) return; - + + self.captureSession.zoomLevels = self.zoomLevels; [self.captureSession setZoomLevel:zoomLevel animated:animated]; }]; } -- (int32_t)maxMarkZoomValue { - return self.captureSession.maxMarkZoomValue; -} - -- (int32_t)secondMarkZoomValue { - return self.captureSession.secondMarkZoomValue; -} - #pragma mark - Device Angle - (void)startDeviceAngleMeasuring diff --git a/submodules/LegacyComponents/Sources/PGCameraCaptureSession.m b/submodules/LegacyComponents/Sources/PGCameraCaptureSession.m index fe40edfcba..14692feffa 100644 --- a/submodules/LegacyComponents/Sources/PGCameraCaptureSession.m +++ b/submodules/LegacyComponents/Sources/PGCameraCaptureSession.m @@ -504,9 +504,9 @@ const NSInteger PGCameraFrameRate = 30; if (backingLevel < firstMark) { realLevel = 0.5 + 0.5 * (backingLevel - 1.0) / (firstMark - 1.0); } else if (backingLevel < secondMark) { - realLevel = 1.0 + 1.0 * (backingLevel - firstMark) / (secondMark - firstMark); + realLevel = 1.0 + ([self secondMarkZoomValue] - 1.0) * (backingLevel - firstMark) / (secondMark - firstMark); } else { - realLevel = 2.0 + 6.0 * (backingLevel - secondMark) / (self.maxZoomLevel - secondMark); + realLevel = [self secondMarkZoomValue] + 6.0 * (backingLevel - secondMark) / (self.maxZoomLevel - secondMark); } } else if (marks.count == 1) { CGFloat mark = [marks.firstObject floatValue]; @@ -551,12 +551,12 @@ const NSInteger PGCameraFrameRate = 30; [self setZoomLevel:zoomLevel animated:false]; } -- (int32_t)maxMarkZoomValue { - return 25.0; -} - -- (int32_t)secondMarkZoomValue { - return 5.0; +- (CGFloat)secondMarkZoomValue { + if (self.zoomLevels.count > 2) { + return self.zoomLevels.lastObject.doubleValue; + } else { + return 2.0; + } } - (void)setZoomLevel:(CGFloat)zoomLevel animated:(bool)animated @@ -582,10 +582,10 @@ const NSInteger PGCameraFrameRate = 30; if (level < 1.0) { level = MAX(0.5, level); backingLevel = 1.0 + ((level - 0.5) / 0.5) * (firstMark - 1.0); - } else if (zoomLevel < 2.0) { - backingLevel = firstMark + ((level - 1.0) / 1.0) * (secondMark - firstMark); + } else if (zoomLevel < [self secondMarkZoomValue]) { + backingLevel = firstMark + ((level - 1.0) / ([self secondMarkZoomValue] - 1.0)) * (secondMark - firstMark); } else { - backingLevel = secondMark + ((level - 2.0) / 6.0) * (self.maxZoomLevel - secondMark); + backingLevel = secondMark + ((level - [self secondMarkZoomValue]) / 6.0) * (self.maxZoomLevel - secondMark); } } else if (marks.count == 1) { CGFloat mark = [marks.firstObject floatValue]; diff --git a/submodules/LegacyComponents/Sources/TGCameraController.m b/submodules/LegacyComponents/Sources/TGCameraController.m index c751629564..253ee3c803 100644 --- a/submodules/LegacyComponents/Sources/TGCameraController.m +++ b/submodules/LegacyComponents/Sources/TGCameraController.m @@ -184,6 +184,8 @@ static CGPoint TGCameraControllerClampPointToScreenSize(__unused id self, __unus if (iosMajorVersion() >= 10) { _feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; } + + _camera.zoomLevels = context.cameraZoomLevels; } return self; } @@ -307,12 +309,12 @@ static CGPoint TGCameraControllerClampPointToScreenSize(__unused id self, __unus if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone) { - _interfaceView = [[TGCameraMainPhoneView alloc] initWithFrame:screenBounds avatar:_intent == TGCameraControllerAvatarIntent videoModeByDefault:_intent == TGCameraControllerGenericVideoOnlyIntent hasUltrawideCamera:_camera.hasUltrawideCamera hasTelephotoCamera:_camera.hasTelephotoCamera camera:_camera]; + _interfaceView = [[TGCameraMainPhoneView alloc] initWithFrame:screenBounds avatar:_intent == TGCameraControllerAvatarIntent videoModeByDefault:_intent == TGCameraControllerGenericVideoOnlyIntent camera:_camera]; [_interfaceView setInterfaceOrientation:interfaceOrientation animated:false]; } else { - _interfaceView = [[TGCameraMainTabletView alloc] initWithFrame:screenBounds avatar:_intent == TGCameraControllerAvatarIntent videoModeByDefault:_intent == TGCameraControllerGenericVideoOnlyIntent hasUltrawideCamera:_camera.hasUltrawideCamera hasTelephotoCamera:_camera.hasTelephotoCamera camera:_camera]; + _interfaceView = [[TGCameraMainTabletView alloc] initWithFrame:screenBounds avatar:_intent == TGCameraControllerAvatarIntent videoModeByDefault:_intent == TGCameraControllerGenericVideoOnlyIntent camera:_camera]; [_interfaceView setInterfaceOrientation:interfaceOrientation animated:false]; CGSize referenceSize = [self referenceViewSizeForOrientation:interfaceOrientation]; diff --git a/submodules/LegacyComponents/Sources/TGCameraMainPhoneView.m b/submodules/LegacyComponents/Sources/TGCameraMainPhoneView.m index 2016bea4bf..b2b12745e4 100644 --- a/submodules/LegacyComponents/Sources/TGCameraMainPhoneView.m +++ b/submodules/LegacyComponents/Sources/TGCameraMainPhoneView.m @@ -101,7 +101,7 @@ @synthesize cancelPressed; @synthesize actionHandle = _actionHandle; -- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera camera:(PGCamera *)camera +- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault camera:(PGCamera *)camera { self = [super initWithFrame:frame]; if (self != nil) @@ -245,7 +245,13 @@ _topPanelBackgroundView.backgroundColor = [TGCameraInterfaceAssets transparentPanelBackgroundColor]; [_topPanelView addSubview:_topPanelBackgroundView]; - _zoomModeView = [[TGCameraZoomModeView alloc] initWithFrame:CGRectMake(floor((frame.size.width - 129.0) / 2.0), frame.size.height - _bottomPanelHeight - _bottomPanelOffset - 18 - 43, 129, 43) hasUltrawideCamera:hasUltrawideCamera hasTelephotoCamera:hasTelephotoCamera minZoomLevel:hasUltrawideCamera ? 0.5 : 1.0 maxZoomLevel:8.0]; + bool hasMultipleCameras = camera.zoomLevels.count > 1; + CGFloat minZoomLevel = 1.0f; + if (camera.zoomLevels.firstObject != nil) { + minZoomLevel = camera.zoomLevels.firstObject.doubleValue; + } + + _zoomModeView = [[TGCameraZoomModeView alloc] initWithFrame:CGRectMake(floor((frame.size.width - 172.0) / 2.0), frame.size.height - _bottomPanelHeight - _bottomPanelOffset - 18 - 43, 172, 43) zoomLevels: camera.zoomLevels minZoomLevel:minZoomLevel maxZoomLevel:8.0]; _zoomModeView.zoomChanged = ^(CGFloat zoomLevel, bool done, bool animated) { __strong TGCameraMainPhoneView *strongSelf = weakSelf; if (strongSelf == nil) @@ -278,28 +284,15 @@ strongSelf.zoomChanged(zoomLevel, animated); }; [_zoomModeView setZoomLevel:1.0]; - if (hasTelephotoCamera || hasUltrawideCamera) { + if (hasMultipleCameras) { [self addSubview:_zoomModeView]; } - - _zoomWheelView = [[TGCameraZoomWheelView alloc] initWithFrame:CGRectMake(0.0, frame.size.height - _bottomPanelHeight - _bottomPanelOffset - 132, frame.size.width, 132) hasUltrawideCamera:hasUltrawideCamera hasTelephotoCamera:hasTelephotoCamera]; - [_zoomWheelView setHidden:true animated:false]; - [_zoomWheelView setZoomLevel:1.0]; - _zoomWheelView.panGesture = ^(UIPanGestureRecognizer *gestureRecognizer) { - __strong TGCameraMainPhoneView *strongSelf = weakSelf; - if (strongSelf == nil) - return; - [strongSelf->_zoomModeView panGesture:gestureRecognizer]; - }; - if (hasTelephotoCamera || hasUltrawideCamera) { - [self addSubview:_zoomWheelView]; - } - + _zoomView = [[TGCameraZoomView alloc] initWithFrame:CGRectMake(10, frame.size.height - _bottomPanelHeight - _bottomPanelOffset - 18, frame.size.width - 20, 1.5f)]; _zoomView.activityChanged = ^(bool active) { }; - if (!hasTelephotoCamera && !hasUltrawideCamera) { + if (!hasMultipleCameras) { [self addSubview:_zoomView]; } diff --git a/submodules/LegacyComponents/Sources/TGCameraMainTabletView.m b/submodules/LegacyComponents/Sources/TGCameraMainTabletView.m index 5e92aecb0b..7fbb82fca2 100644 --- a/submodules/LegacyComponents/Sources/TGCameraMainTabletView.m +++ b/submodules/LegacyComponents/Sources/TGCameraMainTabletView.m @@ -42,7 +42,7 @@ const CGFloat TGCameraTabletPanelViewWidth = 102.0f; @synthesize shutterReleased; @synthesize cancelPressed; -- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera camera:(PGCamera *)camera +- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar videoModeByDefault:(bool)videoModeByDefault camera:(PGCamera *)camera { self = [super initWithFrame:frame]; if (self != nil) diff --git a/submodules/LegacyComponents/Sources/TGCameraMainView.m b/submodules/LegacyComponents/Sources/TGCameraMainView.m index 447fa0d9b9..1b257dddb9 100644 --- a/submodules/LegacyComponents/Sources/TGCameraMainView.m +++ b/submodules/LegacyComponents/Sources/TGCameraMainView.m @@ -70,7 +70,7 @@ @dynamic thumbnailSignalForItem; @dynamic editingContext; -- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera camera:(PGCamera *)camera { +- (instancetype)initWithFrame:(CGRect)frame avatar:(bool)avatar camera:(PGCamera *)camera { self = [super init]; if (self != nil) { } diff --git a/submodules/LegacyComponents/Sources/TGCameraZoomView.m b/submodules/LegacyComponents/Sources/TGCameraZoomView.m index f673cff9bb..48b1f47915 100644 --- a/submodules/LegacyComponents/Sources/TGCameraZoomView.m +++ b/submodules/LegacyComponents/Sources/TGCameraZoomView.m @@ -231,54 +231,44 @@ @interface TGCameraZoomModeView () { + NSArray *_zoomLevels; CGFloat _minZoomLevel; CGFloat _maxZoomLevel; - UIView *_backgroundView; - bool _hasUltrawideCamera; - bool _hasTelephotoCamera; - + NSMutableArray *_zoomItems; + bool _beganFromPress; - - TGCameraZoomModeItemView *_leftItem; - TGCameraZoomModeItemView *_centerItem; - TGCameraZoomModeItemView *_rightItem; - bool _lockedOn; } @end @implementation TGCameraZoomModeView -- (instancetype)initWithFrame:(CGRect)frame hasUltrawideCamera:(bool)hasUltrawideCamera hasTelephotoCamera:(bool)hasTelephotoCamera minZoomLevel:(CGFloat)minZoomLevel maxZoomLevel:(CGFloat)maxZoomLevel +- (instancetype)initWithFrame:(CGRect)frame zoomLevels:(NSArray *)zoomLevels minZoomLevel:(CGFloat)minZoomLevel maxZoomLevel:(CGFloat)maxZoomLevel { self = [super initWithFrame:frame]; if (self != nil) { - _hasUltrawideCamera = hasUltrawideCamera; - _hasTelephotoCamera = hasTelephotoCamera; + _zoomLevels = [zoomLevels copy]; _minZoomLevel = minZoomLevel; _maxZoomLevel = maxZoomLevel; _backgroundView = [[UIView alloc] initWithFrame:self.bounds]; _backgroundView.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.15]; _backgroundView.layer.cornerRadius = self.bounds.size.height / 2.0; - - _leftItem = [[TGCameraZoomModeItemView alloc] initWithFrame:CGRectMake(0, 0, 43, 43)]; - [_leftItem addTarget:self action:@selector(leftPressed) forControlEvents:UIControlEventTouchUpInside]; - - _centerItem = [[TGCameraZoomModeItemView alloc] initWithFrame:CGRectMake(43, 0, 43, 43)]; - [_centerItem addTarget:self action:@selector(centerPressed) forControlEvents:UIControlEventTouchUpInside]; - - _rightItem = [[TGCameraZoomModeItemView alloc] initWithFrame:CGRectMake(86, 0, 43, 43)]; - [_rightItem addTarget:self action:@selector(rightPressed) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:_backgroundView]; - [self addSubview:_centerItem]; - if (hasTelephotoCamera && hasUltrawideCamera) { - [self addSubview:_leftItem]; - [self addSubview:_rightItem]; + + _zoomItems = [NSMutableArray array]; + + CGFloat itemWidth = 43.0; + + for (NSInteger i = 0; i < zoomLevels.count; i++) { + TGCameraZoomModeItemView *item = [[TGCameraZoomModeItemView alloc] initWithFrame:CGRectMake(i * itemWidth, 0, itemWidth, itemWidth)]; + [item addTarget:self action:@selector(itemPressed:) forControlEvents:UIControlEventTouchUpInside]; + item.tag = i; + [_zoomItems addObject:item]; + [self addSubview:item]; } UIPanGestureRecognizer *panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGesture:)]; @@ -288,6 +278,13 @@ UILongPressGestureRecognizer *pressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(pressGesture:)]; pressGestureRecognizer.delegate = self; [self addGestureRecognizer:pressGestureRecognizer]; + + NSUInteger defaultIndex = [_zoomLevels indexOfObject:@(1.0)]; + if (defaultIndex == NSNotFound) { + defaultIndex = 0; + } + _zoomLevel = [_zoomLevels[defaultIndex] floatValue]; + [self setZoomLevel:_zoomLevel animated:NO]; } return self; } @@ -346,12 +343,14 @@ } CGFloat newLevel = MAX(_minZoomLevel, MIN(_maxZoomLevel, _zoomLevel + delta)); - CGFloat near = floor(newLevel); - if (near <= 2.0 && ABS(newLevel - near) < 0.05 && previousLevel != near && translation.x < 15.0) { - newLevel = near; - _lockedOn = true; - - [gestureRecognizer setTranslation:CGPointZero inView:self]; + for (NSNumber *level in _zoomLevels) { + CGFloat zoomValue = [level floatValue]; + if (ABS(newLevel - zoomValue) < 0.05 && previousLevel != zoomValue && translation.x < 15.0) { + newLevel = zoomValue; + _lockedOn = true; + [gestureRecognizer setTranslation:CGPointZero inView:self]; + break; + } } _zoomLevel = newLevel; @@ -376,39 +375,14 @@ } } -- (void)leftPressed { - if (_zoomLevel != 0.5) { - [self setZoomLevel:0.5 animated:true]; - self.zoomChanged(0.5, true, true); - } -} - -- (void)centerPressed { - if (!(_hasTelephotoCamera && _hasUltrawideCamera)) { - if (_zoomLevel == 1.0) { - if (_hasUltrawideCamera) { - [self setZoomLevel:0.5 animated:true]; - self.zoomChanged(0.5, true, true); - } else if (_hasTelephotoCamera) { - [self setZoomLevel:2.0 animated:true]; - self.zoomChanged(2.0, true, true); - } - } else { - [self setZoomLevel:1.0 animated:true]; - self.zoomChanged(1.0, true, true); +- (void)itemPressed:(TGCameraZoomModeItemView *)sender { + NSInteger index = sender.tag; + if (index >= 0 && index < _zoomLevels.count) { + CGFloat newZoomLevel = [_zoomLevels[index] floatValue]; + if (_zoomLevel != newZoomLevel) { + [self setZoomLevel:newZoomLevel animated:true]; + self.zoomChanged(newZoomLevel, true, true); } - } else { - if (_zoomLevel != 1.0) { - [self setZoomLevel:1.0 animated:true]; - self.zoomChanged(1.0, true, true); - } - } -} - -- (void)rightPressed { - if (_zoomLevel != 2.0) { - [self setZoomLevel:2.0 animated:true]; - self.zoomChanged(2.0, true, true); } } @@ -419,47 +393,63 @@ - (void)setZoomLevel:(CGFloat)zoomLevel animated:(bool)animated { _zoomLevel = zoomLevel; - if (zoomLevel < 1.0) { - NSString *value = [NSString stringWithFormat:@"%.1f×", zoomLevel]; - value = [value stringByReplacingOccurrencesOfString:@"." withString:@","]; - if ([value isEqual:@"1,0×"] || [value isEqual:@"1×"]) { - value = @"0,9×"; - } - if (_leftItem.superview != nil) { - [_leftItem setValue:value selected:true animated:animated]; - [_centerItem setValue:@"1" selected:false animated:animated]; - } else { - [_centerItem setValue:value selected:false animated:animated]; - } - [_rightItem setValue:@"2" selected:false animated:animated]; - } else if (zoomLevel < 2.0) { - [_leftItem setValue:@"0,5" selected:false animated:animated]; - bool selected = _hasTelephotoCamera && _hasUltrawideCamera; - if ((zoomLevel - 1.0) < 0.025) { - [_centerItem setValue:@"1×" selected:true animated:animated]; - } else { - NSString *value = [NSString stringWithFormat:@"%.1f×", zoomLevel]; - value = [value stringByReplacingOccurrencesOfString:@"." withString:@","]; - value = [value stringByReplacingOccurrencesOfString:@",0×" withString:@"×"]; - if ([value isEqual:@"2×"]) { - value = @"1,9×"; - } - [_centerItem setValue:value selected:selected animated:animated]; - } - [_rightItem setValue:@"2" selected:false animated:animated]; - } else { - [_leftItem setValue:@"0,5" selected:false animated:animated]; - - NSString *value = [[NSString stringWithFormat:@"%.1f×", zoomLevel] stringByReplacingOccurrencesOfString:@"." withString:@","]; - value = [value stringByReplacingOccurrencesOfString:@",0×" withString:@"×"]; + + NSInteger selectedIndex = -1; + CGFloat closestDistance = MAXFLOAT; + + CGFloat nextLevelValue = 8.0; + for (NSInteger i = 0; i < _zoomLevels.count; i++) { + CGFloat levelValue = [_zoomLevels[i] floatValue]; + CGFloat distance = ABS(levelValue - zoomLevel); - if (_rightItem.superview != nil) { - [_centerItem setValue:@"1" selected:false animated:animated]; - [_rightItem setValue:value selected:true animated:animated]; + if (distance < 0.025) { + selectedIndex = i; + break; } else { - [_centerItem setValue:value selected:true animated:animated]; + if (zoomLevel >= levelValue) { + selectedIndex = i; + closestDistance = distance; + } else { + break; + } } } + + if (selectedIndex < _zoomLevels.count - 1) { + nextLevelValue = [_zoomLevels[selectedIndex + 1] floatValue]; + } else { + nextLevelValue = 16.0; + } + + for (NSInteger i = 0; i < _zoomItems.count; i++) { + TGCameraZoomModeItemView *item = _zoomItems[i]; + NSNumber *levelNumber = _zoomLevels[i]; + CGFloat level = [levelNumber floatValue]; + + NSString *value; + if (level == (NSInteger)level && level >= 1.0) { + value = [NSString stringWithFormat:@"%d", (int)level]; + } else { + value = [NSString stringWithFormat:@"%.1f", level]; + value = [value stringByReplacingOccurrencesOfString:@"," withString:@"."]; + value = [value stringByReplacingOccurrencesOfString:@".0" withString:@""]; + } + + bool selected = (i == selectedIndex); + if (i == selectedIndex && closestDistance < MAXFLOAT && closestDistance > 0.025) { + NSString *currentValue = [NSString stringWithFormat:@"%.1f", zoomLevel]; + currentValue = [currentValue stringByReplacingOccurrencesOfString:@"," withString:@"."]; + currentValue = [currentValue stringByReplacingOccurrencesOfString:@".0" withString:@""]; + if ([currentValue isEqualToString:[NSString stringWithFormat:@"%d", (int)nextLevelValue]]) { + currentValue = [NSString stringWithFormat:@"%.1f", nextLevelValue - 0.1]; + } + value = currentValue; + } + if (selected) { + value = [NSString stringWithFormat:@"%@×", value]; + } + [item setValue:value selected:selected animated:animated]; + } } - (void)setHidden:(BOOL)hidden @@ -495,29 +485,22 @@ - (void)layoutSubviews { - if (_leftItem.superview == nil && _rightItem.superview == nil) { - _backgroundView.frame = CGRectMake(43, 0, 43, 43); - } else if (_leftItem.superview != nil && _rightItem.superview == nil) { - _backgroundView.frame = CGRectMake(21 + TGScreenPixel, 0, 86, 43); - _leftItem.frame = CGRectMake(21 + TGScreenPixel, 0, 43, 43); - _centerItem.frame = CGRectMake(21 + TGScreenPixel + 43, 0, 43, 43); - } else if (_leftItem.superview == nil && _rightItem.superview != nil) { - _backgroundView.frame = CGRectMake(21 + TGScreenPixel, 0, 86, 43); - _centerItem.frame = CGRectMake(21 + TGScreenPixel, 0, 43, 43); - _rightItem.frame = CGRectMake(21 + TGScreenPixel + 43, 0, 43, 43); - } else { - _leftItem.frame = CGRectMake(0, 0, 43, 43.0); - _centerItem.frame = CGRectMake(43, 0, 43, 43.0); - _rightItem.frame = CGRectMake(86, 0, 43, 43.0); + CGFloat itemWidth = 43.0; + CGFloat totalWidth = itemWidth * _zoomLevels.count; + _backgroundView.frame = CGRectMake(TGScreenPixelFloor((172.0f - totalWidth) / 2.0), 0, totalWidth, 43); + + for (NSInteger i = 0; i < _zoomItems.count; i++) { + TGCameraZoomModeItemView *item = _zoomItems[i]; + item.frame = CGRectMake(_backgroundView.frame.origin.x + i * itemWidth, 0, itemWidth, 43); } } - (void)setInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation { _interfaceOrientation = interfaceOrientation; - _leftItem.transform = CGAffineTransformMakeRotation(TGRotationForInterfaceOrientation(interfaceOrientation)); - _centerItem.transform = CGAffineTransformMakeRotation(TGRotationForInterfaceOrientation(interfaceOrientation)); - _rightItem.transform = CGAffineTransformMakeRotation(TGRotationForInterfaceOrientation(interfaceOrientation)); + for (TGCameraZoomModeItemView *item in _zoomItems) { + item.transform = CGAffineTransformMakeRotation(TGRotationForInterfaceOrientation(interfaceOrientation)); + } } @end diff --git a/submodules/LegacyComponents/Sources/TGMediaAssetsController.m b/submodules/LegacyComponents/Sources/TGMediaAssetsController.m index bf0ca5e06f..778ba168fc 100644 --- a/submodules/LegacyComponents/Sources/TGMediaAssetsController.m +++ b/submodules/LegacyComponents/Sources/TGMediaAssetsController.m @@ -949,6 +949,8 @@ NSInteger num = 0; bool grouping = selectionContext.grouping; + bool isHighQualityPhoto = editingContext.isHighQualityPhoto; + NSNumber *price; bool hasAnyTimers = false; if (editingContext != nil || grouping) @@ -1071,9 +1073,11 @@ if (price != nil) dict[@"price"] = price; - if (spoiler) { + if (spoiler) dict[@"spoiler"] = @true; - } + + if (isHighQualityPhoto) + dict[@"hd"] = @true; id generatedItem = descriptionGenerator(dict, caption, nil, asset.identifier); return generatedItem; @@ -1202,6 +1206,9 @@ if (adjustments.paintingData.stickers.count > 0) dict[@"stickers"] = adjustments.paintingData.stickers; + if (isHighQualityPhoto) + dict[@"hd"] = @true; + bool animated = adjustments.paintingData.hasAnimation; if (animated) { dict[@"isAnimation"] = @true; diff --git a/submodules/LegacyComponents/Sources/TGMediaEditingContext.m b/submodules/LegacyComponents/Sources/TGMediaEditingContext.m index 35e9581560..ef53e8cd5b 100644 --- a/submodules/LegacyComponents/Sources/TGMediaEditingContext.m +++ b/submodules/LegacyComponents/Sources/TGMediaEditingContext.m @@ -131,10 +131,13 @@ SPipe *_fullSizePipe; SPipe *_cropPipe; SPipe *_captionAbovePipe; + SPipe *_highQualityPhotoPipe; NSAttributedString *_forcedCaption; bool _captionAbove; + + bool _highQualityPhoto; } @end @@ -209,6 +212,7 @@ _fullSizePipe = [[SPipe alloc] init]; _cropPipe = [[SPipe alloc] init]; _captionAbovePipe = [[SPipe alloc] init]; + _highQualityPhotoPipe = [[SPipe alloc] init]; } return self; } @@ -888,6 +892,29 @@ _captionAbovePipe.sink(@(captionAbove)); } +- (bool)isHighQualityPhoto { + return _highQualityPhoto; +} + +- (SSignal *)highQualityPhoto +{ + __weak TGMediaEditingContext *weakSelf = self; + SSignal *updateSignal = [_highQualityPhotoPipe.signalProducer() map:^NSNumber *(NSNumber *update) + { + __strong TGMediaEditingContext *strongSelf = weakSelf; + return @(strongSelf->_highQualityPhoto); + }]; + + return [[SSignal single:@(_highQualityPhoto)] then:updateSignal]; +} + +- (void)setHighQualityPhoto:(bool)highQualityPhoto +{ + _highQualityPhoto = highQualityPhoto; + _highQualityPhotoPipe.sink(@(highQualityPhoto)); +} + + - (SSignal *)facesForItem:(NSObject *)item { NSString *itemId = [self _contextualIdForItemId:item.uniqueIdentifier]; diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryInterfaceView.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryInterfaceView.m index 6144b3c4f0..bff61ba1a5 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryInterfaceView.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryInterfaceView.m @@ -598,6 +598,14 @@ return [_landscapeToolbarView buttonForTab:TGPhotoEditorTimerTab]; } +- (UIView *)qualityButton +{ + if (UIInterfaceOrientationIsPortrait(self.interfaceOrientation)) + return [_portraitToolbarView buttonForTab:TGPhotoEditorQualityTab]; + else + return [_landscapeToolbarView buttonForTab:TGPhotoEditorQualityTab]; +} + - (void)setSelectedItemsModel:(TGMediaPickerGallerySelectedItemsModel *)selectedItemsModel { _selectedPhotosView.selectedItemsModel = selectedItemsModel; @@ -1100,33 +1108,43 @@ TGPhotoEditorButton *qualityButton = [_portraitToolbarView buttonForTab:TGPhotoEditorQualityTab]; if (qualityButton != nil) { - TGMediaVideoConversionPreset preset = 0; - TGMediaVideoConversionPreset adjustmentsPreset = TGMediaVideoConversionPresetCompressedDefault; - if ([adjustments isKindOfClass:[TGMediaVideoEditAdjustments class]]) - adjustmentsPreset = ((TGMediaVideoEditAdjustments *)adjustments).preset; - - if (adjustmentsPreset != TGMediaVideoConversionPresetCompressedDefault) - { - preset = adjustmentsPreset; - } - else - { - NSNumber *presetValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"TG_preferredVideoPreset_v0"]; - if (presetValue != nil) - preset = (TGMediaVideoConversionPreset)[presetValue integerValue]; + bool isPhoto = [_currentItemView isKindOfClass:[TGMediaPickerGalleryPhotoItemView class]]; + if (isPhoto) { + bool isHd = _editingContext.isHighQualityPhoto; + UIImage *icon = [TGPhotoEditorInterfaceAssets qualityIconForHighQuality:isHd filled: false]; + qualityButton.iconImage = icon; + + qualityButton = [_landscapeToolbarView buttonForTab:TGPhotoEditorQualityTab]; + qualityButton.iconImage = icon; + } else { + TGMediaVideoConversionPreset preset = 0; + TGMediaVideoConversionPreset adjustmentsPreset = TGMediaVideoConversionPresetCompressedDefault; + if ([adjustments isKindOfClass:[TGMediaVideoEditAdjustments class]]) + adjustmentsPreset = ((TGMediaVideoEditAdjustments *)adjustments).preset; + + if (adjustmentsPreset != TGMediaVideoConversionPresetCompressedDefault) + { + preset = adjustmentsPreset; + } else - preset = TGMediaVideoConversionPresetCompressedMedium; + { + NSNumber *presetValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"TG_preferredVideoPreset_v0"]; + if (presetValue != nil) + preset = (TGMediaVideoConversionPreset)[presetValue integerValue]; + else + preset = TGMediaVideoConversionPresetCompressedMedium; + } + + TGMediaVideoConversionPreset bestPreset = [TGMediaVideoConverter bestAvailablePresetForDimensions:dimensions]; + if (preset > bestPreset) + preset = bestPreset; + + UIImage *icon = [TGPhotoEditorInterfaceAssets qualityIconForPreset:preset]; + qualityButton.iconImage = icon; + + qualityButton = [_landscapeToolbarView buttonForTab:TGPhotoEditorQualityTab]; + qualityButton.iconImage = icon; } - - TGMediaVideoConversionPreset bestPreset = [TGMediaVideoConverter bestAvailablePresetForDimensions:dimensions]; - if (preset > bestPreset) - preset = bestPreset; - - UIImage *icon = [TGPhotoEditorInterfaceAssets qualityIconForPreset:preset]; - qualityButton.iconImage = icon; - - qualityButton = [_landscapeToolbarView buttonForTab:TGPhotoEditorQualityTab]; - qualityButton.iconImage = icon; } TGPhotoEditorButton *timerButton = [_portraitToolbarView buttonForTab:TGPhotoEditorTimerTab]; @@ -1207,6 +1225,25 @@ [_tooltipContainerView hideMenu]; } +- (void)showPhotoQualityTooltip:(bool)hd { + [self updateEditorButtonsForItem:_currentItem animated:false]; + + UIView *button = [self qualityButton]; + CGRect rect = [button convertRect:button.bounds toView:nil]; + [self showPhotoQualityTooltip:rect hd:hd]; +} + +- (void)showPhotoQualityTooltip:(CGRect)rect hd:(bool)hd +{ + if (_tooltipContainerView != nil) { + [self tooltipTimerTick]; + } + + //TODO:localize + NSString *text = hd ? @"The photo will be sent in high quality." : @"The photo will be sent in standard quality."; + [_context presentTooltip:text icon:[TGPhotoEditorInterfaceAssets qualityIconForHighQuality:hd filled: true] sourceRect:rect]; +} + #pragma mark - Grouping Tooltip - (void)actionStageActionRequested:(NSString *)action options:(id)__unused options diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryModel.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryModel.m index a65545ddab..b1d4ca2279 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryModel.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryModel.m @@ -11,6 +11,7 @@ #import "TGModernGalleryEditableItemView.h" #import "TGMediaPickerGalleryItem.h" #import +#import "TGMediaPickerGalleryPhotoItem.h" #import "TGMediaPickerGalleryVideoItem.h" #import "TGMediaPickerGalleryVideoItemView.h" @@ -195,8 +196,14 @@ return; __strong TGModernGalleryController *controller = strongSelf.controller; - if ([controller.currentItem conformsToProtocol:@protocol(TGModernGalleryEditableItem)]) - [strongSelf presentPhotoEditorForItem:(id)controller.currentItem tab:tab]; + if ([controller.currentItem conformsToProtocol:@protocol(TGModernGalleryEditableItem)]) { + if (tab == TGPhotoEditorQualityTab && [controller.currentItem isKindOfClass:[TGMediaPickerGalleryFetchResultItem class]] && [((TGMediaPickerGalleryFetchResultItem *)controller.currentItem).backingItem isKindOfClass:[TGMediaPickerGalleryPhotoItem class]]) { + [strongSelf->_editingContext setHighQualityPhoto:!strongSelf->_editingContext.isHighQualityPhoto]; + [strongSelf->_interfaceView showPhotoQualityTooltip:strongSelf->_editingContext.isHighQualityPhoto]; + } else { + [strongSelf presentPhotoEditorForItem:(id)controller.currentItem tab:tab]; + } + } }]; _interfaceView.photoStripItemSelected = ^(NSInteger index) { diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItem.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItem.m index 596a206f45..de6ec48c46 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItem.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItem.m @@ -26,7 +26,7 @@ - (TGPhotoEditorTab)toolbarTabs { - return TGPhotoEditorCropTab | TGPhotoEditorToolsTab | TGPhotoEditorPaintTab; + return TGPhotoEditorCropTab | TGPhotoEditorToolsTab | TGPhotoEditorPaintTab | TGPhotoEditorQualityTab; } - (Class)viewClass diff --git a/submodules/LegacyComponents/Sources/TGPhotoEditorInterfaceAssets.m b/submodules/LegacyComponents/Sources/TGPhotoEditorInterfaceAssets.m index ac683a46ce..0feeb77108 100644 --- a/submodules/LegacyComponents/Sources/TGPhotoEditorInterfaceAssets.m +++ b/submodules/LegacyComponents/Sources/TGPhotoEditorInterfaceAssets.m @@ -215,6 +215,48 @@ return backgroundImage; } ++ (UIImage *)qualityIconForHighQuality:(bool)highQuality filled:(bool)filled +{ + CGFloat lineWidth = 2.0f - TGScreenPixel; + + CGSize size = CGSizeMake(26.0f, 22.0f); + CGRect rect = CGRectInset(CGRectMake(0.0f, 0.0f, size.width, size.height), lineWidth / 2.0, lineWidth / 2.0); + UIGraphicsBeginImageContextWithOptions(size, false, 0.0f); + + CGContextRef context = UIGraphicsGetCurrentContext(); + + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:5.0f]; + + NSString *label = highQuality ? @"HD" : @"SD"; + + CGContextAddPath(context, path.CGPath); + if (filled) { + CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor); + CGContextFillPath(context); + } else { + CGContextSetStrokeColorWithColor(context, [UIColor whiteColor].CGColor); + CGContextSetLineWidth(context, lineWidth); + CGContextStrokePath(context); + } + + if (filled) { + CGContextSetBlendMode(context, kCGBlendModeClear); + } + + UIFont *font = [TGFont roundedFontOfSize:11]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CGSize textSize = [label sizeWithFont:font]; + [[UIColor whiteColor] setFill]; + [label drawInRect:CGRectMake((size.width - textSize.width) / 2.0f + TGScreenPixel, 4.0f, textSize.width, textSize.height) withFont:font]; +#pragma clang diagnostic pop + + UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return result; +} + + (UIImage *)qualityIconForPreset:(TGMediaVideoConversionPreset)preset { CGFloat lineWidth = 2.0f - TGScreenPixel; diff --git a/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift b/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift index c88ee2e026..2772772e0e 100644 --- a/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift +++ b/submodules/LegacyMediaPickerUI/Sources/LegacyMediaPickers.swift @@ -140,14 +140,16 @@ private final class LegacyAssetItemWrapper: NSObject { let timer: Int? let spoiler: Bool? let price: Int64? + let forceHd: Bool let groupedId: Int64? let uniqueId: String? - init(item: LegacyAssetItem, timer: Int?, spoiler: Bool?, price: Int64?, groupedId: Int64?, uniqueId: String?) { + init(item: LegacyAssetItem, timer: Int?, spoiler: Bool?, price: Int64?, forceHd: Bool = false, groupedId: Int64?, uniqueId: String?) { self.item = item self.timer = timer self.spoiler = spoiler self.price = price + self.forceHd = forceHd self.groupedId = groupedId self.uniqueId = uniqueId @@ -202,10 +204,10 @@ public func legacyAssetPickerItemGenerator() -> ((Any?, NSAttributedString?, Str if let customName = dict["fileName"] as? String { name = customName } - result["item" as NSString] = LegacyAssetItemWrapper(item: .file(data: .asset(asset.backingAsset), thumbnail: thumbnail, mimeType: mimeType, name: name, caption: caption), timer: nil, spoiler: nil, price: price, groupedId: (dict["groupedId"] as? NSNumber)?.int64Value, uniqueId: uniqueId) } else { - result["item" as NSString] = LegacyAssetItemWrapper(item: .image(data: .asset(asset.backingAsset), thumbnail: thumbnail, caption: caption, stickers: []), timer: (dict["timer"] as? NSNumber)?.intValue, spoiler: (dict["spoiler"] as? NSNumber)?.boolValue, price: price, groupedId: (dict["groupedId"] as? NSNumber)?.int64Value, uniqueId: uniqueId) + let forceHd = (dict["hd"] as? NSNumber)?.boolValue ?? false + result["item" as NSString] = LegacyAssetItemWrapper(item: .image(data: .asset(asset.backingAsset), thumbnail: thumbnail, caption: caption, stickers: []), timer: (dict["timer"] as? NSNumber)?.intValue, spoiler: (dict["spoiler"] as? NSNumber)?.boolValue, price: price, forceHd: forceHd, groupedId: (dict["groupedId"] as? NSNumber)?.int64Value, uniqueId: uniqueId) } return result } else if (dict["type"] as! NSString) == "file" { @@ -578,7 +580,7 @@ public func legacyAssetPickerEnqueueMessages(context: AccountContext, account: A arc4random_buf(&randomId, 8) let size = CGSize(width: CGFloat(asset.pixelWidth), height: CGFloat(asset.pixelHeight)) let scaledSize = size.aspectFittedOrSmaller(CGSize(width: 1280.0, height: 1280.0)) - let resource = PhotoLibraryMediaResource(localIdentifier: asset.localIdentifier, uniqueId: Int64.random(in: Int64.min ... Int64.max)) + let resource = PhotoLibraryMediaResource(localIdentifier: asset.localIdentifier, uniqueId: Int64.random(in: Int64.min ... Int64.max), forceHd: item.forceHd) let media: Media representations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(scaledSize), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) diff --git a/submodules/LegacyUI/BUILD b/submodules/LegacyUI/BUILD index d398f76aed..598c588775 100644 --- a/submodules/LegacyUI/BUILD +++ b/submodules/LegacyUI/BUILD @@ -20,6 +20,9 @@ swift_library( "//submodules/DeviceAccess:DeviceAccess", "//submodules/LegacyComponents:LegacyComponents", "//submodules/StickerResources:StickerResources", + "//submodules/Camera", + "//submodules/Utils/DeviceModel", + "//submodules/TooltipUI", ], visibility = [ "//visibility:public", diff --git a/submodules/LegacyUI/Sources/LegacyController.swift b/submodules/LegacyUI/Sources/LegacyController.swift index e1d22b21f4..4b293eb13f 100644 --- a/submodules/LegacyUI/Sources/LegacyController.swift +++ b/submodules/LegacyUI/Sources/LegacyController.swift @@ -4,6 +4,9 @@ import Display import SwiftSignalKit import LegacyComponents import TelegramPresentationData +import Camera +import DeviceModel +import TooltipUI public enum LegacyControllerPresentation { case custom @@ -193,6 +196,11 @@ public final class LegacyControllerContext: NSObject, LegacyComponentsContext { public func forceStatusBarAppearanceUpdate() { } + public func cameraZoomLevels() -> [NSNumber]! { + let metrics = Camera.Metrics(model: DeviceModel.current) + return metrics.zoomLevels.map { NSNumber(value: $0) } + } + public func currentlyInSplitView() -> Bool { if let controller = self.controller as? LegacyController, let validLayout = controller.validLayout { return validLayout.isNonExclusive @@ -258,6 +266,25 @@ public final class LegacyControllerContext: NSObject, LegacyComponentsContext { } + public func presentTooltip(_ text: String!, icon: UIImage!, sourceRect: CGRect) { + guard let context = legacyContextGet() else { + return + } + + let controller = TooltipScreen( + account: context.account, + sharedContext: context.sharedContext, + text: .plain(text: text), + style: .customBlur(UIColor(rgb: 0x18181a), 0.0), + icon: .image(icon), + location: .point(sourceRect, .bottom), + displayDuration: .custom(2.0), + shouldDismissOnTouch: { _, _ in + return .dismiss(consume: false) + }) + context.sharedContext.presentGlobalController(controller, nil) + } + public func makeOverlayWindowManager() -> LegacyComponentsOverlayWindowManager! { return LegacyComponentsOverlayWindowManagerImpl(parentController: self.controller, theme: self.theme) } diff --git a/submodules/LocalMediaResources/Sources/FetchPhotoLibraryImageResource.swift b/submodules/LocalMediaResources/Sources/FetchPhotoLibraryImageResource.swift index 0f2efd4372..f19199c1aa 100644 --- a/submodules/LocalMediaResources/Sources/FetchPhotoLibraryImageResource.swift +++ b/submodules/LocalMediaResources/Sources/FetchPhotoLibraryImageResource.swift @@ -104,7 +104,7 @@ extension UIImage.Orientation { private let fetchPhotoWorkers = ThreadPool(threadCount: 3, threadPriority: 0.2) -public func fetchPhotoLibraryResource(localIdentifier: String, width: Int32?, height: Int32?, format: MediaImageFormat?, quality: Int32?, useExif: Bool) -> Signal { +public func fetchPhotoLibraryResource(localIdentifier: String, width: Int32?, height: Int32?, format: MediaImageFormat?, quality: Int32?, hd: Bool, useExif: Bool) -> Signal { return Signal { subscriber in let queue = ThreadPoolQueue(threadPool: fetchPhotoWorkers) @@ -121,7 +121,11 @@ public func fetchPhotoLibraryResource(localIdentifier: String, width: Int32?, he if let width, let height { size = CGSize(width: CGFloat(width), height: CGFloat(height)) } else { - size = CGSize(width: 1280.0, height: 1280.0) + if hd { + size = CGSize(width: 2560.0, height: 2560.0) + } else { + size = CGSize(width: 1280.0, height: 1280.0) + } } var targetSize = PHImageManagerMaximumSize diff --git a/submodules/LocalMediaResources/Sources/MediaResources.swift b/submodules/LocalMediaResources/Sources/MediaResources.swift index 779fbdc2c1..6e4d1490bf 100644 --- a/submodules/LocalMediaResources/Sources/MediaResources.swift +++ b/submodules/LocalMediaResources/Sources/MediaResources.swift @@ -214,6 +214,74 @@ public final class LocalFileVideoMediaResource: TelegramMediaResource { } } +public struct LocalFileAudioMediaResourceId { + public let randomId: Int64 + + public var uniqueId: String { + return "lad-\(self.randomId)" + } + + public var hashValue: Int { + return self.randomId.hashValue + } +} + +public final class LocalFileAudioMediaResource: TelegramMediaResource { + public var size: Int64? { + return nil + } + + public let randomId: Int64 + public let path: String + public let trimRange: Range? + + public var headerSize: Int32 { + return 32 * 1024 + } + + public init(randomId: Int64, path: String, trimRange: Range?) { + self.randomId = randomId + self.path = path + self.trimRange = trimRange + } + + public required init(decoder: PostboxDecoder) { + self.randomId = decoder.decodeInt64ForKey("i", orElse: 0) + self.path = decoder.decodeStringForKey("p", orElse: "") + + if let trimLowerBound = decoder.decodeOptionalDoubleForKey("tl"), let trimUpperBound = decoder.decodeOptionalDoubleForKey("tu") { + self.trimRange = trimLowerBound ..< trimUpperBound + } else { + self.trimRange = nil + } + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt64(self.randomId, forKey: "i") + encoder.encodeString(self.path, forKey: "p") + + if let trimRange = self.trimRange { + encoder.encodeDouble(trimRange.lowerBound, forKey: "tl") + encoder.encodeDouble(trimRange.upperBound, forKey: "tu") + } else { + encoder.encodeNil(forKey: "tl") + encoder.encodeNil(forKey: "tu") + } + } + + public var id: MediaResourceId { + return MediaResourceId(LocalFileAudioMediaResourceId(randomId: self.randomId).uniqueId) + } + + public func isEqual(to: MediaResource) -> Bool { + if let to = to as? LocalFileAudioMediaResource { + return self.randomId == to.randomId && self.path == to.path && self.trimRange == to.trimRange + } else { + return false + } + } +} + public struct PhotoLibraryMediaResourceId { public let localIdentifier: String public let resourceId: Int64 @@ -247,14 +315,16 @@ public class PhotoLibraryMediaResource: TelegramMediaResource { public let height: Int32? public let format: MediaImageFormat? public let quality: Int32? + public let forceHd: Bool - public init(localIdentifier: String, uniqueId: Int64, width: Int32? = nil, height: Int32? = nil, format: MediaImageFormat? = nil, quality: Int32? = nil) { + public init(localIdentifier: String, uniqueId: Int64, width: Int32? = nil, height: Int32? = nil, format: MediaImageFormat? = nil, quality: Int32? = nil, forceHd: Bool = false) { self.localIdentifier = localIdentifier self.uniqueId = uniqueId self.width = width self.height = height self.format = format self.quality = quality + self.forceHd = forceHd } public required init(decoder: PostboxDecoder) { @@ -264,6 +334,7 @@ public class PhotoLibraryMediaResource: TelegramMediaResource { self.height = decoder.decodeOptionalInt32ForKey("h") self.format = decoder.decodeOptionalInt32ForKey("f").flatMap(MediaImageFormat.init(rawValue:)) self.quality = decoder.decodeOptionalInt32ForKey("q") + self.forceHd = decoder.decodeBoolForKey("hd", orElse: false) } public func encode(_ encoder: PostboxEncoder) { @@ -289,6 +360,7 @@ public class PhotoLibraryMediaResource: TelegramMediaResource { } else { encoder.encodeNil(forKey: "q") } + encoder.encodeBool(self.forceHd, forKey: "hd") } public var id: MediaResourceId { @@ -315,6 +387,9 @@ public class PhotoLibraryMediaResource: TelegramMediaResource { if self.quality != to.quality { return false } + if self.forceHd != to.forceHd { + return false + } return true } else { return false diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index 5d848b66c9..bc6de55cd1 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -2717,6 +2717,19 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att self?.controllerNode.send(asFile: true, silently: false, scheduleTime: nil, animated: true, parameters: nil, completion: {}) }))) } + if price == nil { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Send in High Quality", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/QualityHd"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + if let editingContext = self?.interaction?.editingState { + editingContext.setHighQualityPhoto(true) + } + self?.controllerNode.send(asFile: false, silently: false, scheduleTime: nil, animated: true, parameters: nil, completion: {}) + }))) + } if selectionCount > 1, price == nil { items.append(.action(ContextMenuActionItem(text: strings.Attachment_SendWithoutGrouping, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Media Grid/GroupingOff"), color: theme.contextMenu.primaryColor) diff --git a/submodules/MediaPlayer/Sources/MediaPlayerTimeTextNode.swift b/submodules/MediaPlayer/Sources/MediaPlayerTimeTextNode.swift index 0ab3d88b64..151483a23d 100644 --- a/submodules/MediaPlayer/Sources/MediaPlayerTimeTextNode.swift +++ b/submodules/MediaPlayer/Sources/MediaPlayerTimeTextNode.swift @@ -3,8 +3,6 @@ import AsyncDisplayKit import SwiftSignalKit import Display -private let textFont = Font.regular(13.0) - public enum MediaPlayerTimeTextNodeMode { case normal case reversed @@ -54,12 +52,14 @@ private final class MediaPlayerTimeTextNodeParameters: NSObject { let alignment: NSTextAlignment let mode: MediaPlayerTimeTextNodeMode let textColor: UIColor + let textFont: UIFont - init(state: MediaPlayerTimeTextNodeState, alignment: NSTextAlignment, mode: MediaPlayerTimeTextNodeMode, textColor: UIColor) { + init(state: MediaPlayerTimeTextNodeState, alignment: NSTextAlignment, mode: MediaPlayerTimeTextNodeMode, textColor: UIColor, textFont: UIFont) { self.state = state self.alignment = alignment self.mode = mode self.textColor = textColor + self.textFont = textFont super.init() } @@ -76,12 +76,27 @@ public final class MediaPlayerTimeTextNode: ASDisplayNode { self.updateTimestamp() } } + + public var textFont: UIFont { + didSet { + self.updateTimestamp() + } + } + public var defaultDuration: Double? { didSet { self.updateTimestamp() } } + public var trimRange: Range? { + didSet { + self.updateTimestamp() + } + } + + public var showDurationIfNotStarted = false + private var updateTimer: SwiftSignalKit.Timer? private var statusValue: MediaPlayerStatus? { @@ -118,8 +133,9 @@ public final class MediaPlayerTimeTextNode: ASDisplayNode { } } - public init(textColor: UIColor) { + public init(textColor: UIColor, textFont: UIFont = Font.regular(13.0)) { self.textColor = textColor + self.textFont = textFont super.init() @@ -157,24 +173,38 @@ public final class MediaPlayerTimeTextNode: ASDisplayNode { if ((self.statusValue?.duration ?? 0.0) < 0.1) && self.state.seconds != nil && self.keepPreviousValueOnEmptyState { return } - + if let statusValue = self.statusValue, Double(0.0).isLess(than: statusValue.duration) { - let timestampSeconds: Double - if !statusValue.generationTimestamp.isZero { - timestampSeconds = statusValue.timestamp + (CACurrentMediaTime() - statusValue.generationTimestamp) - } else { - timestampSeconds = statusValue.timestamp + let timestamp = statusValue.timestamp - (self.trimRange?.lowerBound ?? 0.0) + var duration = statusValue.duration + if let trimRange = self.trimRange { + duration = trimRange.upperBound - trimRange.lowerBound } - switch self.mode { + + if self.showDurationIfNotStarted && timestamp < .ulpOfOne { + let timestamp = Int32(duration) + self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) + } else { + let timestampSeconds: Double + if !statusValue.generationTimestamp.isZero { + timestampSeconds = timestamp + (CACurrentMediaTime() - statusValue.generationTimestamp) + } else { + timestampSeconds = timestamp + } + switch self.mode { case .normal: let timestamp = Int32(truncatingIfNeeded: Int64(floor(timestampSeconds))) self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) case .reversed: - let timestamp = abs(Int32(Int32(truncatingIfNeeded: Int64(floor(timestampSeconds - statusValue.duration))))) + let timestamp = abs(Int32(Int32(truncatingIfNeeded: Int64(floor(timestampSeconds - duration))))) self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) + } } } else if let defaultDuration = self.defaultDuration { - let timestamp = Int32(defaultDuration) + var timestamp = Int32(defaultDuration) + if let trimRange = self.trimRange { + timestamp = Int32(trimRange.upperBound - trimRange.lowerBound) + } self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) } else { self.state = MediaPlayerTimeTextNodeState() @@ -190,7 +220,7 @@ public final class MediaPlayerTimeTextNode: ASDisplayNode { } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return MediaPlayerTimeTextNodeParameters(state: self.state, alignment: self.alignment, mode: self.mode, textColor: self.textColor) + return MediaPlayerTimeTextNodeParameters(state: self.state, alignment: self.alignment, mode: self.mode, textColor: self.textColor, textFont: self.textFont) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { @@ -203,7 +233,7 @@ public final class MediaPlayerTimeTextNode: ASDisplayNode { } if let parameters = parameters as? MediaPlayerTimeTextNodeParameters { - let string = NSAttributedString(string: parameters.state.string, font: textFont, textColor: parameters.textColor) + let string = NSAttributedString(string: parameters.state.string, font: parameters.textFont, textColor: parameters.textColor) let size = string.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size if parameters.alignment == .left { string.draw(at: CGPoint()) diff --git a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGDataItem.h b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGDataItem.h index 1d1cb73b14..f5782ff7a6 100644 --- a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGDataItem.h +++ b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGDataItem.h @@ -2,6 +2,7 @@ @interface TGDataItem : NSObject +- (instancetype)initWithData:(NSData *)data; - (void)appendData:(NSData *)data; - (NSData *)data; diff --git a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h index ffaaaf8564..d8c13f7733 100644 --- a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h +++ b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h @@ -11,6 +11,10 @@ NS_ASSUME_NONNULL_BEGIN - (NSUInteger)encodedBytes; - (NSTimeInterval)encodedDuration; +- (NSDictionary *)pause; +- (bool)resumeWithDataItem:(TGDataItem *)dataItem encoderState:(NSDictionary *)state; + + @end NS_ASSUME_NONNULL_END diff --git a/submodules/OpusBinding/Sources/TGDataItem.m b/submodules/OpusBinding/Sources/TGDataItem.m index edd66a9d5a..abb0b4dbfb 100644 --- a/submodules/OpusBinding/Sources/TGDataItem.m +++ b/submodules/OpusBinding/Sources/TGDataItem.m @@ -16,6 +16,15 @@ return self; } +- (instancetype)initWithData:(NSData *)data { + self = [super init]; + if (self != nil) { + _data = [[NSMutableData alloc] init]; + [self appendData:data]; + } + return self; +} + - (void)appendData:(NSData *)data { [_data appendData:data]; } diff --git a/submodules/OpusBinding/Sources/opusenc/opusenc.m b/submodules/OpusBinding/Sources/opusenc/opusenc.m index c6bcfc7997..f52e89b8bc 100644 --- a/submodules/OpusBinding/Sources/opusenc/opusenc.m +++ b/submodules/OpusBinding/Sources/opusenc/opusenc.m @@ -468,6 +468,138 @@ static inline int writeOggPage(ogg_page *page, TGDataItem *fileItem) return total_samples / (NSTimeInterval)coding_rate; } +- (NSDictionary *)pause +{ + [self flushPages]; + return [self saveState]; +} + +- (bool)resumeWithDataItem:(TGDataItem *)dataItem encoderState:(NSDictionary *)state +{ + if (![self restoreState:state withDataItem:dataItem]) + return false; + + _packetId++; + + return true; +} + +- (bool)flushPages +{ + while (ogg_stream_flush_fill(&os, &og, 255 * 255)) + { + if (ogg_page_packets(&og) != 0) + last_granulepos = ogg_page_granulepos(&og); + + last_segments -= og.header[26]; + int writtenPageBytes = writeOggPage(&og, _dataItem); + if (writtenPageBytes != og.header_len + og.body_len) + { + NSLog(@"Error: failed writing data to output stream"); + return false; + } + bytes_written += writtenPageBytes; + pages_out++; + } + return true; +} + +- (NSDictionary *)saveState +{ + NSMutableDictionary *state = [NSMutableDictionary dictionary]; + + [state setObject:@(_packetId) forKey:@"packetId"]; + [state setObject:@(enc_granulepos) forKey:@"enc_granulepos"]; + [state setObject:@(last_granulepos) forKey:@"last_granulepos"]; + [state setObject:@(last_segments) forKey:@"last_segments"]; + [state setObject:@(nb_encoded) forKey:@"nb_encoded"]; + [state setObject:@(bytes_written) forKey:@"bytes_written"]; + [state setObject:@(pages_out) forKey:@"pages_out"]; + [state setObject:@(total_bytes) forKey:@"total_bytes"]; + [state setObject:@(total_samples) forKey:@"total_samples"]; + [state setObject:@(serialno) forKey:@"serialno"]; + + [state setObject:@(rate) forKey:@"rate"]; + [state setObject:@(coding_rate) forKey:@"coding_rate"]; + [state setObject:@(frame_size) forKey:@"frame_size"]; + [state setObject:@(bitrate) forKey:@"bitrate"]; + [state setObject:@(with_cvbr) forKey:@"with_cvbr"]; + [state setObject:@(lookahead) forKey:@"lookahead"]; + + NSDictionary *headerDict = @{ + @"channels": @(header.channels), + @"channel_mapping": @(header.channel_mapping), + @"input_sample_rate": @(header.input_sample_rate), + @"gain": @(header.gain), + @"nb_streams": @(header.nb_streams), + @"preskip": @(header.preskip) + }; + [state setObject:headerDict forKey:@"header"]; + + return state; +} + +- (bool)restoreState:(NSDictionary *)state withDataItem:(TGDataItem *)dataItem +{ + if (state == nil || dataItem == nil) + return false; + + [self cleanup]; + + _dataItem = dataItem; + + _packetId = [state[@"packetId"] intValue]; + enc_granulepos = [state[@"enc_granulepos"] longLongValue]; + last_granulepos = [state[@"last_granulepos"] longLongValue]; + last_segments = [state[@"last_segments"] intValue]; + nb_encoded = [state[@"nb_encoded"] longLongValue]; + bytes_written = [state[@"bytes_written"] longLongValue]; + pages_out = [state[@"pages_out"] longLongValue]; + total_bytes = [state[@"total_bytes"] longLongValue]; + total_samples = [state[@"total_samples"] longLongValue]; + serialno = [state[@"serialno"] intValue]; + + rate = [state[@"rate"] intValue]; + coding_rate = [state[@"coding_rate"] intValue]; + frame_size = [state[@"frame_size"] intValue]; + bitrate = [state[@"bitrate"] intValue]; + with_cvbr = [state[@"with_cvbr"] intValue]; + lookahead = [state[@"lookahead"] intValue]; + + NSDictionary *headerDict = state[@"header"]; + header.channels = [headerDict[@"channels"] intValue]; + header.channel_mapping = [headerDict[@"channel_mapping"] intValue]; + header.input_sample_rate = [headerDict[@"input_sample_rate"] intValue]; + header.gain = [headerDict[@"gain"] intValue]; + header.nb_streams = [headerDict[@"nb_streams"] intValue]; + header.preskip = [headerDict[@"preskip"] intValue]; + + int result = OPUS_OK; + _encoder = opus_encoder_create(coding_rate, header.channels, OPUS_APPLICATION_AUDIO, &result); + if (result != OPUS_OK) + { + NSLog(@"Error cannot create encoder: %s", opus_strerror(result)); + return false; + } + + opus_encoder_ctl(_encoder, OPUS_SET_BITRATE(bitrate)); + +#ifdef OPUS_SET_LSB_DEPTH + opus_encoder_ctl(_encoder, OPUS_SET_LSB_DEPTH(16)); +#endif + + if (ogg_stream_init(&os, serialno) == -1) + { + NSLog(@"Error: stream init failed"); + return false; + } + + min_bytes = max_frame_bytes = (1275 * 3 + 7) * header.nb_streams; + _packet = malloc(max_frame_bytes); + + return true; +} + @end /* diff --git a/submodules/Postbox/Sources/MediaBox.swift b/submodules/Postbox/Sources/MediaBox.swift index fea99ac088..d8c7d51011 100644 --- a/submodules/Postbox/Sources/MediaBox.swift +++ b/submodules/Postbox/Sources/MediaBox.swift @@ -333,6 +333,13 @@ public final class MediaBox { } } + public func moveResourceData(_ id: MediaResourceId, toTempPath: String) { + self.dataQueue.async { + let paths = self.storePathsForId(id) + let _ = try? FileManager.default.moveItem(at: URL(fileURLWithPath: paths.complete), to: URL(fileURLWithPath: toTempPath)) + } + } + public func copyResourceData(_ id: MediaResourceId, fromTempPath: String) { self.dataQueue.async { let paths = self.storePathsForId(id) @@ -340,11 +347,11 @@ public final class MediaBox { } } - public func moveResourceData(from: MediaResourceId, to: MediaResourceId) { + public func moveResourceData(from: MediaResourceId, to: MediaResourceId, synchronous: Bool = false) { if from == to { return } - self.dataQueue.async { + let begin = { let pathsFrom = self.storePathsForId(from) let pathsTo = self.storePathsForId(to) link(pathsFrom.partial, pathsTo.partial) @@ -352,6 +359,11 @@ public final class MediaBox { unlink(pathsFrom.partial) unlink(pathsFrom.complete) } + if synchronous { + begin() + } else { + self.dataQueue.async(begin) + } } public func copyResourceData(from: MediaResourceId, to: MediaResourceId, synchronous: Bool = false) { diff --git a/submodules/QrCodeUI/Sources/QrCodeScanScreen.swift b/submodules/QrCodeUI/Sources/QrCodeScanScreen.swift index 21cc6684a0..ea5f892175 100644 --- a/submodules/QrCodeUI/Sources/QrCodeScanScreen.swift +++ b/submodules/QrCodeUI/Sources/QrCodeScanScreen.swift @@ -100,6 +100,7 @@ public final class QrCodeScanScreen: ViewController { public var showMyCode: () -> Void = {} public var completion: (String?) -> Void = { _ in } + public var dismissed: (() -> Void)? private var codeResolved = false @@ -133,8 +134,6 @@ public final class QrCodeScanScreen: ViewController { if case .custom = subject { self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) - } else if case .peer = subject { - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Contacts_QrCode_MyCode, style: .plain, target: self, action: #selector(self.myCodePressed)) } else { #if DEBUG self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Test", style: .plain, target: self, action: #selector(self.testPressed)) @@ -910,6 +909,9 @@ private final class QrCodeScanScreenNode: ViewControllerTracingNode, ASScrollVie if controller is QrCodeScanScreen { return false } + if controller is ChatQrCodeScreen { + return false + } return true } navigationController.setViewControllers(viewControllers, animated: false) diff --git a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift index 9c93669319..058037dbd4 100644 --- a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift +++ b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift @@ -71,6 +71,12 @@ public final class SelectablePeerNodeTheme { } public final class SelectablePeerNode: ASDisplayNode { + public enum StoryMode { + case createStory + case repostStory + case repostMessage + } + private let contextContainer: ContextControllerSourceNode private let avatarSelectionNode: ASImageNode private let avatarNodeContainer: ASDisplayNode @@ -176,14 +182,32 @@ public final class SelectablePeerNode: ASDisplayNode { ) } - public func setupStoryRepost(accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, theme: PresentationTheme, strings: PresentationStrings, synchronousLoad: Bool, isMessage: Bool) { + public func setupStoryRepost(accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, theme: PresentationTheme, strings: PresentationStrings, synchronousLoad: Bool, storyMode: StoryMode) { self.peer = nil - self.textNode.maximumNumberOfLines = 2 - self.textNode.attributedText = NSAttributedString(string: isMessage ? strings.Share_RepostToStory : strings.Share_RepostStory, font: textFont, textColor: self.theme.textColor, paragraphAlignment: .center) - self.avatarNode.setPeer(accountPeerId: accountPeerId, postbox: postbox, network: network, contentSettings: ContentSettings.default, theme: theme, peer: nil, overrideImage: .repostIcon, emptyColor: self.theme.avatarPlaceholderColor, clipStyle: .round, synchronousLoad: synchronousLoad) + let title: String + let overrideImage: AvatarNodeImageOverride + + switch storyMode { + case .createStory: + //TODO:localize + title = "Post\nto Story" + overrideImage = .storyIcon + case .repostStory: + title = strings.Share_RepostStory + overrideImage = .repostIcon + case .repostMessage: + title = strings.Share_RepostToStory + overrideImage = .repostIcon + } - self.avatarNode.playRepostAnimation() + self.textNode.maximumNumberOfLines = 2 + self.textNode.attributedText = NSAttributedString(string: title, font: textFont, textColor: self.theme.textColor, paragraphAlignment: .center) + self.avatarNode.setPeer(accountPeerId: accountPeerId, postbox: postbox, network: network, contentSettings: ContentSettings.default, theme: theme, peer: nil, overrideImage: overrideImage, emptyColor: self.theme.avatarPlaceholderColor, clipStyle: .round, synchronousLoad: synchronousLoad) + + if case .repostIcon = overrideImage { + self.avatarNode.playRepostAnimation() + } } public func setup(accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, energyUsageSettings: EnergyUsageSettings, contentSettings: ContentSettings, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, resolveInlineStickers: @escaping ([Int64]) -> Signal<[Int64: TelegramMediaFile], NoError>, theme: PresentationTheme, strings: PresentationStrings, peer: EngineRenderedPeer, requiresPremiumForMessaging: Bool, requiresStars: Int64? = nil, customTitle: String? = nil, iconId: Int64? = nil, iconColor: Int32? = nil, online: Bool = false, numberOfLines: Int = 2, synchronousLoad: Bool) { diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 9a65643f33..2addbd6134 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -1573,7 +1573,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate func updatePeers(context: ShareControllerAccountContext, switchableAccounts: [ShareControllerSwitchableAccount], peers: [(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, requiresStars: Int64?)], accountPeer: EnginePeer, defaultAction: ShareControllerAction?) { self.context = context - + if let peersContentNode = self.peersContentNode, peersContentNode.accountPeer.id == accountPeer.id { peersContentNode.peersValue.set(.single(peers)) return @@ -1610,7 +1610,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } let animated = self.peersContentNode == nil - let peersContentNode = SharePeersContainerNode(environment: self.environment, context: context, switchableAccounts: switchableAccounts, theme: self.presentationData.theme, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, peers: peers, accountPeer: accountPeer, controllerInteraction: self.controllerInteraction!, externalShare: self.externalShare, switchToAnotherAccount: { [weak self] in + let peersContentNode = SharePeersContainerNode(environment: self.environment, context: context, switchableAccounts: switchableAccounts, theme: self.presentationData.theme, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, peers: peers, accountPeer: accountPeer, controllerInteraction: self.controllerInteraction!, externalShare: self.externalShare, isMainApp: self.environment.isMainApp, switchToAnotherAccount: { [weak self] in self?.switchToAnotherAccount?() }, debugAction: { [weak self] in self?.debugAction?() diff --git a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift index e345229d48..6a116d5a5b 100644 --- a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift +++ b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift @@ -96,8 +96,14 @@ final class ShareControllerGridSectionNode: ASDisplayNode { final class ShareControllerPeerGridItem: GridItem { enum ShareItem: Equatable { + enum StoryMode { + case createStory + case repostStory + case repostMessage + } + case peer(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, topicId: Int64?, threadData: MessageHistoryThreadData?, requiresPremiumForMessaging: Bool, requiresStars: Int64?) - case story(isMessage: Bool) + case story(mode: StoryMode) var peerId: EnginePeer.Id? { if case let .peer(peer, _, _, _, _, _) = self { @@ -254,7 +260,16 @@ final class ShareControllerPeerGridItemNode: GridItemNode { self.placeholderNode = nil shimmerNode.removeFromSupernode() } - } else if let item, case let .story(isMessage) = item { + } else if let item, case let .story(mode) = item { + let storyMode: SelectablePeerNode.StoryMode + switch mode { + case .createStory: + storyMode = .createStory + case .repostStory: + storyMode = .repostStory + case .repostMessage: + storyMode = .repostMessage + } self.peerNode.setupStoryRepost( accountPeerId: context.accountPeerId, postbox: context.stateManager.postbox, @@ -262,7 +277,7 @@ final class ShareControllerPeerGridItemNode: GridItemNode { theme: theme, strings: strings, synchronousLoad: synchronousLoad, - isMessage: isMessage + storyMode: storyMode ) } else { let shimmerNode: ShimmerEffectNode diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index 6680872509..63caa2459c 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -146,7 +146,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } private let tick = ValuePromise(0) - init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, switchableAccounts: [ShareControllerSwitchableAccount], theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, peers: [(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, requiresStars: Int64?)], accountPeer: EnginePeer, controllerInteraction: ShareControllerInteraction, externalShare: Bool, switchToAnotherAccount: @escaping () -> Void, debugAction: @escaping () -> Void, extendedInitialReveal: Bool, segmentedValues: [ShareControllerSegmentedValue]?, fromPublicChannel: Bool) { + init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, switchableAccounts: [ShareControllerSwitchableAccount], theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, peers: [(peer: EngineRenderedPeer, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, requiresStars: Int64?)], accountPeer: EnginePeer, controllerInteraction: ShareControllerInteraction, externalShare: Bool, isMainApp: Bool, switchToAnotherAccount: @escaping () -> Void, debugAction: @escaping () -> Void, extendedInitialReveal: Bool, segmentedValues: [ShareControllerSegmentedValue]?, fromPublicChannel: Bool) { self.environment = environment self.context = context self.theme = theme @@ -171,7 +171,17 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { var index: Int32 = 0 if canShareStory { - entries.append(SharePeerEntry(index: index, item: .story(isMessage: fromPublicChannel), theme: theme, strings: strings)) + let storyMode: ShareControllerPeerGridItem.ShareItem.StoryMode + if fromPublicChannel { + storyMode = .repostMessage + } else { + if !isMainApp { + storyMode = .createStory + } else { + storyMode = .repostStory + } + } + entries.append(SharePeerEntry(index: index, item: .story(mode: storyMode), theme: theme, strings: strings)) index += 1 } diff --git a/submodules/TelegramPermissions/Sources/Permission.swift b/submodules/TelegramPermissions/Sources/Permission.swift index 02f94db8df..c48f46d83b 100644 --- a/submodules/TelegramPermissions/Sources/Permission.swift +++ b/submodules/TelegramPermissions/Sources/Permission.swift @@ -28,6 +28,8 @@ public enum PermissionRequestStatus { self = .unreachable case .allowed: self = .allowed + case .limited: + self = .allowed } } } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 7c0ed52fed..2dbaa6367f 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -477,6 +477,7 @@ swift_library( "//submodules/TelegramUI/Components/CheckComponent", "//submodules/TelegramUI/Components/MarqueeComponent", "//submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen", + "//submodules/TelegramUI/Components/ForumSettingsScreen", "//submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index 8259e27965..e7d811f8f4 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -517,6 +517,38 @@ private final class AdminUserActionsSheetComponent: Component { let sideInset: CGFloat = 16.0 + environment.safeInsets.left if self.component == nil { + let _ = (component.context.account.postbox.peerView(id: component.chatPeer.id) + |> take(1)).start(next: { [weak self] peerView in + guard let self else{ + return + } + + var selectAll = false + if let cachedData = peerView.cachedData as? CachedChannelData { + if let memberCount = cachedData.participantsSummary.memberCount, memberCount >= 1000 { + selectAll = true + } else if case let .known(peerId) = cachedData.linkedDiscussionPeerId, let _ = peerId { + selectAll = true + } else if case let .channel(channel) = component.chatPeer, let _ = channel.addressName { + selectAll = true + } + } + + if selectAll { + var selectedPeers = Set() + for peer in component.peers { + selectedPeers.insert(peer.peer.id) + } + self.optionReportSelectedPeers = selectedPeers + self.optionDeleteAllSelectedPeers = selectedPeers + self.optionBanSelectedPeers = selectedPeers + } + + if !self.isUpdating { + self.state?.updated() + } + }) + var (allowedParticipantRights, allowedMediaRights) = rightsFromBannedRights([]) if case let .channel(channel) = component.chatPeer { (allowedParticipantRights, allowedMediaRights) = rightsFromBannedRights(channel.defaultBannedRights?.flags ?? []) diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/BUILD b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/BUILD index 8ee4eade32..1d1499967b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/BUILD @@ -44,6 +44,7 @@ swift_library( "//submodules/SegmentedControlNode", "//submodules/AnimatedCountLabelNode", "//submodules/HexColor", + "//submodules/QrCodeUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 68d70b547a..59c9905105 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -35,6 +35,7 @@ import SaveToCameraRoll import SegmentedControlNode import AnimatedCountLabelNode import HexColor +import QrCodeUI private func closeButtonImage(theme: PresentationTheme) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in @@ -536,7 +537,7 @@ private final class ThemeSettingsThemeItemIconNode : ListViewItemNode { snapshotView.frame = self.containerNode.view.frame self.view.insertSubview(snapshotView, aboveSubview: self.containerNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreen.themeCrossfadeDuration, delay: ChatQrCodeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreenImpl.themeCrossfadeDuration, delay: ChatQrCodeScreenImpl.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } @@ -561,7 +562,7 @@ private final class ThemeSettingsThemeItemIconNode : ListViewItemNode { } } -public final class ChatQrCodeScreen: ViewController { +public final class ChatQrCodeScreenImpl: ViewController, ChatQrCodeScreen { public static let themeCrossfadeDuration: Double = 0.3 public static let themeCrossfadeDelay: Double = 0.05 @@ -603,7 +604,7 @@ public final class ChatQrCodeScreen: ViewController { private var animatedIn = false private let context: AccountContext - fileprivate let subject: ChatQrCodeScreen.Subject + fileprivate let subject: ChatQrCodeScreenImpl.Subject private var presentationData: PresentationData private var presentationThemePromise = Promise() @@ -611,13 +612,15 @@ public final class ChatQrCodeScreen: ViewController { public var dismissed: (() -> Void)? - public init(context: AccountContext, subject: ChatQrCodeScreen.Subject) { + public init(context: AccountContext, subject: ChatQrCodeScreenImpl.Subject) { self.context = context self.presentationData = context.sharedContext.currentPresentationData.with { $0 } self.subject = subject super.init(navigationBarPresentationData: nil) + self.navigationPresentation = .flatModal + self.statusBar.statusBarStyle = .Ignore self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) @@ -664,14 +667,11 @@ public final class ChatQrCodeScreen: ViewController { } strongSelf.dismiss() } - self.controllerNode.dismiss = { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) - } self.controllerNode.cancel = { [weak self] in guard let strongSelf = self else { return } - strongSelf.dismiss() + strongSelf.dismiss(animated: true) } } @@ -690,19 +690,25 @@ public final class ChatQrCodeScreen: ViewController { } } - override public func dismiss(completion: (() -> Void)? = nil) { + public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) - - self.controllerNode.animateOut(completion: completion) + + if flag { + self.controllerNode.animateOut(completion: { + super.dismiss(animated: false, completion: completion) + }) + } else { + super.dismiss(animated: flag, completion: completion) + } self.dismissed?() } - + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) @@ -754,7 +760,7 @@ private func generateShadowImage() -> UIImage? { private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDelegate { private let context: AccountContext private var presentationData: PresentationData - private weak var controller: ChatQrCodeScreen? + private weak var controller: ChatQrCodeScreenImpl? private let contentNode: ContentNode @@ -772,6 +778,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg private let animationContainerNode: ASDisplayNode private var animationNode: AnimationNode private let doneButton: SolidRoundedButtonNode + private let scanButton: SolidRoundedButtonNode private let listNode: ListView private var entries: [ThemeSettingsThemeEntry]? @@ -805,10 +812,9 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg var present: ((ViewController) -> Void)? var previewTheme: ((String?, Bool?, PresentationTheme) -> Void)? var completion: ((String?) -> Void)? - var dismiss: (() -> Void)? var cancel: (() -> Void)? - init(context: AccountContext, presentationData: PresentationData, controller: ChatQrCodeScreen) { + init(context: AccountContext, presentationData: PresentationData, controller: ChatQrCodeScreenImpl) { self.context = context self.controller = controller self.presentationData = presentationData @@ -817,7 +823,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.wrappingScrollNode.view.alwaysBounceVertical = true self.wrappingScrollNode.view.delaysContentTouches = false self.wrappingScrollNode.view.canCancelContentTouches = true - + switch controller.subject { case let .peer(peer, threadId, temporary): self.contentNode = QrContentNode(context: context, peer: peer, threadId: threadId, isStatic: false, temporary: temporary) @@ -888,6 +894,11 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.doneButton.title = self.presentationData.strings.Share_ShareMessage } + //TODO:localize + self.scanButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .clear, foregroundColor: self.presentationData.theme.actionSheet.controlAccentColor), font: .regular, height: 42.0, cornerRadius: 0.0, gloss: false) + self.scanButton.title = "Scan QR Code" + self.scanButton.icon = UIImage(bundleImageName: "Settings/ScanQr") + self.listNode = ListView() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) @@ -910,6 +921,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.contentContainerNode.addSubnode(self.titleNode) self.contentContainerNode.addSubnode(self.segmentedNode) self.contentContainerNode.addSubnode(self.doneButton) + self.contentContainerNode.addSubnode(self.scanButton) self.topContentContainerNode.addSubnode(self.animationContainerNode) self.animationContainerNode.addSubnode(self.animationNode) @@ -992,6 +1004,14 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg } } + self.scanButton.pressed = { [weak self] in + guard let self else { + return + } + let controller = QrCodeScanScreen(context: self.context, subject: .peer) + self.controller?.push(controller) + } + let animatedEmojiStickers = context.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) |> map { animatedEmoji -> [String: [StickerPackItem]] in var animatedEmojiStickers: [String: [StickerPackItem]] = [:] @@ -1246,9 +1266,6 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg private var switchThemeIconAnimator: DisplayLinkAnimator? func updatePresentationData(_ presentationData: PresentationData) { - guard !self.animatedOut else { - return - } let previousTheme = self.presentationData.theme self.presentationData = presentationData @@ -1261,6 +1278,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.cancelButton.setImage(closeButtonImage(theme: self.presentationData.theme), for: .normal) self.doneButton.updateTheme(SolidRoundedButtonTheme(theme: self.presentationData.theme)) + self.scanButton.updateTheme(SolidRoundedButtonTheme(backgroundColor: .clear, foregroundColor: self.presentationData.theme.actionSheet.controlAccentColor)) let previousIconColors = iconColors(theme: previousTheme) let newIconColors = iconColors(theme: self.presentationData.theme) @@ -1336,7 +1354,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg if let snapshotView = self.contentNode.containerNode.view.snapshotView(afterScreenUpdates: false) { self.contentNode.view.insertSubview(snapshotView, aboveSubview: self.contentNode.containerNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreen.themeCrossfadeDuration, delay: ChatQrCodeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreenImpl.themeCrossfadeDuration, delay: ChatQrCodeScreenImpl.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } @@ -1345,14 +1363,14 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg snapshotView.frame = self.animationNode.frame self.animationNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.animationNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreen.themeCrossfadeDuration, delay: ChatQrCodeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreenImpl.themeCrossfadeDuration, delay: ChatQrCodeScreenImpl.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } - Queue.mainQueue().after(ChatQrCodeScreen.themeCrossfadeDelay) { + Queue.mainQueue().after(ChatQrCodeScreenImpl.themeCrossfadeDelay) { if let effectView = self.effectNode.view as? UIVisualEffectView { - UIView.animate(withDuration: ChatQrCodeScreen.themeCrossfadeDuration, delay: 0.0, options: .curveLinear) { + UIView.animate(withDuration: ChatQrCodeScreenImpl.themeCrossfadeDuration, delay: 0.0, options: .curveLinear) { effectView.effect = UIBlurEffect(style: self.presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark) } completion: { _ in } @@ -1360,14 +1378,14 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg let previousColor = self.contentBackgroundNode.backgroundColor ?? .clear self.contentBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor - self.contentBackgroundNode.layer.animate(from: previousColor.cgColor, to: (self.contentBackgroundNode.backgroundColor ?? .clear).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: ChatQrCodeScreen.themeCrossfadeDuration) + self.contentBackgroundNode.layer.animate(from: previousColor.cgColor, to: (self.contentBackgroundNode.backgroundColor ?? .clear).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: ChatQrCodeScreenImpl.themeCrossfadeDuration) } if let snapshotView = self.contentContainerNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = self.contentContainerNode.frame self.contentContainerNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.contentContainerNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreen.themeCrossfadeDuration, delay: ChatQrCodeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatQrCodeScreenImpl.themeCrossfadeDuration, delay: ChatQrCodeScreenImpl.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } @@ -1395,11 +1413,8 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.animatedOut = true let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY - self.wrappingScrollNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.dismiss?() - completion?() - } + self.wrappingScrollNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in + completion?() }) } @@ -1421,7 +1436,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg let bottomInset: CGFloat = 10.0 + cleanInsets.bottom let titleHeight: CGFloat = 54.0 - let contentHeight = titleHeight + bottomInset + 188.0 + let contentHeight = titleHeight + bottomInset + 188.0 + 52.0 let width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: 0.0) @@ -1459,9 +1474,12 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg transition.updateFrame(node: self.cancelButton, frame: cancelFrame) let buttonInset: CGFloat = 16.0 - let doneButtonHeight = self.doneButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) - transition.updateFrame(node: self.doneButton, frame: CGRect(x: buttonInset, y: contentHeight - doneButtonHeight - insets.bottom - 6.0, width: contentFrame.width, height: doneButtonHeight)) + let scanButtonHeight = self.scanButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) + transition.updateFrame(node: self.scanButton, frame: CGRect(x: buttonInset, y: contentHeight - scanButtonHeight - insets.bottom - 6.0, width: contentFrame.width, height: scanButtonHeight)) + let doneButtonHeight = self.doneButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) + transition.updateFrame(node: self.doneButton, frame: CGRect(x: buttonInset, y: contentHeight - doneButtonHeight - scanButtonHeight - 10.0 - insets.bottom - 6.0, width: contentFrame.width, height: doneButtonHeight)) + transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame) transition.updateFrame(node: self.topContentContainerNode, frame: contentContainerFrame) diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index c3aa582cac..616846fe62 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -1444,6 +1444,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { break case .stars: break + case .shareStory: + break } } })) diff --git a/submodules/TelegramUI/Components/ForumSettingsScreen/BUILD b/submodules/TelegramUI/Components/ForumSettingsScreen/BUILD new file mode 100644 index 0000000000..aca03b4f73 --- /dev/null +++ b/submodules/TelegramUI/Components/ForumSettingsScreen/BUILD @@ -0,0 +1,34 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ForumSettingsScreen", + module_name = "ForumSettingsScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/PresentationDataUtils", + "//submodules/Markdown", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/LottieComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumModeComponent.swift b/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumModeComponent.swift new file mode 100644 index 0000000000..4dceea5b90 --- /dev/null +++ b/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumModeComponent.swift @@ -0,0 +1,268 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import AccountContext +import TelegramPresentationData +import PlainButtonComponent +import MultilineTextComponent +import LottieComponent + +final class ForumModeComponent: Component { + enum Mode: Equatable { + case tabs + case list + } + let theme: PresentationTheme + let strings: PresentationStrings + let mode: Mode? + let modeUpdated: (Mode) -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + mode: Mode?, + modeUpdated: @escaping (Mode) -> Void + ) { + self.theme = theme + self.strings = strings + self.mode = mode + self.modeUpdated = modeUpdated + } + + static func ==(lhs: ForumModeComponent, rhs: ForumModeComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.mode != rhs.mode { + return false + } + return true + } + + final class View: UIView { + private let tabs = ComponentView() + private let list = ComponentView() + + private var component: ForumModeComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ForumModeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + self.state = state + + let size = CGSize(width: availableSize.width, height: 224.0) + + let sideInset = (size.width - 160.0 * 2.0) / 2.0 + //TODO:localize + let tabsSize = self.tabs.update( + transition: transition, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + ItemComponent( + theme: component.theme, + animation: "ForumTabs", + title: "Tabs", + isSelected: component.mode == .tabs + ) + ), + effectAlignment: .center, + action: { + component.modeUpdated(.tabs) + }, + animateScale: false + ) + ), + environment: {}, + containerSize: CGSize(width: 160.0, height: size.height) + ) + if let tabsView = self.tabs.view { + if tabsView.superview == nil { + self.addSubview(tabsView) + } + transition.setFrame(view: tabsView, frame: CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: tabsSize)) + } + + let listSize = self.list.update( + transition: transition, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + ItemComponent( + theme: component.theme, + animation: "ForumList", + title: "List", + isSelected: component.mode == .list + ) + ), + effectAlignment: .center, + action: { + component.modeUpdated(.list) + }, + animateScale: false + ) + ), + environment: {}, + containerSize: CGSize(width: 160.0, height: size.height) + ) + if let listView = self.list.view { + if listView.superview == nil { + self.addSubview(listView) + } + transition.setFrame(view: listView, frame: CGRect(origin: CGPoint(x: sideInset + tabsSize.width, y: 0.0), size: listSize)) + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +private final class ItemComponent: Component { + let theme: PresentationTheme + let animation: String + let title: String + let isSelected: Bool + + init( + theme: PresentationTheme, + animation: String, + title: String, + isSelected: Bool + ) { + self.theme = theme + self.animation = animation + self.title = title + self.isSelected = isSelected + } + + static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.animation != rhs.animation { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + return true + } + + final class View: UIView { + private let animation = ComponentView() + private let selection = ComponentView() + private let title = ComponentView() + + private var component: ItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + + self.component = component + self.state = state + + let size = CGSize(width: availableSize.width, height: 224.0) + + let animationSize = self.animation.update( + transition: transition, + component: AnyComponent( + LottieComponent( + content: LottieComponent.AppBundleContent(name: component.animation), + color: component.isSelected ? component.theme.list.itemCheckColors.fillColor : component.theme.list.itemSecondaryTextColor, + loop: false + ) + ), + environment: {}, + containerSize: CGSize(width: 170.0, height: 170.0) + ) + if let animationView = self.animation.view { + if animationView.superview == nil { + self.addSubview(animationView) + } + transition.setFrame(view: animationView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - animationSize.width) / 2.0), y: 9.0), size: animationSize)) + } + + let titleSize = self.title.update( + transition: transition, + component: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: component.title, font: Font.medium(13.0), textColor: component.isSelected ? component.theme.list.itemCheckColors.foregroundColor : component.theme.list.itemPrimaryTextColor))) + ), + environment: {}, + containerSize: size + ) + let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: size.height - titleSize.height - 23.0), size: titleSize) + + let selectionFrame = titleFrame.insetBy(dx: -10.0, dy: -6.0) + let _ = self.selection.update( + transition: transition, + component: AnyComponent(RoundedRectangle(color: component.theme.list.itemCheckColors.fillColor, cornerRadius: selectionFrame.size.height / 2.0)), + environment: {}, + containerSize: selectionFrame.size + ) + if let selectionView = self.selection.view { + if selectionView.superview == nil { + self.addSubview(selectionView) + } + transition.setFrame(view: selectionView, frame: selectionFrame) + selectionView.alpha = component.isSelected ? 1.0 : 0.0 + } + + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + + if previousComponent?.isSelected != component.isSelected && component.isSelected { + if let animationView = self.animation.view as? LottieComponent.View { + animationView.playOnce() + } + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumSettingsScreen.swift b/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumSettingsScreen.swift new file mode 100644 index 0000000000..26bf968fc7 --- /dev/null +++ b/submodules/TelegramUI/Components/ForumSettingsScreen/Sources/ForumSettingsScreen.swift @@ -0,0 +1,470 @@ +import Foundation +import UIKit +import Photos +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import ListSectionComponent +import ListActionItemComponent +import BundleIconComponent +import LottieComponent +import PlainButtonComponent +import TelegramStringFormatting +import Markdown + +final class ForumSettingsScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + + init( + context: AccountContext, + peerId: EnginePeer.Id + ) { + self.context = context + self.peerId = peerId + } + + static func ==(lhs: ForumSettingsScreenComponent, rhs: ForumSettingsScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + + private let navigationTitle = ComponentView() + private let icon = ComponentView() + private let subtitle = ComponentView() + private let generalSection = ComponentView() + private let modeSection = ComponentView() + + private var ignoreScrolling: Bool = false + private var isUpdating: Bool = false + + private var component: ForumSettingsScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var peer: EnginePeer? + private var peerDisposable: Disposable? + + private var isOn = false + private var mode: ForumModeComponent.Mode = .tabs + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.peerDisposable?.dispose() + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + var scrolledUp = true + private func updateScrolling(transition: ComponentTransition) { + let navigationRevealOffsetY: CGFloat = 0.0 + + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + + var scrolledUp = false + if navigationAlpha < 0.5 { + scrolledUp = true + } else if navigationAlpha > 0.5 { + scrolledUp = false + } + + if self.scrolledUp != scrolledUp { + self.scrolledUp = scrolledUp + if !self.isUpdating { + self.state?.updated() + } + } + + if let navigationTitleView = self.navigationTitle.view { + transition.setAlpha(view: navigationTitleView, alpha: 1.0) + } + } + + func update(component: ForumSettingsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + if self.component == nil { + self.peerDisposable = (component.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self else { + return + } + self.peer = peer + if case let .channel(channel) = peer { + self.isOn = channel.flags.contains(.isForum) + self.mode = channel.flags.contains(.displayForumAsTabs) ? .tabs : .list + } + self.state?.updated() + }) + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? transition : transition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + //TODO:localize + let navigationTitleSize = self.navigationTitle.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Topics", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + navigationBar.view.addSubview(navigationTitleView) + } + } + transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let sectionSpacing: CGFloat = 32.0 + + var contentHeight: CGFloat = 0.0 + + contentHeight += environment.navigationHeight + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "Topics"), + loop: false + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 8.0), size: iconSize) + if let iconView = self.icon.view as? LottieComponent.View { + if iconView.superview == nil { + self.scrollView.addSubview(iconView) + iconView.playOnce() + } + transition.setPosition(view: iconView, position: iconFrame.center) + iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size) + } + + contentHeight += 124.0 + + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("The group chat will be divided into topics created by admins or users.", attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { attributes in + return ("URL", "") + }), textAlignment: .center + )) + + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(subtitleString), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.25, + highlightColor: environment.theme.list.itemAccentColor.withMultipliedAlpha(0.1), + highlightAction: { attributes in + if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { + return NSAttributedString.Key(rawValue: "URL") + } else { + return nil + } + }, + tapAction: { [weak self] _, _ in + guard let self, let component = self.component else { + return + } + let _ = component + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.scrollView.addSubview(subtitleView) + } + transition.setPosition(view: subtitleView, position: subtitleFrame.center) + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + } + contentHeight += subtitleSize.height + contentHeight += 27.0 + + var generalSectionItems: [AnyComponentWithIdentity] = [] + generalSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Enable Topics", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isOn, action: { [weak self] value in + guard let self, let component = self.component else { + return + } + self.isOn = !self.isOn + let displayForumAsTabs = self.mode == .tabs + let _ = component.context.engine.peers.setChannelForumMode(id: component.peerId, isForum: self.isOn, displayForumAsTabs: displayForumAsTabs).startStandalone() + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + let generalSectionSize = self.generalSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: generalSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let generalSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: generalSectionSize) + if let generalSectionView = self.generalSection.view { + if generalSectionView.superview == nil { + self.scrollView.addSubview(generalSectionView) + } + transition.setFrame(view: generalSectionView, frame: generalSectionFrame) + } + contentHeight += generalSectionSize.height + contentHeight += sectionSpacing + + var otherSectionsHeight: CGFloat = 0.0 + + let modeSectionSize = self.modeSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "DISPLAY AS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: AnyComponent(MultilineTextComponent( + text: .markdown( + text: "Choose how topics appear for all members.", + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { _ in + return nil + } + ) + ), + maximumNumberOfLines: 0 + )), + items: [ + AnyComponentWithIdentity( + id: 0, + component: AnyComponent( + ForumModeComponent( + theme: environment.theme, + strings: environment.strings, + mode: self.isOn ? self.mode : nil, + modeUpdated: { [weak self] mode in + guard let self else { + return + } + self.mode = mode + let displayForumAsTabs = self.mode == .tabs + let _ = component.context.engine.peers.setChannelForumMode(id: component.peerId, isForum: true, displayForumAsTabs: displayForumAsTabs).startStandalone() + self.state?.updated(transition: .spring(duration: 0.4)) + } + ) + ) + ) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let modeSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: modeSectionSize) + if let modeSectionView = self.modeSection.view { + if modeSectionView.superview == nil { + modeSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(modeSectionView) + } + transition.setFrame(view: modeSectionView, frame: modeSectionFrame) + alphaTransition.setAlpha(view: modeSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += modeSectionSize.height + otherSectionsHeight += sectionSpacing + + if self.isOn { + contentHeight += otherSectionsHeight + } + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + self.ignoreScrolling = true + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + self.ignoreScrolling = false + + if !previousBounds.isEmpty, !transition.animation.isImmediate { + let bounds = self.scrollView.bounds + if bounds.maxY != previousBounds.maxY { + let offsetY = previousBounds.maxY - bounds.maxY + transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) + } + } + + self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class ForumSettingsScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext, peerId: EnginePeer.Id) { + self.context = context + + super.init(context: context, component: ForumSettingsScreenComponent( + context: context, + peerId: peerId + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = "" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? ForumSettingsScreenComponent.View else { + return + } + componentView.scrollToTop() + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index 2c574d0a09..7fbcf56c82 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -1359,7 +1359,8 @@ final class GiftSetupScreenComponent: Component { color: environment.theme.list.itemCheckColors.fillColor, foreground: environment.theme.list.itemCheckColors.foregroundColor, pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), - cornerRadius: 10.0 + cornerRadius: 10.0, + isShimmering: true ), content: AnyComponentWithIdentity( id: AnyHashable(buttonString), diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift index 0b3e30a019..e8cc73e516 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift @@ -42,7 +42,7 @@ public extension MediaEditorScreenImpl { if let image = UIImage(contentsOfFile: data.path) { return .single(nil) |> then( - .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) |> delay(0.1, queue: Queue.mainQueue()) ) } else { @@ -56,7 +56,7 @@ public extension MediaEditorScreenImpl { } return .single(nil) |> then( - .single(.video(videoPath: symlinkPath, thumbnail: nil, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: PixelDimensions(width: 720, height: 1280), duration: duration ?? 0.0, videoPositionChanges: [], additionalVideoPosition: .bottomRight)) + .single(.video(videoPath: symlinkPath, thumbnail: nil, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: PixelDimensions(width: 720, height: 1280), duration: duration ?? 0.0, videoPositionChanges: [], additionalVideoPosition: .bottomRight, fromCamera: false)) ) } } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index ecacd3939b..f4bc125538 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -1358,7 +1358,7 @@ final class MediaEditorScreenComponent: Component { var canRecordVideo = true if let subject = controller.node.subject { - if case let .video(_, _, _, additionalPath, _, _, _, _, _) = subject, additionalPath != nil { + if case let .video(_, _, _, additionalPath, _, _, _, _, _, _) = subject, additionalPath != nil { canRecordVideo = false } if case .videoCollage = subject { @@ -2348,8 +2348,8 @@ final class MediaEditorScreenComponent: Component { ) var selectedItemId = "" - if case let .asset(asset) = controller.node.subject { - selectedItemId = asset.localIdentifier + if let subject = controller.node.subject, let item = controller.node.items.first(where: { $0.source.identifier == subject.sourceIdentifier }) { + selectedItemId = item.identifier } let selectionPanel: ComponentView @@ -2385,7 +2385,7 @@ final class MediaEditorScreenComponent: Component { guard let self, let controller else { return } - if let itemIndex = controller.node.items.firstIndex(where: { $0.asset.localIdentifier == id }) { + if let itemIndex = controller.node.items.firstIndex(where: { $0.identifier == id }) { controller.node.items[itemIndex].isEnabled = !controller.node.items[itemIndex].isEnabled } self.state?.updated(transition: .spring(duration: 0.3)) @@ -2394,7 +2394,7 @@ final class MediaEditorScreenComponent: Component { guard let self, let controller else { return } - guard let fromIndex = controller.node.items.firstIndex(where: { $0.asset.localIdentifier == fromId }), let toIndex = controller.node.items.firstIndex(where: { $0.asset.localIdentifier == toId }), toIndex < controller.node.items.count else { + guard let fromIndex = controller.node.items.firstIndex(where: { $0.identifier == fromId }), let toIndex = controller.node.items.firstIndex(where: { $0.identifier == toId }), toIndex < controller.node.items.count else { return } let fromItem = controller.node.items[fromIndex] @@ -2928,19 +2928,87 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } struct EditingItem: Equatable { - let asset: PHAsset + enum Source: Equatable { + case image(UIImage, PixelDimensions) + case video(String, UIImage?, PixelDimensions, Double) + case asset(PHAsset) + + static func ==(lhs: Source, rhs: Source) -> Bool { + switch lhs { + case let .image(lhsImage, _): + if case let .image(rhsImage, _) = rhs { + return lhsImage === rhsImage + } + case let .video(lhsPath, _, _, _): + if case let .video(rhsPath, _, _, _) = rhs { + return lhsPath == rhsPath + } + case let .asset(lhsAsset): + if case let .asset(rhsAsset) = rhs { + return lhsAsset.localIdentifier == rhsAsset.localIdentifier + } + } + return false + } + + var identifier: String { + switch self { + case let .image(image, _): + return "\(Unmanaged.passUnretained(image).toOpaque())" + case let .video(videoPath, _, _, _): + return videoPath + case let .asset(asset): + return asset.localIdentifier + } + } + + var subject: MediaEditorScreenImpl.Subject { + switch self { + case let .image(image, dimensions): + return .image(image: image, dimensions: dimensions, additionalImage: nil, additionalImagePosition: .bottomLeft, fromCamera: false) + case let .video(videoPath, thumbnail, dimensions, duration): + return .video(videoPath: videoPath, thumbnail: thumbnail, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: dimensions, duration: duration, videoPositionChanges: [], additionalVideoPosition: .bottomLeft, fromCamera: false) + case let .asset(asset): + return .asset(asset) + } + } + + var isVideo: Bool { + switch self { + case .image: + return false + case .video: + return true + case let .asset(asset): + return asset.mediaType == .video + } + } + } + + var identifier: String + var source: Source var values: MediaEditorValues? var caption = NSAttributedString() var thumbnail: UIImage? var isEnabled = true var version: Int = 0 - init(asset: PHAsset) { - self.asset = asset + init?(subject: MediaEditorScreenImpl.Subject) { + self.identifier = "\(Int64.random(in: 0 ..< .max))" + switch subject { + case let .image(image, dimensions, _, _, _): + self.source = .image(image, dimensions) + case let .video(videoPath, thumbnail, _, _, _, dimensions, duration, _, _, _): + self.source = .video(videoPath, thumbnail, dimensions, duration) + case let .asset(asset): + self.source = .asset(asset) + default: + return nil + } } public static func ==(lhs: EditingItem, rhs: EditingItem) -> Bool { - if lhs.asset.localIdentifier != rhs.asset.localIdentifier { + if lhs.source != rhs.source { return false } if lhs.values != rhs.values { @@ -3235,9 +3303,9 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID var effectiveSubject = subject switch subject { - case let .assets(assets): - effectiveSubject = .asset(assets.first!) - self.items = assets.map { EditingItem(asset: $0) } + case let .multiple(subjects): + effectiveSubject = subjects.first! + self.items = subjects.compactMap { EditingItem(subject: $0) } case let .draft(draft, _): for entity in draft.values.entities { if case let .sticker(sticker) = entity { @@ -3415,9 +3483,12 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID var isFromCamera = false let isSavingAvailable: Bool switch subject { - case .image, .video: + case let .image(_, _, _, _, fromCamera): + isFromCamera = fromCamera + isSavingAvailable = !controller.isEmbeddedEditor + case let .video(_, _, _, _, _, _, _, _, _, fromCamera): + isFromCamera = fromCamera isSavingAvailable = !controller.isEmbeddedEditor - isFromCamera = true case .draft, .message,. gift: isSavingAvailable = true default: @@ -3599,7 +3670,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } switch subject { - case let .image(_, _, additionalImage, position): + case let .image(_, _, additionalImage, position, _): if let additionalImage { let image = generateImage(CGSize(width: additionalImage.size.width, height: additionalImage.size.width), contextGenerator: { size, context in let bounds = CGRect(origin: .zero, size: size) @@ -3617,7 +3688,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID imageEntity.position = position.getPosition(storyDimensions) self.entitiesView.add(imageEntity, announce: false) } - case let .video(_, _, mirror, additionalVideoPath, _, _, _, changes, position): + case let .video(_, _, mirror, additionalVideoPath, _, _, _, changes, position, _): mediaEditor.setVideoIsMirrored(mirror) if let additionalVideoPath { let videoEntity = DrawingStickerEntity(content: .dualVideoReference(false)) @@ -3784,7 +3855,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID self.backgroundDimView.isHidden = false }) } - } else if CACurrentMediaTime() - self.initializationTimestamp > 0.2, case .image = subject { + } else if CACurrentMediaTime() - self.initializationTimestamp > 0.2, case .image = subject, self.items.isEmpty { self.previewContainerView.alpha = 1.0 self.previewContainerView.layer.allowsGroupOpacity = true self.previewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { _ in @@ -4427,7 +4498,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID case .camera: self.componentHostView?.animateIn(from: .camera, completion: completion) - if let subject = self.subject, case let .video(_, mainTransitionImage, _, _, additionalTransitionImage, _, _, positionChangeTimestamps, pipPosition) = subject, let mainTransitionImage { + if let subject = self.subject, case let .video(_, mainTransitionImage, _, _, additionalTransitionImage, _, _, positionChangeTimestamps, pipPosition, _) = subject, let mainTransitionImage { var transitionImage = mainTransitionImage if let additionalTransitionImage { var backgroundImage = mainTransitionImage @@ -5448,7 +5519,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } func switchToItem(_ identifier: String) { - guard let controller = self.controller, let mediaEditor = self.mediaEditor, let itemIndex = self.items.firstIndex(where: { $0.asset.localIdentifier == identifier }), case let .asset(asset) = self.subject, let currentItemIndex = self.items.firstIndex(where: { $0.asset.localIdentifier == asset.localIdentifier }) else { + guard let controller = self.controller, let mediaEditor = self.mediaEditor, let itemIndex = self.items.firstIndex(where: { $0.identifier == identifier }), let subject = self.subject, let currentItemIndex = self.items.firstIndex(where: { $0.source.identifier == subject.sourceIdentifier }) else { return } @@ -5489,7 +5560,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID let targetItem = self.items[itemIndex] controller.node.setup( - subject: .asset(targetItem.asset), + subject: targetItem.source.subject, values: targetItem.values, caption: targetItem.caption ) @@ -6448,27 +6519,40 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } case empty(PixelDimensions) - case image(image: UIImage, dimensions: PixelDimensions, additionalImage: UIImage?, additionalImagePosition: PIPPosition) - case video(videoPath: String, thumbnail: UIImage?, mirror: Bool, additionalVideoPath: String?, additionalThumbnail: UIImage?, dimensions: PixelDimensions, duration: Double, videoPositionChanges: [(Bool, Double)], additionalVideoPosition: PIPPosition) + case image(image: UIImage, dimensions: PixelDimensions, additionalImage: UIImage?, additionalImagePosition: PIPPosition, fromCamera: Bool) + case video(videoPath: String, thumbnail: UIImage?, mirror: Bool, additionalVideoPath: String?, additionalThumbnail: UIImage?, dimensions: PixelDimensions, duration: Double, videoPositionChanges: [(Bool, Double)], additionalVideoPosition: PIPPosition, fromCamera: Bool) case videoCollage(items: [VideoCollageItem]) case asset(PHAsset) case draft(MediaEditorDraft, Int64?) case message([MessageId]) case gift(StarGift.UniqueGift) case sticker(TelegramMediaFile, [String]) - case assets([PHAsset]) + case multiple([Subject]) + + var sourceIdentifier: String { + switch self { + case let .image(image, _, _, _, _): + return "\(Unmanaged.passUnretained(image).toOpaque())" + case let .video(videoPath, _, _, _, _, _, _, _, _, _): + return videoPath + case let .asset(asset): + return asset.localIdentifier + default: + fatalError() + } + } var dimensions: PixelDimensions { switch self { case let .empty(dimensions): return dimensions - case let .image(_, dimensions, _, _), let .video(_, _, _, _, _, dimensions, _, _, _): + case let .image(_, dimensions, _, _, _), let .video(_, _, _, _, _, dimensions, _, _, _, _): return dimensions case let .asset(asset): return PixelDimensions(width: Int32(asset.pixelWidth), height: Int32(asset.pixelHeight)) case let .draft(draft, _): return draft.dimensions - case .message, .gift, .sticker, .videoCollage, .assets: + case .message, .gift, .sticker, .videoCollage, .multiple: return PixelDimensions(storyDimensions) } } @@ -6480,9 +6564,9 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID context.clear(CGRect(origin: .zero, size: size)) })! return .image(image, dimensions) - case let .image(image, dimensions, _, _): + case let .image(image, dimensions, _, _, _): return .image(image, dimensions) - case let .video(videoPath, transitionImage, mirror, additionalVideoPath, _, dimensions, duration, _, _): + case let .video(videoPath, transitionImage, mirror, additionalVideoPath, _, dimensions, duration, _, _, _): return .video(videoPath, transitionImage, mirror, additionalVideoPath, dimensions, duration) case let .videoCollage(items): return .videoCollage(items.map { $0.editorItem }) @@ -6496,8 +6580,8 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID return .gift(gift) case let .sticker(sticker, _): return .sticker(sticker) - case let .assets(assets): - return .asset(assets.first!) + case let .multiple(subjects): + return subjects.first!.editorSubject } } @@ -6525,7 +6609,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID return false case .sticker: return false - case .assets: + case .multiple: return false } } @@ -8113,7 +8197,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID context.clear(CGRect(origin: .zero, size: size)) })! exportSubject = .single(.image(image: image)) - case let .video(path, _, _, _, _, _, _, _, _): + case let .video(path, _, _, _, _, _, _, _, _, _): let asset = AVURLAsset(url: NSURL(fileURLWithPath: path) as URL) exportSubject = .single(.video(asset: asset, isStory: true)) case let .videoCollage(items): @@ -8164,7 +8248,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID |> map { asset in return .video(asset: asset, isStory: true) } - case let .image(image, _, _, _): + case let .image(image, _, _, _, _): exportSubject = .single(.image(image: image)) case let .asset(asset): exportSubject = Signal { subscriber in @@ -8222,7 +8306,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } case let .sticker(file, _): exportSubject = .single(.sticker(file: file)) - case .assets: + case .multiple: fatalError() } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreenDrafts.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreenDrafts.swift index 26d9dad3e6..7ede825219 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreenDrafts.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreenDrafts.swift @@ -20,7 +20,7 @@ extension MediaEditorScreenImpl { if case .coverEditor = self.mode { return false } - if case .assets = self.node.actualSubject { + if case .multiple = self.node.actualSubject { return false } guard let mediaEditor = self.node.mediaEditor else { @@ -208,11 +208,11 @@ extension MediaEditorScreenImpl { switch subject { case .empty: break - case let .image(image, dimensions, _, _): + case let .image(image, dimensions, _, _, _): if let draft = innerSaveDraft(media: .image(image: image, dimensions: dimensions)) { completion?(draft) } - case let .video(path, _, _, _, _, dimensions, _, _, _): + case let .video(path, _, _, _, _, dimensions, _, _, _, _): if let draft = innerSaveDraft(media: .video(path: path, dimensions: dimensions, duration: duration)) { completion?(draft) } @@ -256,7 +256,7 @@ extension MediaEditorScreenImpl { } case .sticker: break - case .assets: + case .multiple: break } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift index 1b48021c76..2777369e75 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift @@ -47,9 +47,10 @@ extension MediaEditorScreenImpl { let end = values.videoTrimRange?.upperBound ?? (min(originalDuration, start + storyMaxCombinedVideoDuration)) for i in 0 ..< storyCount { + guard var editingItem = EditingItem(subject: .asset(asset)) else { + continue + } let trimmedValues = values.withUpdatedVideoTrimRange(start ..< min(end, start + storyMaxVideoDuration)) - - var editingItem = EditingItem(asset: asset) if i == 0 { editingItem.caption = self.node.getCaption() } @@ -176,7 +177,7 @@ extension MediaEditorScreenImpl { duration = 3.0 firstFrame = .single((image, nil)) - case let .image(image, _, _, _): + case let .image(image, _, _, _, _): let tempImagePath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).jpg" if let data = image.jpegData(compressionQuality: 0.85) { try? data.write(to: URL(fileURLWithPath: tempImagePath)) @@ -185,7 +186,7 @@ extension MediaEditorScreenImpl { duration = 5.0 firstFrame = .single((image, nil)) - case let .video(path, _, mirror, additionalPath, _, _, durationValue, _, _): + case let .video(path, _, mirror, additionalPath, _, _, durationValue, _, _, _): videoIsMirrored = mirror videoResult = .single(.videoFile(path: path)) if let videoTrimRange = mediaEditor.values.videoTrimRange { @@ -426,7 +427,7 @@ extension MediaEditorScreenImpl { duration = 3.0 firstFrame = .single((image, nil)) - case .assets: + case .multiple: fatalError() } @@ -528,7 +529,7 @@ extension MediaEditorScreenImpl { } var items = items - if !isLongVideo, let mediaEditor = self.node.mediaEditor, case let .asset(asset) = self.node.subject, let currentItemIndex = items.firstIndex(where: { $0.asset.localIdentifier == asset.localIdentifier }) { + if !isLongVideo, let mediaEditor = self.node.mediaEditor, let subject = self.node.subject, let currentItemIndex = items.firstIndex(where: { $0.source.identifier == subject.sourceIdentifier }) { var updatedCurrentItem = items[currentItemIndex] updatedCurrentItem.caption = self.node.getCaption() updatedCurrentItem.values = mediaEditor.values @@ -563,7 +564,7 @@ extension MediaEditorScreenImpl { let randomId = Int64.random(in: .min ... .max) order.append(randomId) - if item.asset.mediaType == .video { + if item.source.isVideo { processVideoItem(item: item, index: index, randomId: randomId, isLongVideo: isLongVideo) { result in let _ = multipleResults.modify { results in var updatedResults = results @@ -573,7 +574,7 @@ extension MediaEditorScreenImpl { dispatchGroup.leave() } - } else if item.asset.mediaType == .image { + } else { processImageItem(item: item, index: index, randomId: randomId) { result in let _ = multipleResults.modify { results in var updatedResults = results @@ -583,8 +584,6 @@ extension MediaEditorScreenImpl { dispatchGroup.leave() } - } else { - dispatchGroup.leave() } } @@ -610,13 +609,8 @@ extension MediaEditorScreenImpl { } private func processVideoItem(item: EditingItem, index: Int, randomId: Int64, isLongVideo: Bool, completion: @escaping (MediaEditorScreenImpl.Result) -> Void) { - let asset = item.asset - let itemMediaEditor = setupMediaEditorForItem(item: item) - - var caption = item.caption - caption = convertMarkdownToAttributes(caption) - + var mediaAreas: [MediaArea] = [] var stickers: [TelegramMediaFile] = [] @@ -636,12 +630,13 @@ extension MediaEditorScreenImpl { firstFrameTime = CMTime(seconds: item.values?.videoTrimRange?.lowerBound ?? 0.0, preferredTimescale: CMTimeScale(60)) } - PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { [weak self] avAsset, _, _ in + let process: (AVAsset?, MediaResult.VideoResult) -> Void = { [weak self] avAsset, videoResult in + guard let self else { + return + } guard let avAsset else { - DispatchQueue.main.async { - if let self { - completion(self.createEmptyResult(randomId: randomId)) - } + Queue.mainQueue().async { + completion(self.createEmptyResult(randomId: randomId)) } return } @@ -650,16 +645,16 @@ extension MediaEditorScreenImpl { if let videoTrimRange = item.values?.videoTrimRange { duration = videoTrimRange.upperBound - videoTrimRange.lowerBound } else { - duration = min(asset.duration, storyMaxVideoDuration) + duration = min(avAsset.duration.seconds, storyMaxVideoDuration) } - + let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)]) { [weak self] _, cgImage, _, _, _ in guard let self else { return } - DispatchQueue.main.async { + Queue.mainQueue().async { if let cgImage { let image = UIImage(cgImage: cgImage) itemMediaEditor.replaceSource(image, additionalImage: nil, time: firstFrameTime, mirror: false) @@ -677,14 +672,14 @@ extension MediaEditorScreenImpl { if let coverImage = coverImage { let result = MediaEditorScreenImpl.Result( media: .video( - video: .asset(localIdentifier: asset.localIdentifier), + video: videoResult, coverImage: coverImage, values: itemMediaEditor.values, duration: duration, dimensions: itemMediaEditor.values.resultDimensions ), mediaAreas: mediaAreas, - caption: caption, + caption: convertMarkdownToAttributes(item.caption), coverTimestamp: itemMediaEditor.values.coverImageTimestamp, options: self.state.privacy, stickers: stickers, @@ -704,11 +699,21 @@ extension MediaEditorScreenImpl { } } } + + switch item.source { + case let .video(videoPath, _, _, _): + let avAsset = AVURLAsset(url: URL(fileURLWithPath: videoPath)) + process(avAsset, .videoFile(path: videoPath)) + case let .asset(asset): + PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in + process(avAsset, .asset(localIdentifier: asset.localIdentifier)) + } + default: + fatalError() + } } private func processImageItem(item: EditingItem, index: Int, randomId: Int64, completion: @escaping (MediaEditorScreenImpl.Result) -> Void) { - let asset = item.asset - let itemMediaEditor = setupMediaEditorForItem(item: item) var caption = item.caption @@ -726,56 +731,67 @@ extension MediaEditorScreenImpl { } } - let options = PHImageRequestOptions() - options.deliveryMode = .highQualityFormat - options.isNetworkAccessAllowed = true - - PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { [weak self] image, _ in + let process: (UIImage?) -> Void = { [weak self] image in guard let self else { return } - DispatchQueue.main.async { - if let image { - itemMediaEditor.replaceSource(image, additionalImage: nil, time: .zero, mirror: false) - if itemMediaEditor.values.gradientColors == nil { - itemMediaEditor.setGradientColors(mediaEditorGetGradientColors(from: image)) - } - - if let resultImage = itemMediaEditor.resultImage { - makeEditorImageComposition( - context: self.node.ciContext, - postbox: self.context.account.postbox, - inputImage: resultImage, - dimensions: storyDimensions, - values: itemMediaEditor.values, - time: .zero, - textScale: 2.0 - ) { resultImage in - if let resultImage = resultImage { - let result = MediaEditorScreenImpl.Result( - media: .image( - image: resultImage, - dimensions: PixelDimensions(resultImage.size) - ), - mediaAreas: mediaAreas, - caption: caption, - coverTimestamp: nil, - options: self.state.privacy, - stickers: stickers, - randomId: randomId - ) - completion(result) - } else { - completion(self.createEmptyResult(randomId: randomId)) - } - } + guard let image else { + completion(self.createEmptyResult(randomId: randomId)) + return + } + itemMediaEditor.replaceSource(image, additionalImage: nil, time: .zero, mirror: false) + if itemMediaEditor.values.gradientColors == nil { + itemMediaEditor.setGradientColors(mediaEditorGetGradientColors(from: image)) + } + + if let resultImage = itemMediaEditor.resultImage { + makeEditorImageComposition( + context: self.node.ciContext, + postbox: self.context.account.postbox, + inputImage: resultImage, + dimensions: storyDimensions, + values: itemMediaEditor.values, + time: .zero, + textScale: 2.0 + ) { resultImage in + if let resultImage = resultImage { + let result = MediaEditorScreenImpl.Result( + media: .image( + image: resultImage, + dimensions: PixelDimensions(resultImage.size) + ), + mediaAreas: mediaAreas, + caption: caption, + coverTimestamp: nil, + options: self.state.privacy, + stickers: stickers, + randomId: randomId + ) + completion(result) } else { completion(self.createEmptyResult(randomId: randomId)) } - } else { - completion(self.createEmptyResult(randomId: randomId)) + } + } else { + completion(self.createEmptyResult(randomId: randomId)) + } + } + + switch item.source { + case let .image(image, _): + process(image) + case let .asset(asset): + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + + PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in + Queue.mainQueue().async { + process(image) } } + default: + fatalError() } } @@ -784,10 +800,21 @@ extension MediaEditorScreenImpl { if values?.videoTrimRange == nil { values = values?.withUpdatedVideoTrimRange(0 ..< storyMaxVideoDuration) } + + let editorSubject: MediaEditor.Subject + switch item.source { + case let .image(image, dimensions): + editorSubject = .image(image, dimensions) + case let .video(videoPath, thumbnailImage, dimensions, duration): + editorSubject = .video(videoPath, thumbnailImage, false, nil, dimensions, duration) + case let .asset(asset): + editorSubject = .asset(asset) + } + return MediaEditor( context: self.context, mode: .default, - subject: .asset(item.asset), + subject: editorSubject, values: values, hasHistogram: false, isStandalone: true diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift index b915534b4f..7033428d0c 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift @@ -84,13 +84,20 @@ final class SelectionPanelComponent: Component { let previousItem = self.item self.item = item - if previousItem?.asset.localIdentifier != item.asset.localIdentifier || previousItem?.version != item.version { + if previousItem?.identifier != item.identifier || previousItem?.version != item.version { let imageSignal: Signal if let thumbnail = item.thumbnail { imageSignal = .single(thumbnail) self.imageNode.contentMode = .scaleAspectFill } else { - imageSignal = assetImage(asset: item.asset, targetSize:CGSize(width: 128.0 * UIScreenScale, height: 128.0 * UIScreenScale), exact: false, synchronous: true) + switch item.source { + case let .image(image, _): + imageSignal = .single(image) + case let .video(_, image, _, _): + imageSignal = .single(image) + case let .asset(asset): + imageSignal = assetImage(asset: asset, targetSize:CGSize(width: 128.0 * UIScreenScale, height: 128.0 * UIScreenScale), exact: false, synchronous: true) + } self.imageNode.contentUpdated = { [weak self] image in if let self { if self.backgroundNode.image == nil { @@ -249,8 +256,8 @@ final class SelectionPanelComponent: Component { self.reorderRecognizer?.isEnabled = true let location = gestureRecognizer.location(in: self) - if let itemView = self.item(at: location), let item = itemView.item, item.asset.localIdentifier != component.selectedItemId { - component.itemTapped(item.asset.localIdentifier) + if let itemView = self.item(at: location), let item = itemView.item, item.identifier != component.selectedItemId { + component.itemTapped(item.identifier) } else { component.itemTapped(nil) } @@ -346,12 +353,12 @@ final class SelectionPanelComponent: Component { let mappedPosition = self.convert(targetPosition, to: self.scrollView) - if let visibleReorderingItem = self.itemViews[id], let fromId = self.itemViews[id]?.item?.asset.localIdentifier { + if let visibleReorderingItem = self.itemViews[id], let fromId = self.itemViews[id]?.item?.identifier { for (_, visibleItem) in self.itemViews { if visibleItem === visibleReorderingItem { continue } - if visibleItem.frame.contains(mappedPosition), let toId = visibleItem.item?.asset.localIdentifier { + if visibleItem.frame.contains(mappedPosition), let toId = visibleItem.item?.identifier { component.itemReordered(fromId, toId) break } @@ -381,7 +388,7 @@ final class SelectionPanelComponent: Component { let mainCircleDelay: Double = 0.02 let backgroundWidth = self.backgroundMaskPanelView.frame.width for item in component.items { - guard let itemView = self.itemViews[item.asset.localIdentifier] else { + guard let itemView = self.itemViews[item.identifier] else { continue } @@ -433,7 +440,7 @@ final class SelectionPanelComponent: Component { let backgroundWidth = self.backgroundMaskPanelView.frame.width for item in component.items { - guard let itemView = self.itemViews[item.asset.localIdentifier] else { + guard let itemView = self.itemViews[item.identifier] else { continue } let distance = abs(itemView.frame.center.x - backgroundWidth) @@ -478,7 +485,7 @@ final class SelectionPanelComponent: Component { var index = 1 for item in component.items { - let id = item.asset.localIdentifier + let id = item.identifier validIds.insert(id) var itemTransition = transition @@ -498,7 +505,7 @@ final class SelectionPanelComponent: Component { } component.itemSelectionToggled(id) } - itemView.update(item: item, number: index, isSelected: item.asset.localIdentifier == component.selectedItemId, isEnabled: item.isEnabled, size: itemFrame.size, portalView: self.portalView, transition: itemTransition) + itemView.update(item: item, number: index, isSelected: item.identifier == component.selectedItemId, isEnabled: item.isEnabled, size: itemFrame.size, portalView: self.portalView, transition: itemTransition) itemTransition.setBounds(view: itemView, bounds: CGRect(origin: .zero, size: itemFrame.size)) itemTransition.setPosition(view: itemView, position: itemFrame.center) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/Weather.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/Weather.swift index fb1624ad74..f6f93ba274 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/Weather.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/Weather.swift @@ -57,7 +57,7 @@ func getWeather(context: AccountContext, load: Bool) -> Signal Void = { _, _, _, _ in } + public var trimUpdated: (Double, Double, Bool, Bool) -> Void = { _, _, _, _ in } var updated: (ComponentTransition) -> Void = { _ in } - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: .zero) self.zoneView.image = UIImage() @@ -1792,7 +1793,7 @@ private class TrimView: UIView { self.updated(transition) } - var params: ( + private var params: ( scrubberSize: CGSize, duration: Double, startPosition: Double, @@ -1802,7 +1803,7 @@ private class TrimView: UIView { maxDuration: Double )? - func update( + public func update( style: MediaScrubberComponent.Style, theme: PresentationTheme, visualInsets: UIEdgeInsets, @@ -1823,6 +1824,7 @@ private class TrimView: UIView { let capsuleOffset: CGFloat let color: UIColor let highlightColor: UIColor + var borderColor: UIColor switch style { case .editor, .cover: @@ -1880,15 +1882,41 @@ private class TrimView: UIView { self.leftHandleView.image = handleImage self.rightHandleView.image = handleImage + self.leftCapsuleView.backgroundColor = .white + self.rightCapsuleView.backgroundColor = .white + } + + case .voiceMessage: + effectiveHandleWidth = 16.0 + fullTrackHeight = 33.0 + capsuleOffset = 8.0 + color = .clear + highlightColor = .clear + + self.zoneView.backgroundColor = UIColor(white: 1.0, alpha: 0.4) + + if isFirstTime { + self.borderView.image = generateImage(CGSize(width: 3.0, height: fullTrackHeight), rotatedContext: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: .zero, size: CGSize(width: 1.0, height: size.height))) + context.fill(CGRect(origin: CGPoint(x: size.width - 1.0, y: 0.0), size: CGSize(width: 1.0, height: size.height))) + })?.withRenderingMode(.alwaysTemplate).resizableImage(withCapInsets: UIEdgeInsets(top: 0.0, left: 1.0, bottom: 0.0, right: 1.0)) + self.leftCapsuleView.backgroundColor = .white self.rightCapsuleView.backgroundColor = .white } } let trimColor = self.isPanningTrimHandle ? highlightColor : color + borderColor = trimColor + if case .voiceMessage = style { + borderColor = theme.chat.inputPanel.panelBackgroundColor + } + transition.setTintColor(view: self.leftHandleView, color: trimColor) transition.setTintColor(view: self.rightHandleView, color: trimColor) - transition.setTintColor(view: self.borderView, color: trimColor) + transition.setTintColor(view: self.borderView, color: borderColor) let totalWidth = scrubberSize.width let totalRange = totalWidth - effectiveHandleWidth @@ -1919,7 +1947,7 @@ private class TrimView: UIView { return (leftHandleFrame, rightHandleFrame) } - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let leftHandleFrame = self.leftHandleView.frame.insetBy(dx: -8.0, dy: -9.0) let rightHandleFrame = self.rightHandleView.frame.insetBy(dx: -8.0, dy: -9.0) let areaFrame = CGRect(x: leftHandleFrame.minX, y: leftHandleFrame.minY, width: rightHandleFrame.maxX - leftHandleFrame.minX, height: rightHandleFrame.maxY - rightHandleFrame.minY) diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 52492030e1..3db8d6c396 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -1842,13 +1842,12 @@ public final class MessageInputPanelComponent: Component { } } - var lightFieldColor = UIColor(white: 1.0, alpha: 0.09) + let lightFieldColor = UIColor(white: 1.0, alpha: 0.09) var fieldBackgroundIsDark = false if component.useGrayBackground { fieldBackgroundIsDark = false } else if component.style == .media { fieldBackgroundIsDark = false - lightFieldColor = UIColor(white: 0.2, alpha: 0.45) } else if self.textFieldExternalState.hasText && component.alwaysDarkWhenHasText { fieldBackgroundIsDark = true } else if isEditing || component.style == .story || component.style == .editor { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 1d103b0656..06048392af 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -600,7 +600,7 @@ private final class PeerInfoInteraction { let openQrCode: () -> Void let editingOpenReactionsSetup: () -> Void let dismissInput: () -> Void - let toggleForumTopics: (Bool) -> Void + let openForumSettings: () -> Void let displayTopicsLimited: (TopicsLimitedReason) -> Void let openPeerMention: (String, ChatControllerInteractionNavigateToPeer) -> Void let openBotApp: (AttachMenuBot) -> Void @@ -674,7 +674,7 @@ private final class PeerInfoInteraction { openQrCode: @escaping () -> Void, editingOpenReactionsSetup: @escaping () -> Void, dismissInput: @escaping () -> Void, - toggleForumTopics: @escaping (Bool) -> Void, + openForumSettings: @escaping () -> Void, displayTopicsLimited: @escaping (TopicsLimitedReason) -> Void, openPeerMention: @escaping (String, ChatControllerInteractionNavigateToPeer) -> Void, openBotApp: @escaping (AttachMenuBot) -> Void, @@ -747,7 +747,7 @@ private final class PeerInfoInteraction { self.openQrCode = openQrCode self.editingOpenReactionsSetup = editingOpenReactionsSetup self.dismissInput = dismissInput - self.toggleForumTopics = toggleForumTopics + self.openForumSettings = openForumSettings self.displayTopicsLimited = displayTopicsLimited self.openPeerMention = openPeerMention self.openBotApp = openBotApp @@ -2581,11 +2581,13 @@ private func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostSt } if canSetupTopics { - items[.peerDataSettings]!.append(PeerInfoScreenSwitchItem(id: ItemTopics, text: presentationData.strings.PeerInfo_OptionTopics, value: channel.flags.contains(.isForum), icon: UIImage(bundleImageName: "Settings/Menu/Topics"), isLocked: topicsLimitedReason != nil, toggled: { value in + //TODO:localize + let label = channel.flags.contains(.isForum) ? "Enabled" : "Disabled" + items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemTopics, label: .text(label), text: presentationData.strings.PeerInfo_OptionTopics, icon: UIImage(bundleImageName: "Settings/Menu/Topics"), action: { if let topicsLimitedReason = topicsLimitedReason { interaction.displayTopicsLimited(topicsLimitedReason) } else { - interaction.toggleForumTopics(value) + interaction.openForumSettings() } })) @@ -2708,11 +2710,12 @@ private func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostSt } if canSetupTopics { - items[.peerPublicSettings]!.append(PeerInfoScreenSwitchItem(id: ItemTopics, text: presentationData.strings.PeerInfo_OptionTopics, value: false, icon: UIImage(bundleImageName: "Settings/Menu/Topics"), isLocked: topicsLimitedReason != nil, toggled: { value in + //TODO:localize + items[.peerPublicSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemTopics, label: .text("Disabled"), text: presentationData.strings.PeerInfo_OptionTopics, icon: UIImage(bundleImageName: "Settings/Menu/Topics"), action: { if let topicsLimitedReason = topicsLimitedReason { interaction.displayTopicsLimited(topicsLimitedReason) } else { - interaction.toggleForumTopics(value) + interaction.openForumSettings() } })) @@ -3134,11 +3137,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro dismissInput: { [weak self] in self?.view.endEditing(true) }, - toggleForumTopics: { [weak self] value in - guard let strongSelf = self else { - return - } - strongSelf.toggleForumTopics(isEnabled: value) + openForumSettings: { [weak self] in + self?.openForumSettings() }, displayTopicsLimited: { [weak self] reason in guard let self else { @@ -9235,6 +9235,14 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }) } + private func openForumSettings() { + guard let controller = self.controller else { + return + } + let settingsController = self.context.sharedContext.makeForumSettingsScreen(context: self.context, peerId: self.peerId) + controller.push(settingsController) + } + private func toggleForumTopics(isEnabled: Bool) { guard let data = self.data, let peer = data.peer else { return @@ -10071,7 +10079,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro if (self.isSettings || self.isMyProfile) && self.data?.globalSettings?.privacySettings?.phoneDiscoveryEnabled == false && (self.data?.peer?.addressName ?? "").isEmpty { temporary = true } - controller.present(self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: peer, threadId: threadId, temporary: temporary), in: .window(.root)) + let qrController = self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: peer, threadId: threadId, temporary: temporary) + controller.push(qrController) } private func openPremiumGift() { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift index abc488d7df..57ff0e48e5 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift @@ -112,7 +112,7 @@ extension PeerInfoScreenImpl { if let asset = result as? PHAsset { subject = .single(.asset(asset)) } else if let image = result as? UIImage { - subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) } else if let result = result as? Signal { subject = result |> map { value -> MediaEditorScreenImpl.Subject? in @@ -120,9 +120,9 @@ extension PeerInfoScreenImpl { case .pendingImage: return nil case let .image(image): - return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft) + return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false) case let .video(video): - return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: [], additionalVideoPosition: .topLeft) + return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: [], additionalVideoPosition: .topLeft, fromCamera: false) default: return nil } diff --git a/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/BUILD b/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/BUILD new file mode 100644 index 0000000000..24183c1036 --- /dev/null +++ b/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "FetchAudioMediaResource", + module_name = "FetchAudioMediaResource", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Postbox", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramCore", + "//submodules/LegacyComponents", + "//submodules/FFMpegBinding", + "//submodules/LocalMediaResources", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/Sources/FetchAudioMediaResource.swift b/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/Sources/FetchAudioMediaResource.swift new file mode 100644 index 0000000000..97acda1ddb --- /dev/null +++ b/submodules/TelegramUI/Components/Resources/FetchAudioMediaResource/Sources/FetchAudioMediaResource.swift @@ -0,0 +1,14 @@ +import Foundation +import UIKit +import Postbox +import SwiftSignalKit +import TelegramCore +import FFMpegBinding +import LocalMediaResources + +public func fetchLocalFileAudioMediaResource(postbox: Postbox, resource: LocalFileAudioMediaResource) -> Signal { + let tempFile = EngineTempBox.shared.tempFile(fileName: "audio.ogg") + FFMpegOpusTrimmer.trim(resource.path, to: tempFile.path, start: resource.trimRange?.lowerBound ?? 0.0, end: resource.trimRange?.upperBound ?? 1.0) + + return .single(.moveTempFile(file: tempFile)) +} diff --git a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift index a02526928e..bdf41bf34c 100644 --- a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift +++ b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift @@ -189,6 +189,8 @@ public class ShareRootControllerImpl { private weak var navigationController: NavigationController? + public var openUrl: (String) -> Void = { _ in } + public init(initializationData: ShareRootControllerInitializationData, getExtensionContext: @escaping () -> NSExtensionContext?) { self.initializationData = initializationData self.getExtensionContext = getExtensionContext @@ -237,7 +239,8 @@ public class ShareRootControllerImpl { setupSharedLogger(rootPath: rootPath, path: logsPath) - let applicationBindings = TelegramApplicationBindings(isMainApp: false, appBundleId: self.initializationData.appBundleId, appBuildType: self.initializationData.appBuildType, containerPath: self.initializationData.appGroupPath, appSpecificScheme: "tg", openUrl: { _ in + let applicationBindings = TelegramApplicationBindings(isMainApp: false, appBundleId: self.initializationData.appBundleId, appBuildType: self.initializationData.appBuildType, containerPath: self.initializationData.appGroupPath, appSpecificScheme: "tg", openUrl: { [weak self] url in + self?.openUrl(url) }, openUniversalUrl: { _, completion in completion.completion(false) return @@ -593,6 +596,61 @@ public class ShareRootControllerImpl { //inForeground.set(false) self?.getExtensionContext()?.completeRequest(returningItems: nil, completionHandler: nil) } + shareController.shareStory = { [weak self] in + guard let self else { + return + } + + if let inputItems = self.getExtensionContext()?.inputItems, inputItems.count == 1, let item = inputItems[0] as? NSExtensionItem, let attachments = item.attachments { + let sessionId = Int64.random(in: 1000000 ..< .max) + + let storiesPath = rootPath + "/share/stories/\(sessionId)" + let _ = try? FileManager.default.createDirectory(atPath: storiesPath, withIntermediateDirectories: true, attributes: nil) + var index = 0 + + let dispatchGroup = DispatchGroup() + + for attachment in attachments { + let fileIndex = index + if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { + dispatchGroup.enter() + attachment.loadFileRepresentation(forTypeIdentifier: kUTTypeImage as String, completionHandler: { url, _ in + if let url, let imageData = try? Data(contentsOf: url) { + let filePath = storiesPath + "/\(fileIndex).jpg" + try? FileManager.default.removeItem(atPath: filePath) + + do { + try imageData.write(to: URL(fileURLWithPath: filePath)) + } catch { + print("Error: \(error)") + } + } + dispatchGroup.leave() + }) + } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) { + dispatchGroup.enter() + attachment.loadFileRepresentation(forTypeIdentifier: kUTTypeMovie as String, completionHandler: { url, _ in + if let url { + let filePath = storiesPath + "/\(fileIndex).mp4" + try? FileManager.default.removeItem(atPath: filePath) + + do { + try FileManager.default.copyItem(at: url, to: URL(fileURLWithPath: filePath)) + } catch { + print("Error: \(error)") + } + } + dispatchGroup.leave() + }) + } + index += 1 + } + + dispatchGroup.notify(queue: .main) { + self.openUrl("tg://shareStory?session=\(sessionId)") + } + } + } /*shareController.debugAction = { guard let strongSelf = self else { return diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index fdc183766b..a40d162780 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -926,7 +926,7 @@ final class StoryItemSetContainerSendMessage { } } else { if self.audioRecorderValue == nil { - self.audioRecorder.set(component.context.sharedContext.mediaManager.audioRecorder(beginWithTone: false, applicationBindings: component.context.sharedContext.applicationBindings, beganWithTone: { _ in + self.audioRecorder.set(component.context.sharedContext.mediaManager.audioRecorder(resumeData: nil, beginWithTone: false, applicationBindings: component.context.sharedContext.applicationBindings, beganWithTone: { _ in })) } } diff --git a/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/BUILD b/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/BUILD index 0f5bf919fa..a8b63b5637 100644 --- a/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/BUILD +++ b/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/SSignalKit/SwiftSignalKit", "//submodules/ICloudResources", "//submodules/TelegramUI/Components/Resources/FetchVideoMediaResource", + "//submodules/TelegramUI/Components/Resources/FetchAudioMediaResource", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/Sources/TelegramAccountAuxiliaryMethods.swift b/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/Sources/TelegramAccountAuxiliaryMethods.swift index d0b50ef37d..b7f6cfdd89 100644 --- a/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/Sources/TelegramAccountAuxiliaryMethods.swift +++ b/submodules/TelegramUI/Components/TelegramAccountAuxiliaryMethods/Sources/TelegramAccountAuxiliaryMethods.swift @@ -13,6 +13,7 @@ import AppBundle import SwiftSignalKit import ICloudResources import FetchVideoMediaResource +import FetchAudioMediaResource import Display public func makeTelegramAccountAuxiliaryMethods(uploadInBackground: ((Postbox, MediaResource) -> Signal)?) -> AccountAuxiliaryMethods { @@ -43,6 +44,8 @@ public func makeTelegramAccountAuxiliaryMethods(uploadInBackground: ((Postbox, M |> mapToSignal { useModernPipeline -> Signal in return fetchLocalFileVideoMediaResource(postbox: postbox, resource: resource, alwaysUseModernPipeline: useModernPipeline) } + } else if let resource = resource as? LocalFileAudioMediaResource { + return fetchLocalFileAudioMediaResource(postbox: postbox, resource: resource) } else if let resource = resource as? LocalFileGifMediaResource { return fetchLocalFileGifMediaResource(resource: resource) } else if let photoLibraryResource = resource as? PhotoLibraryMediaResource { @@ -56,7 +59,7 @@ public func makeTelegramAccountAuxiliaryMethods(uploadInBackground: ((Postbox, M } |> castError(MediaResourceDataFetchError.self) |> mapToSignal { useExif -> Signal in - return fetchPhotoLibraryResource(localIdentifier: photoLibraryResource.localIdentifier, width: photoLibraryResource.width, height: photoLibraryResource.height, format: photoLibraryResource.format, quality: photoLibraryResource.quality, useExif: useExif) + return fetchPhotoLibraryResource(localIdentifier: photoLibraryResource.localIdentifier, width: photoLibraryResource.width, height: photoLibraryResource.height, format: photoLibraryResource.format, quality: photoLibraryResource.quality, hd: photoLibraryResource.forceHd, useExif: useExif) } } else if let resource = resource as? ICloudFileResource { return fetchICloudFileResource(resource: resource) diff --git a/submodules/TelegramUI/Components/TelegramUIDeclareEncodables/Sources/TelegramUIDeclareEncodables.swift b/submodules/TelegramUI/Components/TelegramUIDeclareEncodables/Sources/TelegramUIDeclareEncodables.swift index 2d6d4c853b..461dc1999b 100644 --- a/submodules/TelegramUI/Components/TelegramUIDeclareEncodables/Sources/TelegramUIDeclareEncodables.swift +++ b/submodules/TelegramUI/Components/TelegramUIDeclareEncodables/Sources/TelegramUIDeclareEncodables.swift @@ -17,6 +17,7 @@ import ICloudResources private var telegramUIDeclaredEncodables: Void = { declareEncodable(VideoLibraryMediaResource.self, f: { VideoLibraryMediaResource(decoder: $0) }) declareEncodable(LocalFileVideoMediaResource.self, f: { LocalFileVideoMediaResource(decoder: $0) }) + declareEncodable(LocalFileAudioMediaResource.self, f: { LocalFileAudioMediaResource(decoder: $0) }) declareEncodable(LocalFileGifMediaResource.self, f: { LocalFileGifMediaResource(decoder: $0) }) declareEncodable(PhotoLibraryMediaResource.self, f: { PhotoLibraryMediaResource(decoder: $0) }) declareEncodable(ICloudFileResource.self, f: { ICloudFileResource(decoder: $0) }) diff --git a/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/Contents.json new file mode 100644 index 0000000000..75455dd93f --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "scanqr_24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/scanqr_24.pdf b/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/scanqr_24.pdf new file mode 100644 index 0000000000..3c315dfb94 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Settings/ScanQr.imageset/scanqr_24.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/Contents.json new file mode 100644 index 0000000000..b1a2faca32 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "addtostory.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/addtostory.pdf b/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/addtostory.pdf new file mode 100644 index 0000000000..ea7e375afb Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Share/Story.imageset/addtostory.pdf differ diff --git a/submodules/TelegramUI/Resources/Animations/ForumList.tgs b/submodules/TelegramUI/Resources/Animations/ForumList.tgs new file mode 100644 index 0000000000..bd36a950bf Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/ForumList.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/ForumTabs.tgs b/submodules/TelegramUI/Resources/Animations/ForumTabs.tgs new file mode 100644 index 0000000000..f45db7f439 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/ForumTabs.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/Topics.tgs b/submodules/TelegramUI/Resources/Animations/Topics.tgs new file mode 100644 index 0000000000..a5552a4acb Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/Topics.tgs differ diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 6d5bdbbedc..708980cfba 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -5986,6 +5986,8 @@ extension ChatControllerImpl { }, updateVideoTrimRange: { [weak self] start, end, updatedEnd, apply in if let videoRecorder = self?.videoRecorderValue { videoRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) + } else if let audioRecorder = self?.audioRecorderValue { + audioRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) } }, updateHistoryFilter: { [weak self] update in guard let self else { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift index 455ea29bdf..4d4f149e0b 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift @@ -53,6 +53,7 @@ import TelegramIntents import TooltipUI import StatisticsUI import MediaResources +import LocalMediaResources import GalleryData import ChatInterfaceState import InviteLinksUI @@ -125,15 +126,27 @@ import ChatMediaInputStickerGridItem import AdsInfoScreen extension ChatControllerImpl { - func requestAudioRecorder(beginWithTone: Bool) { + func requestAudioRecorder(beginWithTone: Bool, existingDraft: ChatInterfaceMediaDraftState.Audio? = nil) { if self.audioRecorderValue == nil { - if self.recorderFeedback == nil { + if self.recorderFeedback == nil && existingDraft == nil { self.recorderFeedback = HapticFeedback() self.recorderFeedback?.prepareImpact(.light) } - self.audioRecorder.set(self.context.sharedContext.mediaManager.audioRecorder(beginWithTone: beginWithTone, applicationBindings: self.context.sharedContext.applicationBindings, beganWithTone: { _ in - })) + var resumeData: AudioRecorderResumeData? + if let existingDraft, let path = self.context.account.postbox.mediaBox.completedResourcePath(existingDraft.resource), let compressedData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedIfSafe]), let recorderResumeData = existingDraft.resumeData { + resumeData = AudioRecorderResumeData(compressedData: compressedData, resumeData: recorderResumeData) + } + + self.audioRecorder.set( + self.context.sharedContext.mediaManager.audioRecorder( + resumeData: resumeData, + beginWithTone: beginWithTone, + applicationBindings: self.context.sharedContext.applicationBindings, + beganWithTone: { _ in + } + ) + ) } } @@ -269,46 +282,59 @@ extension ChatControllerImpl { self.chatDisplayNode.updateRecordedMediaDeleted(true) self.audioRecorder.set(.single(nil)) case .preview, .pause: - if case .preview = updatedAction { - self.audioRecorder.set(.single(nil)) - } self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in return panelState.withUpdatedMediaRecordingState(.waitingForPreview) } }) - self.recorderDataDisposable.set((audioRecorderValue.takenRecordedData() - |> deliverOnMainQueue).startStrict(next: { [weak self] data in - if let strongSelf = self, let data = data { - if data.duration < 0.5 { - strongSelf.recorderFeedback?.error() - strongSelf.recorderFeedback = nil - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - return panelState.withUpdatedMediaRecordingState(nil) + + var resource: LocalFileMediaResource? + self.recorderDataDisposable.set( + (audioRecorderValue.takenRecordedData() + |> deliverOnMainQueue).startStrict( + next: { [weak self] data in + if let strongSelf = self, let data = data { + if data.duration < 0.5 { + strongSelf.recorderFeedback?.error() + strongSelf.recorderFeedback = nil + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(nil) + } + }) + strongSelf.recorderDataDisposable.set(nil) + } else if let waveform = data.waveform { + if resource == nil { + resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.compressedData.count)) + strongSelf.context.account.postbox.mediaBox.storeResourceData(resource!.id, data: data.compressedData) + } + + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInterfaceState { + $0.withUpdatedMediaDraftState(.audio( + ChatInterfaceMediaDraftState.Audio( + resource: resource!, + fileSize: Int32(data.compressedData.count), + duration: Int32(data.duration), + waveform: AudioWaveform(bitstream: waveform, bitsPerSample: 5), + trimRange: data.trimRange, + resumeData: data.resumeData + ) + )) + }.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(nil) + } + }) + strongSelf.recorderFeedback = nil + strongSelf.updateDownButtonVisibility() + + if sendImmediately { + strongSelf.interfaceInteraction?.sendRecordedMedia(false, false) + } } - }) - strongSelf.recorderDataDisposable.set(nil) - } else if let waveform = data.waveform { - let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.compressedData.count)) - - strongSelf.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data.compressedData) - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInterfaceState { $0.withUpdatedMediaDraftState(.audio(ChatInterfaceMediaDraftState.Audio(resource: resource, fileSize: Int32(data.compressedData.count), duration: Int32(data.duration), waveform: AudioWaveform(bitstream: waveform, bitsPerSample: 5)))) }.updatedInputTextPanelState { panelState in - return panelState.withUpdatedMediaRecordingState(nil) - } - }) - strongSelf.recorderFeedback = nil - strongSelf.updateDownButtonVisibility() - strongSelf.recorderDataDisposable.set(nil) - - if sendImmediately { - strongSelf.interfaceInteraction?.sendRecordedMedia(false, false) } - } - } - })) + }) + ) case let .send(viewOnce): self.chatDisplayNode.updateRecordedMediaDeleted(false) self.recorderDataDisposable.set((audioRecorderValue.takenRecordedData() @@ -449,7 +475,21 @@ extension ChatControllerImpl { return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorderValue, isLocked: true)) }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } }) - } else if let videoRecorderValue = self.videoRecorderValue { + } else if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview { + self.requestAudioRecorder(beginWithTone: false, existingDraft: audio) + + if let audioRecorderValue = self.audioRecorderValue { + audioRecorderValue.resume() + + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorderValue, isLocked: true)) + }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } + }) + } + } + + if let videoRecorderValue = self.videoRecorderValue { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInputTextPanelState { panelState in let recordingStatus = videoRecorderValue.recordingStatus @@ -486,7 +526,13 @@ extension ChatControllerImpl { self.updateDownButtonVisibility() } - func sendMediaRecording(silentPosting: Bool? = nil, scheduleTime: Int32? = nil, viewOnce: Bool = false, messageEffect: ChatSendMessageEffect? = nil, postpone: Bool = false) { + func sendMediaRecording( + silentPosting: Bool? = nil, + scheduleTime: Int32? = nil, + viewOnce: Bool = false, + messageEffect: ChatSendMessageEffect? = nil, + postpone: Bool = false + ) { self.chatDisplayNode.updateRecordedMediaDeleted(false) guard let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState else { @@ -531,7 +577,20 @@ extension ChatControllerImpl { attributes.append(EffectMessageAttribute(id: messageEffect.id)) } - let messages: [EnqueueMessage] = [.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: audio.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: Int(audio.duration), title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: self.chatLocation.threadId, replyToMessageId: self.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] + let resource: TelegramMediaResource + var finalDuration: Int = Int(audio.duration) + if let trimRange = audio.trimRange, trimRange.lowerBound > 0.0 || trimRange.upperBound < Double(audio.duration) { + let randomId = Int64.random(in: Int64.min ... Int64.max) + let tempPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).ogg" + resource = LocalFileAudioMediaResource(randomId: randomId, path: tempPath, trimRange: audio.trimRange) + self.context.account.postbox.mediaBox.moveResourceData(audio.resource.id, toTempPath: tempPath) + + finalDuration = Int(trimRange.upperBound - trimRange.lowerBound) + } else { + resource = audio.resource + } + + let messages: [EnqueueMessage] = [.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: finalDuration, title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: self.chatLocation.threadId, replyToMessageId: self.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] let transformedMessages: [EnqueueMessage] if let silentPosting = silentPosting { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 7a4be77234..b737f92b7f 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -5386,9 +5386,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } audioRecorder.start() strongSelf.audioRecorderStatusDisposable = (audioRecorder.recordingState - |> deliverOnMainQueue).startStrict(next: { value in - if case .stopped = value { - self?.stopMediaRecorder() + |> deliverOnMainQueue).startStrict(next: { [weak self] value in + if let self, case .stopped = value { + if self.presentationInterfaceState.interfaceState.mediaDraftState != nil { + + } else { + self.stopMediaRecorder() + } } }) } else { diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index 9540acbeab..e4ed508f02 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -1893,7 +1893,7 @@ extension ChatControllerImpl { if let asset = result as? PHAsset { subject = .single(.asset(asset)) } else if let image = result as? UIImage { - subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) } else if let result = result as? Signal { subject = result |> map { value -> MediaEditorScreenImpl.Subject? in @@ -1901,7 +1901,7 @@ extension ChatControllerImpl { case .pendingImage: return nil case let .image(image): - return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft) + return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false) default: return nil } diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift index e284e26d67..ad379eadb4 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift @@ -143,7 +143,7 @@ extension ChatControllerImpl { guard let self else { return } - self.present(ChatQrCodeScreen(context: self.context, subject: .messages(messages)), in: .window(.root)) + self.present(ChatQrCodeScreenImpl(context: self.context, subject: .messages(messages)), in: .window(.root)) } shareController.dismissed = { [weak self] shared in if shared { diff --git a/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift index 5c1a69df48..93e7b87d10 100644 --- a/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift @@ -66,6 +66,68 @@ final class ChatRecordingPreviewViewForOverlayContent: UIView, ChatInputPanelVie } } +final class PlayButtonNode: ASDisplayNode { + let backgroundNode: ASDisplayNode + let playButton: HighlightableButtonNode + fileprivate let playPauseIconNode: PlayPauseIconNode + let durationLabel: MediaPlayerTimeTextNode + + var pressed: () -> Void = {} + + init(theme: PresentationTheme) { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.clipsToBounds = true + self.backgroundNode.backgroundColor = theme.chat.inputPanel.actionControlFillColor + self.backgroundNode.cornerRadius = 11.0 + self.backgroundNode.displaysAsynchronously = false + + self.playButton = HighlightableButtonNode() + self.playButton.displaysAsynchronously = false + + self.playPauseIconNode = PlayPauseIconNode() + self.playPauseIconNode.enqueueState(.play, animated: false) + self.playPauseIconNode.customColor = theme.chat.inputPanel.actionControlForegroundColor + + self.durationLabel = MediaPlayerTimeTextNode(textColor: theme.chat.inputPanel.actionControlForegroundColor, textFont: Font.with(size: 13.0, weight: .semibold, traits: .monospacedNumbers)) + self.durationLabel.alignment = .right + self.durationLabel.mode = .normal + self.durationLabel.showDurationIfNotStarted = true + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.playButton) + self.backgroundNode.addSubnode(self.playPauseIconNode) + self.backgroundNode.addSubnode(self.durationLabel) + + self.playButton.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return self.backgroundNode.frame.contains(point) + } + + @objc private func buttonPressed() { + self.pressed() + } + + func update(size: CGSize, transition: ContainedViewLayoutTransition) { + var buttonSize = CGSize(width: 63.0, height: 22.0) + if size.width < 70.0 { + buttonSize.width = 27.0 + } + + transition.updateFrame(node: self.backgroundNode, frame: buttonSize.centered(in: CGRect(origin: .zero, size: size))) + + self.playPauseIconNode.frame = CGRect(origin: CGPoint(x: 4.0, y: 1.0 - UIScreenPixel), size: CGSize(width: 21.0, height: 21.0)) + + transition.updateFrame(node: self.durationLabel, frame: CGRect(origin: CGPoint(x: 18.0, y: 3.0), size: CGSize(width: 35.0, height: 20.0))) + transition.updateAlpha(node: self.durationLabel, alpha: buttonSize.width > 27.0 ? 1.0 : 0.0) + + self.playButton.frame = CGRect(origin: .zero, size: size) + } +} + final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { let deleteButton: HighlightableButtonNode let binNode: AnimationNode @@ -75,11 +137,12 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { let textNode: ImmediateAnimatedCountLabelNode private var sendButtonRadialStatusNode: ChatSendButtonRadialStatusNode? - let playButton: HighlightableButtonNode - private let playPauseIconNode: PlayPauseIconNode private let waveformButton: ASButtonNode let waveformBackgroundNode: ASImageNode + let trimView: TrimView + let playButtonNode: PlayButtonNode + let scrubber = ComponentView() var viewOnce = false @@ -93,7 +156,6 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { private var presentationInterfaceState: ChatPresentationInterfaceState? private var mediaPlayer: MediaPlayer? - let durationLabel: MediaPlayerTimeTextNode private let statusDisposable = MetaDisposable() @@ -139,13 +201,6 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.waveformBackgroundNode.displayWithoutProcessing = true self.waveformBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 33.0, color: theme.chat.inputPanel.actionControlFillColor) - self.playButton = HighlightableButtonNode() - self.playButton.displaysAsynchronously = false - - self.playPauseIconNode = PlayPauseIconNode() - self.playPauseIconNode.enqueueState(.play, animated: false) - self.playPauseIconNode.customColor = theme.chat.inputPanel.actionControlForegroundColor - self.waveformButton = ASButtonNode() self.waveformButton.accessibilityTraits.insert(.startsMediaSession) @@ -156,9 +211,9 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.waveformScrubberNode = MediaPlayerScrubbingNode(content: .custom(backgroundNode: self.waveformNode, foregroundContentNode: self.waveformForegroundNode)) - self.durationLabel = MediaPlayerTimeTextNode(textColor: theme.chat.inputPanel.actionControlForegroundColor) - self.durationLabel.alignment = .right - self.durationLabel.mode = .normal + self.trimView = TrimView(frame: .zero) + self.trimView.isHollow = true + self.playButtonNode = PlayButtonNode(theme: theme) super.init() @@ -185,10 +240,10 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.sendButton.addSubnode(self.sendIconNode) self.sendButton.addSubnode(self.textNode) self.addSubnode(self.waveformScrubberNode) - self.addSubnode(self.playButton) - self.addSubnode(self.durationLabel) - self.addSubnode(self.waveformButton) - self.playButton.addSubnode(self.playPauseIconNode) + //self.addSubnode(self.waveformButton) + + //self.view.addSubview(self.trimView) + self.addSubnode(self.playButtonNode) self.sendButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -199,7 +254,21 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { } } } + + self.playButtonNode.pressed = { [weak self] in + guard let self else { + return + } + self.waveformPressed() + } + self.waveformScrubberNode.seek = { [weak self] timestamp in + guard let self else { + return + } + self.mediaPlayer?.seek(timestamp: timestamp) + } + self.deleteButton.addTarget(self, action: #selector(self.deletePressed), forControlEvents: [.touchUpInside]) self.sendButton.addTarget(self, action: #selector(self.sendPressed), forControlEvents: [.touchUpInside]) self.viewOnceButton.addTarget(self, action: #selector(self.viewOncePressed), forControlEvents: [.touchUpInside]) @@ -315,6 +384,8 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { transition.updateFrame(node: self.sendIconNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((innerSize.width - icon.size.width) / 2.0), y: floorToScreenPixels((innerSize.height - icon.size.height) / 2.0)), size: icon.size)) } + let waveformBackgroundFrame = CGRect(origin: CGPoint(x: leftInset + 45.0, y: 7.0 - UIScreenPixel), size: CGSize(width: width - leftInset - rightInset - 45.0 - innerSize.width - 1.0, height: 33.0)) + if self.presentationInterfaceState != interfaceState { var updateWaveform = false if self.presentationInterfaceState?.interfaceState.mediaDraftState != interfaceState.interfaceState.mediaDraftState { @@ -335,8 +406,7 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.waveformBackgroundNode.isHidden = false self.waveformForegroundNode.isHidden = false self.waveformScrubberNode.isHidden = false - self.playButton.isHidden = false - self.durationLabel.isHidden = false + self.playButtonNode.isHidden = false if let view = self.scrubber.view, view.superview != nil { view.removeFromSuperview() @@ -350,32 +420,71 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { } let mediaManager = context.sharedContext.mediaManager let mediaPlayer = MediaPlayer(audioSessionManager: mediaManager.audioSession, postbox: context.account.postbox, userLocation: .other, userContentType: .audio, resourceReference: .standalone(resource: audio.resource), streamable: .none, video: false, preferSoftwareDecoding: false, enableSound: true, fetchAutomatically: true) - mediaPlayer.actionAtEnd = .action { [weak mediaPlayer] in - mediaPlayer?.seek(timestamp: 0.0) + mediaPlayer.actionAtEnd = .action { [weak self] in + guard let self, let interfaceState = self.presentationInterfaceState else { + return + } + var timestamp: Double = 0.0 + if let recordedMediaPreview = interfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview, let trimRange = audio.trimRange { + timestamp = trimRange.lowerBound + } + self.mediaPlayer?.seek(timestamp: timestamp) } self.mediaPlayer = mediaPlayer - self.durationLabel.defaultDuration = Double(audio.duration) - self.durationLabel.status = mediaPlayer.status + self.playButtonNode.durationLabel.defaultDuration = Double(audio.duration) + self.playButtonNode.durationLabel.status = mediaPlayer.status + self.playButtonNode.durationLabel.trimRange = audio.trimRange self.waveformScrubberNode.status = mediaPlayer.status self.statusDisposable.set((mediaPlayer.status |> deliverOnMainQueue).startStrict(next: { [weak self] status in if let strongSelf = self { switch status.status { case .playing, .buffering(_, true, _, _): - strongSelf.playPauseIconNode.enqueueState(.pause, animated: true) + strongSelf.playButtonNode.playPauseIconNode.enqueueState(.pause, animated: true) default: - strongSelf.playPauseIconNode.enqueueState(.play, animated: true) + strongSelf.playButtonNode.playPauseIconNode.enqueueState(.play, animated: true) } } })) } + + let (leftHandleFrame, rightHandleFrame) = self.trimView.update( + style: .voiceMessage, + theme: interfaceState.theme, + visualInsets: .zero, + scrubberSize: waveformBackgroundFrame.size, + duration: Double(audio.duration), + startPosition: audio.trimRange?.lowerBound ?? 0.0, + endPosition: audio.trimRange?.upperBound ?? Double(audio.duration), + position: 0.0, + minDuration: 2.0, + maxDuration: Double(audio.duration), + transition: .immediate + ) + self.trimView.trimUpdated = { [weak self] start, end, updatedEnd, apply in + if let self { + self.mediaPlayer?.pause() + self.interfaceInteraction?.updateVideoTrimRange(start, end, updatedEnd, apply) + if apply { + if !updatedEnd { + self.mediaPlayer?.seek(timestamp: start, play: true) + } else { + self.mediaPlayer?.seek(timestamp: end - 1.0, play: true) + } + } + } + } + self.trimView.frame = waveformBackgroundFrame + + let playButtonSize = CGSize(width: rightHandleFrame.minX - leftHandleFrame.maxX, height: waveformBackgroundFrame.height) + self.playButtonNode.update(size: playButtonSize, transition: transition) + transition.updateFrame(node: self.playButtonNode, frame: CGRect(origin: CGPoint(x: waveformBackgroundFrame.minX + leftHandleFrame.maxX, y: waveformBackgroundFrame.minY), size: playButtonSize)) case let .video(video): self.waveformButton.isHidden = true self.waveformBackgroundNode.isHidden = true self.waveformForegroundNode.isHidden = true self.waveformScrubberNode.isHidden = true - self.playButton.isHidden = true - self.durationLabel.isHidden = true + self.playButtonNode.isHidden = true let scrubberSize = self.scrubber.update( transition: .immediate, @@ -412,7 +521,7 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { ), environment: {}, forceUpdate: false, - containerSize: CGSize(width: min(424, width - leftInset - rightInset - 45.0 - innerSize.width - 1.0), height: 33.0) + containerSize: CGSize(width: min(424.0, width - leftInset - rightInset - 45.0 - innerSize.width - 1.0), height: 33.0) ) if let view = self.scrubber.view { @@ -475,14 +584,9 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { transition.updateSublayerTransformScale(layer: self.sendButton.layer, scale: CGPoint(x: 1.0, y: 1.0)) } - transition.updateFrame(node: self.playButton, frame: CGRect(origin: CGPoint(x: leftInset + 52.0, y: 10.0), size: CGSize(width: 26.0, height: 26.0))) - self.playPauseIconNode.frame = CGRect(origin: CGPoint(x: -2.0, y: -1.0), size: CGSize(width: 26.0, height: 26.0)) - - let waveformBackgroundFrame = CGRect(origin: CGPoint(x: leftInset + 45.0, y: 7.0 - UIScreenPixel), size: CGSize(width: width - leftInset - rightInset - 45.0 - innerSize.width - 1.0, height: 33.0)) transition.updateFrame(node: self.waveformBackgroundNode, frame: waveformBackgroundFrame) transition.updateFrame(node: self.waveformButton, frame: CGRect(origin: CGPoint(x: leftInset + 45.0, y: 0.0), size: CGSize(width: width - leftInset - rightInset - 45.0 - innerSize.width - 1.0, height: panelHeight))) - transition.updateFrame(node: self.waveformScrubberNode, frame: CGRect(origin: CGPoint(x: leftInset + 45.0 + 35.0, y: 7.0 + floor((33.0 - 13.0) / 2.0)), size: CGSize(width: width - leftInset - rightInset - 45.0 - innerSize.width - 1.0 - 45.0 - 40.0, height: 13.0))) - transition.updateFrame(node: self.durationLabel, frame: CGRect(origin: CGPoint(x: width - rightInset - 45.0 - innerSize.width - 1.0 - 4.0, y: 15.0), size: CGSize(width: 35.0, height: 20.0))) + transition.updateFrame(node: self.waveformScrubberNode, frame: CGRect(origin: CGPoint(x: leftInset + 45.0 + 21.0, y: 7.0 + floor((33.0 - 13.0) / 2.0)), size: CGSize(width: width - leftInset - rightInset - 45.0 - innerSize.width - 41.0, height: 13.0))) prevInputPanelNode?.frame = CGRect(origin: .zero, size: CGSize(width: width, height: panelHeight)) if let prevTextInputPanelNode = self.prevInputPanelNode as? ChatTextInputPanelNode { @@ -536,10 +640,10 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.deleteButton.layer.animateScale(from: 0.3, to: 1.0, duration: 0.15) self.deleteButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - self.playButton.layer.animateScale(from: 0.01, to: 1.0, duration: 0.3, delay: 0.1) - self.playButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.1) - - self.durationLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, delay: 0.1) + self.playButtonNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.3, delay: 0.1) + self.playButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.1) + + self.trimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.1) self.waveformScrubberNode.layer.animateScaleY(from: 0.1, to: 1.0, duration: 0.3, delay: 0.1) self.waveformScrubberNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.1) @@ -664,7 +768,7 @@ private final class PlayPauseIconNode: ManagedAnimationNode { private var iconState: PlayPauseIconNodeState = .pause init() { - super.init(size: CGSize(width: 28.0, height: 28.0)) + super.init(size: CGSize(width: 21.0, height: 21.0)) self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01)) } diff --git a/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift index e831513195..d0e6c904cd 100644 --- a/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift @@ -590,7 +590,7 @@ final class ChatReportPeerTitlePanelNode: ChatTitleAccessoryPanelNode { var emojiStatus: PeerEmojiStatus? if let user = interfaceState.renderedPeer?.peer as? TelegramUser, let emojiStatusValue = user.emojiStatus { - if user.isFake || user.isScam { + if user.isFake || user.isScam { } else { emojiStatus = emojiStatusValue } @@ -601,7 +601,8 @@ final class ChatReportPeerTitlePanelNode: ChatTitleAccessoryPanelNode { } } - if let emojiStatus = emojiStatus, case let .emoji(fileId) = emojiStatus.content { + if let emojiStatus { + let fileId = emojiStatus.fileId if self.emojiStatusFileId != fileId { self.emojiStatusFileId = fileId diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 3c2e7b1283..7aea29de18 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -2804,8 +2804,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch animatePosition(for: prevPreviewInputPanelNode.waveformBackgroundNode.layer) animatePosition(for: prevPreviewInputPanelNode.waveformScrubberNode.layer) - animatePosition(for: prevPreviewInputPanelNode.durationLabel.layer) - animatePosition(for: prevPreviewInputPanelNode.playButton.layer) + animatePosition(for: prevPreviewInputPanelNode.playButtonNode.layer) + animatePosition(for: prevPreviewInputPanelNode.trimView.layer) if let view = prevPreviewInputPanelNode.scrubber.view { animatePosition(for: view.layer) } @@ -2821,8 +2821,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch } animateAlpha(for: prevPreviewInputPanelNode.waveformBackgroundNode.layer) animateAlpha(for: prevPreviewInputPanelNode.waveformScrubberNode.layer) - animateAlpha(for: prevPreviewInputPanelNode.durationLabel.layer) - animateAlpha(for: prevPreviewInputPanelNode.playButton.layer) + animateAlpha(for: prevPreviewInputPanelNode.playButtonNode.layer) + animateAlpha(for: prevPreviewInputPanelNode.trimView.layer) if let view = prevPreviewInputPanelNode.scrubber.view { animateAlpha(for: view.layer) } diff --git a/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift b/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift index d0eab604ec..2b882c267c 100644 --- a/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift +++ b/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift @@ -150,6 +150,8 @@ final class ManagedAudioRecorderContext { private let beginWithTone: Bool private let beganWithTone: (Bool) -> Void + private var trimRange: Range? + private var paused = true private let queue: Queue @@ -184,7 +186,16 @@ final class ManagedAudioRecorderContext { private var toneTimer: SwiftSignalKit.Timer? private var idleTimerExtensionDisposable: Disposable? - init(queue: Queue, mediaManager: MediaManager, pushIdleTimerExtension: @escaping () -> Disposable, micLevel: ValuePromise, recordingState: ValuePromise, beginWithTone: Bool, beganWithTone: @escaping (Bool) -> Void) { + init( + queue: Queue, + mediaManager: MediaManager, + resumeData: AudioRecorderResumeData?, + pushIdleTimerExtension: @escaping () -> Disposable, + micLevel: ValuePromise, + recordingState: ValuePromise, + beginWithTone: Bool, + beganWithTone: @escaping (Bool) -> Void + ) { assert(queue.isCurrent()) self.id = getNextRecorderContextId() @@ -196,9 +207,14 @@ final class ManagedAudioRecorderContext { self.queue = queue self.mediaManager = mediaManager - self.dataItem = TGDataItem() self.oggWriter = TGOggOpusWriter() + if let resumeData { + self.dataItem = TGDataItem(data: resumeData.compressedData) + } else { + self.dataItem = TGDataItem() + } + if beginWithTone, let toneData = audioRecordingToneData { self.processSamples = false let toneRenderer = MediaPlayerAudioRenderer(audioSession: .custom({ [weak self] control in @@ -313,7 +329,19 @@ final class ManagedAudioRecorderContext { addAudioRecorderContext(self.id, self) addAudioUnitHolder(self.id, queue, self.audioUnit) - self.oggWriter.begin(with: self.dataItem) + if let resumeData { + guard let stateDict = try? JSONSerialization.jsonObject(with: resumeData.resumeData, options: []) as? [String: Any] else { + Logger.shared.log("ManagedAudioRecorder", "Failed to deserialize JSON") + return + } + let success = self.oggWriter.resume(with: self.dataItem, encoderState: stateDict) + if !success { + Logger.shared.log("ManagedAudioRecorder", "Failed to resume OggWriter") + return + } + } else { + self.oggWriter.begin(with: self.dataItem) + } self.idleTimerExtensionDisposable = (Signal { subscriber in return pushIdleTimerExtension() @@ -453,7 +481,7 @@ final class ManagedAudioRecorderContext { func pause() { assert(self.queue.isCurrent()) - self.stop() + return self.stop() } func resume() { @@ -462,9 +490,12 @@ final class ManagedAudioRecorderContext { self.start() } + private var resumeData: Data? func stop() { assert(self.queue.isCurrent()) + let state = self.oggWriter.pause() + self.paused = true if let audioUnit = self.audioUnit.swap(nil) { @@ -494,6 +525,19 @@ final class ManagedAudioRecorderContext { let audioSessionDisposable = self.audioSessionDisposable self.audioSessionDisposable = nil audioSessionDisposable?.dispose() + + if let stateDict = state as? [String: Any] { + do { + let jsonData = try JSONSerialization.data(withJSONObject: stateDict, options: []) + self.resumeData = jsonData + } catch { + Logger.shared.log("ManagedAudioRecorder", "Failed to JSON: \(error)") + } + } + } + + func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) { + self.trimRange = start..? +} + final class ManagedAudioRecorderImpl: ManagedAudioRecorder { private let queue = Queue() private var contextRef: Unmanaged? private let micLevelValue = ValuePromise(0.0) private let recordingStateValue = ValuePromise(.paused(duration: 0.0)) + private let previewStateValue = ValuePromise(AudioPreviewState(trimRange: nil)) let beginWithTone: Bool @@ -691,10 +746,16 @@ final class ManagedAudioRecorderImpl: ManagedAudioRecorder { return self.recordingStateValue.get() } - init(mediaManager: MediaManager, pushIdleTimerExtension: @escaping () -> Disposable, beginWithTone: Bool, beganWithTone: @escaping (Bool) -> Void) { + init( + mediaManager: MediaManager, + resumeData: AudioRecorderResumeData?, + pushIdleTimerExtension: @escaping () -> Disposable, + beginWithTone: Bool, + beganWithTone: @escaping (Bool) -> Void + ) { self.beginWithTone = beginWithTone self.queue.async { - let context = ManagedAudioRecorderContext(queue: self.queue, mediaManager: mediaManager, pushIdleTimerExtension: pushIdleTimerExtension, micLevel: self.micLevelValue, recordingState: self.recordingStateValue, beginWithTone: beginWithTone, beganWithTone: beganWithTone) + let context = ManagedAudioRecorderContext(queue: self.queue, mediaManager: mediaManager, resumeData: resumeData, pushIdleTimerExtension: pushIdleTimerExtension, micLevel: self.micLevelValue, recordingState: self.recordingStateValue, beginWithTone: beginWithTone, beganWithTone: beganWithTone) self.contextRef = Unmanaged.passRetained(context) } } @@ -739,7 +800,7 @@ final class ManagedAudioRecorderImpl: ManagedAudioRecorder { } func takenRecordedData() -> Signal { - return Signal { subscriber in + let dataState: Signal = Signal { subscriber in self.queue.async { if let context = self.contextRef?.takeUnretainedValue() { subscriber.putNext(context.takeData()) @@ -751,5 +812,32 @@ final class ManagedAudioRecorderImpl: ManagedAudioRecorder { } return EmptyDisposable } + let previewState = self.previewStateValue.get() + + return combineLatest( + dataState, + previewState + ) |> map { data, preview -> RecordedAudioData? in + if let data { + return RecordedAudioData( + compressedData: data.compressedData, + resumeData: data.resumeData, + duration: data.duration, + waveform: data.waveform, + trimRange: preview.trimRange + ) + } else { + return nil + } + } + } + + func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) { + self.previewStateValue.set(AudioPreviewState(trimRange: start ..< end)) + self.queue.async { + if let context = self.contextRef?.takeUnretainedValue() { + context.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) + } + } } } diff --git a/submodules/TelegramUI/Sources/MediaManager.swift b/submodules/TelegramUI/Sources/MediaManager.swift index 6d73e8b327..1d7556ecec 100644 --- a/submodules/TelegramUI/Sources/MediaManager.swift +++ b/submodules/TelegramUI/Sources/MediaManager.swift @@ -449,12 +449,17 @@ public final class MediaManagerImpl: NSObject, MediaManager { self.voiceMediaPlayerStateDisposable.dispose() } - public func audioRecorder(beginWithTone: Bool, applicationBindings: TelegramApplicationBindings, beganWithTone: @escaping (Bool) -> Void) -> Signal { + public func audioRecorder( + resumeData: AudioRecorderResumeData?, + beginWithTone: Bool, + applicationBindings: TelegramApplicationBindings, + beganWithTone: @escaping (Bool) -> Void + ) -> Signal { return Signal { subscriber in let disposable = MetaDisposable() self.queue.async { - let audioRecorder = ManagedAudioRecorderImpl(mediaManager: self, pushIdleTimerExtension: { [weak applicationBindings] in + let audioRecorder = ManagedAudioRecorderImpl(mediaManager: self, resumeData: resumeData, pushIdleTimerExtension: { [weak applicationBindings] in return applicationBindings?.pushIdleTimerExtension() ?? EmptyDisposable }, beginWithTone: beginWithTone, beganWithTone: beganWithTone) subscriber.putNext(audioRecorder) diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index bda78d9ba0..706cd0751d 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AVFoundation import AsyncDisplayKit import TelegramCore import Postbox @@ -35,6 +36,7 @@ import WallpaperGalleryScreen import TelegramStringFormatting import TextFormat import BrowserUI +import MediaEditorScreen private func defaultNavigationForPeerId(_ peerId: PeerId?, navigation: ChatControllerInteractionNavigateToPeer) -> ChatControllerInteractionNavigateToPeer { if case .default = navigation { @@ -865,6 +867,83 @@ func openResolvedUrlImpl( present(controller, nil) } } + case let .shareStory(sessionId): + dismissInput() + + let rootPath = context.sharedContext.applicationBindings.containerPath + "/telegram-data" + let storiesPath = rootPath + "/share/stories/\(sessionId)" + + var filePaths: [String] = [] + do { + let directoryContents = try FileManager.default.contentsOfDirectory(atPath: storiesPath) + + for item in directoryContents { + let fullPath = storiesPath + "/" + item + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory) && !isDirectory.boolValue { + filePaths.append(fullPath) + } + } + filePaths.sort { lhs, rhs in + let lhsName = ((lhs as NSString).lastPathComponent as NSString).deletingPathExtension + let rhsName = ((rhs as NSString).lastPathComponent as NSString).deletingPathExtension + if let lhsValue = Int(lhsName), let rhsValue = Int(rhsName) { + return lhsValue < rhsValue + } + return lhsName < rhsName + } + } catch { + } + + func subject(for path: String) -> MediaEditorScreenImpl.Subject? { + if path.hasSuffix(".jpg") { + if let image = UIImage(contentsOfFile: path) { + return .image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false) + } + } else { + let asset = AVURLAsset(url: URL(fileURLWithPath: path)) + var dimensions = PixelDimensions(width: 1080, height: 1920) + if let videoTrack = asset.tracks(withMediaType: .video).first { + dimensions = PixelDimensions(videoTrack.naturalSize) + } + return .video(videoPath: path, thumbnail: nil, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: dimensions, duration: asset.duration.seconds, videoPositionChanges: [], additionalVideoPosition: .bottomLeft, fromCamera: false) + } + return nil + } + + var source: Any? + if filePaths.count > 1 { + var subjects: [MediaEditorScreenImpl.Subject] = [] + for path in filePaths { + if let subject = subject(for: path) { + subjects.append(subject) + } + } + source = subjects + } else if let path = filePaths.first { + if let subject = subject(for: path) { + source = subject + } + } + + let externalState = MediaEditorTransitionOutExternalState( + storyTarget: nil, + isForcedTarget: false, + isPeerArchived: false, + transitionOut: nil + ) + let controller = context.sharedContext.makeStoryMediaEditorScreen(context: context, source: source, text: nil, link: nil, completion: { results, commit in + let target: Stories.PendingTarget = results.first!.target + externalState.storyTarget = target + + if let rootController = context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { + rootController.proceedWithStoryUpload(target: target, results: results, existingMedia: nil, forwardInfo: nil, externalState: externalState, commit: commit) + } + }) + if let navigationController { + navigationController.pushViewController(controller) + } case let .startAttach(peerId, payload, choose): let presentError: (String) -> Void = { errorText in present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: errorText, timeout: nil, customUndoText: nil), elevatedLayout: true, animateInAsReplacement: false, action: { _ in diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index c440cb496f..97ef621b84 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -1014,6 +1014,19 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur convertedUrl = "https://t.me/call/\(slug)" } } + } else if parsedUrl.host == "shareStory" { + if let components = URLComponents(string: "/?" + query) { + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "session", let sessionId = Int64(value) { + handleResolvedUrl(.shareStory(sessionId)) + break + } + } + } + } + } } } else { if parsedUrl.host == "stars" { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index e9c49ce330..cfdb30d267 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -84,6 +84,7 @@ import InviteLinksUI import GiftStoreScreen import SendInviteLinkScreen import PostSuggestionsSettingsScreen +import ForumSettingsScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -2465,7 +2466,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { } public func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController { - return ChatQrCodeScreen(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary)) + return ChatQrCodeScreenImpl(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary)) } public func makePrivacyAndSecurityController(context: AccountContext) -> ViewController { @@ -3433,7 +3434,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { if let asset = source as? PHAsset { subject = .single(.asset(asset)) } else if let image = source as? UIImage { - subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) } else { subject = .single(.empty(PixelDimensions(width: 1080, height: 1920))) } @@ -3486,7 +3487,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { subject = .single(.asset(asset)) mode = .addingToPack } else if let image = source as? UIImage { - subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) mode = .addingToPack } else if let source = source as? Signal { subject = source @@ -3495,7 +3496,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { case .pendingImage: return nil case let .image(image): - return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft) + return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false) default: return nil } @@ -3543,12 +3544,16 @@ public final class SharedAccountContextImpl: SharedAccountContext { return editorController } - public func makeStoryMediaEditorScreen(context: AccountContext, source: Any?, text: String?, link: (url: String, name: String?)?, completion: @escaping (MediaEditorScreenResult, @escaping (@escaping () -> Void) -> Void) -> Void) -> ViewController { + public func makeStoryMediaEditorScreen(context: AccountContext, source: Any?, text: String?, link: (url: String, name: String?)?, completion: @escaping ([MediaEditorScreenResult], @escaping (@escaping () -> Void) -> Void) -> Void) -> ViewController { let subject: Signal if let image = source as? UIImage { - subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight)) + subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false)) } else if let path = source as? String { - subject = .single(.video(videoPath: path, thumbnail: nil, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: PixelDimensions(width: 1080, height: 1920), duration: 0.0, videoPositionChanges: [], additionalVideoPosition: .bottomRight)) + subject = .single(.video(videoPath: path, thumbnail: nil, mirror: false, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: PixelDimensions(width: 1080, height: 1920), duration: 0.0, videoPositionChanges: [], additionalVideoPosition: .bottomRight, fromCamera: false)) + } else if let subjects = source as? [MediaEditorScreenImpl.Subject] { + subject = .single(.multiple(subjects)) + } else if let subjectValue = source as? MediaEditorScreenImpl.Subject { + subject = .single(subjectValue) } else { subject = .single(.empty(PixelDimensions(width: 1080, height: 1920))) } @@ -3563,7 +3568,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { transitionOut: { finished, isNew in return nil }, completion: { results, commit in - completion(results.first!, commit) + completion(results, commit) } as ([MediaEditorScreenImpl.Result], @escaping (@escaping () -> Void) -> Void) -> Void ) return editorController @@ -3854,6 +3859,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { public func makePostSuggestionsSettingsScreen(context: AccountContext, peerId: EnginePeer.Id) async -> ViewController { return await PostSuggestionsSettingsScreen(context: context, peerId: peerId, completion: {}) } + + public func makeForumSettingsScreen(context: AccountContext, peerId: EnginePeer.Id) -> ViewController { + return ForumSettingsScreen(context: context, peerId: peerId) + } } private func peerInfoControllerImpl(context: AccountContext, updatedPresentationData: (PresentationData, Signal)?, peer: Peer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, isOpenedFromChat: Bool, requestsContext: PeerInvitationImportersContext? = nil) -> ViewController? { diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index c1e99fec5d..40951c029f 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -365,9 +365,9 @@ public final class TelegramRootController: NavigationController, TelegramRootCon case .pendingImage: return nil case let .image(image): - return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: image.additionalImage, additionalImagePosition: editorPIPPosition(image.additionalImagePosition)) + return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: image.additionalImage, additionalImagePosition: editorPIPPosition(image.additionalImagePosition), fromCamera: true) case let .video(video): - return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: video.additionalVideoPath, additionalThumbnail: video.additionalCoverImage, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: video.positionChangeTimestamps, additionalVideoPosition: editorPIPPosition(video.additionalVideoPosition)) + return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: video.additionalVideoPath, additionalThumbnail: video.additionalCoverImage, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: video.positionChangeTimestamps, additionalVideoPosition: editorPIPPosition(video.additionalVideoPosition), fromCamera: true) case let .videoCollage(collage): func editorCollageItem(_ item: CameraScreenImpl.Result.VideoCollage.Item) -> MediaEditorScreenImpl.Subject.VideoCollageItem { let content: MediaEditorScreenImpl.Subject.VideoCollageItem.Content @@ -392,7 +392,7 @@ public final class TelegramRootController: NavigationController, TelegramRootCon case let .draft(draft): return .draft(draft, nil) case let .assets(assets): - return .assets(assets) + return .multiple(assets.map { .asset($0) }) } } diff --git a/submodules/TooltipUI/Sources/TooltipScreen.swift b/submodules/TooltipUI/Sources/TooltipScreen.swift index e94c4ddce0..bc283f2462 100644 --- a/submodules/TooltipUI/Sources/TooltipScreen.swift +++ b/submodules/TooltipUI/Sources/TooltipScreen.swift @@ -147,6 +147,7 @@ private final class TooltipScreenNode: ViewControllerTracingNode { private let arrowContainer: ASDisplayNode private let animatedStickerNode: DefaultAnimatedStickerNodeImpl private var downArrowsNode: DownArrowsIconNode? + private var iconNode: ASImageNode? private var avatarNode: AvatarNode? private var avatarStoryIndicator: ComponentView? private let textView = ComponentView() @@ -408,6 +409,11 @@ private final class TooltipScreenNode: ViewControllerTracingNode { self.animatedStickerNode.setup(source: AnimatedStickerNodeLocalFileSource(name: animationName), width: Int(70 * UIScreenScale), height: Int(70 * UIScreenScale), playbackMode: .once, mode: .direct(cachePathPrefix: nil)) self.animatedStickerNode.automaticallyLoadFirstFrame = true self.animatedStickerNode.dynamicColor = animationTintColor + case let .image(image): + self.iconNode = ASImageNode() + self.iconNode?.image = image + self.iconNode?.contentMode = .center + self.iconNode?.displaysAsynchronously = false case .downArrows: self.downArrowsNode = DownArrowsIconNode() case let .peer(peer, _): @@ -437,7 +443,9 @@ private final class TooltipScreenNode: ViewControllerTracingNode { if let closeButtonNode = self.closeButtonNode { self.containerNode.addSubnode(closeButtonNode) } - + if let iconNode = self.iconNode { + self.containerNode.addSubnode(iconNode) + } if let downArrowsNode = self.downArrowsNode { self.containerNode.addSubnode(downArrowsNode) } @@ -505,6 +513,10 @@ private final class TooltipScreenNode: ViewControllerTracingNode { animationInset = 0.0 } animationSpacing = 8.0 + case .image: + animationSize = CGSize(width: 32.0, height: 32.0) + animationInset = 0.0 + animationSpacing = 8.0 case .peer: animationSize = CGSize(width: 32.0, height: 32.0) animationInset = 0.0 @@ -869,6 +881,11 @@ private final class TooltipScreenNode: ViewControllerTracingNode { let animationFrame = CGRect(origin: CGPoint(x: contentInset - animationInset, y: floorToScreenPixels((backgroundHeight - animationSize.height - animationInset * 2.0) / 2.0) + animationOffset), size: CGSize(width: animationSize.width + animationInset * 2.0, height: animationSize.height + animationInset * 2.0)) transition.updateFrame(node: self.animatedStickerNode, frame: animationFrame) self.animatedStickerNode.updateLayout(size: CGSize(width: animationSize.width + animationInset * 2.0, height: animationSize.height + animationInset * 2.0)) + + if let iconNode = self.iconNode { + let iconSize = CGSize(width: 32.0, height: 32.0) + transition.updateFrame(node: iconNode, frame: CGRect(origin: CGPoint(x: animationFrame.midX - iconSize.width / 2.0, y: animationFrame.midY - iconSize.height / 2.0), size: iconSize)) + } if let downArrowsNode = self.downArrowsNode { let arrowsSize = CGSize(width: 16.0, height: 16.0) @@ -1068,7 +1085,7 @@ private final class TooltipScreenNode: ViewControllerTracingNode { animationDelay = delay case .none, .downArrows: animationDelay = 0.0 - case .peer: + case .peer, .image: animationDelay = 0.0 } @@ -1148,6 +1165,7 @@ public final class TooltipScreen: ViewController { public enum Icon { case animation(name: String, delay: Double, tintColor: UIColor?) + case image(UIImage) case peer(peer: EnginePeer, isStory: Bool) case downArrows } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index dda6079112..40e2f3246a 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1491,12 +1491,12 @@ public final class WebAppController: ViewController, AttachmentContainable { isPeerArchived: false, transitionOut: nil ) - let controller = self.context.sharedContext.makeStoryMediaEditorScreen(context: self.context, source: source, text: text, link: linkUrl.flatMap { ($0, linkName) }, completion: { result, commit in - let target: Stories.PendingTarget = result.target + let controller = self.context.sharedContext.makeStoryMediaEditorScreen(context: self.context, source: source, text: text, link: linkUrl.flatMap { ($0, linkName) }, completion: { results, commit in + let target: Stories.PendingTarget = results.first!.target externalState.storyTarget = target if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { - rootController.proceedWithStoryUpload(target: target, results: [result], existingMedia: nil, forwardInfo: nil, externalState: externalState, commit: commit) + rootController.proceedWithStoryUpload(target: target, results: results, existingMedia: nil, forwardInfo: nil, externalState: externalState, commit: commit) } }) if let navigationController = self.controller?.getNavigationController() { diff --git a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh index f045941424..3976bef89c 100755 --- a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh +++ b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh @@ -54,7 +54,7 @@ CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska,mpegts, \ --enable-parser=aac,h264,mp3,libopus \ --enable-protocol=file \ - --enable-muxer=mp4,matroska,mpegts \ + --enable-muxer=mp4,matroska,ogg,mpegts \ --enable-hwaccel=h264_videotoolbox,hevc_videotoolbox,av1_videotoolbox \ "